"""Entity-resolution audit log + reversibility primitive.

Captures the BEFORE state of every destructive ``merge_entities`` call so
the operation can be reversed with a single ``unmerge_entities`` call.

Architectural commitments (NOT speculative future-proofing — each one
matches an existing AgntSpace pattern that costs near-zero now and is
expensive to retrofit later):

  * **Generic table name** ``entity_resolution_audit`` — accommodates
    future non-destructive equivalence records (sameAs assertions from
    fuzzy matchers, identifier-anchored auto-links) without requiring a
    rename + migration.
  * **Forward-compatible columns** ``source`` and ``evidence`` mirror the
    shape of ``decision_log`` and ``cost_log``. Today every row is
    ``source='user_initiated'`` / ``evidence='{}'``; future linkers
    populate ``source='identifier_match'`` / ``evidence={...}`` without
    schema change.
  * **Standard accountability stack** — every merge / unmerge call is
    paired with a ``DecisionLogger`` record + ``ActivityLogger`` event at
    the API layer (where async is available); this module owns persistence
    only.

Storage: SQLite table ``entity_resolution_audit`` in the per-vault
``agntspace.db`` (initialized alongside ``decision_log``,
``cost_log``, etc.). In-memory fallback when ``db=None`` so unit tests
exercise the API without a real connection.

Every method is non-fatal: a broken audit log must never break a merge
operation. Failures log + return sentinel values (``record`` returns 0,
``mark_retracted`` returns False).
"""

from __future__ import annotations

import asyncio
import json
import logging
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    import aiosqlite

log = logging.getLogger(__name__)


def _now_iso() -> str:
    """ISO-8601 UTC timestamp truncated to seconds (matches DecisionLogger)."""
    return datetime.now(UTC).isoformat(timespec="seconds")


@dataclass
class EntityResolutionAudit:
    """Single audit row capturing one merge.

    Stored fields are the minimum needed to reverse the merge:
    ``removed_*`` describe the entity that was destroyed (so we can
    recreate it in the registry), ``repointed_triple_ids`` lists every
    triple whose subject or object was rewritten from ``removed_slug``
    to ``kept_slug`` (so we can restore them).

    ``source`` and ``evidence`` are forward-compatible — see module
    docstring. Today only ``user_initiated`` / ``{}`` are used.
    """

    id: int = 0
    ts: str = field(default_factory=_now_iso)
    kept_slug: str = ""
    removed_slug: str = ""
    removed_canonical_name: str = ""
    removed_entity_type: str = ""
    removed_aliases: list[str] = field(default_factory=list)
    repointed_triple_ids: list[int] = field(default_factory=list)
    source: str = "user_initiated"
    evidence: dict[str, Any] = field(default_factory=dict)
    correlation_id: str = ""
    retracted_at: str = ""
    retracted_reason: str = ""

    def to_dict(self) -> dict[str, Any]:
        return asdict(self)


class EntityResolutionAuditLog:
    """SQLite-backed audit log for entity merges.

    Thread-safe via a single ``asyncio.Lock`` covering all writes.
    Non-fatal on every failure path — caller is the merge code, which
    must never crash on observability problems.

    Pattern mirrors ``DecisionLogger`` (``activity/decisions.py``) so
    operators reading the codebase recognize the shape immediately.
    """

    # Base schema (v1). Additive changes go in ``_MIGRATIONS`` so existing
    # databases upgrade in place — same idempotent pattern as decision_log.
    _SCHEMA = """
    CREATE TABLE IF NOT EXISTS entity_resolution_audit (
        id                       INTEGER PRIMARY KEY AUTOINCREMENT,
        ts                       TEXT    NOT NULL,
        kept_slug                TEXT    NOT NULL,
        removed_slug             TEXT    NOT NULL,
        removed_canonical_name   TEXT    NOT NULL,
        removed_entity_type      TEXT    DEFAULT '',
        removed_aliases          TEXT    NOT NULL DEFAULT '[]',
        repointed_triple_ids     TEXT    NOT NULL DEFAULT '[]',
        source                   TEXT    NOT NULL DEFAULT 'user_initiated',
        evidence                 TEXT    NOT NULL DEFAULT '{}',
        correlation_id           TEXT    DEFAULT '',
        retracted_at             TEXT    DEFAULT '',
        retracted_reason         TEXT    DEFAULT ''
    );
    CREATE INDEX IF NOT EXISTS idx_eraudit_ts        ON entity_resolution_audit(ts);
    CREATE INDEX IF NOT EXISTS idx_eraudit_kept      ON entity_resolution_audit(kept_slug);
    CREATE INDEX IF NOT EXISTS idx_eraudit_retracted ON entity_resolution_audit(retracted_at);
    """

    # Reserved for future additive migrations; same pattern as decisions._MIGRATIONS.
    _MIGRATIONS: list[str] = [  # arch:deterministic — SQL schema migrations, not strategy
    ]

    def __init__(self, db: aiosqlite.Connection | None = None) -> None:
        self._db = db
        self._lock = asyncio.Lock()
        self._initialized = False
        # In-memory fallback for tests + degraded operation when no db wired
        self._fallback: list[dict[str, Any]] = []
        self._next_fallback_id = 1

    async def initialize(self) -> None:
        """Create the table. Safe to call multiple times."""
        if self._initialized or self._db is None:
            self._initialized = True
            return
        async with self._lock:
            try:
                await self._db.executescript(self._SCHEMA)
                for stmt in self._MIGRATIONS:
                    try:
                        await self._db.execute(stmt)
                    except Exception:
                        # Already applied — same idempotent pattern as decisions._MIGRATIONS
                        pass
                await self._db.commit()
                self._initialized = True
            except Exception:
                log.exception("Failed to initialize entity_resolution_audit table")

    async def record(
        self,
        kept_slug: str,
        removed_slug: str,
        removed_canonical_name: str,
        removed_entity_type: str,
        removed_aliases: list[str],
        repointed_triple_ids: list[int],
        *,
        source: str = "user_initiated",
        evidence: dict[str, Any] | None = None,
        correlation_id: str = "",
    ) -> int:
        """Persist one audit row. Returns the audit_id, or 0 on failure.

        ``correlation_id`` is auto-filled from the current
        ``correlation_scope`` if empty (same convention as DecisionLogger).
        """
        from agntspace.activity.decisions import current_correlation_id

        audit = EntityResolutionAudit(
            kept_slug=kept_slug,
            removed_slug=removed_slug,
            removed_canonical_name=removed_canonical_name,
            removed_entity_type=removed_entity_type or "",
            removed_aliases=list(removed_aliases or []),
            repointed_triple_ids=list(repointed_triple_ids or []),
            source=source or "user_initiated",
            evidence=dict(evidence or {}),
            correlation_id=correlation_id or current_correlation_id(),
        )

        if self._db is None:
            audit.id = self._next_fallback_id
            self._next_fallback_id += 1
            self._fallback.append(audit.to_dict())
            return audit.id

        if not self._initialized:
            await self.initialize()

        try:
            async with self._lock:
                cursor = await self._db.execute(
                    "INSERT INTO entity_resolution_audit "
                    "(ts, kept_slug, removed_slug, removed_canonical_name, "
                    "removed_entity_type, removed_aliases, repointed_triple_ids, "
                    "source, evidence, correlation_id) "
                    "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                    (
                        audit.ts,
                        audit.kept_slug,
                        audit.removed_slug,
                        audit.removed_canonical_name,
                        audit.removed_entity_type,
                        json.dumps(audit.removed_aliases),
                        json.dumps(audit.repointed_triple_ids),
                        audit.source,
                        json.dumps(audit.evidence, default=str),
                        audit.correlation_id,
                    ),
                )
                await self._db.commit()
                return cursor.lastrowid or 0
        except Exception:
            log.exception("Failed to record entity_resolution_audit row")
            return 0

    async def get(self, audit_id: int) -> EntityResolutionAudit | None:
        """Look up one audit row by id. Returns None if not found."""
        if self._db is None:
            for row in self._fallback:
                if row.get("id") == audit_id:
                    return _audit_from_dict(row)
            return None

        if not self._initialized:
            await self.initialize()

        try:
            async with self._lock:
                cursor = await self._db.execute(
                    "SELECT id, ts, kept_slug, removed_slug, removed_canonical_name, "
                    "removed_entity_type, removed_aliases, repointed_triple_ids, "
                    "source, evidence, correlation_id, retracted_at, retracted_reason "
                    "FROM entity_resolution_audit WHERE id = ?",
                    (int(audit_id),),
                )
                row = await cursor.fetchone()
                if not row:
                    return None
                return _audit_from_row(row)
        except Exception:
            log.exception("Failed to load entity_resolution_audit row")
            return None

    async def list_recent(
        self,
        limit: int = 20,
        *,
        include_retracted: bool = False,
    ) -> list[EntityResolutionAudit]:
        """Return the newest N audits. By default hides retracted rows."""
        limit = max(1, min(int(limit), 500))

        if self._db is None:
            rows = sorted(self._fallback, key=lambda r: r.get("id", 0), reverse=True)
            if not include_retracted:
                rows = [r for r in rows if not r.get("retracted_at")]
            return [_audit_from_dict(r) for r in rows[:limit]]

        if not self._initialized:
            await self.initialize()

        try:
            async with self._lock:
                base_query = (
                    "SELECT id, ts, kept_slug, removed_slug, removed_canonical_name, "
                    "removed_entity_type, removed_aliases, repointed_triple_ids, "
                    "source, evidence, correlation_id, retracted_at, retracted_reason "
                    "FROM entity_resolution_audit"
                )
                where = "" if include_retracted else " WHERE retracted_at = ''"
                cursor = await self._db.execute(
                    base_query + where + " ORDER BY id DESC LIMIT ?",
                    (limit,),
                )
                rows = await cursor.fetchall()
                return [_audit_from_row(r) for r in rows]
        except Exception:
            log.exception("Failed to list entity_resolution_audit rows")
            return []

    async def mark_retracted(self, audit_id: int, reason: str) -> bool:
        """Mark a row as retracted. Returns True on success.

        Idempotent: re-marking an already-retracted row is a no-op
        returning False (the row didn't transition this call).
        """
        ts = _now_iso()
        reason = reason or ""

        if self._db is None:
            for row in self._fallback:
                if row.get("id") == audit_id and not row.get("retracted_at"):
                    row["retracted_at"] = ts
                    row["retracted_reason"] = reason
                    return True
            return False

        if not self._initialized:
            await self.initialize()

        try:
            async with self._lock:
                cursor = await self._db.execute(
                    "UPDATE entity_resolution_audit "
                    "SET retracted_at = ?, retracted_reason = ? "
                    "WHERE id = ? AND retracted_at = ''",
                    (ts, reason, int(audit_id)),
                )
                await self._db.commit()
                return (cursor.rowcount or 0) > 0
        except Exception:
            log.exception("Failed to mark entity_resolution_audit row retracted")
            return False


# ── Row / dict deserialization helpers ──


def _audit_from_row(row: tuple) -> EntityResolutionAudit:
    """Build an EntityResolutionAudit from a SQLite row tuple."""
    return EntityResolutionAudit(
        id=int(row[0]),
        ts=row[1] or "",
        kept_slug=row[2] or "",
        removed_slug=row[3] or "",
        removed_canonical_name=row[4] or "",
        removed_entity_type=row[5] or "",
        removed_aliases=_safe_json_loads(row[6], default=[]),
        repointed_triple_ids=_safe_json_loads(row[7], default=[]),
        source=row[8] or "user_initiated",
        evidence=_safe_json_loads(row[9], default={}),
        correlation_id=row[10] or "",
        retracted_at=row[11] or "",
        retracted_reason=row[12] or "",
    )


def _audit_from_dict(d: dict[str, Any]) -> EntityResolutionAudit:
    """Build an EntityResolutionAudit from the in-memory fallback dict shape."""
    return EntityResolutionAudit(
        id=int(d.get("id", 0)),
        ts=d.get("ts", ""),
        kept_slug=d.get("kept_slug", ""),
        removed_slug=d.get("removed_slug", ""),
        removed_canonical_name=d.get("removed_canonical_name", ""),
        removed_entity_type=d.get("removed_entity_type", ""),
        removed_aliases=list(d.get("removed_aliases", [])),
        repointed_triple_ids=list(d.get("repointed_triple_ids", [])),
        source=d.get("source", "user_initiated"),
        evidence=dict(d.get("evidence", {})),
        correlation_id=d.get("correlation_id", ""),
        retracted_at=d.get("retracted_at", ""),
        retracted_reason=d.get("retracted_reason", ""),
    )


def _safe_json_loads(s: str | None, *, default: Any) -> Any:
    """JSON-decode safely. Returns ``default`` on any error or empty input."""
    if not s:
        return default
    try:
        v = json.loads(s)
        return v if v is not None else default
    except Exception:
        return default
