"""
SQLite backend — default for local development and single-node deployments.
"""

import hashlib
import json
import secrets
import sqlite3
from datetime import datetime, timedelta, timezone

from autopil.models import (
    AgentAction, AuditEvent, CatalogClassification, CatalogConnection, Decision, SyncResult
)

from .base import (
    AuditLogBase, AlertStoreBase, CatalogStoreBase, KeyStoreBase,
    LineageStoreBase, SessionStoreBase, TenantStoreBase, PolicyStoreBase,
    TrialStoreBase,
)

KEY_PREFIX = "apl_"


def _hash(key: str) -> str:
    return hashlib.sha256(key.encode()).hexdigest()


def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
    rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
    return any(r[1] == column for r in rows)


def _ts_str(ts) -> str:
    """Normalise a timestamp to an ISO-format string regardless of input type."""
    if isinstance(ts, datetime):
        return ts.isoformat()
    return str(ts)


def _compute_chain_hash(prev_hash: str, event, tenant_id: str) -> str:
    """SHA-256 over the immutable audit fields and the previous hash."""
    ts = _ts_str(event.timestamp)
    payload = "|".join([
        prev_hash,
        event.event_id,
        tenant_id,
        ts,
        event.agent_role,
        event.user_id,
        event.source_id,
        event.decision.value,
        event.query,
    ])
    return hashlib.sha256(payload.encode()).hexdigest()


class SQLiteAuditLog(AuditLogBase):

    def __init__(self, db_path: str):
        self.db_path = db_path
        self._init_db()

    def _init_db(self):
        with sqlite3.connect(self.db_path) as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS audit_events (
                    event_id              TEXT PRIMARY KEY,
                    session_id            TEXT NOT NULL,
                    agent_role            TEXT NOT NULL,
                    user_id               TEXT NOT NULL,
                    source_id             TEXT NOT NULL,
                    query                 TEXT NOT NULL,
                    decision              TEXT NOT NULL,
                    policy_name           TEXT NOT NULL,
                    reason                TEXT NOT NULL,
                    context_hash          TEXT,
                    timestamp             TEXT NOT NULL,
                    tenant_id             TEXT NOT NULL DEFAULT 'default',
                    sensitivity_level     TEXT,
                    source_type           TEXT NOT NULL DEFAULT 'rest',
                    catalog_enriched      INTEGER NOT NULL DEFAULT 0,
                    catalog_connection_id TEXT,
                    chain_hash            TEXT,
                    prev_hash             TEXT
                )
            """)
            # Migrations: add columns for databases created before these fields existed
            if not _has_column(conn, "audit_events", "tenant_id"):
                conn.execute(
                    "ALTER TABLE audit_events ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'"
                )
            if not _has_column(conn, "audit_events", "sensitivity_level"):
                conn.execute(
                    "ALTER TABLE audit_events ADD COLUMN sensitivity_level TEXT"
                )
            if not _has_column(conn, "audit_events", "source_type"):
                conn.execute(
                    "ALTER TABLE audit_events ADD COLUMN source_type TEXT NOT NULL DEFAULT 'rest'"
                )
            if not _has_column(conn, "audit_events", "catalog_enriched"):
                conn.execute(
                    "ALTER TABLE audit_events ADD COLUMN catalog_enriched INTEGER NOT NULL DEFAULT 0"
                )
            if not _has_column(conn, "audit_events", "catalog_connection_id"):
                conn.execute(
                    "ALTER TABLE audit_events ADD COLUMN catalog_connection_id TEXT"
                )
            if not _has_column(conn, "audit_events", "chain_hash"):
                conn.execute("ALTER TABLE audit_events ADD COLUMN chain_hash TEXT")
            if not _has_column(conn, "audit_events", "prev_hash"):
                conn.execute("ALTER TABLE audit_events ADD COLUMN prev_hash TEXT")
            conn.execute("""
                CREATE TABLE IF NOT EXISTS audit_chain_anchors (
                    tenant_id     TEXT PRIMARY KEY,
                    anchor_hash   TEXT NOT NULL,
                    purged_before TEXT NOT NULL,
                    purged_count  INTEGER NOT NULL,
                    created_at    TEXT NOT NULL
                )
            """)
            conn.commit()

    def record(self, event: AuditEvent, tenant_id: str = "default") -> None:
        with sqlite3.connect(self.db_path) as conn:
            row = conn.execute(
                "SELECT chain_hash FROM audit_events WHERE tenant_id=? ORDER BY timestamp DESC LIMIT 1",
                (tenant_id,),
            ).fetchone()
            prev = (row[0] if row and row[0] else None) or "GENESIS"
            chain_hash = _compute_chain_hash(prev, event, tenant_id)
            conn.execute(
                """INSERT INTO audit_events
                   (event_id, session_id, agent_role, user_id, source_id,
                    query, decision, policy_name, reason, context_hash,
                    timestamp, tenant_id, sensitivity_level, source_type,
                    catalog_enriched, catalog_connection_id, chain_hash, prev_hash)
                   VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
                (
                    event.event_id, event.session_id, event.agent_role,
                    event.user_id, event.source_id, event.query,
                    event.decision.value, event.policy_name, event.reason,
                    event.context_hash, _ts_str(event.timestamp), tenant_id,
                    event.sensitivity_level, event.source_type,
                    int(event.catalog_enriched), event.catalog_connection_id,
                    chain_hash, prev,
                ),
            )
            conn.commit()

    def get_session_events(self, session_id: str, tenant_id: str = "default") -> list[AuditEvent]:
        with sqlite3.connect(self.db_path) as conn:
            rows = conn.execute(
                "SELECT * FROM audit_events WHERE session_id = ? AND tenant_id = ? ORDER BY timestamp",
                (session_id, tenant_id),
            ).fetchall()
        return [self._row_to_event(r) for r in rows]

    def get_all_events(self, tenant_id: str = "default") -> list[AuditEvent]:
        with sqlite3.connect(self.db_path) as conn:
            rows = conn.execute(
                "SELECT * FROM audit_events WHERE tenant_id = ? ORDER BY timestamp",
                (tenant_id,),
            ).fetchall()
        return [self._row_to_event(r) for r in rows]

    def get_session_owner(self, session_id: str, tenant_id: str = "default") -> str | None:
        with sqlite3.connect(self.db_path) as conn:
            row = conn.execute(
                """SELECT agent_role FROM audit_events
                   WHERE session_id = ? AND tenant_id = ?
                   ORDER BY timestamp LIMIT 1""",
                (session_id, tenant_id),
            ).fetchone()
        return row[0] if row else None

    def _row_to_event(self, row) -> AuditEvent:
        return AuditEvent(
            event_id=row[0],
            session_id=row[1],
            agent_role=row[2],
            user_id=row[3],
            source_id=row[4],
            query=row[5],
            decision=Decision(row[6]),
            policy_name=row[7],
            reason=row[8],
            timestamp=datetime.fromisoformat(row[10]),
            context_hash=row[9],
            sensitivity_level=row[12] if len(row) > 12 else None,
            source_type=row[13] if len(row) > 13 else "rest",
            catalog_enriched=bool(row[14]) if len(row) > 14 and row[14] is not None else False,
            catalog_connection_id=row[15] if len(row) > 15 else None,
            chain_hash=row[16] if len(row) > 16 else None,
            prev_hash=row[17] if len(row) > 17 else None,
        )

    def _get_chain_anchor(self, tenant_id: str) -> dict | None:
        with sqlite3.connect(self.db_path) as conn:
            row = conn.execute(
                "SELECT anchor_hash, purged_before, purged_count FROM audit_chain_anchors WHERE tenant_id=?",
                (tenant_id,),
            ).fetchone()
        if not row:
            return None
        return {"anchor_hash": row[0], "purged_before": row[1], "purged_count": row[2]}

    def verify_chain(self, tenant_id: str = "default") -> dict:
        events = self.get_all_events(tenant_id)
        total = len(events)
        # Find where chaining begins (first event with a chain_hash)
        chain_start = next((i for i, e in enumerate(events) if e.chain_hash is not None), None)
        if chain_start is None:
            return {"valid": True, "total": total, "chained": 0, "legacy": total, "broken_at": None}
        legacy = chain_start
        chained_events = events[chain_start:]
        # Seed from stored anchor if a purge has occurred, otherwise use GENESIS
        anchor = self._get_chain_anchor(tenant_id)
        prev = anchor["anchor_hash"] if anchor else "GENESIS"
        for idx, event in enumerate(chained_events):
            if event.prev_hash != prev:
                return {
                    "valid": False, "total": total,
                    "chained": idx, "legacy": legacy,
                    "broken_at": event.event_id,
                }
            expected = _compute_chain_hash(prev, event, tenant_id)
            if event.chain_hash != expected:
                return {
                    "valid": False, "total": total,
                    "chained": idx, "legacy": legacy,
                    "broken_at": event.event_id,
                }
            prev = event.chain_hash
        return {"valid": True, "total": total, "chained": len(chained_events), "legacy": legacy, "broken_at": None}

    def purge_old_events(self, retention_days: int, tenant_id: str | None = None) -> int:
        cutoff = (datetime.utcnow() - timedelta(days=retention_days)).isoformat()
        with sqlite3.connect(self.db_path) as conn:
            if tenant_id:
                tenant_ids = [tenant_id]
            else:
                rows = conn.execute(
                    "SELECT DISTINCT tenant_id FROM audit_events WHERE timestamp < ?", (cutoff,)
                ).fetchall()
                tenant_ids = [r[0] for r in rows]

            total_deleted = 0
            for tid in tenant_ids:
                # Capture chain_hash of the last event being deleted — becomes the anchor
                row = conn.execute(
                    """SELECT chain_hash FROM audit_events
                       WHERE tenant_id=? AND timestamp < ?
                       ORDER BY timestamp DESC LIMIT 1""",
                    (tid, cutoff),
                ).fetchone()
                if not row:
                    continue
                anchor_hash = row[0] or "GENESIS"
                count_row = conn.execute(
                    "SELECT COUNT(*) FROM audit_events WHERE tenant_id=? AND timestamp < ?",
                    (tid, cutoff),
                ).fetchone()
                purged_count = count_row[0] if count_row else 0

                conn.execute(
                    """INSERT INTO audit_chain_anchors
                           (tenant_id, anchor_hash, purged_before, purged_count, created_at)
                       VALUES (?, ?, ?, ?, ?)
                       ON CONFLICT(tenant_id) DO UPDATE SET
                           anchor_hash=excluded.anchor_hash,
                           purged_before=excluded.purged_before,
                           purged_count=excluded.purged_count,
                           created_at=excluded.created_at""",
                    (tid, anchor_hash, cutoff, purged_count, datetime.utcnow().isoformat()),
                )
                conn.execute(
                    "DELETE FROM audit_events WHERE tenant_id=? AND timestamp < ?",
                    (tid, cutoff),
                )
                total_deleted += purged_count

            conn.commit()
        return total_deleted


class SQLiteKeyStore(KeyStoreBase):

    def __init__(self, db_path: str):
        self.db_path = db_path
        self._init_db()

    def _init_db(self):
        with sqlite3.connect(self.db_path) as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS api_keys (
                    key_id        TEXT PRIMARY KEY,
                    key_hash      TEXT NOT NULL UNIQUE,
                    name          TEXT NOT NULL,
                    created_at    TEXT NOT NULL,
                    last_used     TEXT,
                    is_active     INTEGER NOT NULL DEFAULT 1,
                    tenant_id     TEXT NOT NULL DEFAULT 'default',
                    is_superadmin INTEGER NOT NULL DEFAULT 0,
                    scope         TEXT NOT NULL DEFAULT 'admin',
                    expires_at    TEXT
                )
            """)
            if not _has_column(conn, "api_keys", "tenant_id"):
                conn.execute("ALTER TABLE api_keys ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'")
            if not _has_column(conn, "api_keys", "is_superadmin"):
                conn.execute("ALTER TABLE api_keys ADD COLUMN is_superadmin INTEGER NOT NULL DEFAULT 0")
            if not _has_column(conn, "api_keys", "scope"):
                conn.execute("ALTER TABLE api_keys ADD COLUMN scope TEXT NOT NULL DEFAULT 'admin'")
            if not _has_column(conn, "api_keys", "expires_at"):
                conn.execute("ALTER TABLE api_keys ADD COLUMN expires_at TEXT")

    def create_key(
        self,
        name: str,
        tenant_id: str = "default",
        is_superadmin: bool = False,
        scope: str = "admin",
        expires_days: int | None = None,
    ) -> tuple[str, str]:
        key_id    = secrets.token_hex(8)
        plaintext = KEY_PREFIX + secrets.token_hex(24)
        expires_at = (
            (datetime.utcnow() + timedelta(days=expires_days)).isoformat()
            if expires_days is not None else None
        )
        with sqlite3.connect(self.db_path) as conn:
            conn.execute(
                """INSERT INTO api_keys
                   (key_id, key_hash, name, created_at, tenant_id, is_superadmin, scope, expires_at)
                   VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
                (key_id, _hash(plaintext), name, datetime.utcnow().isoformat(),
                 tenant_id, int(is_superadmin), scope, expires_at),
            )
        return key_id, plaintext

    def validate(self, plaintext: str) -> dict | None:
        if not plaintext.startswith(KEY_PREFIX):
            return None
        with sqlite3.connect(self.db_path) as conn:
            row = conn.execute(
                """SELECT key_id, tenant_id, is_superadmin, scope, expires_at
                   FROM api_keys WHERE key_hash = ? AND is_active = 1""",
                (_hash(plaintext),),
            ).fetchone()
            if row:
                expires_at = row[4]
                if expires_at and datetime.utcnow() > datetime.fromisoformat(expires_at):
                    return None  # expired
                conn.execute(
                    "UPDATE api_keys SET last_used = ? WHERE key_id = ?",
                    (datetime.utcnow().isoformat(), row[0]),
                )
                return {
                    "key_id": row[0], "tenant_id": row[1],
                    "is_superadmin": bool(row[2]), "scope": row[3] or "admin",
                }
        return None

    def list_keys(self, tenant_id: str = "default") -> list[dict]:
        with sqlite3.connect(self.db_path) as conn:
            rows = conn.execute(
                """SELECT key_id, name, created_at, last_used, is_active,
                          tenant_id, is_superadmin, scope, expires_at
                   FROM api_keys WHERE tenant_id = ? ORDER BY created_at""",
                (tenant_id,),
            ).fetchall()
        return [
            {
                "key_id": r[0], "name": r[1], "created_at": r[2], "last_used": r[3],
                "is_active": bool(r[4]), "tenant_id": r[5], "is_superadmin": bool(r[6]),
                "scope": r[7] or "admin", "expires_at": r[8],
            }
            for r in rows
        ]

    def rotate_key(self, key_id: str, tenant_id: str) -> tuple[str, str] | None:
        with sqlite3.connect(self.db_path) as conn:
            row = conn.execute(
                "SELECT name, is_superadmin, scope, expires_at FROM api_keys WHERE key_id = ? AND tenant_id = ? AND is_active = 1",
                (key_id, tenant_id),
            ).fetchone()
            if not row:
                return None
            name, is_superadmin, scope, old_expires_at = row
            # Preserve relative TTL if the old key had one
            expires_days = None
            if old_expires_at:
                remaining = (datetime.fromisoformat(old_expires_at) - datetime.utcnow()).days
                expires_days = max(1, remaining)
        new_id, plaintext = self.create_key(name, tenant_id, bool(is_superadmin), scope or "admin", expires_days)
        self.revoke(key_id, tenant_id)
        return new_id, plaintext

    def revoke(self, key_id: str, tenant_id: str = "default") -> bool:
        with sqlite3.connect(self.db_path) as conn:
            cur = conn.execute(
                "UPDATE api_keys SET is_active = 0 WHERE key_id = ? AND tenant_id = ? AND is_active = 1",
                (key_id, tenant_id),
            )
        return cur.rowcount > 0

    def count(self, tenant_id: str | None = None) -> int:
        with sqlite3.connect(self.db_path) as conn:
            if tenant_id:
                return conn.execute(
                    "SELECT COUNT(*) FROM api_keys WHERE tenant_id = ?", (tenant_id,)
                ).fetchone()[0]
            return conn.execute("SELECT COUNT(*) FROM api_keys").fetchone()[0]


class SQLiteTenantStore(TenantStoreBase):

    def __init__(self, db_path: str):
        self.db_path = db_path
        self._init_db()

    def _init_db(self):
        with sqlite3.connect(self.db_path) as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS tenants (
                    tenant_id  TEXT PRIMARY KEY,
                    name       TEXT NOT NULL UNIQUE,
                    created_at TEXT NOT NULL,
                    is_active  INTEGER NOT NULL DEFAULT 1
                )
            """)

    def create_tenant(self, name: str) -> str:
        tenant_id = "ten_" + secrets.token_hex(8)
        with sqlite3.connect(self.db_path) as conn:
            conn.execute(
                "INSERT INTO tenants (tenant_id, name, created_at) VALUES (?, ?, ?)",
                (tenant_id, name, datetime.utcnow().isoformat()),
            )
        return tenant_id

    def get_tenant(self, tenant_id: str) -> dict | None:
        with sqlite3.connect(self.db_path) as conn:
            row = conn.execute(
                "SELECT tenant_id, name, created_at, is_active FROM tenants WHERE tenant_id = ?",
                (tenant_id,),
            ).fetchone()
        if not row:
            return None
        return {"tenant_id": row[0], "name": row[1], "created_at": row[2], "is_active": bool(row[3])}

    def list_tenants(self) -> list[dict]:
        with sqlite3.connect(self.db_path) as conn:
            rows = conn.execute(
                "SELECT tenant_id, name, created_at, is_active FROM tenants ORDER BY created_at"
            ).fetchall()
        return [
            {"tenant_id": r[0], "name": r[1], "created_at": r[2], "is_active": bool(r[3])}
            for r in rows
        ]

    def deactivate_tenant(self, tenant_id: str) -> bool:
        with sqlite3.connect(self.db_path) as conn:
            cur = conn.execute(
                "UPDATE tenants SET is_active = 0 WHERE tenant_id = ? AND is_active = 1",
                (tenant_id,),
            )
        return cur.rowcount > 0

    def count(self) -> int:
        with sqlite3.connect(self.db_path) as conn:
            return conn.execute("SELECT COUNT(*) FROM tenants").fetchone()[0]


import json


class SQLitePolicyStore(PolicyStoreBase):

    def __init__(self, db_path: str):
        self.db_path = db_path
        self._init_db()

    def _init_db(self):
        with sqlite3.connect(self.db_path) as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS policies (
                    policy_id       TEXT PRIMARY KEY,
                    name            TEXT NOT NULL,
                    agent_role      TEXT NOT NULL,
                    allowed_sources TEXT NOT NULL DEFAULT '[]',
                    denied_sources  TEXT NOT NULL DEFAULT '[]',
                    max_sensitivity TEXT NOT NULL DEFAULT 'high',
                    tenant_id       TEXT NOT NULL,
                    created_at      TEXT NOT NULL,
                    updated_at      TEXT NOT NULL,
                    version         INTEGER NOT NULL DEFAULT 1,
                    changed_by      TEXT,
                    is_active       INTEGER NOT NULL DEFAULT 1,
                    UNIQUE(name, tenant_id)
                )
            """)
            conn.execute("""
                CREATE TABLE IF NOT EXISTS policy_versions (
                    version_id      TEXT PRIMARY KEY,
                    policy_id       TEXT NOT NULL,
                    name            TEXT NOT NULL,
                    agent_role      TEXT NOT NULL,
                    allowed_sources TEXT NOT NULL DEFAULT '[]',
                    denied_sources  TEXT NOT NULL DEFAULT '[]',
                    max_sensitivity TEXT NOT NULL,
                    version         INTEGER NOT NULL,
                    tenant_id       TEXT NOT NULL,
                    changed_by      TEXT,
                    changed_at      TEXT NOT NULL
                )
            """)
            # Migrations: add columns to existing schemas
            for table in ("policies", "policy_versions"):
                if not _has_column(conn, table, "allowed_tasks"):
                    conn.execute(f"ALTER TABLE {table} ADD COLUMN allowed_tasks TEXT NOT NULL DEFAULT '[]'")
                if not _has_column(conn, table, "denied_tasks"):
                    conn.execute(f"ALTER TABLE {table} ADD COLUMN denied_tasks TEXT NOT NULL DEFAULT '[]'")
                if not _has_column(conn, table, "industry"):
                    conn.execute(f"ALTER TABLE {table} ADD COLUMN industry TEXT")
                if not _has_column(conn, table, "category"):
                    conn.execute(f"ALTER TABLE {table} ADD COLUMN category TEXT")
            conn.commit()

    def upsert(self, policy: dict, tenant_id: str, changed_by: str | None = None) -> str:
        name = policy["name"]
        now  = datetime.utcnow().isoformat()
        allowed       = json.dumps(policy.get("allowed_sources", []))
        denied        = json.dumps(policy.get("denied_sources", []))
        allowed_tasks = json.dumps(policy.get("allowed_tasks", []))
        denied_tasks  = json.dumps(policy.get("denied_tasks", []))
        sensitivity   = policy.get("max_sensitivity", "high")
        agent_role    = policy["agent_role"]
        industry      = policy.get("industry")
        category      = policy.get("category")

        with sqlite3.connect(self.db_path) as conn:
            existing = conn.execute(
                "SELECT policy_id, version FROM policies WHERE name = ? AND tenant_id = ? AND is_active = 1",
                (name, tenant_id),
            ).fetchone()

            if existing:
                policy_id, version = existing
                new_version = version + 1
                conn.execute(
                    """UPDATE policies SET
                       agent_role=?, allowed_sources=?, denied_sources=?,
                       max_sensitivity=?, allowed_tasks=?, denied_tasks=?,
                       industry=?, category=?,
                       updated_at=?, version=?, changed_by=?
                       WHERE policy_id=?""",
                    (agent_role, allowed, denied, sensitivity,
                     allowed_tasks, denied_tasks, industry, category,
                     now, new_version, changed_by, policy_id),
                )
                conn.execute(
                    """INSERT INTO policy_versions
                       (version_id, policy_id, name, agent_role, allowed_sources,
                        denied_sources, max_sensitivity, allowed_tasks, denied_tasks,
                        industry, category, version, tenant_id, changed_by, changed_at)
                       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
                    (secrets.token_hex(8), policy_id, name, agent_role, allowed, denied,
                     sensitivity, allowed_tasks, denied_tasks, industry, category,
                     new_version, tenant_id, changed_by, now),
                )
            else:
                policy_id = "pol_" + secrets.token_hex(8)
                conn.execute(
                    """INSERT INTO policies
                       (policy_id, name, agent_role, allowed_sources, denied_sources,
                        max_sensitivity, allowed_tasks, denied_tasks, industry, category,
                        tenant_id, created_at, updated_at, version, changed_by)
                       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)""",
                    (policy_id, name, agent_role, allowed, denied, sensitivity,
                     allowed_tasks, denied_tasks, industry, category,
                     tenant_id, now, now, changed_by),
                )
                conn.execute(
                    """INSERT INTO policy_versions
                       (version_id, policy_id, name, agent_role, allowed_sources,
                        denied_sources, max_sensitivity, allowed_tasks, denied_tasks,
                        industry, category, version, tenant_id, changed_by, changed_at)
                       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)""",
                    (secrets.token_hex(8), policy_id, name, agent_role, allowed, denied,
                     sensitivity, allowed_tasks, denied_tasks, industry, category,
                     tenant_id, changed_by, now),
                )
            conn.commit()
        return policy_id

    def get(self, name: str, tenant_id: str) -> dict | None:
        with sqlite3.connect(self.db_path) as conn:
            row = conn.execute(
                """SELECT policy_id, name, agent_role, allowed_sources, denied_sources,
                          max_sensitivity, version, created_at, updated_at, changed_by,
                          allowed_tasks, denied_tasks, industry, category
                   FROM policies WHERE name = ? AND tenant_id = ? AND is_active = 1""",
                (name, tenant_id),
            ).fetchone()
        return self._row_to_dict(row) if row else None

    def list_active(self, tenant_id: str) -> list[dict]:
        with sqlite3.connect(self.db_path) as conn:
            rows = conn.execute(
                """SELECT policy_id, name, agent_role, allowed_sources, denied_sources,
                          max_sensitivity, version, created_at, updated_at, changed_by,
                          allowed_tasks, denied_tasks, industry, category
                   FROM policies WHERE tenant_id = ? AND is_active = 1
                   ORDER BY industry, category, name""",
                (tenant_id,),
            ).fetchall()
        return [self._row_to_dict(r) for r in rows]

    def delete(self, name: str, tenant_id: str) -> bool:
        with sqlite3.connect(self.db_path) as conn:
            cur = conn.execute(
                "UPDATE policies SET is_active = 0 WHERE name = ? AND tenant_id = ? AND is_active = 1",
                (name, tenant_id),
            )
            conn.commit()
        return cur.rowcount > 0

    def get_history(self, name: str, tenant_id: str) -> list[dict]:
        with sqlite3.connect(self.db_path) as conn:
            # Get the policy_id first
            row = conn.execute(
                "SELECT policy_id FROM policies WHERE name = ? AND tenant_id = ?",
                (name, tenant_id),
            ).fetchone()
            if not row:
                return []
            policy_id = row[0]
            rows = conn.execute(
                """SELECT version_id, version, agent_role, allowed_sources, denied_sources,
                          max_sensitivity, changed_by, changed_at, allowed_tasks, denied_tasks,
                          industry, category
                   FROM policy_versions WHERE policy_id = ? AND tenant_id = ?
                   ORDER BY version DESC""",
                (policy_id, tenant_id),
            ).fetchall()
        return [
            {
                "version_id":      r[0],
                "version":         r[1],
                "agent_role":      r[2],
                "allowed_sources": json.loads(r[3]),
                "denied_sources":  json.loads(r[4]),
                "max_sensitivity": r[5],
                "changed_by":      r[6],
                "changed_at":      r[7],
                "allowed_tasks":   json.loads(r[8]) if r[8] else [],
                "denied_tasks":    json.loads(r[9]) if r[9] else [],
                "industry":        r[10],
                "category":        r[11],
            }
            for r in rows
        ]

    def count(self, tenant_id: str) -> int:
        with sqlite3.connect(self.db_path) as conn:
            return conn.execute(
                "SELECT COUNT(*) FROM policies WHERE tenant_id = ? AND is_active = 1",
                (tenant_id,),
            ).fetchone()[0]

    def _row_to_dict(self, row) -> dict:
        return {
            "policy_id":       row[0],
            "name":            row[1],
            "agent_role":      row[2],
            "allowed_sources": json.loads(row[3]),
            "denied_sources":  json.loads(row[4]),
            "max_sensitivity": row[5],
            "version":         row[6],
            "created_at":      row[7],
            "updated_at":      row[8],
            "changed_by":      row[9],
            "allowed_tasks":   json.loads(row[10]) if row[10] else [],
            "denied_tasks":    json.loads(row[11]) if row[11] else [],
            "industry":        row[12] if len(row) > 12 else None,
            "category":        row[13] if len(row) > 13 else None,
        }


class SQLiteAlertStore(AlertStoreBase):

    def __init__(self, db_path: str):
        self.db_path = db_path
        self._init_db()

    def _init_db(self):
        with sqlite3.connect(self.db_path) as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS alert_rules (
                    rule_id          TEXT PRIMARY KEY,
                    tenant_id        TEXT NOT NULL,
                    name             TEXT NOT NULL,
                    rule_type        TEXT NOT NULL,
                    threshold        INTEGER NOT NULL DEFAULT 5,
                    window_minutes   INTEGER NOT NULL DEFAULT 60,
                    cooldown_minutes INTEGER NOT NULL DEFAULT 60,
                    webhook_url      TEXT,
                    email            TEXT,
                    is_enabled       INTEGER NOT NULL DEFAULT 1,
                    created_at       TEXT NOT NULL,
                    last_fired_at    TEXT
                )
            """)
            conn.execute("""
                CREATE TABLE IF NOT EXISTS alert_deliveries (
                    delivery_id TEXT PRIMARY KEY,
                    rule_id     TEXT NOT NULL,
                    tenant_id   TEXT NOT NULL,
                    fired_at    TEXT NOT NULL,
                    rule_name   TEXT NOT NULL,
                    rule_type   TEXT NOT NULL,
                    detail      TEXT NOT NULL,
                    status      TEXT NOT NULL,
                    error       TEXT
                )
            """)
            conn.commit()

    def create_rule(self, rule: dict, tenant_id: str) -> str:
        rule_id = "alr_" + secrets.token_hex(8)
        now = datetime.utcnow().isoformat()
        with sqlite3.connect(self.db_path) as conn:
            conn.execute(
                """INSERT INTO alert_rules
                   (rule_id, tenant_id, name, rule_type, threshold, window_minutes,
                    cooldown_minutes, webhook_url, email, is_enabled, created_at)
                   VALUES (?,?,?,?,?,?,?,?,?,1,?)""",
                (rule_id, tenant_id, rule["name"], rule["rule_type"],
                 rule.get("threshold", 5), rule.get("window_minutes", 60),
                 rule.get("cooldown_minutes", 60),
                 rule.get("webhook_url"), rule.get("email"), now),
            )
            conn.commit()
        return rule_id

    def get_rule(self, rule_id: str, tenant_id: str) -> dict | None:
        with sqlite3.connect(self.db_path) as conn:
            row = conn.execute(
                "SELECT * FROM alert_rules WHERE rule_id=? AND tenant_id=?",
                (rule_id, tenant_id),
            ).fetchone()
        return self._rule_row(row) if row else None

    def list_rules(self, tenant_id: str) -> list[dict]:
        with sqlite3.connect(self.db_path) as conn:
            rows = conn.execute(
                "SELECT * FROM alert_rules WHERE tenant_id=? ORDER BY created_at",
                (tenant_id,),
            ).fetchall()
        return [self._rule_row(r) for r in rows]

    def update_rule(self, rule_id: str, updates: dict, tenant_id: str) -> bool:
        allowed = {"name", "threshold", "window_minutes", "cooldown_minutes",
                   "webhook_url", "email", "is_enabled"}
        fields = {k: v for k, v in updates.items() if k in allowed}
        if not fields:
            return False
        set_clause = ", ".join(f"{k}=?" for k in fields)
        with sqlite3.connect(self.db_path) as conn:
            cur = conn.execute(
                f"UPDATE alert_rules SET {set_clause} WHERE rule_id=? AND tenant_id=?",
                (*fields.values(), rule_id, tenant_id),
            )
            conn.commit()
        return cur.rowcount > 0

    def delete_rule(self, rule_id: str, tenant_id: str) -> bool:
        with sqlite3.connect(self.db_path) as conn:
            cur = conn.execute(
                "DELETE FROM alert_rules WHERE rule_id=? AND tenant_id=?",
                (rule_id, tenant_id),
            )
            conn.commit()
        return cur.rowcount > 0

    def mark_fired(self, rule_id: str, fired_at: str) -> None:
        with sqlite3.connect(self.db_path) as conn:
            conn.execute(
                "UPDATE alert_rules SET last_fired_at=? WHERE rule_id=?",
                (fired_at, rule_id),
            )
            conn.commit()

    def record_delivery(self, delivery: dict) -> None:
        delivery_id = secrets.token_hex(12)
        with sqlite3.connect(self.db_path) as conn:
            conn.execute(
                """INSERT INTO alert_deliveries
                   (delivery_id, rule_id, tenant_id, fired_at, rule_name,
                    rule_type, detail, status, error)
                   VALUES (?,?,?,?,?,?,?,?,?)""",
                (delivery_id, delivery["rule_id"], delivery["tenant_id"],
                 delivery["fired_at"], delivery["rule_name"], delivery["rule_type"],
                 delivery["detail"], delivery["status"], delivery.get("error")),
            )
            conn.commit()

    def list_deliveries(self, tenant_id: str, limit: int = 50) -> list[dict]:
        with sqlite3.connect(self.db_path) as conn:
            rows = conn.execute(
                """SELECT delivery_id, rule_id, tenant_id, fired_at, rule_name,
                          rule_type, detail, status, error
                   FROM alert_deliveries WHERE tenant_id=?
                   ORDER BY fired_at DESC LIMIT ?""",
                (tenant_id, limit),
            ).fetchall()
        return [
            {
                "delivery_id": r[0], "rule_id": r[1], "tenant_id": r[2],
                "fired_at": r[3], "rule_name": r[4], "rule_type": r[5],
                "detail": json.loads(r[6]), "status": r[7], "error": r[8],
            }
            for r in rows
        ]

    def _rule_row(self, row) -> dict:
        return {
            "rule_id": row[0], "tenant_id": row[1], "name": row[2],
            "rule_type": row[3], "threshold": row[4], "window_minutes": row[5],
            "cooldown_minutes": row[6], "webhook_url": row[7], "email": row[8],
            "is_enabled": bool(row[9]), "created_at": row[10], "last_fired_at": row[11],
        }


class SQLiteLineageStore(LineageStoreBase):

    def __init__(self, db_path: str):
        self.db_path = db_path
        self._init_db()

    def _init_db(self):
        with sqlite3.connect(self.db_path) as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS agent_actions (
                    action_id     TEXT PRIMARY KEY,
                    event_id      TEXT NOT NULL,
                    session_id    TEXT NOT NULL,
                    agent_role    TEXT NOT NULL,
                    action_type   TEXT NOT NULL,
                    action_detail TEXT NOT NULL DEFAULT '{}',
                    outcome       TEXT,
                    timestamp     TEXT NOT NULL,
                    tenant_id     TEXT NOT NULL
                )
            """)
            conn.execute("CREATE INDEX IF NOT EXISTS idx_actions_event_id ON agent_actions(event_id, tenant_id)")
            conn.execute("CREATE INDEX IF NOT EXISTS idx_actions_session_id ON agent_actions(session_id, tenant_id)")
            conn.commit()

    def record_action(self, action: AgentAction, tenant_id: str) -> None:
        with sqlite3.connect(self.db_path) as conn:
            conn.execute(
                """INSERT INTO agent_actions
                   (action_id, event_id, session_id, agent_role, action_type,
                    action_detail, outcome, timestamp, tenant_id)
                   VALUES (?,?,?,?,?,?,?,?,?)""",
                (
                    action.action_id, action.event_id, action.session_id,
                    action.agent_role, action.action_type,
                    json.dumps(action.action_detail),
                    action.outcome,
                    action.timestamp.isoformat(),
                    tenant_id,
                ),
            )
            conn.commit()

    def get_actions_for_event(self, event_id: str, tenant_id: str) -> list[AgentAction]:
        with sqlite3.connect(self.db_path) as conn:
            rows = conn.execute(
                """SELECT action_id, event_id, session_id, agent_role, action_type,
                          action_detail, outcome, timestamp
                   FROM agent_actions WHERE event_id = ? AND tenant_id = ?
                   ORDER BY timestamp""",
                (event_id, tenant_id),
            ).fetchall()
        return [self._row_to_action(r) for r in rows]

    def get_actions_for_session(self, session_id: str, tenant_id: str) -> list[AgentAction]:
        with sqlite3.connect(self.db_path) as conn:
            rows = conn.execute(
                """SELECT action_id, event_id, session_id, agent_role, action_type,
                          action_detail, outcome, timestamp
                   FROM agent_actions WHERE session_id = ? AND tenant_id = ?
                   ORDER BY timestamp""",
                (session_id, tenant_id),
            ).fetchall()
        return [self._row_to_action(r) for r in rows]

    def _row_to_action(self, row) -> AgentAction:
        return AgentAction(
            action_id=row[0],
            event_id=row[1],
            session_id=row[2],
            agent_role=row[3],
            action_type=row[4],
            action_detail=json.loads(row[5]),
            outcome=row[6],
            timestamp=datetime.fromisoformat(row[7]),
        )


class SQLiteSessionStore(SessionStoreBase):
    """Tracks session lifecycle: creation, activity, TTL expiration, and revocation."""

    def __init__(self, db_path: str):
        self.db_path = db_path
        self._init_db()

    def _init_db(self):
        with sqlite3.connect(self.db_path) as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS sessions (
                    session_id   TEXT NOT NULL,
                    tenant_id    TEXT NOT NULL,
                    agent_role   TEXT NOT NULL,
                    user_id      TEXT NOT NULL,
                    created_at   TEXT NOT NULL,
                    last_active  TEXT NOT NULL,
                    expires_at   TEXT,
                    ttl_minutes  INTEGER,
                    is_revoked   INTEGER NOT NULL DEFAULT 0,
                    PRIMARY KEY (session_id, tenant_id)
                )
            """)
            conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_sessions_tenant ON sessions(tenant_id, created_at)"
            )
            conn.commit()

    def create_session(
        self,
        session_id: str,
        agent_role: str,
        user_id: str,
        tenant_id: str,
        ttl_minutes: int | None = None,
    ) -> None:
        now = datetime.now(timezone.utc)
        expires_at = (
            (now + timedelta(minutes=ttl_minutes)).isoformat()
            if ttl_minutes is not None
            else None
        )
        with sqlite3.connect(self.db_path) as conn:
            conn.execute(
                """INSERT OR IGNORE INTO sessions
                   (session_id, tenant_id, agent_role, user_id,
                    created_at, last_active, expires_at, ttl_minutes)
                   VALUES (?,?,?,?,?,?,?,?)""",
                (
                    session_id, tenant_id, agent_role, user_id,
                    now.isoformat(), now.isoformat(),
                    expires_at, ttl_minutes,
                ),
            )
            conn.commit()

    def get_session(self, session_id: str, tenant_id: str) -> dict | None:
        with sqlite3.connect(self.db_path) as conn:
            row = conn.execute(
                """SELECT session_id, tenant_id, agent_role, user_id,
                          created_at, last_active, expires_at, ttl_minutes, is_revoked
                   FROM sessions WHERE session_id = ? AND tenant_id = ?""",
                (session_id, tenant_id),
            ).fetchone()
        if row is None:
            return None
        return self._row_to_session(row)

    def touch_session(self, session_id: str, tenant_id: str) -> None:
        now = datetime.now(timezone.utc).isoformat()
        with sqlite3.connect(self.db_path) as conn:
            conn.execute(
                "UPDATE sessions SET last_active = ? WHERE session_id = ? AND tenant_id = ?",
                (now, session_id, tenant_id),
            )
            conn.commit()

    def revoke_session(self, session_id: str, tenant_id: str) -> bool:
        with sqlite3.connect(self.db_path) as conn:
            cur = conn.execute(
                "UPDATE sessions SET is_revoked = 1 WHERE session_id = ? AND tenant_id = ?",
                (session_id, tenant_id),
            )
            conn.commit()
        return cur.rowcount > 0

    def list_sessions(self, tenant_id: str, include_expired: bool = False) -> list[dict]:
        now = datetime.now(timezone.utc).isoformat()
        with sqlite3.connect(self.db_path) as conn:
            if include_expired:
                rows = conn.execute(
                    """SELECT session_id, tenant_id, agent_role, user_id,
                              created_at, last_active, expires_at, ttl_minutes, is_revoked
                       FROM sessions WHERE tenant_id = ?
                       ORDER BY created_at DESC""",
                    (tenant_id,),
                ).fetchall()
            else:
                rows = conn.execute(
                    """SELECT session_id, tenant_id, agent_role, user_id,
                              created_at, last_active, expires_at, ttl_minutes, is_revoked
                       FROM sessions
                       WHERE tenant_id = ?
                         AND is_revoked = 0
                         AND (expires_at IS NULL OR expires_at > ?)
                       ORDER BY created_at DESC""",
                    (tenant_id, now),
                ).fetchall()
        return [self._row_to_session(r) for r in rows]

    def is_valid(self, session_id: str, tenant_id: str) -> tuple[bool, str]:
        session = self.get_session(session_id, tenant_id)
        if session is None:
            return False, "session_not_found"
        if session["is_revoked"]:
            return False, "session_revoked"
        if session["expires_at"] is not None:
            now = datetime.now(timezone.utc)
            expires = datetime.fromisoformat(session["expires_at"])
            if expires.tzinfo is None:
                expires = expires.replace(tzinfo=timezone.utc)
            if now > expires:
                return False, "session_expired"
        return True, ""

    def _row_to_session(self, row) -> dict:
        return {
            "session_id":  row[0],
            "tenant_id":   row[1],
            "agent_role":  row[2],
            "user_id":     row[3],
            "created_at":  row[4],
            "last_active": row[5],
            "expires_at":  row[6],
            "ttl_minutes": row[7],
            "is_revoked":  bool(row[8]),
        }


class SQLiteCatalogStore(CatalogStoreBase):

    def __init__(self, db_path: str):
        self.db_path = db_path
        self._init_db()

    def _init_db(self):
        with sqlite3.connect(self.db_path) as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS catalog_connections (
                    connection_id         TEXT PRIMARY KEY,
                    tenant_id             TEXT NOT NULL,
                    catalog_type          TEXT NOT NULL,
                    display_name          TEXT NOT NULL,
                    base_url              TEXT NOT NULL,
                    credentials_enc       TEXT NOT NULL,
                    sync_interval_minutes INTEGER NOT NULL DEFAULT 15,
                    is_enabled            INTEGER NOT NULL DEFAULT 1,
                    created_at            TEXT NOT NULL,
                    updated_at            TEXT NOT NULL,
                    last_sync_at          TEXT,
                    webhook_secret        TEXT,
                    status                TEXT NOT NULL DEFAULT 'active'
                )
            """)
            conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_catalog_conn_tenant ON catalog_connections(tenant_id)"
            )
            # Migration: add status column to existing databases
            if not _has_column(conn, "catalog_connections", "status"):
                conn.execute(
                    "ALTER TABLE catalog_connections ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"
                )

            conn.execute("""
                CREATE TABLE IF NOT EXISTS catalog_classifications (
                    classification_id  TEXT NOT NULL,
                    connection_id      TEXT NOT NULL,
                    tenant_id          TEXT NOT NULL,
                    asset_fqn          TEXT NOT NULL,
                    asset_type         TEXT NOT NULL DEFAULT 'table',
                    sensitivity_label  TEXT NOT NULL,
                    raw_label          TEXT NOT NULL,
                    data_classes       TEXT NOT NULL DEFAULT '[]',
                    is_certified       INTEGER,
                    trust_score        REAL,
                    catalog_policy_ids TEXT NOT NULL DEFAULT '[]',
                    fetched_at         TEXT NOT NULL,
                    expires_at         TEXT NOT NULL,
                    PRIMARY KEY (connection_id, asset_fqn)
                )
            """)
            conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_cls_tenant_fqn ON catalog_classifications(tenant_id, asset_fqn)"
            )
            conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_cls_conn ON catalog_classifications(connection_id)"
            )
            conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_cls_expires ON catalog_classifications(expires_at)"
            )

            conn.execute("""
                CREATE TABLE IF NOT EXISTS catalog_sync_history (
                    sync_id        TEXT PRIMARY KEY,
                    connection_id  TEXT NOT NULL,
                    tenant_id      TEXT NOT NULL,
                    status         TEXT NOT NULL,
                    assets_synced  INTEGER NOT NULL DEFAULT 0,
                    assets_skipped INTEGER NOT NULL DEFAULT 0,
                    error_message  TEXT,
                    started_at     TEXT NOT NULL,
                    completed_at   TEXT
                )
            """)
            conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_sync_conn ON catalog_sync_history(connection_id, started_at)"
            )
            conn.commit()

    # ── connections ─────────────────────────────────────────────────────────

    def create_connection(self, conn_obj: CatalogConnection) -> str:
        with sqlite3.connect(self.db_path) as conn:
            conn.execute(
                """INSERT INTO catalog_connections
                   (connection_id, tenant_id, catalog_type, display_name, base_url,
                    credentials_enc, sync_interval_minutes, is_enabled, created_at,
                    updated_at, last_sync_at, webhook_secret, status)
                   VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
                (
                    conn_obj.connection_id, conn_obj.tenant_id, conn_obj.catalog_type,
                    conn_obj.display_name, conn_obj.base_url, conn_obj.credentials_enc,
                    conn_obj.sync_interval_minutes, int(conn_obj.is_enabled),
                    conn_obj.created_at.isoformat(), conn_obj.updated_at.isoformat(),
                    conn_obj.last_sync_at.isoformat() if conn_obj.last_sync_at else None,
                    conn_obj.webhook_secret,
                    conn_obj.status,
                ),
            )
            conn.commit()
        return conn_obj.connection_id

    def get_connection(self, connection_id: str, tenant_id: str) -> CatalogConnection | None:
        with sqlite3.connect(self.db_path) as conn:
            row = conn.execute(
                "SELECT * FROM catalog_connections WHERE connection_id = ? AND tenant_id = ?",
                (connection_id, tenant_id),
            ).fetchone()
        return self._row_to_connection(row) if row else None

    def list_connections(self, tenant_id: str) -> list[CatalogConnection]:
        with sqlite3.connect(self.db_path) as conn:
            rows = conn.execute(
                "SELECT * FROM catalog_connections WHERE tenant_id = ? ORDER BY display_name",
                (tenant_id,),
            ).fetchall()
        return [self._row_to_connection(r) for r in rows]

    def list_all_connections(self) -> list[CatalogConnection]:
        with sqlite3.connect(self.db_path) as conn:
            rows = conn.execute(
                "SELECT * FROM catalog_connections ORDER BY tenant_id, display_name"
            ).fetchall()
        return [self._row_to_connection(r) for r in rows]

    def update_connection(self, connection_id: str, updates: dict, tenant_id: str) -> bool:
        allowed = {
            "display_name", "base_url", "credentials_enc",
            "sync_interval_minutes", "is_enabled", "webhook_secret", "status",
        }
        fields = {k: v for k, v in updates.items() if k in allowed}
        if not fields:
            return False
        fields["updated_at"] = datetime.utcnow().isoformat()
        cols = ", ".join(f"{k} = ?" for k in fields)
        vals = list(fields.values()) + [connection_id, tenant_id]
        with sqlite3.connect(self.db_path) as conn:
            cur = conn.execute(
                f"UPDATE catalog_connections SET {cols} WHERE connection_id = ? AND tenant_id = ?",
                vals,
            )
            conn.commit()
        return cur.rowcount > 0

    def delete_connection(self, connection_id: str, tenant_id: str) -> bool:
        with sqlite3.connect(self.db_path) as conn:
            cur = conn.execute(
                "DELETE FROM catalog_connections WHERE connection_id = ? AND tenant_id = ?",
                (connection_id, tenant_id),
            )
            conn.execute(
                "DELETE FROM catalog_classifications WHERE connection_id = ?",
                (connection_id,),
            )
            conn.execute(
                "DELETE FROM catalog_sync_history WHERE connection_id = ?",
                (connection_id,),
            )
            conn.commit()
        return cur.rowcount > 0

    def mark_sync_completed(self, connection_id: str, sync_at: datetime) -> None:
        with sqlite3.connect(self.db_path) as conn:
            conn.execute(
                "UPDATE catalog_connections SET last_sync_at = ? WHERE connection_id = ?",
                (sync_at.isoformat(), connection_id),
            )
            conn.commit()

    def _row_to_connection(self, row) -> CatalogConnection:
        return CatalogConnection(
            connection_id=row[0],
            tenant_id=row[1],
            catalog_type=row[2],
            display_name=row[3],
            base_url=row[4],
            credentials_enc=row[5],
            sync_interval_minutes=row[6],
            is_enabled=bool(row[7]),
            created_at=datetime.fromisoformat(row[8]),
            updated_at=datetime.fromisoformat(row[9]),
            last_sync_at=datetime.fromisoformat(row[10]) if row[10] else None,
            webhook_secret=row[11],
            status=row[12] if len(row) > 12 and row[12] else "active",
        )

    # ── classifications ──────────────────────────────────────────────────────

    def upsert_classifications(self, classifications: list[CatalogClassification]) -> int:
        written = 0
        with sqlite3.connect(self.db_path) as conn:
            for cls in classifications:
                conn.execute(
                    """INSERT INTO catalog_classifications
                       (classification_id, connection_id, tenant_id, asset_fqn, asset_type,
                        sensitivity_label, raw_label, data_classes, is_certified, trust_score,
                        catalog_policy_ids, fetched_at, expires_at)
                       VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
                       ON CONFLICT(connection_id, asset_fqn) DO UPDATE SET
                           classification_id  = excluded.classification_id,
                           asset_type         = excluded.asset_type,
                           sensitivity_label  = excluded.sensitivity_label,
                           raw_label          = excluded.raw_label,
                           data_classes       = excluded.data_classes,
                           is_certified       = excluded.is_certified,
                           trust_score        = excluded.trust_score,
                           catalog_policy_ids = excluded.catalog_policy_ids,
                           fetched_at         = excluded.fetched_at,
                           expires_at         = excluded.expires_at""",
                    (
                        cls.classification_id, cls.connection_id, cls.tenant_id,
                        cls.asset_fqn, cls.asset_type, cls.sensitivity_label, cls.raw_label,
                        json.dumps(cls.data_classes),
                        int(cls.is_certified) if cls.is_certified is not None else None,
                        cls.trust_score,
                        json.dumps(cls.catalog_policy_ids),
                        cls.fetched_at.isoformat(), cls.expires_at.isoformat(),
                    ),
                )
                written += 1
            conn.commit()
        return written

    def get_classifications_for_asset(
        self, tenant_id: str, asset_fqn: str
    ) -> list[CatalogClassification]:
        now = datetime.utcnow().isoformat()
        # Prefix match: 'reports' matches 'catalog.schema.reports' and 'reports.col'
        with sqlite3.connect(self.db_path) as conn:
            rows = conn.execute(
                """SELECT * FROM catalog_classifications
                   WHERE tenant_id = ? AND expires_at > ?
                   AND (asset_fqn = ? OR asset_fqn LIKE ? OR ? LIKE asset_fqn || '%')
                   ORDER BY fetched_at DESC""",
                (tenant_id, now, asset_fqn, f"%{asset_fqn}%", asset_fqn),
            ).fetchall()
        return [self._row_to_cls(r) for r in rows]

    def list_classifications(
        self,
        tenant_id: str,
        connection_id: str | None = None,
        sensitivity_label: str | None = None,
    ) -> list[CatalogClassification]:
        query = "SELECT * FROM catalog_classifications WHERE tenant_id = ?"
        params: list = [tenant_id]
        if connection_id:
            query += " AND connection_id = ?"
            params.append(connection_id)
        if sensitivity_label:
            query += " AND sensitivity_label = ?"
            params.append(sensitivity_label)
        query += " ORDER BY asset_fqn"
        with sqlite3.connect(self.db_path) as conn:
            rows = conn.execute(query, params).fetchall()
        return [self._row_to_cls(r) for r in rows]

    def delete_stale_classifications(self, connection_id: str) -> int:
        now = datetime.utcnow().isoformat()
        with sqlite3.connect(self.db_path) as conn:
            cur = conn.execute(
                "DELETE FROM catalog_classifications WHERE connection_id = ? AND expires_at <= ?",
                (connection_id, now),
            )
            conn.commit()
        return cur.rowcount

    def _row_to_cls(self, row) -> CatalogClassification:
        return CatalogClassification(
            classification_id=row[0],
            connection_id=row[1],
            tenant_id=row[2],
            asset_fqn=row[3],
            asset_type=row[4],
            sensitivity_label=row[5],
            raw_label=row[6],
            data_classes=json.loads(row[7]),
            is_certified=bool(row[8]) if row[8] is not None else None,
            trust_score=row[9],
            catalog_policy_ids=json.loads(row[10]),
            fetched_at=datetime.fromisoformat(row[11]),
            expires_at=datetime.fromisoformat(row[12]),
        )

    # ── sync history ─────────────────────────────────────────────────────────

    def record_sync(self, result: SyncResult) -> None:
        with sqlite3.connect(self.db_path) as conn:
            conn.execute(
                """INSERT INTO catalog_sync_history
                   (sync_id, connection_id, tenant_id, status, assets_synced,
                    assets_skipped, error_message, started_at, completed_at)
                   VALUES (?,?,?,?,?,?,?,?,?)""",
                (
                    result.sync_id, result.connection_id, result.tenant_id,
                    result.status, result.assets_synced, result.assets_skipped,
                    result.error_message,
                    result.started_at.isoformat(),
                    result.completed_at.isoformat() if result.completed_at else None,
                ),
            )
            conn.commit()

    def list_sync_history(
        self, connection_id: str, tenant_id: str, limit: int = 25
    ) -> list[SyncResult]:
        with sqlite3.connect(self.db_path) as conn:
            rows = conn.execute(
                """SELECT * FROM catalog_sync_history
                   WHERE connection_id = ? AND tenant_id = ?
                   ORDER BY started_at DESC LIMIT ?""",
                (connection_id, tenant_id, limit),
            ).fetchall()
        return [
            SyncResult(
                sync_id=r[0], connection_id=r[1], tenant_id=r[2], status=r[3],
                assets_synced=r[4], assets_skipped=r[5],
                started_at=datetime.fromisoformat(r[7]),
                error_message=r[6],
                completed_at=datetime.fromisoformat(r[8]) if r[8] else None,
            )
            for r in rows
        ]

    def get_catalog_stats(self, tenant_id: str) -> dict:
        with sqlite3.connect(self.db_path) as conn:
            # Connection counts by status
            conn_rows = conn.execute(
                "SELECT status, is_enabled, COUNT(*) FROM catalog_connections WHERE tenant_id=? GROUP BY status, is_enabled",
                (tenant_id,),
            ).fetchall()

            total_conn = sum(r[2] for r in conn_rows)
            active_conn = sum(r[2] for r in conn_rows if r[0] == "active" and r[1])
            degraded_conn = sum(r[2] for r in conn_rows if r[0] == "degraded")
            disabled_conn = sum(r[2] for r in conn_rows if not r[1])

            # Classification count
            total_cls = conn.execute(
                "SELECT COUNT(*) FROM catalog_classifications WHERE tenant_id=?",
                (tenant_id,),
            ).fetchone()[0]

            # Sensitivity distribution
            sens_rows = conn.execute(
                "SELECT sensitivity_label, COUNT(*) FROM catalog_classifications WHERE tenant_id=? GROUP BY sensitivity_label",
                (tenant_id,),
            ).fetchall()
            sensitivity_dist = {r[0]: r[1] for r in sens_rows}

            # Coverage by catalog type (join classifications → connections)
            type_rows = conn.execute(
                """SELECT cc.catalog_type, COUNT(cls.classification_id)
                   FROM catalog_connections cc
                   LEFT JOIN catalog_classifications cls ON cc.connection_id = cls.connection_id
                   WHERE cc.tenant_id=?
                   GROUP BY cc.catalog_type""",
                (tenant_id,),
            ).fetchall()
            coverage_by_type = {r[0]: r[1] for r in type_rows}

        return {
            "total_connections":       total_conn,
            "active_connections":      active_conn,
            "degraded_connections":    degraded_conn,
            "disabled_connections":    disabled_conn,
            "total_classifications":   total_cls,
            "sensitivity_distribution": sensitivity_dist,
            "coverage_by_catalog_type": coverage_by_type,
        }


class SQLiteTrialStore(TrialStoreBase):

    def __init__(self, db_path: str):
        self.db_path = db_path
        self._init_db()

    def _init_db(self):
        with sqlite3.connect(self.db_path) as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS trials (
                    trial_id          TEXT PRIMARY KEY,
                    tenant_id         TEXT NOT NULL UNIQUE,
                    name              TEXT NOT NULL,
                    email             TEXT NOT NULL,
                    company           TEXT NOT NULL,
                    industry          TEXT NOT NULL DEFAULT 'financial_services',
                    trial_start       TEXT NOT NULL,
                    trial_end         TEXT NOT NULL,
                    status            TEXT NOT NULL DEFAULT 'active',
                    email_day7_sent   INTEGER NOT NULL DEFAULT 0,
                    email_day13_sent  INTEGER NOT NULL DEFAULT 0
                )
            """)

    def create_trial(self, trial_id, tenant_id, name, email, company, industry, trial_end):
        now = datetime.now(timezone.utc).isoformat()
        with sqlite3.connect(self.db_path) as conn:
            conn.execute(
                "INSERT INTO trials (trial_id,tenant_id,name,email,company,industry,trial_start,trial_end) VALUES (?,?,?,?,?,?,?,?)",
                (trial_id, tenant_id, name, email, company, industry, now, trial_end),
            )

    def get_trial_by_tenant(self, tenant_id):
        with sqlite3.connect(self.db_path) as conn:
            conn.row_factory = sqlite3.Row
            row = conn.execute("SELECT * FROM trials WHERE tenant_id = ?", (tenant_id,)).fetchone()
            return dict(row) if row else None

    def list_active_trials(self):
        with sqlite3.connect(self.db_path) as conn:
            conn.row_factory = sqlite3.Row
            return [dict(r) for r in conn.execute("SELECT * FROM trials WHERE status='active'").fetchall()]

    def list_all_trials(self):
        with sqlite3.connect(self.db_path) as conn:
            conn.row_factory = sqlite3.Row
            return [dict(r) for r in conn.execute("SELECT * FROM trials ORDER BY trial_start DESC").fetchall()]

    def expire_trials(self, before):
        before_str = before.isoformat()
        with sqlite3.connect(self.db_path) as conn:
            conn.row_factory = sqlite3.Row
            rows = conn.execute(
                "SELECT tenant_id FROM trials WHERE status='active' AND trial_end < ?", (before_str,)
            ).fetchall()
            tenant_ids = [r["tenant_id"] for r in rows]
            if tenant_ids:
                conn.execute(
                    f"UPDATE trials SET status='expired' WHERE tenant_id IN ({','.join('?'*len(tenant_ids))})",
                    tenant_ids,
                )
            return tenant_ids

    def mark_email_sent(self, trial_id, email_type):
        col = "email_day7_sent" if email_type == "day7" else "email_day13_sent"
        with sqlite3.connect(self.db_path) as conn:
            conn.execute(f"UPDATE trials SET {col}=1 WHERE trial_id=?", (trial_id,))
