from app.db import get_db, DB_PATH
import sqlite3
import os
import secrets
import uuid
from datetime import datetime, timezone

import bcrypt

import json

from app.crypto import (
    encrypt_value, try_decrypt, key_is_configured,
    encrypt_user_value, decrypt_user_value, try_decrypt_user_value,
    try_decrypt_user_submission, encrypt_user_submission,
    encrypt_entity, decrypt_entity, try_decrypt_entity,
    hash_value, is_encrypted,
)

# ─── Tier configuration ──────────────────────────────────────────────────────
# SendGrid-style tiers. Override via env vars.
TIERS = {
    "free": {
        "name": "Free",
        "max_sites": int(os.environ.get("TIER_FREE_SITES", "2")),
        "max_submissions": int(os.environ.get("TIER_FREE_SUBS", "50")),
        "price": 0,
        "analytics": "basic",
        "max_campaigns": 0,
        "max_recipients_per_campaign": 0,
        "max_custom_templates": 0,
        "max_api_email_sends": 0,
        "max_chains": 0,
        "max_documents": 0,
        "max_agents": 0,
    },
    "starter": {
        "name": "Starter",
        "max_sites": int(os.environ.get("TIER_STARTER_SITES", "5")),
        "max_submissions": int(os.environ.get("TIER_STARTER_SUBS", "500")),
        "price": 9,
        "analytics": "standard",
        "max_campaigns": 5,
        "max_recipients_per_campaign": 100,
        "max_custom_templates": 3,
        "max_api_email_sends": 100,
        "max_chains": 1,
        "max_documents": 10,
        "max_agents": 2,
    },
    "pro": {
        "name": "Pro",
        "max_sites": int(os.environ.get("TIER_PRO_SITES", "0")),
        "max_submissions": int(os.environ.get("TIER_PRO_SUBS", "5000")),
        "price": 29,
        "analytics": "advanced",
        "max_campaigns": 20,
        "max_recipients_per_campaign": 1000,
        "max_custom_templates": 10,
        "max_api_email_sends": 500,
        "max_chains": 3,
        "max_documents": 100,
        "max_agents": 10,
    },
    "teams": {
        "name": "Teams",
        "max_sites": 0,
        "max_submissions": int(os.environ.get("TIER_TEAMS_SUBS", "25000")),
        "max_members": 5,
        "price": 49,
        "analytics": "advanced",
        "max_campaigns": 50,
        "max_recipients_per_campaign": 5000,
        "max_custom_templates": 25,
        "max_api_email_sends": 2000,
        "max_chains": 10,
        "max_documents": 500,
        "max_agents": 50,
    },
    "teams_plus": {
        "name": "Teams Plus",
        "max_sites": 0,
        "max_submissions": int(os.environ.get("TIER_TEAMS_PLUS_SUBS", "50000")),
        "max_members": 0,
        "price": 99,
        "analytics": "advanced",
        "max_campaigns": -1,
        "max_recipients_per_campaign": -1,
        "max_custom_templates": -1,  # unlimited
        "max_api_email_sends": -1,  # unlimited
          "max_chains": -1,  # unlimited
        "max_documents": -1,  # unlimited
        "max_agents": -1,  # unlimited
    },
}

# Stripe price IDs — override via env vars for test/live
STRIPE_PRICE_IDS = {
    "starter": os.environ.get("STRIPE_STARTER_PRICE_ID") or os.environ.get("STRIPE_PRICE_STARTER", ""),
    "pro": os.environ.get("STRIPE_PRO_PRICE_ID") or os.environ.get("STRIPE_PRICE_PRO", ""),
    "teams": os.environ.get("STRIPE_TEAMS_PRICE_ID") or os.environ.get("STRIPE_PRICE_TEAMS", ""),
    "teams_plus": os.environ.get("STRIPE_TEAMS_PLUS_PRICE_ID") or os.environ.get("STRIPE_PRICE_TEAMS_PLUS", ""),
}
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
API_KEY_PREFIX = "afk_live_"

# ── Re-exports from split modules (models.py → domain modules) ──────────────
from app.migrations import (
    migrate_add_abuse_protection, migrate_add_agents, migrate_add_ai_builder, migrate_add_analytics,
    migrate_add_api_keys, migrate_add_branding, migrate_add_email_campaigns,
    migrate_add_email_phase3, migrate_add_email_phase4, migrate_add_email_tracking,
    migrate_add_email_verification, migrate_add_field_config,
    migrate_add_form_actions, migrate_add_form_analytics, migrate_add_form_sessions,
    migrate_add_form_templates, migrate_add_form_versions, migrate_add_form_variants,
    migrate_add_indexes, migrate_add_invite_expiration,
    migrate_add_rate_limit, migrate_add_submission_data, migrate_add_teams,
    migrate_add_usage, migrate_add_user_id_to_doc_templates,
    migrate_add_user_id_to_invoice_schedules, migrate_add_user_id_to_sites,
    migrate_add_user_referral_cols, migrate_add_webhook_destinations,
   migrate_add_webhook_retry_locking, migrate_add_document_templates,
    migrate_encrypt_existing_data, migrate_encrypt_user_pii,
    migrate_normalize_field_configs, migrate_rescope_to_user_keys,
    migrate_rotate_encryption_keys,
)
from app.helpers import (
    _check_spam_content, _current_month, _decrypt_invoice_schedule,
    _decrypt_submission_fields, _decrypt_team_member, _decrypt_user_row,
    _decrypt_webhook_dest, _decrypt_webhook_log, _encrypt_submission_field,
    _get_site_owner_password_hash, _is_encrypted_value, _SPAM_KEYWORDS, _SPAM_URL_PATTERNS, _table_exists,
    parse_user_agent, resolve_geo,
)
from app.analytics import (
    ab_test_stats, dismiss_spam, get_admin_stats, get_site_analytics,
    get_site_analytics_summary, get_spam_submissions, get_team_analytics,
    get_user_analytics, site_device_stats, site_field_dropoff,
    site_geo_stats, site_hourly_trends, site_impression_stats,
    site_session_stats,
)
from app.model_documents import (
    advance_schedule_next_run, create_document_template,
    create_invoice_schedule, delete_document_template, delete_invoice_schedule,
    generate_email_html, generate_invoice_number, generate_qr_code,
    get_auto_generate_template, get_document, get_document_template,
    get_due_schedules, get_invoice_schedule, increment_document_download,
    list_documents, list_document_templates, list_invoice_schedules,
    list_user_documents, count_user_documents, count_user_documents_by_type, list_documents_by_email,
    store_document, update_document_due_date, update_document_status,
    update_document_stripe_payment, update_document_template,
   update_invoice_schedule,
   update_next_invoice_number, update_site_branding,
)
from app.model_email_campaigns import (
    add_campaign_recipients, assign_recipients_to_variants,
    cancel_reminder, check_pending_ab_tests, check_send_quota,
    cleanup_old_send_log, create_ab_test, create_campaign, create_custom_template,
    create_reminder, declare_ab_winner, delete_campaign, delete_custom_template,
    get_ab_test_info, get_ab_variant, get_ab_variants, get_campaign,
    get_campaign_analytics, get_campaign_by_id, get_campaign_recipients,
    get_campaign_recipients_by_email, get_custom_template, get_custom_templates, get_email_settings,
    get_pending_recipients, get_pending_reminders, get_recipient_by_message_id,
    get_recipient_by_token, get_reminder, get_reminder_eligible_recipients,
    get_reminders, list_campaigns, mark_reminder_sent, record_bounce,
    record_click, record_open, record_send, schedule_reminders_for_campaign,
    send_ab_winner_to_remaining, update_campaign_status, update_custom_template,
    update_recipient_status, upsert_email_settings,
)

# ── Re-exports from domain modules (C2 refactor) ──────────────────────────
# These functions have been extracted to separate modules for better
# organization. New code should import directly from the domain modules.

from app.models_user import (
    register_user, authenticate_user, get_user, find_user_by_email,
    update_user_tier, update_user_magic_login_pref, update_user_profile,
    change_user_password, change_user_email, is_magic_login_enabled,
    update_stripe_customer, get_user_tier, get_site_owner_tier,
    get_user_site_count, get_user_chain_count, check_chain_quota,
    get_user_document_count, check_document_quota, list_all_users,
    generate_verification_token, verify_email_token,
    generate_password_reset_token, reset_password_with_token,
    generate_magic_login_token, validate_magic_login_token,
    generate_api_key, validate_api_key, list_api_keys, revoke_api_key,
    renew_api_key, check_api_permission, export_user_data, delete_user,
    generate_referral_code, get_referral_stats, try_decrypt_user_value,
    check_rate_limit, record_api_usage,
)

from app.models_site import (
    find_site_by_token, find_site_by_domain, add_site, list_sites,
    delete_site, get_site, get_user_sites, get_version_limit,
    save_site_version, get_site_versions, get_site_version,
    rollback_site_version, prune_old_versions, update_site_fields,
    parse_site_fields, create_action, get_action,
    get_actions_by_site, update_action, delete_action, toggle_action,
    create_variant, get_variants, get_variant, assign_variant,
    update_variant, deactivate_variant, create_or_update_session,
    add_waitlist, waitlist_count,
)

from app.models_submission import (
    add_submission, list_submissions, list_submissions_raw,
    get_submission, add_form_impression,
)

from app.models_team import (
    create_team, get_team, get_user_teams, get_team_members,
    invite_to_team, accept_invite, remove_team_member, change_member_role,
    get_user_team_role, delete_team,
)

from app.models_webhook import (
    log_webhook_attempt, claim_webhook_retries, release_webhook_retry,
    get_pending_webhook_retries, get_permanently_failed_webhooks,
    mark_webhook_delivered, get_site_webhook, get_site_destinations,
    create_destination, update_destination, delete_destination,
    get_destination, update_destination_stats, get_destination_delivery_stats,
)

from app.models_usage import (
    get_monthly_submission_count, increment_submission_count,
    can_create_site, accept_site_creation, can_accept_submission,
    get_user_usage,
)

from app.models_template import (
    list_templates, get_template_by_slug, seed_templates,
    create_template, update_template, delete_template,
)

from app.models_initdb import init_db



def get_user_tier(user_id):
    """Return the tier key for a user, defaulting to 'free'.
    
    Returns:
        tuple: (tier_key, tier_config) or ("free", TIERS["free"])
    """
    conn = None
    try:
        conn = get_db()
        row = conn.execute(
            "SELECT tier FROM users WHERE id = ?", (user_id,)
        ).fetchone()
    finally:
        if conn:
            conn.close()
    
    if row and row["tier"] and row["tier"] in TIERS:
        return row["tier"], TIERS[row["tier"]]
    return "free", TIERS["free"]


def get_site_owner_tier(site_id):
    """Return the tier for a site's owner.
    
    Returns:
        tuple: (tier_key, tier_config) or ("free", TIERS["free"])
    """
    conn = None
    try:
        conn = get_db()
        row = conn.execute(
            "SELECT user_id FROM sites WHERE id = ?", (site_id,)
        ).fetchone()
    finally:
        if conn:
            conn.close()
    
    if not row:
        return "free", TIERS["free"]
    
    return get_user_tier(row["user_id"])


def create_or_update_session(site_id, session_id, visitor_id, event, client_ip=None,
                               user_agent=None, referrer=None, fields_viewed=None,
                               last_field_reached=None, step_reached=None):
    """Create or update a form session record.

    For 'session_start': inserts new record.
    For 'session_update': updates existing record by (site_id, session_id).
    For 'session_complete': updates existing record with completed_at.

    Returns dict with session record on success, None on failure.
    """
    import hashlib

    conn = None
    try:
        conn = get_db()
        ip_hash = None
        if client_ip:
            ip_hash = hashlib.sha256(client_ip.encode()).hexdigest()

        if event == "session_start":
            cursor = conn.execute(
                """INSERT INTO form_sessions
                   (site_id, session_id, visitor_id, ip_hash, user_agent, referrer,
                    event, fields_viewed, last_field_reached, step_reached)
                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
                (site_id, session_id, visitor_id, ip_hash, user_agent, referrer,
                 event, json.dumps(fields_viewed) if fields_viewed else None,
                 last_field_reached, step_reached),
            )
            conn.commit()
            return {"ok": True, "id": cursor.lastrowid}
        elif event in ("session_update", "session_complete"):
            updates = ["event = ?"]
            values = [event]

            if fields_viewed is not None:
                updates.append("fields_viewed = ?")
                values.append(json.dumps(fields_viewed))
            if last_field_reached is not None:
                updates.append("last_field_reached = ?")
                values.append(last_field_reached)
            if step_reached is not None:
                updates.append("step_reached = ?")
                values.append(step_reached)
            if event == "session_complete":
                updates.append("completed_at = CURRENT_TIMESTAMP")

            values.append(site_id)
            values.append(session_id)

            conn.execute(
                f"UPDATE form_sessions SET {', '.join(updates)} WHERE site_id = ? AND session_id = ?",
                values,
            )
            conn.commit()
            return {"ok": True}
        return {"ok": False, "error": f"Unknown event: {event}"}
    except Exception as e:
        print(f"[model] create_or_update_session error: {e}")
        return None
    finally:
        if conn:
            conn.close()


def create_variant(site_id, variant_key, name, field_config, success_message=None):
    """Create a new A/B test variant. Returns variant dict or None."""
    conn = None
    try:
        conn = get_db()
        existing = conn.execute(
            "SELECT id FROM form_variants WHERE site_id = ? AND variant_key = ? AND active = 1",
            (site_id, variant_key)
        ).fetchone()
        if existing:
            return None

        cursor = conn.execute(
            """INSERT INTO form_variants (site_id, variant_key, name, field_config, success_message, weight)
               VALUES (?, ?, ?, ?, ?, ?)""",
            (site_id, variant_key, name, json.dumps(field_config), success_message, 50)
        )
        conn.commit()
        return get_variant(cursor.lastrowid)
    finally:
        if conn:
            conn.close()


def get_variants(site_id, active_only=True):
    """Get all variants for a site. Returns list of variant dicts."""
    conn = None
    try:
        conn = get_db()
        query = "SELECT * FROM form_variants WHERE site_id = ?"
        params = [site_id]
        if active_only:
            query += " AND active = 1"
        query += " ORDER BY variant_key"

        rows = conn.execute(query, params).fetchall()
        result = []
        for row in rows:
            result.append({
                "id": row["id"],
                "site_id": row["site_id"],
                "variant_key": row["variant_key"],
                "name": row["name"],
                "field_config": json.loads(row["field_config"]) if row["field_config"] else [],
                "success_message": row["success_message"],
                "weight": row["weight"],
                "active": bool(row["active"]),
                "created_at": row["created_at"],
            })
        return result
    finally:
        if conn:
            conn.close()


def get_variant(variant_id):
    """Get a single variant by ID. Returns dict or None."""
    conn = None
    try:
        conn = get_db()
        row = conn.execute("SELECT * FROM form_variants WHERE id = ?", (variant_id,)).fetchone()
        if not row:
            return None
        return {
            "id": row["id"],
            "site_id": row["site_id"],
            "variant_key": row["variant_key"],
            "name": row["name"],
            "field_config": json.loads(row["field_config"]) if row["field_config"] else [],
            "success_message": row["success_message"],
            "weight": row["weight"],
            "active": bool(row["active"]),
            "created_at": row["created_at"],
        }
    finally:
        if conn:
            conn.close()


def assign_variant(site_id, visitor_id):
    """Determine which variant a visitor should see.
    
    Uses a deterministic hash based on visitor_id for consistent assignment.
    Returns variant dict or None if no active test exists.
    """
    conn = None
    try:
        conn = get_db()
        variants = conn.execute(
            "SELECT * FROM form_variants WHERE site_id = ? AND active = 1 ORDER BY variant_key",
            (site_id,)
        ).fetchall()

        if len(variants) < 2:
            return None

        # Deterministic assignment based on visitor_id hash
        import hashlib
        hash_val = int(hashlib.sha256(str(visitor_id).encode()).hexdigest(), 16)
        total_weight = sum(v["weight"] for v in variants)
        bucket = hash_val % total_weight

        cumulative = 0
        for variant in variants:
            cumulative += variant["weight"]
            if bucket < cumulative:
                return {
                    "variant_key": variant["variant_key"],
                    "variant_id": variant["id"],
                    "field_config": json.loads(variant["field_config"]) if variant["field_config"] else [],
                    "success_message": variant["success_message"],
                }

        # Fallback to last variant
        variant = variants[-1]
        return {
            "variant_key": variant["variant_key"],
            "variant_id": variant["id"],
            "field_config": json.loads(variant["field_config"]) if variant["field_config"] else [],
            "success_message": variant["success_message"],
        }
    finally:
        if conn:
            conn.close()


def update_variant(variant_id, **kwargs):
    """Update a variant. Allowed: name, field_config, success_message, weight, active."""
    allowed = {"name", "field_config", "success_message", "weight", "active"}
    updates = {k: v for k, v in kwargs.items() if k in allowed and v is not None}
    if not updates:
        return False

    conn = None
    try:
        conn = get_db()
        for key, val in updates.items():
            if key == "field_config" and isinstance(val, (list, dict)):
                val = json.dumps(val)
            updates[key] = val

        clauses = ", ".join(f"{k} = ?" for k in updates)
        values = list(updates.values()) + [variant_id]
        conn.execute(f"UPDATE form_variants SET {clauses} WHERE id = ?", values)
        conn.commit()
        return get_variant(variant_id)
    finally:
        if conn:
            conn.close()


def deactivate_variant(variant_id):
    """Deactivate a variant. Returns True if deactivated."""
    conn = None
    try:
        conn = get_db()
        cursor = conn.execute(
            "UPDATE form_variants SET active = 0 WHERE id = ?", (variant_id,)
        )
        conn.commit()
        return cursor.rowcount > 0
    finally:
        if conn:
            conn.close()


def init_db():
    # Create base tables first, then run migrations (idempotent on existing DBs)
    conn = None
    try:
        conn = get_db()
        conn.executescript("""
            CREATE TABLE IF NOT EXISTS users (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                email_hash TEXT UNIQUE NOT NULL,
                email_encrypted TEXT,
                password_hash TEXT NOT NULL,
                name_encrypted TEXT,
                display_name_encrypted TEXT,
                tier TEXT DEFAULT 'free',
                stripe_customer_id TEXT,
                stripe_subscription_id TEXT,
                email_verified INTEGER DEFAULT 0,
                email_verification_token TEXT,
                email_verification_sent_at TIMESTAMP,
                pending_email TEXT,
                password_reset_token TEXT,
                password_reset_sent_at TIMESTAMP,
                magic_login_token TEXT,
                magic_login_sent_at TIMESTAMP,
                magic_login_enabled INTEGER DEFAULT 1,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            );
    
            CREATE TABLE IF NOT EXISTS sites (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                token TEXT UNIQUE NOT NULL,
                name TEXT NOT NULL,
                owner_email TEXT NOT NULL,
                smtp_from TEXT,
                user_id INTEGER,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                FOREIGN KEY (user_id) REFERENCES users(id)
            );
    
            CREATE TABLE IF NOT EXISTS submissions (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                site_id INTEGER NOT NULL REFERENCES sites(id),
                customer_name TEXT,
                customer_phone TEXT,
                customer_email TEXT,
                customer_equipment TEXT,
                customer_message TEXT,
                submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            );
    
            CREATE INDEX IF NOT EXISTS idx_submissions_site_id ON submissions(site_id);
            CREATE INDEX IF NOT EXISTS idx_submissions_created ON submissions(submitted_at DESC);
    
            CREATE TABLE IF NOT EXISTS waitlist (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                email TEXT UNIQUE NOT NULL,
                source TEXT DEFAULT 'landing',
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            );
    
            CREATE INDEX IF NOT EXISTS idx_sites_user_id ON sites(user_id);
        """)
        conn.commit()
    finally:
        if conn:
            conn.close()

    # Now run migrations (all idempotent — safe on both fresh and existing DBs)
    migrate_add_field_config()
    migrate_add_submission_data()
    migrate_add_user_id_to_sites()
    migrate_add_usage()
    migrate_add_abuse_protection()
    migrate_add_rate_limit()
    migrate_add_analytics()
    migrate_add_email_verification()
    migrate_add_indexes()
    migrate_add_api_keys()
    migrate_normalize_field_configs()
    migrate_add_form_templates()
    migrate_add_teams()
    migrate_add_form_analytics()
    migrate_add_webhook_destinations()
    migrate_add_ai_builder()
    migrate_add_form_versions()
    migrate_add_form_sessions()
    migrate_add_form_variants()
    migrate_add_branding()
    migrate_add_webhook_retry_locking()
    migrate_add_document_templates()
    migrate_add_invite_expiration()
    migrate_add_user_id_to_doc_templates()
    migrate_add_user_id_to_invoice_schedules()
    migrate_add_email_campaigns()
    migrate_add_email_tracking()
    migrate_add_email_phase3()
    migrate_add_email_phase4()
    migrate_add_form_actions()
    migrate_add_user_referral_cols()
    migrate_add_agents()

    # Run last — encrypts existing plaintext PII data
    try:
        migrate_encrypt_existing_data()
    except (sqlite3.OperationalError, AttributeError):
        pass

    # Encrypt remaining plaintext PII (users, webhook destinations, invoice schedules, etc.)
    try:
        migrate_encrypt_user_pii()
    except (sqlite3.OperationalError, AttributeError):
        pass

    # Re-encrypt submissions from site-scoped keys to user-scoped keys
    try:
        migrate_rescope_to_user_keys()
    except (sqlite3.OperationalError, AttributeError):
        pass

    # Rotate encryption keys if V2 is configured
    try:
        migrate_rotate_encryption_keys()
    except (sqlite3.OperationalError, AttributeError):
        pass


    # ─── Form Actions model functions ────────────────────────────────────────────

def create_action(site_id, action_type, config, trigger_event='submission', enabled=True, execution_order=0):
    """Create a new form action for a site."""
    conn = None
    try:
        conn = get_db()
        cursor = conn.execute(
            """INSERT INTO form_actions (site_id, type, config, trigger_event, enabled, execution_order)
               VALUES (?, ?, ?, ?, ?, ?)""",
            (site_id, action_type, json.dumps(config) if isinstance(config, dict) else config,
             trigger_event, 1 if enabled else 0, execution_order)
        )
        conn.commit()
        return cursor.lastrowid
    finally:
        if conn:
            conn.close()


def get_action(action_id):
    """Get a single action by ID."""
    conn = None
    try:
        conn = get_db()
        row = conn.execute(
            "SELECT * FROM form_actions WHERE id = ?", (action_id,)
        ).fetchone()
        if row:
            row = dict(row)
            if row.get("config"):
                try:
                    row["config"] = json.loads(row["config"])
                except (json.JSONDecodeError, TypeError):
                    pass
            return row
        return None
    finally:
        if conn:
            conn.close()


def get_actions_by_site(site_id):
    """Get all actions for a site, ordered by execution_order."""
    conn = None
    try:
        conn = get_db()
        rows = conn.execute(
            "SELECT * FROM form_actions WHERE site_id = ? ORDER BY execution_order ASC",
            (site_id,)
        ).fetchall()
        actions = []
        for row in rows:
            row = dict(row)
            if row.get("config"):
                try:
                    row["config"] = json.loads(row["config"])
                except (json.JSONDecodeError, TypeError):
                    pass
            actions.append(row)
        return actions
    finally:
        if conn:
            conn.close()


def update_action(action_id, **kwargs):
    """Update an action by ID. Only updates provided fields."""
    allowed = ("type", "config", "trigger_event", "enabled", "execution_order")
    updates = {k: v for k, v in kwargs.items() if k in allowed}
    if not updates:
        return False
    set_clause = ", ".join(f"{k} = ?" for k in updates.keys())
    keys = list(updates.keys())
    values = list(updates.values())
    # Serialize config dict to JSON
    if "config" in updates and isinstance(updates["config"], dict):
        values[keys.index("config")] = json.dumps(updates["config"])
    values.append(action_id)
    conn = None
    try:
        conn = get_db()
        conn.execute(f"UPDATE form_actions SET {set_clause} WHERE id = ?", values)
        conn.commit()
        return True
    finally:
        if conn:
            conn.close()


def delete_action(action_id):
    """Delete an action by ID."""
    conn = None
    try:
        conn = get_db()
        conn.execute("DELETE FROM form_actions WHERE id = ?", (action_id,))
        conn.commit()
        return True
    finally:
        if conn:
            conn.close()


def toggle_action(action_id):
    """Toggle enabled status of an action. Returns new enabled state."""
    conn = None
    try:
        conn = get_db()
        row = conn.execute(
            "SELECT enabled FROM form_actions WHERE id = ?", (action_id,)
        ).fetchone()
        if not row:
            return None
        new_state = 0 if row["enabled"] else 1
        conn.execute(
            "UPDATE form_actions SET enabled = ? WHERE id = ?",
            (new_state, action_id)
        )
        conn.commit()
        return bool(new_state)
    finally:
        if conn:
            conn.close()


# ─── Team model functions ─────────────────────────────────────────────────────

def create_team(name, owner_id):
    """Create a new team. Owner becomes the team owner."""
    conn = None
    try:
        conn = get_db()
        conn.execute(
            "INSERT INTO teams (name, owner_id) VALUES (?, ?)",
            (name, owner_id),
        )
        conn.commit()
        team = conn.execute("SELECT * FROM teams WHERE id = ?", (conn.lastrowid,)).fetchone()
        # Add owner as first member with 'owner' role
        conn.execute(
            "INSERT INTO team_members (team_id, user_id, role, status, joined_at) VALUES (?, ?, 'owner', 'active', ?)",
            (conn.lastrowid, owner_id, datetime.now(timezone.utc).isoformat()),
        )
        conn.commit()
        return dict(team)
    finally:
        if conn:
            conn.close()


def get_team(team_id):
    """Get team by ID."""
    conn = None
    try:
        conn = get_db()
        team = conn.execute("SELECT * FROM teams WHERE id = ?", (team_id,)).fetchone()
    finally:
        if conn:
            conn.close()
    return dict(team) if team else None


def get_user_teams(user_id):
    """Get all teams a user is a member of."""
    conn = None
    try:
        conn = get_db()
        rows = conn.execute(
            """SELECT t.*, tm.role, tm.status, tm.joined_at
               FROM team_members tm
               JOIN teams t ON t.id = tm.team_id
               WHERE tm.user_id = ? AND tm.status = 'active'
               ORDER BY t.created_at DESC""",
            (user_id,),
        ).fetchall()
    finally:
        if conn:
            conn.close()
    return [dict(r) for r in rows]


def get_team_members(team_id):
    """Get all members of a team (active + pending invites)."""
    conn = None
    try:
        conn = get_db()
        rows = conn.execute(
            """SELECT tm.*, u.email_encrypted, u.name_encrypted
               FROM team_members tm
               LEFT JOIN users u ON u.id = tm.user_id
               WHERE tm.team_id = ?
               ORDER BY tm.role = 'owner' DESC, tm.created_at ASC""",
            (team_id,),
        ).fetchall()
    finally:
        if conn:
            conn.close()
    return [_decrypt_team_member(dict(r)) for r in rows]


def invite_to_team(team_id, inviter_user_id, email, role="member"):
    """Invite someone to a team via email. Returns invite token."""
    import secrets
    from datetime import timedelta
    conn = None
    try:
        conn = get_db()
        # Check if already invited or member — use hash for email lookup
        email_hash = hash_value(email)
        existing = conn.execute(
            "SELECT * FROM team_members WHERE team_id = ? AND (invited_email_hash = ? OR (user_id IS NOT NULL AND (SELECT email FROM users WHERE users.id = user_id) = ?))",
            (team_id, email_hash, email),
        ).fetchone()
        if existing:
            return {"error": "Already a member or invited"}

        # Check member limit for team tier
        team = conn.execute("SELECT * FROM teams WHERE id = ?", (team_id,)).fetchone()
        if not team:
            return {"error": "Team not found"}
        active_count = conn.execute(
            "SELECT count(*) as cnt FROM team_members WHERE team_id = ? AND status = 'active'",
            (team_id,),
        ).fetchone()["cnt"]
        if team["tier"] == "teams" and active_count >= 5:
            return {"error": "Team tier limited to 5 members. Upgrade to Teams Plus for unlimited."}

        token = secrets.token_urlsafe(32)
        expires_at = (datetime.now(timezone.utc) + timedelta(days=INVITE_TOKEN_EXPIRY_DAYS)).isoformat()
        # Insert with hash for lookup, encrypted email in the encrypted column
        conn.execute(
            "INSERT INTO team_members (team_id, role, invited_email_encrypted, invited_email_hash, status, invite_token, expires_at) VALUES (?, ?, ?, ?, 'pending', ?, ?)",
            (team_id, role, None, email_hash, token, expires_at),
        )
        member_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
        # Encrypt with the actual member ID
        enc_email = encrypt_entity("team_member", member_id, email)
        conn.execute(
            "UPDATE team_members SET invited_email_encrypted = ? WHERE id = ?",
            (enc_email, member_id),
        )
        conn.commit()

        # Send invite email
        try:
            from app.routes.email import NotificationService
            domain = os.environ.get("FRONTEND_DOMAIN", "agentforms.io")
            invite_url = f"https://{domain}/invite/{token}"
            html_body = f"""
            <h2>Join a team on AgentForms</h2>
            <p>You've been invited to join <strong>{team['name']}</strong> as a <strong>{role}</strong>.</p>
            <p><a href="{invite_url}">Click here to accept the invitation</a></p>
            <p>Or visit: {invite_url}</p>
            """
            plain_body = f"Join a team on AgentForms\n\nYou've been invited to join {team['name']} as a {role}.\n\nAccept at: {invite_url}"
            NotificationService.send_html(
                to=email,
                subject="You're invited to join a team on AgentForms",
                html_body=html_body,
                plain_body=plain_body,
            )
        except Exception as e:
            print(f"[team] Failed to send invite email: {e}")

        return {"token": token, "email": email}
    finally:
        if conn:
            conn.close()


def accept_invite(token, user_id):
    """Accept a team invitation. Links user to team."""
    conn = None
    try:
        conn = get_db()
        member = conn.execute(
            "SELECT * FROM team_members WHERE invite_token = ? AND status = 'pending'",
            (token,),
        ).fetchone()
        if not member:
            return {"error": "Invalid or expired invite"}

        # Check token expiration
        expires_at = member.get("expires_at")
        if expires_at:
            expiry = datetime.fromisoformat(expires_at)
            if datetime.now(timezone.utc) > expiry:
                return {"error": "This invite has expired"}

        conn.execute(
            "UPDATE team_members SET user_id = ?, status = 'active', joined_at = ?, invited_email = NULL, invited_email_encrypted = NULL, invited_email_hash = NULL, invite_token = NULL WHERE id = ?",
            (user_id, datetime.now(timezone.utc).isoformat(), member["id"]),
        )
        conn.commit()
        return {"team_id": member["team_id"], "role": member["role"]}
    finally:
        if conn:
            conn.close()


def remove_team_member(team_id, member_id, remover_user_id):
    """Remove a member from a team. Only owner/admin can do this."""
    conn = None
    try:
        conn = get_db()
        # Check remover has permission
        remover = conn.execute(
            "SELECT role FROM team_members WHERE team_id = ? AND user_id = ? AND status = 'active'",
            (team_id, remover_user_id),
        ).fetchone()
        if not remover or remover["role"] not in ("owner", "admin"):
            return {"error": "Permission denied"}

        # Prevent removing owner
        target = conn.execute(
            "SELECT role FROM team_members WHERE team_id = ? AND id = ?",
            (team_id, member_id),
        ).fetchone()
        if not target:
            return {"error": "Member not found"}
        if target["role"] == "owner":
            return {"error": "Cannot remove team owner"}

        conn.execute(
            "DELETE FROM team_members WHERE team_id = ? AND id = ?",
            (team_id, member_id),
        )
        conn.commit()
        return {"success": True}
    finally:
        if conn:
            conn.close()


def change_member_role(team_id, member_id, new_role, changer_user_id):
    """Change a member's role. Only owner/admin can do this."""
    conn = None
    try:
        conn = get_db()
        changer = conn.execute(
            "SELECT role FROM team_members WHERE team_id = ? AND user_id = ? AND status = 'active'",
            (team_id, changer_user_id),
        ).fetchone()
        if not changer or changer["role"] not in ("owner", "admin"):
            return {"error": "Permission denied"}

        if new_role not in ("owner", "admin", "member", "viewer"):
            return {"error": "Invalid role"}

        conn.execute(
            "UPDATE team_members SET role = ? WHERE team_id = ? AND id = ?",
            (new_role, team_id, member_id),
        )
        conn.commit()
        return {"success": True}
    finally:
        if conn:
            conn.close()


def get_user_team_role(user_id, team_id):
    """Get a user's role in a team. Returns None if not a member."""
    conn = None
    try:
        conn = get_db()
        row = conn.execute(
            "SELECT role FROM team_members WHERE team_id = ? AND user_id = ? AND status = 'active'",
            (team_id, user_id),
        ).fetchone()
    finally:
        if conn:
            conn.close()
    return row["role"] if row else None


def delete_team(team_id, user_id):
    """Delete a team. Only owner can do this."""
    conn = None
    try:
        conn = get_db()
        member = conn.execute(
            "SELECT role FROM team_members WHERE team_id = ? AND user_id = ? AND status = 'active'",
            (team_id, user_id),
        ).fetchone()
        if not member or member["role"] != "owner":
            return {"error": "Only team owner can delete"}

        # Unassign team sites
        conn.execute("UPDATE sites SET team_id = NULL WHERE team_id = ?", (team_id,))
        # Delete members
        conn.execute("DELETE FROM team_members WHERE team_id = ?", (team_id,))
        # Delete team
        conn.execute("DELETE FROM teams WHERE id = ?", (team_id,))
        conn.commit()
        return {"success": True}
    finally:
        if conn:
            conn.close()


def find_site_by_token(token):
    conn = None
    try:
        conn = get_db()
        site = conn.execute("SELECT * FROM sites WHERE token = ?", (token,)).fetchone()
    finally:
        if conn:
            conn.close()
    return dict(site) if site else None


def find_site_by_domain(domain):
    """Find a site by its custom_domain column."""
    conn = None
    try:
        conn = get_db()
        site = conn.execute("SELECT * FROM sites WHERE custom_domain = ?", (domain,)).fetchone()
    finally:
        if conn:
            conn.close()
    return dict(site) if site else None


def add_site(name, owner_email, smtp_from=None, user_id=None, field_config=None, webhook_url=None, team_id=None):
    import secrets
    import json
    token = f"fr-{secrets.token_hex(6)}"
    conn = None
    try:
        conn = get_db()
        fc_json = json.dumps(field_config) if field_config else None
        if team_id:
            conn.execute(
                "INSERT INTO sites (token, name, owner_email, smtp_from, user_id, field_config, webhook_url, team_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
                (token, name, owner_email, smtp_from, user_id, fc_json, webhook_url, team_id),
            )
        else:
            conn.execute(
                "INSERT INTO sites (token, name, owner_email, smtp_from, user_id, field_config, webhook_url) VALUES (?, ?, ?, ?, ?, ?, ?)",
                (token, name, owner_email, smtp_from, user_id, fc_json, webhook_url),
            )
        conn.commit()
        site = conn.execute("SELECT * FROM sites WHERE token = ? ", (token,)).fetchone()
    finally:
        if conn:
            conn.close()
    site_dict = dict(site)
    # Encrypt webhook_url now that we have site_id
    if site_dict.get("webhook_url"):
        encrypted = encrypt_value(site_dict["id"], site_dict["webhook_url"])
        if encrypted:
            conn = None
            try:
                conn = get_db()
                conn.execute("UPDATE sites SET webhook_url = ? WHERE id = ?", (encrypted, site_dict["id"]))
                conn.commit()
            finally:
                if conn:
                    conn.close()
            site_dict["webhook_url"] = encrypted
    return site_dict


def list_sites():
    conn = None
    try:
        conn = get_db()
        sites = conn.execute("SELECT * FROM sites ORDER BY created_at DESC").fetchall()
    finally:
        if conn:
            conn.close()
    return [dict(s) for s in sites]


def delete_site(site_id):
    """Delete a site and all associated data (submissions, analytics, webhooks)."""
    conn = None
    try:
        conn = get_db()
        conn.execute("DELETE FROM webhook_logs WHERE site_id = ?", (site_id,))
        conn.execute("DELETE FROM email_send_log WHERE submission_id IN (SELECT id FROM submissions WHERE site_id = ?)", (site_id,))
        conn.execute("DELETE FROM documents WHERE site_id = ?", (site_id,))
        conn.execute("DELETE FROM site_analytics WHERE site_id = ?", (site_id,))
        conn.execute("DELETE FROM submissions WHERE site_id = ?", (site_id,))
        conn.execute("DELETE FROM sites WHERE id = ?", (site_id,))
        conn.commit()
    finally:
        if conn:
            conn.close()


def add_submission(site_id, data, client_ip=None, user_agent=None, spam_flag=False, geo_city=None, geo_country=None, device_type=None, browser=None, os_name=None):
    """Add a submission without tier enforcement.

    Use `accept_submission` for the public API endpoint — it atomically
    checks the limit AND inserts the submission in one transaction.

    Submissions are encrypted with the site owner's password-derived key.
    """
    # Get site owner's password hash for user-scoped encryption
    password_hash = _get_site_owner_password_hash(site_id)

    conn = None
    try:
        conn = get_db()
        # Store dynamic fields in JSON (everything not captured by hardcoded columns)
        hardcoded_keys = {"name", "phone", "email", "equipment", "message", "token", "_fr_hp", "recaptcha_token"}
        dynamic_data = {k: v for k, v in data.items() if k not in hardcoded_keys}
        data_json = json.dumps(dynamic_data) if dynamic_data else None
    
        # Encrypt PII fields (user-scoped if we have password_hash)
        cust_name = _encrypt_submission_field(site_id, data.get("name", ""), password_hash)
        cust_phone = _encrypt_submission_field(site_id, data.get("phone", ""), password_hash)
        cust_email = _encrypt_submission_field(site_id, data.get("email", ""), password_hash)
        cust_equip = _encrypt_submission_field(site_id, data.get("equipment", ""), password_hash)
        cust_msg = _encrypt_submission_field(site_id, data.get("message", ""), password_hash)
        enc_data_json = encrypt_user_submission(password_hash, data_json) if password_hash and data_json else (encrypt_value(site_id, data_json) if data_json else None)
    
        cursor = conn.execute(
            """INSERT INTO submissions
               (site_id, customer_name, customer_phone, customer_email,
                customer_equipment, customer_message, data, client_ip, user_agent, spam_flag,
                geo_city, geo_country, device_type, browser, os)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
            (
                site_id,
                cust_name,
                cust_phone,
                cust_email,
                cust_equip,
                cust_msg,
                enc_data_json,
                client_ip,
                user_agent,
                1 if spam_flag else 0,
                geo_city,
                geo_country,
                device_type,
                browser,
                os_name,
            ),
        )
        conn.commit()
        submission_id = cursor.lastrowid
        submission = conn.execute(
            "SELECT * FROM submissions WHERE id = ?", (submission_id,)
        ).fetchone()
    finally:
        if conn:
            conn.close()
    if submission:
        return _decrypt_submission_fields(site_id, dict(submission), password_hash)
    return None


def add_form_impression(site_id, client_ip=None, user_agent=None):
    """Record a form impression (view) with geo/device enrichment.

    Args:
        site_id: The site/form ID.
        client_ip: Client IP address (hashed for privacy).
        user_agent: Client user-agent string.

    Returns:
        dict with impression record or None on failure.
    """
    import hashlib

    conn = None
    try:
        conn = get_db()
        # Hash IP for privacy
        ip_hash = None
        if client_ip:
            ip_hash = hashlib.sha256(client_ip.encode()).hexdigest()

        # Enrich: GeoIP + User-Agent
        geo_city, geo_country = resolve_geo(client_ip)
        ua_info = parse_user_agent(user_agent)

        cursor = conn.execute(
            """INSERT INTO form_impressions
               (site_id, ip_hash, user_agent, geo_city, geo_country, device_type, browser, os)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
            (
                site_id,
                ip_hash,
                user_agent,
                geo_city,
                geo_country,
                ua_info["device_type"],
                ua_info["browser"],
                ua_info["os"],
            ),
        )
        conn.commit()
        impression_id = cursor.lastrowid
        impression = conn.execute(
            "SELECT * FROM form_impressions WHERE id = ?", (impression_id,)
        ).fetchone()
        return dict(impression) if impression else None
    except Exception as e:
        print(f"[model] add_form_impression error: {e}")
        return None
    finally:
        if conn:
            conn.close()


def accept_submission(site_id, user_id, user_tier, data, site=None, client_ip=None, user_agent=None):
    """Atomically check submission limit, insert submission, and increment counter.

    All three operations happen inside a single BEGIN IMMEDIATE transaction.
    If any step fails, nothing is committed — the counter stays accurate.

    Args:
        site_id: The site to submit to.
        user_id: Owner of the site (for tier enforcement).
        user_tier: User's current tier string.
        data: Submission data dict.
        site: Site dict (optional, for honeypot/spam check config).
        client_ip: Client IP address.
        user_agent: Client user-agent string.

    Returns:
        (success: bool, submission: dict or None, current: int, max_count: int, reason: str or None)
    """
    tier_config = TIERS.get(user_tier, TIERS["free"])
    max_subs = tier_config["max_submissions"]
    month = _current_month()

    conn = None
    try:
        conn = get_db()
        conn.execute("BEGIN IMMEDIATE")

        # 1. Check limit under write lock
        row = conn.execute(
            "SELECT submission_count FROM monthly_usage WHERE user_id = ? AND month = ?",
            (user_id, month),
        ).fetchone()
        current = row["submission_count"] if row else 0

        if current >= max_subs:
            conn.execute("ROLLBACK")
            return False, None, current, max_subs, "limit"

        # 2. Insert the submission
        hardcoded_keys = {"name", "phone", "email", "equipment", "message", "token", "_fr_hp", "recaptcha_token"}
        dynamic_data = {k: v for k, v in data.items() if k not in hardcoded_keys}
        data_json = json.dumps(dynamic_data) if dynamic_data else None

        # Get owner's password hash for user-scoped encryption
        owner_row = conn.execute("SELECT password_hash FROM users WHERE id = ?", (user_id,)).fetchone()
        owner_pw_hash = owner_row["password_hash"] if owner_row else None

        # Encrypt PII fields (user-scoped if we have password_hash)
        cust_name = _encrypt_submission_field(site_id, data.get("name", ""), owner_pw_hash)
        cust_phone = _encrypt_submission_field(site_id, data.get("phone", ""), owner_pw_hash)
        cust_email = _encrypt_submission_field(site_id, data.get("email", ""), owner_pw_hash)
        cust_equip = _encrypt_submission_field(site_id, data.get("equipment", ""), owner_pw_hash)
        cust_msg = _encrypt_submission_field(site_id, data.get("message", ""), owner_pw_hash)
        enc_data_json = encrypt_user_submission(owner_pw_hash, data_json) if owner_pw_hash and data_json else (encrypt_value(site_id, data_json) if data_json else None)

        # Determine spam flag
        is_spam = 0
        spam_reason = None
        if site.get("spam_filter_enabled"):
            spam_reason = _check_spam_content(data)
            if spam_reason:
                is_spam = 1

        # Enrich: GeoIP + User-Agent parsing
        geo_city, geo_country = resolve_geo(client_ip)
        ua_info = parse_user_agent(user_agent)

        cursor = conn.execute(
            """INSERT INTO submissions
               (site_id, customer_name, customer_phone, customer_email,
                customer_equipment, customer_message, data, client_ip, user_agent, spam_flag,
                geo_city, geo_country, device_type, browser, os)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
            (
                site_id,
                cust_name,
                cust_phone,
                cust_email,
                cust_equip,
                cust_msg,
                enc_data_json,
                client_ip,
                user_agent,
                is_spam,
                geo_city,
                geo_country,
                ua_info["device_type"],
                ua_info["browser"],
                ua_info["os"],
            ),
        )
        submission_id = cursor.lastrowid

        # 3. Increment the counter
        conn.execute(
            """INSERT INTO monthly_usage (user_id, month, submission_count)
               VALUES (?, ?, 1)
               ON CONFLICT(user_id, month) DO UPDATE SET submission_count = submission_count + 1""",
            (user_id, month),
        )

        # 4. Update site analytics (per-day)
        day = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        analytics_update = "submission_count = submission_count + 1"
        if is_spam:
            analytics_update += ", spam_count = spam_count + 1"
        conn.execute(
            f"""INSERT INTO site_analytics (site_id, day, submission_count, spam_count)
               VALUES (?, ?, 1, {1 if is_spam else 0})
               ON CONFLICT(site_id, day) DO UPDATE SET {analytics_update}""",
            (site_id, day),
        )

        conn.commit()

        # 5. Return the inserted submission
        submission = conn.execute(
            "SELECT * FROM submissions WHERE id = ?", (submission_id,)
        ).fetchone()
        decrypted = _decrypt_submission_fields(site_id, dict(submission), owner_pw_hash) if submission else None
        return True, decrypted, current + 1, max_subs, spam_reason

    except Exception:
        conn.execute("ROLLBACK")
        raise
    finally:
        if conn:
            conn.close()


def list_submissions(limit=50, site_filter=None, password_hash=None):
    """List submissions, optionally decrypted with user-scoped key.

    If password_hash is provided, uses user-scoped decryption.
    Otherwise returns raw encrypted data (admin view).
    """
    conn = None
    try:
        conn = get_db()
        if site_filter:
            rows = conn.execute(
                """SELECT s.*, si.name as site_name 
                   FROM submissions s JOIN sites si ON s.site_id = si.id 
                   WHERE si.id = ? ORDER BY s.submitted_at DESC LIMIT ?""",
                (site_filter, limit),
            ).fetchall()
        else:
            rows = conn.execute(
                """SELECT s.*, si.name as site_name 
                   FROM submissions s JOIN sites si ON s.site_id = si.id 
                   ORDER BY s.submitted_at DESC LIMIT ?""",
                (limit,),
            ).fetchall()
    finally:
        if conn:
            conn.close()
    results = []
    for r in rows:
        d = dict(r)
        results.append(_decrypt_submission_fields(d["site_id"], d, password_hash))
    return results


def list_submissions_raw(limit=50, site_filter=None):
    """List submissions without any decryption (admin view)."""
    return list_submissions(limit=limit, site_filter=site_filter, password_hash=None)


def get_submission(sub_id, password_hash=None):
    """Get a single submission, optionally decrypted with user-scoped key.

    If password_hash is provided, uses user-scoped decryption.
    Otherwise returns raw encrypted data (admin view).
    """
    conn = None
    try:
        conn = get_db()
        row = conn.execute(
            """SELECT s.*, si.name as site_name, si.owner_email 
               FROM submissions s JOIN sites si ON s.site_id = si.id 
               WHERE s.id = ?""",
            (sub_id,),
        ).fetchone()
    finally:
        if conn:
            conn.close()
    if row:
        d = dict(row)
        return _decrypt_submission_fields(d["site_id"], d, password_hash)
    return None


def add_waitlist(email, source="landing"):
    """Add email to waitlist. Returns True if added, False if duplicate."""
    conn = None
    try:
        conn = get_db()
        conn.execute(
            "INSERT INTO waitlist (email, source) VALUES (?, ?)",
            (email.lower().strip(), source),
        )
        conn.commit()
        added = True
    except sqlite3.IntegrityError:
        added = False
    finally:
        if conn:
            conn.close()
    return added


def waitlist_count():
    """Return total waitlist count."""
    conn = None
    try:
        conn = get_db()
        row = conn.execute("SELECT COUNT(*) as cnt FROM waitlist").fetchone()
    finally:
        if conn:
            conn.close()
    return row["cnt"] if row else 0


# ─── User Auth ───────────────────────────────────────────────────────────────


def register_user(email, password, name=None, magic_login_enabled=True):
    """Register a new user. Returns user dict or raises on duplicate email."""
    password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
    email = email.lower().strip()
    email_hash = hash_value(email)
    conn = None
    try:
        conn = get_db()
        cursor = conn.execute(
            "INSERT INTO users (email_hash, email_encrypted, password_hash, name_encrypted, tier, magic_login_enabled) "
            "VALUES (?, ?, ?, ?, 'free', ?)",
            (email_hash, None, password_hash, None, magic_login_enabled),
        )
        conn.commit()
        user_id = cursor.lastrowid
        # Encrypt and store
        if email:
            enc = encrypt_user_value(user_id, email)
            if enc:
                conn.execute("UPDATE users SET email_encrypted = ? WHERE id = ?", (enc, user_id))
        if name:
            enc = encrypt_user_value(user_id, name)
            if enc:
                conn.execute("UPDATE users SET name_encrypted = ? WHERE id = ?", (enc, user_id))
        conn.commit()
        user = conn.execute(
            "SELECT id, email_encrypted, name_encrypted, password_hash, tier, magic_login_enabled, created_at "
            "FROM users WHERE id = ?", (user_id,),
        ).fetchone()
        return _decrypt_user_row(dict(user))
    except sqlite3.IntegrityError:
        raise ValueError("Email already registered")
    finally:
        if conn:
            conn.close()


# ─── User decryption helpers ──────────────────────────────────────────────────

def authenticate_user(email, password):
    """Verify credentials. Returns user dict or None."""
    email = email.lower().strip()
    email_hash = hash_value(email)
    conn = None
    try:
        conn = get_db()
        row = conn.execute("SELECT * FROM users WHERE email_hash = ?", (email_hash,)).fetchone()
    finally:
        if conn:
            conn.close()
    if row is None:
        return None
    row = dict(row)
    if not bcrypt.checkpw(password.encode("utf-8"), row["password_hash"].encode("utf-8")):
        return None
    # Decrypt PII
    email_val = try_decrypt_user_value(row["id"], row.get("email_encrypted"))
    row["email"] = email_val
    return row


def get_user(user_id):
    """Get user by ID (without password hash)."""
    conn = None
    try:
        conn = get_db()
        row = conn.execute(
            "SELECT id, email_encrypted, email_hash, name_encrypted, display_name_encrypted, "
            "tier, stripe_customer_id, stripe_subscription_id, "
            "magic_login_enabled, email_verified, created_at FROM users WHERE id = ?",
            (user_id,),
        ).fetchone()
    finally:
        if conn:
            conn.close()
    if not row:
        return None
    return _decrypt_user_row(dict(row))


def find_user_by_email(email):
    """Find user by email (with password hash for auth flows)."""
    email = email.lower().strip()
    email_hash = hash_value(email)
    conn = None
    try:
        conn = get_db()
        row = conn.execute("SELECT * FROM users WHERE email_hash = ?", (email_hash,)).fetchone()
    finally:
        if conn:
            conn.close()
    if not row:
        return None
    row = dict(row)
    email_val = try_decrypt_user_value(row["id"], row.get("email_encrypted"))
    row["email"] = email_val
    return row


def update_user_tier(user_id, tier, stripe_subscription_id=None):
    """Update user tier and stripe subscription reference."""
    conn = None
    try:
        conn = get_db()
        if stripe_subscription_id:
            conn.execute(
                "UPDATE users SET tier = ?, stripe_subscription_id = ? WHERE id = ?",
                (tier, stripe_subscription_id, user_id),
            )
        else:
            conn.execute("UPDATE users SET tier = ? WHERE id = ?", (tier, user_id))
        conn.commit()
    finally:
        if conn:
            conn.close()


def update_user_magic_login_pref(user_id, enabled):
    """Enable or disable magic login for a user."""
    conn = None
    try:
        conn = get_db()
        conn.execute(
            "UPDATE users SET magic_login_enabled = ? WHERE id = ?",
            (1 if enabled else 0, user_id),
        )
        conn.commit()
    finally:
        if conn:
            conn.close()


def update_user_profile(user_id, name=None, display_name=None):
    """Update user profile fields. Returns dict with updated values."""
    conn = None
    try:
        conn = get_db()
        updates = []
        params = []
    
        if name is not None:
            updates.append("name = ?")
            params.append(name)
            enc = encrypt_user_value(user_id, name)
            if enc:
                updates.append("name_encrypted = ?")
                params.append(enc)
        if display_name is not None:
            updates.append("display_name = ?")
            params.append(display_name)
            enc = encrypt_user_value(user_id, display_name)
            if enc:
                updates.append("display_name_encrypted = ?")
                params.append(enc)
    
        if not updates:
            conn.close()
            return {"success": False, "error": "No fields to update"}
    
        params.append(user_id)
        conn.execute(f"UPDATE users SET {', '.join(updates)} WHERE id = ?", params)
        conn.commit()
    finally:
        if conn:
            conn.close()
    return {"success": True}


def change_user_password(user_id, current_password, new_password):
    """Change user password after verifying current one. Returns success dict."""
    if len(new_password) < 8:
        return {"success": False, "error": "Password must be at least 8 characters"}

    conn = None
    try:
        conn = get_db()
        row = conn.execute("SELECT password_hash FROM users WHERE id = ?", (user_id,)).fetchone()
        if not row:
            conn.close()
            return {"success": False, "error": "User not found"}
    
        # Verify current password
        if not bcrypt.checkpw(current_password.encode("utf-8"), row["password_hash"].encode("utf-8")):
            conn.close()
            return {"success": False, "error": "Current password is incorrect"}
    
        # Set new password
        new_hash = bcrypt.hashpw(new_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
        conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (new_hash, user_id))
        conn.commit()
    finally:
        if conn:
            conn.close()
    return {"success": True}


def change_user_email(user_id, current_password, new_email, new_password_hash=None):
    """Change user email. Requires current password verification.

    Also updates the email_verification_token so the new email needs verification.
    If new_password_hash is provided, sets it (for unauthenticated password resets).
    """
    import secrets

    new_email = new_email.lower().strip()
    conn = None
    try:
        conn = get_db()
    
        # Verify current password
        row = conn.execute("SELECT password_hash FROM users WHERE id = ?", (user_id,)).fetchone()
        if not row:
            conn.close()
            return {"success": False, "error": "User not found"}
    
        if not bcrypt.checkpw(current_password.encode("utf-8"), row["password_hash"].encode("utf-8")):
            conn.close()
            return {"success": False, "error": "Current password is incorrect"}
    
        # Check email not taken (use email_hash)
        existing_hash = hash_value(new_email)
        existing = conn.execute("SELECT id FROM users WHERE email_hash = ? AND id != ?", (existing_hash, user_id)).fetchone()
        if existing:
            conn.close()
            return {"success": False, "error": "Email already in use by another account"}
    
        # Generate verification token for new email
        token = secrets.token_urlsafe(32)
        email_enc = encrypt_user_value(user_id, new_email)
        conn.execute(
            """UPDATE users SET email_hash = ?, email_encrypted = ?, email_verified = 0, email_verification_token = ?
               WHERE id = ?""",
            (existing_hash, email_enc, token, user_id),
        )
        conn.commit()
    finally:
        if conn:
            conn.close()
    return {"success": True, "verification_token": token}


def is_magic_login_enabled(user_id):
    """Check if magic login is enabled for a user."""
    conn = None
    try:
        conn = get_db()
        row = conn.execute(
            "SELECT magic_login_enabled FROM users WHERE id = ?",
            (user_id,),
        ).fetchone()
    finally:
        if conn:
            conn.close()
    if row is None:
        return True  # default enabled
    return bool(row["magic_login_enabled"])


def update_stripe_customer(user_id, stripe_customer_id):
    """Store Stripe customer ID for a user."""
    conn = None
    try:
        conn = get_db()
        conn.execute(
            "UPDATE users SET stripe_customer_id = ? WHERE id = ?",
            (stripe_customer_id, user_id),
        )
        conn.commit()
    finally:
        if conn:
            conn.close()


def get_user_site_count(user_id):
    """Count sites owned by a user."""
    conn = None
    try:
        conn = get_db()
        row = conn.execute("SELECT COUNT(*) as cnt FROM sites WHERE user_id = ?", (user_id,)).fetchone()
    finally:
        if conn:
            conn.close()
    return row["cnt"] if row else 0


def get_user_chain_count(user_id):
    """Count action chains across all sites owned by a user."""
    conn = None
    try:
        conn = get_db()
        row = conn.execute(
            """SELECT COUNT(*) as cnt FROM form_actions fa
               INNER JOIN sites s ON fa.site_id = s.id
               WHERE s.user_id = ? AND fa.type = 'chain'""",
            (user_id,),
        ).fetchone()
    finally:
        if conn:
            conn.close()
    return row["cnt"] if row else 0


def check_chain_quota(user_id):
    """Check if a user can create another chain based on tier limits.

    Returns:
        (allowed: bool, current_count: int, max_chains: int)
        max_chains is -1 for unlimited, 0 for blocked.
    """
    conn = None
    try:
        conn = get_db()
        user_row = conn.execute("SELECT tier FROM users WHERE id = ?", (user_id,)).fetchone()
        if not user_row:
            return False, 0, 0
        user_tier = dict(user_row).get("tier", "free")
        tier_config = TIERS.get(user_tier, TIERS["free"])
        max_chains = tier_config.get("max_chains", 0)
        current_count = get_user_chain_count(user_id)

        if max_chains == 0:
            return False, current_count, 0
        if max_chains == -1:
            return True, current_count, -1
        return current_count < max_chains, current_count, max_chains
    finally:
        if conn:
            conn.close()


def get_user_document_count(user_id):
    """Count total documents for a user across all statuses."""
    counts = count_user_documents(user_id)
    return sum(counts.values())


def check_document_quota(user_id):
    """Check if a user can create another document based on tier limits.

    Returns:
        (allowed: bool, current_count: int, max_documents: int)
        max_documents is -1 for unlimited, 0 for blocked.
    """
    conn = None
    try:
        conn = get_db()
        user_row = conn.execute("SELECT tier FROM users WHERE id = ?", (user_id,)).fetchone()
        if not user_row:
            return False, 0, 0
        user_tier = dict(user_row).get("tier", "free")
        tier_config = TIERS.get(user_tier, TIERS["free"])
        max_documents = tier_config.get("max_documents", 0)
        current_count = get_user_document_count(user_id)

        if max_documents == 0:
            return False, current_count, 0
        if max_documents == -1:
            return True, current_count, -1
        return current_count < max_documents, current_count, max_documents
    finally:
        if conn:
            conn.close()


def get_site(site_id):
    """Get a site by ID. Returns dict or None."""
    conn = None
    try:
        conn = get_db()
        site = conn.execute("SELECT * FROM sites WHERE id = ?", (site_id,)).fetchone()
    finally:
        if conn:
            conn.close()
    if not site:
        return None
    site_dict = dict(site)
    site_dict["webhook_url"] = try_decrypt(site_id, site_dict.get("webhook_url"))
    return site_dict


def get_user_sites(user_id):
    """Get all sites owned by a user directly OR accessible through team membership."""
    conn = None
    try:
        conn = get_db()
        # Personal sites (user_id) + team sites (team_id where user is active member)
        rows = conn.execute("""
            SELECT s.*,
                   CASE WHEN s.team_id IS NULL THEN 'personal'
                        ELSE 'team' END AS site_type,
                   tm.role AS team_role,
                   t.name AS team_name
            FROM sites s
            LEFT JOIN team_members tm ON s.team_id = tm.team_id AND tm.user_id = ? AND tm.status = 'active'
            LEFT JOIN teams t ON s.team_id = t.id
            WHERE s.user_id = ? OR (s.team_id IS NOT NULL AND tm.status = 'active')
            ORDER BY s.created_at DESC
        """, (user_id, user_id)).fetchall()
    finally:
        if conn:
            conn.close()
    sites = [dict(r) for r in rows]
    # Decrypt webhook_url for each site
    for s in sites:
        sid = s["id"]
        s["webhook_url"] = try_decrypt(sid, s.get("webhook_url"))
    return sites


# ─── Form versioning ────────────────────────────────────────────────────────────────

def get_version_limit(tier_key):
    """Return max version count for a tier."""
    limits = {
        "free": 10,
        "starter": 50,
        "pro": None,      # unlimited
        "teams": None,    # unlimited
        "teams_plus": None,
    }
    return limits.get(tier_key, 10)


def save_site_version(site_id, changed_by, change_reason=None):
    """Save current site field_config as a new version snapshot.

    Returns the new version number, or None if no field_config to save.
    """
    conn = None
    try:
        conn = get_db()
        site = conn.execute("SELECT field_config, metadata FROM sites WHERE id = ?", (site_id,)).fetchone()
        if not site or not site["field_config"]:
            return None

        # Get current max version for this site
        max_ver = conn.execute(
            "SELECT COALESCE(MAX(version), 0) as max_v FROM form_versions WHERE site_id = ?",
            (site_id,)
        ).fetchone()["max_v"]
        new_version = max_ver + 1

        conn.execute(
            "INSERT INTO form_versions (site_id, version, field_config, metadata, changed_by, change_reason) VALUES (?, ?, ?, ?, ?, ?)",
            (site_id, new_version, site["field_config"], site.get("metadata"), changed_by, change_reason)
        )
        conn.commit()
        return new_version
    finally:
        if conn:
            conn.close()


def get_site_versions(site_id, limit=None):
    """Get version history for a site, newest first.

    Args:
        site_id: Site ID
        limit: Max versions to return (None = all)

    Returns:
        List of version dicts
    """
    conn = None
    try:
        conn = get_db()
        if limit:
            rows = conn.execute(
                "SELECT * FROM form_versions WHERE site_id = ? ORDER BY version DESC LIMIT ?",
                (site_id, limit)
            ).fetchall()
        else:
            rows = conn.execute(
                "SELECT * FROM form_versions WHERE site_id = ? ORDER BY version DESC",
                (site_id,)
            ).fetchall()

        versions = []
        for row in rows:
            v = dict(row)
            try:
                v["field_config_parsed"] = json.loads(v.get("field_config", "[]"))
            except (json.JSONDecodeError, TypeError):
                v["field_config_parsed"] = []
            try:
                v["metadata_parsed"] = json.loads(v.get("metadata", "{}"))
            except (json.JSONDecodeError, TypeError):
                v["metadata_parsed"] = {}
            versions.append(v)
        return versions
    finally:
        if conn:
            conn.close()


def get_site_version(site_id, version_id):
    """Get a specific version by ID.

    Returns version dict or None.
    """
    conn = None
    try:
        conn = get_db()
        row = conn.execute(
            "SELECT * FROM form_versions WHERE id = ? AND site_id = ?",
            (version_id, site_id)
        ).fetchone()
        if not row:
            return None
        v = dict(row)
        try:
            v["field_config_parsed"] = json.loads(v.get("field_config", "[]"))
        except (json.JSONDecodeError, TypeError):
            v["field_config_parsed"] = []
        try:
            v["metadata_parsed"] = json.loads(v.get("metadata", "{}"))
        except (json.JSONDecodeError, TypeError):
            v["metadata_parsed"] = {}
        return v
    finally:
        if conn:
            conn.close()


def rollback_site_version(site_id, version_id, changed_by, change_reason=None):
    """Rollback a site to a previous version.

    Saves the current config as a new version first, then applies the old config.

    Returns the new version number after rollback, or None on failure.
    """
    conn = None
    try:
        conn = get_db()
        # Get target version
        target = conn.execute(
            "SELECT * FROM form_versions WHERE id = ? AND site_id = ?",
            (version_id, site_id)
        ).fetchone()
        if not target:
            return None

        # Get current max version
        max_ver = conn.execute(
            "SELECT COALESCE(MAX(version), 0) as max_v FROM form_versions WHERE site_id = ?",
            (site_id,)
        ).fetchone()["max_v"]

        # Save current state as new version before overwriting
        current_site = conn.execute(
            "SELECT field_config, metadata FROM sites WHERE id = ?",
            (site_id,)
        ).fetchone()
        if current_site and current_site["field_config"]:
            conn.execute(
                "INSERT INTO form_versions (site_id, version, field_config, metadata, changed_by, change_reason) VALUES (?, ?, ?, ?, ?, ?)",
                (
                    site_id, max_ver + 1,
                    current_site["field_config"], current_site.get("metadata"),
                    changed_by, "Pre-rollback snapshot"
                )
            )

        # Apply rollback
        conn.execute(
            "UPDATE sites SET field_config = ?, metadata = ? WHERE id = ?",
            (target["field_config"], target.get("metadata"), site_id)
        )
        conn.commit()
        return max_ver + 1
    finally:
        if conn:
            conn.close()


def prune_old_versions(site_id, max_versions):
    """Prune old versions beyond the tier limit.

    Returns count of pruned versions.
    """
    if max_versions is None:
        return 0
    conn = None
    try:
        conn = get_db()
        # Get versions to prune (oldest first, beyond limit)
        versions_to_keep = conn.execute(
            "SELECT id FROM form_versions WHERE site_id = ? ORDER BY version DESC LIMIT ?",
            (site_id, max_versions)
        ).fetchall()
        keep_ids = [v["id"] for v in versions_to_keep]

        if len(keep_ids) <= max_versions:
            return 0

        # Actually, we want to delete everything beyond the top N
        # The query above already limits to what we keep
        if len(keep_ids) > 0:
            placeholders = ",".join(["?"] * len(keep_ids))
            conn.execute(
                f"DELETE FROM form_versions WHERE site_id = ? AND id NOT IN ({placeholders})",
                [site_id] + keep_ids
            )
        conn.commit()

        # Count pruned
        remaining = conn.execute(
            "SELECT COUNT(*) as cnt FROM form_versions WHERE site_id = ?",
            (site_id,)
        ).fetchone()["cnt"]
        return max(0, len(keep_ids) - remaining)
    finally:
        if conn:
            conn.close()


def update_site_fields(site_id, field_config, webhook_url=None, webhook_enabled=None, webhook_events=None,
                       honeypot_enabled=None, spam_filter_enabled=None,
                       rate_limit_enabled=None, rate_limit_burst=None, rate_limit_refill=None,
                       changed_by=None, change_reason=None):
    """Update field config, webhook settings, abuse protection, and rate limit config for a site.

    If field_config changes, saves the current state as a version snapshot first.
    """
    import json
    conn = None
    try:
        conn = get_db()
        updates = []
        params = []
    
        # Save current config as a version snapshot if field_config is changing
        if field_config is not None and changed_by:
            current = conn.execute(
                "SELECT field_config, metadata FROM sites WHERE id = ?", (site_id,)
            ).fetchone()
            if current and current["field_config"]:
                max_ver = conn.execute(
                    "SELECT COALESCE(MAX(version), 0) as max_v FROM form_versions WHERE site_id = ?",
                    (site_id,)
                ).fetchone()["max_v"]
                conn.execute(
                    "INSERT INTO form_versions (site_id, version, field_config, metadata, changed_by, change_reason) VALUES (?, ?, ?, ?, ?, ?)",
                    (site_id, max_ver + 1, current["field_config"], current["metadata"], changed_by, change_reason or "Field config updated")
                )
                conn.commit()
    
        if field_config is not None:
            updates.append("field_config = ?")
            params.append(json.dumps(field_config) if isinstance(field_config, list) else field_config)
        if webhook_url is not None:
            updates.append("webhook_url = ?")
            params.append(encrypt_value(site_id, webhook_url) if webhook_url else None)
        if webhook_enabled is not None:
            updates.append("webhook_enabled = ?")
            params.append(int(webhook_enabled))
        if webhook_events is not None:
            updates.append("webhook_events = ?")
            params.append(webhook_events)
        if honeypot_enabled is not None:
            updates.append("honeypot_enabled = ?")
            params.append(int(honeypot_enabled))
        if spam_filter_enabled is not None:
            updates.append("spam_filter_enabled = ?")
            params.append(int(spam_filter_enabled))
        if rate_limit_enabled is not None:
            updates.append("rate_limit_enabled = ?")
            params.append(1 if rate_limit_enabled else 0)
        if rate_limit_burst is not None:
            updates.append("rate_limit_burst = ?")
            params.append(max(1, int(rate_limit_burst)))
        if rate_limit_refill is not None:
            updates.append("rate_limit_refill = ?")
            params.append(max(0.01, float(rate_limit_refill)))
    
        if updates:
            params.append(site_id)
            conn.execute(f"UPDATE sites SET {', '.join(updates)} WHERE id = ?", params)
            conn.commit()
    
        site = conn.execute("SELECT * FROM sites WHERE id = ?", (site_id,)).fetchone()
    finally:
        if conn:
            conn.close()
    return dict(site) if site else None


def parse_site_fields(site):
    """Parse field_config JSON from site dict. Returns list of field definitions or None."""
    if not site or not site.get("field_config"):
        return None
    import json
    try:
        return json.loads(site["field_config"])
    except (json.JSONDecodeError, TypeError):
        return None


# ─── Tier enforcement & usage tracking ────────────────────────────────────────


def seed_monthly_usage():
    """Ensure all users have a monthly_usage row for the current month.

    Called on app startup and periodically. This is the automatic billing
    cycle reset — when the month changes, all users get a fresh row with
    submission_count=0.
    """
    from datetime import timedelta
    conn = None
    try:
        conn = get_db()
        month = _current_month()
        user_ids = [r["id"] for r in conn.execute("SELECT id FROM users").fetchall()]
        for uid in user_ids:
            conn.execute(
                """INSERT INTO monthly_usage (user_id, month, submission_count)
                   VALUES (?, ?, 0)
                   ON CONFLICT(user_id, month) DO NOTHING""",
                (uid, month),
            )
        conn.commit()
    finally:
        if conn:
            conn.close()


def log_webhook_attempt(site_id, submission_id, webhook_url, success, error=None, destination_id=None):
    """Log a webhook delivery attempt. Upserts if a pending/failed entry exists.

    Returns the log entry ID.

    Args:
        site_id: Site ID.
        submission_id: Submission ID.
        webhook_url: The URL that was called (or will be called on retry).
        success: Whether the delivery succeeded.
        error: Error message if delivery failed.
        destination_id: Optional FK to webhook_destinations. NULL for legacy webhooks.
    """
    from datetime import timedelta
    # Redact PII from error messages before logging
    from app.services.webhook import redact_webhook_url, redact_pii
    safe_url = redact_webhook_url(webhook_url)
    safe_error = redact_pii(str(error) if error else "")
    conn = None
    try:
        conn = get_db()
        # Encrypt webhook URL before storing
        url_encrypted = encrypt_entity("webhook_log", f"{site_id}_{submission_id}", webhook_url)
        # Look up existing pending/failed entry — match by destination if present,
        # otherwise fall back to URL-based matching (legacy).
        if destination_id:
            existing = conn.execute(
                """SELECT * FROM webhook_logs
                   WHERE site_id = ? AND submission_id = ? AND destination_id = ?
                   AND status IN ('pending', 'failed')
                   ORDER BY id DESC LIMIT 1""",
                (site_id, submission_id, destination_id),
            ).fetchone()
        else:
            existing = conn.execute(
                """SELECT * FROM webhook_logs
                   WHERE site_id = ? AND submission_id = ? AND webhook_url_encrypted = ?
                   AND status IN ('pending', 'failed')
                   ORDER BY id DESC LIMIT 1""",
                (site_id, submission_id, url_encrypted),
            ).fetchone()

        if existing:
            existing = dict(existing)
            attempt = existing["attempt_count"] + 1
            if success:
                conn.execute(
                    "UPDATE webhook_logs SET status = 'delivered', attempt_count = ?, last_error = ? WHERE id = ?",
                    (attempt, safe_error, existing["id"]),
                )
            else:
                backoff_min = min(30 * (2 ** (attempt - 1)), 60)
                next_retry = (datetime.now(timezone.utc) + timedelta(minutes=backoff_min)).isoformat()
                conn.execute(
                    "UPDATE webhook_logs SET status = 'failed', attempt_count = ?, last_error = ?, next_retry_at = ? WHERE id = ?",
                    (attempt, safe_error, next_retry, existing["id"]),
                )
            conn.commit()
            return existing["id"]
        else:
            status = "delivered" if success else "failed"
            next_retry = None
            if not success:
                next_retry = (datetime.now(timezone.utc) + timedelta(minutes=30)).isoformat()
            cursor = conn.execute(
                """INSERT INTO webhook_logs (site_id, submission_id, webhook_url_encrypted, destination_id, attempt_count, status, last_error, next_retry_at)
                   VALUES (?, ?, ?, ?, 1, ?, ?, ?)""",
                (site_id, submission_id, url_encrypted, destination_id, status, safe_error, next_retry),
            )
            log_id = cursor.lastrowid
            conn.commit()
            return log_id
    finally:
        if conn:
            conn.close()


def claim_webhook_retries(thread_name, max_retries=5, limit=10):
    """Atomically claim failed webhook logs for retry by this thread.

    Uses UPDATE...RETURNING for row-level locking — only one thread
    can claim each row, preventing duplicate deliveries.
    Returns the claimed rows as dicts with decrypted webhook_url.
    """
    conn = None
    try:
        conn = get_db()
        now = datetime.now(timezone.utc).isoformat()
        rows = conn.execute(
            """UPDATE webhook_logs
               SET status = 'retrying',
                   retrying_by = ?
               WHERE id IN (
                   SELECT id FROM webhook_logs
                   WHERE status = 'failed'
                   AND attempt_count < ?
                   AND next_retry_at <= ?
                   ORDER BY created_at ASC
                   LIMIT ?
               )
               RETURNING *""",
            (thread_name, max_retries, now, limit),
        ).fetchall()
        return [_decrypt_webhook_log(dict(r)) for r in rows]
    finally:
        if conn:
            conn.close()


def release_webhook_retry(log_id, new_status, error=None):
    """Release a claimed webhook retry row by updating its status.

    If new_status is 'delivered', mark as delivered.
    Otherwise revert to 'failed' with updated attempt_count and error.
    """
    conn = None
    try:
        conn = get_db()
        if new_status == "delivered":
            conn.execute(
                "UPDATE webhook_logs SET status = 'delivered', retrying_by = NULL WHERE id = ?",
                (log_id,),
            )
        else:
            # Revert to failed, reset retrying_by so another thread can claim
            conn.execute(
                "UPDATE webhook_logs SET status = 'failed', retrying_by = NULL, last_error = ? WHERE id = ?",
                (error, log_id),
            )
        conn.commit()
    finally:
        if conn:
            conn.close()


def get_pending_webhook_retries(max_retries=5):
    """Get failed webhook logs ready for retry (past next_retry_at).

    Deprecated — use claim_webhook_retries() for atomic row locking.
    Kept for backward compatibility with direct calls.
    """
    conn = None
    try:
        conn = get_db()
        now = datetime.now(timezone.utc).isoformat()
        rows = conn.execute(
            """SELECT * FROM webhook_logs
               WHERE status = 'failed'
               AND attempt_count < ?
               AND next_retry_at <= ?
               ORDER BY created_at ASC
               LIMIT 10""",
            (max_retries, now),
        ).fetchall()
        return [_decrypt_webhook_log(dict(r)) for r in rows]
    finally:
        if conn:
            conn.close()


def get_permanently_failed_webhooks(max_retries=5):
    """Return webhook logs that have exhausted all retries.

    These entries have attempt_count >= max_retries and status 'failed'.
    Used for alerting so the operator knows deliveries are permanently down.
    """
    conn = None
    try:
        conn = get_db()
        rows = conn.execute(
            """SELECT * FROM webhook_logs
               WHERE status = 'failed'
               AND attempt_count >= ?
               ORDER BY created_at DESC
               LIMIT 50""",
            (max_retries,),
        ).fetchall()
        return [_decrypt_webhook_log(dict(r)) for r in rows]
    finally:
        if conn:
            conn.close()


def mark_webhook_delivered(log_id):
    """Mark a webhook log entry as successfully delivered."""
    conn = None
    try:
        conn = get_db()
        conn.execute(
            "UPDATE webhook_logs SET status = 'delivered' WHERE id = ?",
            (log_id,),
        )
        conn.commit()
    finally:
        if conn:
            conn.close()


def get_site_webhook(site_id):
    """Get webhook URL for a site (for retries after restart)."""
    conn = None
    try:
        conn = get_db()
        row = conn.execute(
            "SELECT webhook_url FROM sites WHERE id = ?",
            (site_id,),
        ).fetchone()
        if not row:
            return None
        result = dict(row)
        result["webhook_url"] = try_decrypt(site_id, result.get("webhook_url"))
        return result
    finally:
        if conn:
            conn.close()


# ─── Webhook destinations ─────────────────────────────────────────────────────

def get_site_destinations(site_id):
    """Get all webhook destinations for a site."""
    conn = None
    try:
        conn = get_db()
        rows = conn.execute(
            "SELECT * FROM webhook_destinations WHERE site_id = ? ORDER BY created_at ASC",
            (site_id,),
        ).fetchall()
    finally:
        if conn:
            conn.close()
    return [_decrypt_webhook_dest(dict(r)) for r in rows]


def create_destination(site_id, name, dest_type, url, config=None):
    """Create a new webhook destination. Returns destination dict."""
    conn = None
    try:
        conn = get_db()
        config_json = json.dumps(config) if config else None
        url_hash = hash_value(url) if url else None
        conn.execute(
            "INSERT INTO webhook_destinations (site_id, name, type, url, url_encrypted, url_hash, config) VALUES (?, ?, ?, ?, ?, ?, ?)",
            (site_id, name, dest_type, url, None, url_hash, config_json),
        )
        conn.commit()
        dest_id = conn.lastrowid
        # Encrypt URL after insert
        if url:
            enc = encrypt_entity("webhook_dest", dest_id, url)
            if enc:
                conn.execute(
                    "UPDATE webhook_destinations SET url_encrypted = ? WHERE id = ?",
                    (enc, dest_id),
                )
                conn.commit()
        dest = conn.execute(
            "SELECT * FROM webhook_destinations WHERE id = ?",
            (dest_id,),
        ).fetchone()
        return _decrypt_webhook_dest(dict(dest))
    finally:
        if conn:
            conn.close()


def update_destination(dest_id, **kwargs):
    """Update destination fields. Allowed: name, url, config, enabled."""
    allowed = {"name", "url", "config", "enabled"}
    fields = {k: v for k, v in kwargs.items() if k in allowed}
    if not fields:
        return False
    conn = None
    try:
        conn = get_db()
        set_parts = []
        values = []
        for k, v in fields.items():
            if k == "url" and v:
                set_parts.append("url = ?")
                values.append(v)
                enc = encrypt_entity("webhook_dest", dest_id, v)
                if enc:
                    set_parts.append("url_encrypted = ?")
                    values.append(enc)
                # Also update hash
                url_hash = hash_value(v)
                set_parts.append("url_hash = ?")
                values.append(url_hash)
            elif k == "config" and v is not None:
                set_parts.append("config = ?")
                values.append(json.dumps(v))
            else:
                set_parts.append(f"{k} = ?")
                values.append(v)
        set_clause = ", ".join(set_parts)
        values.append(dest_id)
        conn.execute(
            f"UPDATE webhook_destinations SET {set_clause} WHERE id = ?",
            values,
        )
        conn.commit()
        return True
    finally:
        if conn:
            conn.close()


def delete_destination(dest_id):
    """Delete a destination."""
    conn = None
    try:
        conn = get_db()
        conn.execute("DELETE FROM webhook_destinations WHERE id = ?", (dest_id,))
        conn.commit()
    finally:
        if conn:
            conn.close()


def get_destination(dest_id):
    """Get a single destination by ID."""
    conn = None
    try:
        conn = get_db()
        dest = conn.execute(
            "SELECT * FROM webhook_destinations WHERE id = ?",
            (dest_id,),
        ).fetchone()
    finally:
        if conn:
            conn.close()
    if dest:
        return _decrypt_webhook_dest(dict(dest))
    return None


def update_destination_stats(dest_id, success, error=None):
    """Increment success/failure count, update last_status, last_delivered_at."""
    now = datetime.now(timezone.utc).isoformat()
    conn = None
    try:
        conn = get_db()
        if success:
            conn.execute(
                "UPDATE webhook_destinations SET success_count = success_count + 1, last_status = 'success', last_error = NULL, last_delivered_at = ? WHERE id = ?",
                (now, dest_id),
            )
        else:
            status = error or "error"
            conn.execute(
                "UPDATE webhook_destinations SET failure_count = failure_count + 1, last_status = ?, last_error = ?, last_delivered_at = ? WHERE id = ?",
                (status, error, now, dest_id),
            )
        conn.commit()
    finally:
        if conn:
            conn.close()


def get_destination_delivery_stats(site_id):
    """Get delivery stats for all destinations of a site (for UI)."""
    conn = None
    try:
        conn = get_db()
        rows = conn.execute(
            """SELECT id, name, type, enabled, last_status, last_error,
                      last_delivered_at, success_count, failure_count
               FROM webhook_destinations
               WHERE site_id = ?
               ORDER BY last_delivered_at DESC NULLS LAST""",
            (site_id,),
        ).fetchall()
    finally:
        if conn:
            conn.close()
    return [dict(r) for r in rows]


def get_monthly_submission_count(user_id):
    """Get submission count for the current month for a user."""
    conn = None
    try:
        conn = get_db()
        row = conn.execute(
            "SELECT submission_count FROM monthly_usage WHERE user_id = ? AND month = ?",
            (user_id, _current_month()),
        ).fetchone()
    finally:
        if conn:
            conn.close()
    return row["submission_count"] if row else 0


def increment_submission_count(user_id):
    """Increment the monthly submission counter.

    DEPRECATED: Use `accept_submission` which atomically combines limit check,
    submission insert, and counter increment in a single transaction.
    """
    conn = None
    try:
        conn = get_db()
        month = _current_month()
        conn.execute(
            """INSERT INTO monthly_usage (user_id, month, submission_count)
               VALUES (?, ?, 1)
               ON CONFLICT(user_id, month) DO UPDATE SET submission_count = submission_count + 1""",
            (user_id, month),
        )
        conn.commit()
    finally:
        if conn:
            conn.close()


def can_create_site(user_id, user_tier):
    """Check if user can create another site based on tier limits."""
    tier_config = TIERS.get(user_tier, TIERS["free"])
    max_sites = tier_config["max_sites"]
    if max_sites == 0:
        return True  # unlimited
    current_count = get_user_site_count(user_id)
    return current_count < max_sites
def accept_site_creation(name, owner_email, user_id, user_tier, smtp_from=None, field_config=None, webhook_url=None):
    """Atomically check site limit and insert new site.

    All operations happen inside a single BEGIN IMMEDIATE transaction.
    If the user is at their limit, nothing is inserted.

    Args:
        name: Site name.
        owner_email: Owner email.
        user_id: User owning the site.
        user_tier: User's current tier string.
        smtp_from: Optional SMTP from address.
        field_config: Optional field config list.
        webhook_url: Optional webhook URL.

    Returns:
        (success: bool, site: dict or None, current_count: int, max_sites: int)
    """
    import secrets
    tier_config = TIERS.get(user_tier, TIERS["free"])
    max_sites = tier_config["max_sites"]

    conn = None
    try:
        conn = get_db()
        conn.execute("BEGIN IMMEDIATE")

        # 1. Check limit under write lock
        row = conn.execute(
            "SELECT COUNT(*) as cnt FROM sites WHERE user_id = ?",
            (user_id,),
        ).fetchone()
        current_count = row["cnt"]

        if max_sites > 0 and current_count >= max_sites:
            conn.execute("ROLLBACK")
            return False, None, current_count, max_sites

        # 2. Insert the site
        token = f"fr-{secrets.token_hex(6)}"
        fc_json = json.dumps(field_config) if field_config else None
        conn.execute(
            "INSERT INTO sites (token, name, owner_email, smtp_from, user_id, field_config, webhook_url) VALUES (?, ?, ?, ?, ?, ?, ?)",
            (token, name, owner_email, smtp_from, user_id, fc_json, webhook_url),
        )
        conn.commit()

        # 3. Return the inserted site
        site = conn.execute("SELECT * FROM sites WHERE token = ?", (token,)).fetchone()
        return True, dict(site), current_count + 1, max_sites

    except Exception:
        conn.execute("ROLLBACK")
        raise
    finally:
        if conn:
            conn.close()



def can_accept_submission(user_id, user_tier):
    """Check if user's tier can accept more submissions this month.

    Returns (allowed: bool, current_count, max_count).
    """
    tier_config = TIERS.get(user_tier, TIERS["free"])
    max_subs = tier_config["max_submissions"]
    current = get_monthly_submission_count(user_id)
    return current < max_subs, current, max_subs


def get_user_usage(user_id, user_tier):
    """Get full usage snapshot for a user.

    Returns dict with site count, submission count, limits, and percentages.
    """
    tier_config = TIERS.get(user_tier, TIERS["free"])
    max_sites = tier_config["max_sites"]
    max_subs = tier_config["max_submissions"]

    site_count = get_user_site_count(user_id)
    sub_count = get_monthly_submission_count(user_id)

    return {
        "tier": user_tier,
        "tier_name": tier_config["name"],
        "tier_price": tier_config["price"],
        "site_count": site_count,
        "max_sites": max_sites,  # 0 = unlimited
        "submissions": sub_count,
        "max_submissions": max_subs,
        "sites_pct": (site_count / max_sites * 100) if max_sites > 0 else 0,
        "subs_pct": (sub_count / max_subs * 100) if max_subs > 0 else 0,
        "sites_unlimited": max_sites == 0,
    }


def list_all_users():
    """List all users (admin)."""
    conn = None
    try:
        conn = get_db()
        rows = conn.execute(
            "SELECT id, email_encrypted, name_encrypted, tier, created_at FROM users ORDER BY created_at DESC"
        ).fetchall()
    finally:
        if conn:
            conn.close()
    result = []
    for r in rows:
        row = dict(r)
        uid = row.get("id")
        if row.get("email_encrypted"):
            row["email"] = try_decrypt_user_value(uid, row["email_encrypted"])
        if row.get("name_encrypted"):
            row["name"] = try_decrypt_user_value(uid, row["name_encrypted"])
        result.append(row)
    return result


def generate_verification_token(user_id):
    """Generate and store an email verification token. Returns the token."""
    import secrets
    token = secrets.token_urlsafe(32)
    conn = None
    try:
        conn = get_db()
        conn.execute(
            """UPDATE users SET email_verification_token = ?, email_verification_sent_at = CURRENT_TIMESTAMP
               WHERE id = ?""",
            (token, user_id),
        )
        conn.commit()
    finally:
        if conn:
            conn.close()
    return token


def verify_email_token(token):
    """Verify an email using the token. Returns user_id or None."""
    conn = None
    try:
        conn = get_db()
        row = conn.execute(
            """SELECT id, email_verification_sent_at FROM users
               WHERE email_verification_token = ?""",
            (token,),
        ).fetchone()
        
        if not row:
            conn.close()
            return None
        
        # Token expires after 24 hours
        from datetime import timedelta
        sent_at = row["email_verification_sent_at"]
        if sent_at:
            sent_dt = datetime.fromisoformat(sent_at).replace(tzinfo=timezone.utc)
            if datetime.now(timezone.utc) - sent_dt > timedelta(hours=24):
                conn.execute(
                    "UPDATE users SET email_verification_token = NULL WHERE id = ?",
                    (row["id"],),
                )
                conn.commit()
                conn.close()
                return None
        
        # Mark as verified
        conn.execute(
            """UPDATE users SET email_verified = 1, email_verification_token = NULL
               WHERE id = ?""",
            (row["id"],),
        )
        conn.commit()
    finally:
        if conn:
            conn.close()
    return row["id"]


# ─── Password reset ───────────────────────────────────────────────────────────


def generate_password_reset_token(email):
    """Generate a password reset token for a user. Returns token or None."""
    import secrets
    email = email.lower().strip()
    email_hash = hash_value(email)
    conn = None
    try:
        conn = get_db()
        user = conn.execute("SELECT id FROM users WHERE email_hash = ?", (email_hash,)).fetchone()
        if not user:
            conn.close()
            return None
        
        token = secrets.token_urlsafe(32)
        conn.execute(
            """UPDATE users SET password_reset_token = ?, password_reset_sent_at = CURRENT_TIMESTAMP
               WHERE id = ?""",
            (token, user["id"]),
        )
        conn.commit()
    finally:
        if conn:
            conn.close()
    return token


def reset_password_with_token(token, new_password):
    """Reset a user's password using a token. Returns user_id or None."""
    conn = None
    try:
        conn = get_db()
        row = conn.execute(
            """SELECT id, password_reset_sent_at FROM users
               WHERE password_reset_token = ?""",
            (token,),
        ).fetchone()
        
        if not row:
            conn.close()
            return None
        
        # Token expires after 1 hour
        sent_at = row["password_reset_sent_at"]
        if sent_at:
            from datetime import timedelta
            sent_dt = datetime.fromisoformat(sent_at).replace(tzinfo=timezone.utc)
            if datetime.now(timezone.utc) - sent_dt > timedelta(hours=1):
                conn.execute(
                    "UPDATE users SET password_reset_token = NULL WHERE id = ?",
                    (row["id"],),
                )
                conn.commit()
                conn.close()
                return None
        
        # Hash new password
        pw_hash = bcrypt.hashpw(new_password.encode("utf-8"), bcrypt.gensalt())
        conn.execute(
            """UPDATE users SET password_hash = ?, password_reset_token = NULL
               WHERE id = ?""",
            (pw_hash.decode("utf-8"), row["id"]),
        )
        conn.commit()
    finally:
        if conn:
            conn.close()
    return row["id"]


# ─── Magic login (passwordless) ───────────────────────────────────────────────


def generate_magic_login_token(email):
    """Generate a magic login token for a user. Returns token or None."""
    import secrets
    email = email.lower().strip()
    email_hash = hash_value(email)
    conn = None
    try:
        conn = get_db()
        user = conn.execute("SELECT id FROM users WHERE email_hash = ?", (email_hash,)).fetchone()
        if not user:
            conn.close()
            return None
    
        token = secrets.token_urlsafe(32)
        conn.execute(
            """UPDATE users SET magic_login_token = ?, magic_login_sent_at = CURRENT_TIMESTAMP
               WHERE id = ?""",
            (token, user["id"]),
        )
        conn.commit()
    finally:
        if conn:
            conn.close()
    return token


def validate_magic_login_token(token):
    """Validate a magic login token and consume it. Returns user_id or None.

    Token expires after 15 minutes. Consumed tokens are immediately invalidated.
    """
    conn = None
    try:
        conn = get_db()
        row = conn.execute(
            """SELECT id, magic_login_sent_at FROM users
               WHERE magic_login_token = ?""",
            (token,),
        ).fetchone()
    
        if not row:
            conn.close()
            return None
    
        # Token expires after 15 minutes
        sent_at = row["magic_login_sent_at"]
        if sent_at:
            from datetime import timedelta
            sent_dt = datetime.fromisoformat(sent_at).replace(tzinfo=timezone.utc)
            if datetime.now(timezone.utc) - sent_dt > timedelta(minutes=15):
                conn.execute(
                    "UPDATE users SET magic_login_token = NULL WHERE id = ?",
                    (row["id"],),
                )
                conn.commit()
                conn.close()
                return None
    
        # Consume the token
        conn.execute(
            "UPDATE users SET magic_login_token = NULL, magic_login_sent_at = NULL WHERE id = ?",
            (row["id"],),
        )
        conn.commit()
    finally:
        if conn:
            conn.close()
    return row["id"]


# ─── Admin functions ──────────────────────────────────────────────────────────


def generate_api_key(name, user_id, permissions=None, expires_in_days=None):
    """Generate a new API key for a user.

    Args:
        name: Human-readable name for the key
        user_id: Owner user ID
        permissions: Optional permissions dict
        expires_in_days: Optional expiration in days (None = never expires)

    Returns dict with: id, key_prefix, full_key (shown once), expires_at.
    The full_key is NOT stored — only the hash.
    """
    import secrets

    if permissions is None:
        permissions = {
            "read_forms": True,
            "write_forms": True,
            "read_submissions": True,
            "delete_forms": False,
        }

    # Generate raw key
    raw_key = f"{API_KEY_PREFIX}{secrets.token_hex(24)}"
    key_hash = bcrypt.hashpw(raw_key.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
    key_prefix = raw_key[:12]  # afk_live_xx

    # Phase 11: calculate expiration
    from datetime import timedelta
    expires_at = None
    if expires_in_days is not None and expires_in_days > 0:
        expires_at = (datetime.now(timezone.utc) + timedelta(days=expires_in_days)).isoformat()

    conn = None
    try:
        conn = get_db()
        # Check for duplicate name
        existing = conn.execute(
            "SELECT id FROM api_keys WHERE user_id = ? AND name = ?",
            (user_id, name),
        ).fetchone()
        if existing:
            raise ValueError(f"Key with name '{name}' already exists for this user")

        cursor = conn.execute(
            "INSERT INTO api_keys (user_id, key_hash, key_prefix, name, permissions, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
            (user_id, key_hash, key_prefix, name, json.dumps(permissions), expires_at),
        )
        conn.commit()
        key_id = cursor.lastrowid
        return {
            "id": key_id,
            "key_prefix": key_prefix,
            "full_key": raw_key,
            "name": name,
            "permissions": permissions,
            "expires_at": expires_at,
        }
    finally:
        if conn:
            conn.close()


def validate_api_key(raw_key):
    """Validate an API key. Returns (user_id, permissions) or (None, None)."""
    from datetime import timedelta as _td

    conn = None
    try:
        conn = get_db()
        # Prefix lookup first, then verify hash
        prefix = raw_key[:12] if len(raw_key) >= 12 else ""
        if not prefix:
            return None, None

        rows = conn.execute(
            "SELECT ak.*, u.id as user_id FROM api_keys ak JOIN users u ON ak.user_id = u.id WHERE ak.key_prefix = ?",
            (prefix,),
        ).fetchall()

        for row in rows:
            row = dict(row)
            if bcrypt.checkpw(raw_key.encode("utf-8"), row["key_hash"].encode("utf-8")):
                # Phase 11: check expiration
                if row.get("expires_at"):
                    expires_at = datetime.fromisoformat(row["expires_at"])
                    if datetime.now(timezone.utc) > expires_at:
                        # Key expired — log but don't update last_used_at
                        _admin_logger.warning(
                            "Expired API key used: id=%s user=%s", row.get("id"), row.get("user_id")
                        )
                        return None, None

                # Update last_used_at
                conn.execute(
                    "UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?",
                    (row["id"],),
                )
                conn.commit()
                permissions = json.loads(row["permissions"]) if row["permissions"] else {}
                return row["user_id"], permissions

        return None, None
    finally:
        if conn:
            conn.close()


def list_api_keys(user_id):
    """List all API keys for a user (without hashes)."""
    conn = None
    try:
        conn = get_db()
        rows = conn.execute(
            "SELECT id, key_prefix, name, permissions, expires_at, last_used_at, created_at FROM api_keys WHERE user_id = ? ORDER BY created_at DESC",
            (user_id,),
        ).fetchall()
    finally:
        if conn:
            conn.close()
    result = []
    for row in rows:
        d = dict(row)
        try:
            d["permissions"] = json.loads(d["permissions"]) if d["permissions"] else {}
        except (json.JSONDecodeError, TypeError):
            d["permissions"] = {}
        result.append(d)
    return result


def revoke_api_key(key_id, user_id):
    """Revoke an API key (must belong to user)."""
    conn = None
    try:
        conn = get_db()
        result = conn.execute(
            "DELETE FROM api_keys WHERE id = ? AND user_id = ?",
            (key_id, user_id),
        )
        conn.commit()
    finally:
        if conn:
            conn.close()
    return result.rowcount > 0


def renew_api_key(key_id, user_id, expires_in_days):
    """Extend the expiration of an API key. Returns True if updated."""
    from datetime import timedelta as _td

    conn = None
    try:
        conn = get_db()
        new_expiry = (datetime.now(timezone.utc) + timedelta(days=expires_in_days)).isoformat()
        result = conn.execute(
            "UPDATE api_keys SET expires_at = ? WHERE id = ? AND user_id = ?",
            (new_expiry, key_id, user_id),
        )
        conn.commit()
    finally:
        if conn:
            conn.close()
    return result.rowcount > 0


def check_api_permission(permissions, action):
    """Check if an API key has the required permission.

    Actions: 'read_forms', 'write_forms', 'read_submissions', 'delete_forms'
    """
    if not permissions:
        return False
    return bool(permissions.get(action, False))


# ─── Template model functions ────────────────────────────────────────────────


def list_templates(category=None, featured_only=False, search=None):
    """List form templates with optional filtering.

    Args:
        category: Filter by category (Business, Events, Hiring, Support, E-commerce, Onboarding)
        featured_only: If True, return only featured templates
        search: Search in name and description

    Returns:
        List of template dicts with field_config parsed as JSON.
    """
    conn = None
    try:
        conn = get_db()
        query = "SELECT * FROM form_templates WHERE 1=1"
        params = []

        if featured_only:
            query += " AND is_featured = 1"
        if category:
            query += " AND category = ?"
            params.append(category)

        if search:
            query += " AND (name LIKE ? OR description LIKE ?)"
            params.extend([f"%{search}%", f"%{search}%"])

        query += " ORDER BY is_featured DESC, name ASC"

        rows = conn.execute(query, params).fetchall()
        result = []
        for row in rows:
            d = dict(row)
            try:
                d["field_config"] = json.loads(d["field_config"]) if d["field_config"] else []
            except (json.JSONDecodeError, TypeError):
                d["field_config"] = []
            d["field_count"] = len(d["field_config"])
            result.append(d)
        return result
    finally:
        if conn:
            conn.close()


def get_template_by_slug(slug):
    """Get a single template by slug.

    Returns:
        Template dict with field_config parsed as JSON, or None.
    """
    conn = None
    try:
        conn = get_db()
        row = conn.execute("SELECT * FROM form_templates WHERE slug = ?", (slug,)).fetchone()
        if not row:
            return None
        d = dict(row)
        try:
            d["field_config"] = json.loads(d["field_config"]) if d["field_config"] else []
        except (json.JSONDecodeError, TypeError):
            d["field_config"] = []
        d["field_count"] = len(d["field_config"])
        return d
    finally:
        if conn:
            conn.close()


def seed_templates():
    """Seed the form_templates table with launch templates.

    Idempotent — skips templates that already exist by slug.
    """
    from app.services.template_seeds import LAUNCH_TEMPLATES
    conn = None
    try:
        conn = get_db()
        seeded = 0
        for tmpl in LAUNCH_TEMPLATES:
            existing = conn.execute(
                "SELECT id FROM form_templates WHERE slug = ?",
                (tmpl["slug"],),
            ).fetchone()
            if existing:
                continue
            conn.execute(
                """INSERT INTO form_templates
                   (slug, name, description, category, field_config, success_message, created_by, is_featured)
                   VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
                (
                    tmpl["slug"],
                    tmpl["name"],
                    tmpl["description"],
                    tmpl["category"],
                    json.dumps(tmpl["field_config"]),
                    tmpl.get("success_message"),
                    "system",
                    tmpl.get("is_featured", 0),
                ),
            )
            seeded += 1
        conn.commit()
        if seeded:
            print(f"[migration] Seeded {seeded} template(s)")
    finally:
        if conn:
            conn.close()


def create_template(slug, name, description, category, field_config, success_message=None, is_featured=0, created_by="system"):
    """Create a new template. Returns template dict or None on duplicate."""
    conn = None
    try:
        conn = get_db()
        conn.execute(
            """INSERT INTO form_templates
               (slug, name, description, category, field_config, success_message, created_by, is_featured)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
            (slug, name, description, category, json.dumps(field_config), success_message, created_by, is_featured),
        )
        conn.commit()
        return get_template_by_slug(slug)
    except sqlite3.IntegrityError:
        return None
    finally:
        if conn:
            conn.close()


def update_template(slug, **kwargs):
    """Update a template by slug. Returns updated template dict or None if not found."""
    conn = None
    try:
        conn = get_db()
        existing = conn.execute("SELECT id, field_config FROM form_templates WHERE slug = ?", (slug,)).fetchone()
        if not existing:
            return None

        updates = []
        values = []
        for key in ("name", "description", "category", "field_config", "success_message", "is_featured"):
            if key in kwargs:
                val = kwargs[key]
                if key == "field_config" and isinstance(val, (list, dict)):
                    val = json.dumps(val)
                updates.append(f"{key} = ?")
                values.append(val)

        if updates:
            values.append(slug)
            conn.execute(f"UPDATE form_templates SET {', '.join(updates)} WHERE slug = ?", values)
            conn.commit()

        return get_template_by_slug(slug)
    finally:
        if conn:
            conn.close()


def delete_template(slug):
    """Delete a template by slug. Returns True if deleted."""
    conn = None
    try:
        conn = get_db()
        cursor = conn.execute("DELETE FROM form_templates WHERE slug = ?", (slug,))
        conn.commit()
        return cursor.rowcount > 0
    finally:
        if conn:
            conn.close()


def export_user_data(user_id):
    """Export all user data as dict (GDPR right to data portability)."""
    conn = None
    try:
        conn = get_db()
        # Get user record
        user_row = conn.execute("SELECT id, tier FROM users WHERE id = ?", (user_id,)).fetchone()
        if not user_row:
            return None
        # Get user's sites
        sites = [dict(r) for r in conn.execute("SELECT * FROM sites WHERE user_id = ?", (user_id,)).fetchall()]
        # Get monthly usage
        usage = [dict(r) for r in conn.execute("SELECT * FROM monthly_usage WHERE user_id = ?", (user_id,)).fetchall()]
        return {
            "user": {"id": user_row["id"], "tier": user_row["tier"]},
            "sites": sites,
            "monthly_usage": usage,
        }
    finally:
        if conn:
            conn.close()


def delete_user(user_id):
    """Delete a user and all associated data. Returns dict with success/error."""
    conn = None
    try:
        conn = get_db()
        conn.execute("BEGIN IMMEDIATE")
        # Delete cascading: sites, submissions, form_sessions, etc.
        conn.execute("DELETE FROM form_submissions WHERE site_id IN (SELECT id FROM sites WHERE user_id = ?)", (user_id,))
        conn.execute("DELETE FROM form_sessions WHERE site_id IN (SELECT id FROM sites WHERE user_id = ?)", (user_id,))
        conn.execute("DELETE FROM form_impressions WHERE site_id IN (SELECT id FROM sites WHERE user_id = ?)", (user_id,))
        conn.execute("DELETE FROM webhook_logs WHERE site_id IN (SELECT id FROM sites WHERE user_id = ?)", (user_id,))
        conn.execute("DELETE FROM sites WHERE user_id = ?", (user_id,))
        conn.execute("DELETE FROM api_keys WHERE user_id = ?", (user_id,))
        conn.execute("DELETE FROM monthly_usage WHERE user_id = ?", (user_id,))
        conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
        conn.commit()
        return {"success": True}
    except Exception as e:
        conn.execute("ROLLBACK")
        return {"success": False, "error": str(e)}
    finally:
        if conn:
            conn.close()


def generate_referral_code(user_id):
    """Generate or return existing referral code for user. Returns string."""
    import secrets
    conn = None
    try:
        conn = get_db()
        row = conn.execute(
            "SELECT referral_code FROM users WHERE id = ?", (user_id,)
        ).fetchone()
        if row and row["referral_code"]:
            return row["referral_code"]
        code = secrets.token_urlsafe(8)[:12].upper()
        conn.execute(
            "UPDATE users SET referral_code = ? WHERE id = ?", (code, user_id)
        )
        conn.commit()
        return code
    finally:
        if conn:
            conn.close()


def get_referral_stats(user_id):
    """Get referral stats for user. Returns dict."""
    conn = None
    try:
        conn = get_db()
        row = conn.execute(
            "SELECT COUNT(*) as cnt FROM users WHERE referred_by = ?",
            (user_id,),
        ).fetchone()
        return {"total_referrals": row["cnt"] if row else 0}
    finally:
        if conn:
            conn.close()


def try_decrypt_user_value(user_id, encrypted_value):
    """Try to decrypt a user value. Returns decrypted string or original value on failure."""
    from app.crypto import decrypt_user_value, is_encrypted
    if not encrypted_value:
        return None
    if not is_encrypted(encrypted_value):
        return encrypted_value
    try:
        result = decrypt_user_value(user_id, encrypted_value)
        return result if result is not None else encrypted_value
    except Exception:
        return encrypted_value


# ─── Email Campaigns ────────────────────────────────────────────────────────────



# ─── Agent I/O Helpers ─────────────────────────────────────────────────────────

def check_rate_limit(user_id, limit_type="api"):
    """Check if user has exceeded rate limits. Returns True if allowed."""
    # For now, just return True — rate limiting is handled by Flask middleware
    # This is a placeholder for per-user rate limiting
    return True


def record_api_usage(user_id, api_key_id, action_type):
    """Record an API usage event for tracking/analytics."""
    conn = None
    try:
        conn = get_db()
        conn.execute(
            """INSERT INTO api_usage (user_id, api_key_id, action_type, created_at)
               VALUES (?, ?, ?, CURRENT_TIMESTAMP)""",
            (user_id, api_key_id, action_type),
        )
        conn.commit()
    except Exception as e:
        print(f"[model] record_api_usage error: {e}")
    finally:
        if conn:
            conn.close()
