"""Per-correlation-scope write tracking for turn-level revert.

Every ``VaultWriter.write()`` / ``append()`` that fires inside a
correlation scope leaves a breadcrumb in the ``scope_file_writes`` table:
the path, a pre-write content snapshot (copied into ``.history/``), and
whether the file was created or modified. A later "revert scope"
operation reads the breadcrumbs and restores the files — deleting
creates, rolling back modifications.

Design choices:

  - **Sync recording**: VaultWriter.write() is synchronous. Rather than
    bridge to aiosqlite we open a dedicated sqlite3 connection (WAL mode
    is fine with multiple connections to the same DB) and write through
    a threading.Lock. This is low-volume — one row per vault write —
    so the extra connection is negligible.

  - **Fresh snapshot per scope**: we don't rely on VaultWriter's
    existing ``.history/`` versioning, which is gated on
    ``_is_versionable()`` (wiki files only) and deduplicates by hash.
    Revert needs a snapshot for EVERY file and must not be confused by
    hash-dedup skips. So we save a scope-tagged copy under
    ``.history/`` with a name that encodes the correlation_id.

  - **Graceful degradation**: every step is wrapped in try/except.
    A broken recorder never breaks vault writes. Revert only touches
    files whose rows we successfully wrote.
"""

from __future__ import annotations

import hashlib
import logging
import sqlite3
import threading
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path

log = logging.getLogger(__name__)


@dataclass
class ScopeWriteRow:
    """In-memory row shape for revert preview + execution."""

    id: int
    correlation_id: str
    path: str
    action: str  # 'create' | 'modify'
    pre_hash: str | None
    history_version: str | None
    post_hash: str | None
    written_at: str


@dataclass
class RevertPreviewEntry:
    """Human-readable preview row for the UI."""

    path: str
    action: str
    will_do: str  # 'delete' | 'restore' | 'skip_missing_history'
    reason: str = ""


class ScopeWritesRecorder:
    """Sync-callable recorder + async reader/revert-runner.

    One instance per process. Writes use a dedicated ``sqlite3.Connection``
    guarded by a lock — reads/reverts are called from async code and
    use the SAME connection (the SQLite module itself is thread-safe
    when ``check_same_thread=False``, and our operations are short).
    """

    def __init__(self, db_path: Path) -> None:
        self._db_path = Path(db_path)
        self._lock = threading.Lock()
        # Defer connection until first use — avoids touching the DB at
        # import time (tests construct paths that may not exist yet).
        self._conn: sqlite3.Connection | None = None

    def _get_conn(self) -> sqlite3.Connection:
        if self._conn is None:
            self._db_path.parent.mkdir(parents=True, exist_ok=True)
            self._conn = sqlite3.connect(
                str(self._db_path),
                check_same_thread=False,
                timeout=5.0,
            )
            # WAL mode matches the aiosqlite connection we share this
            # file with. Setting it on our connection is a no-op when
            # it's already enabled, safe otherwise.
            try:
                self._conn.execute("PRAGMA journal_mode=WAL")
            except sqlite3.Error:
                pass
        return self._conn

    def record(
        self,
        *,
        correlation_id: str,
        path: str,
        action: str,
        pre_hash: str | None,
        history_version: str | None,
        post_hash: str | None,
    ) -> None:
        """Synchronous write — called from VaultWriter inside its write lock.

        Never raises — a recording failure must not abort the actual
        vault write that just succeeded.
        """
        if not correlation_id or not path or action not in ("create", "modify"):
            return
        try:
            with self._lock:
                conn = self._get_conn()
                conn.execute(
                    """INSERT INTO scope_file_writes
                       (correlation_id, path, action, pre_hash,
                        history_version, post_hash, written_at)
                       VALUES (?, ?, ?, ?, ?, ?, ?)""",
                    (
                        correlation_id, path, action, pre_hash,
                        history_version, post_hash,
                        datetime.now(UTC).isoformat(),
                    ),
                )
                conn.commit()
        except sqlite3.Error:
            log.debug("scope_writes: record failed", exc_info=True)

    def close(self) -> None:
        if self._conn is not None:
            try:
                self._conn.close()
            except sqlite3.Error:
                pass
            self._conn = None

    # ── Async reads (use the shared aiosqlite handle the service passes in) ──

    @staticmethod
    async def list_for_scope(db, correlation_id: str) -> list[ScopeWriteRow]:
        cursor = await db.execute(
            """SELECT id, correlation_id, path, action, pre_hash,
                      history_version, post_hash, written_at
               FROM scope_file_writes
               WHERE correlation_id = ?
               ORDER BY written_at ASC""",
            (correlation_id,),
        )
        rows = await cursor.fetchall()
        return [
            ScopeWriteRow(
                id=int(r[0]),
                correlation_id=str(r[1]),
                path=str(r[2]),
                action=str(r[3]),
                pre_hash=r[4],
                history_version=r[5],
                post_hash=r[6],
                written_at=str(r[7]),
            )
            for r in rows
        ]

    @staticmethod
    async def preview_revert(
        db, correlation_id: str, vault_root: Path,
    ) -> list[RevertPreviewEntry]:
        """Return what a revert would do, without touching anything."""
        rows = await ScopeWritesRecorder.list_for_scope(db, correlation_id)
        out: list[RevertPreviewEntry] = []
        for row in rows:
            entry = _describe_revert(row, vault_root)
            out.append(entry)
        return out

    @staticmethod
    async def revert(
        db, correlation_id: str, vault_root: Path, writer,
    ) -> dict:
        """Restore files touched in a correlation scope.

        For 'create' rows: delete the file if it exists.
        For 'modify' rows: restore pre-write content from the saved
        snapshot in ``.history/``. When the snapshot is missing (ever
        deleted by the user's history-cleanup), the file is left alone
        and the entry reports ``skip_missing_history``.

        Returns a dict: ``{restored, deleted, skipped, errors}``. Each
        is a list of file paths + reason strings the UI can render.
        Never raises — the caller always gets a summary.
        """
        rows = await ScopeWritesRecorder.list_for_scope(db, correlation_id)
        if not rows:
            return {
                "restored": [], "deleted": [],
                "skipped": [], "errors": [],
                "total_touched": 0,
            }

        restored: list[str] = []
        deleted: list[str] = []
        skipped: list[dict] = []
        errors: list[dict] = []

        # Iterate newest-first so later writes are undone before earlier
        # ones — avoids cascading writes if a file was modified twice in
        # the same scope.
        for row in reversed(rows):
            target = (vault_root / row.path).resolve()
            # Safety: target must be inside the vault.
            try:
                target.relative_to(vault_root.resolve())
            except ValueError:
                errors.append({"path": row.path, "error": "path outside vault"})
                continue

            if row.action == "create":
                if target.is_file():
                    try:
                        target.unlink()
                        deleted.append(row.path)
                    except OSError as exc:
                        errors.append({"path": row.path, "error": str(exc)})
                else:
                    skipped.append({"path": row.path, "reason": "already_absent"})
                continue

            if row.action == "modify":
                snapshot = row.history_version
                if not snapshot:
                    skipped.append({
                        "path": row.path,
                        "reason": "no_pre_write_snapshot",
                    })
                    continue
                snapshot_path = Path(snapshot)
                if not snapshot_path.is_absolute():
                    snapshot_path = (vault_root / snapshot_path).resolve()
                if not snapshot_path.is_file():
                    skipped.append({
                        "path": row.path,
                        "reason": "snapshot_missing",
                    })
                    continue
                try:
                    content = snapshot_path.read_text(encoding="utf-8")
                    # Use the writer's trusted_scope so the runtime guard
                    # is satisfied — revert is a legitimate internal op.
                    with writer.trusted_scope("scope_revert"):
                        writer.write(
                            row.path,
                            content,
                            versioned=False,
                            trust_reason="scope_revert",
                        )
                    restored.append(row.path)
                except Exception as exc:
                    errors.append({
                        "path": row.path,
                        "error": f"{type(exc).__name__}: {exc}",
                    })
                continue

            skipped.append({"path": row.path, "reason": f"unknown_action:{row.action}"})

        return {
            "restored": restored,
            "deleted": deleted,
            "skipped": skipped,
            "errors": errors,
            "total_touched": len(rows),
        }


def _describe_revert(row: ScopeWriteRow, vault_root: Path) -> RevertPreviewEntry:
    """Pure helper — what WOULD a revert do for this row."""
    if row.action == "create":
        target = (vault_root / row.path).resolve()
        will_do = "delete" if target.is_file() else "skip_missing_history"
        return RevertPreviewEntry(
            path=row.path,
            action="create",
            will_do=will_do,
            reason="file created in scope; revert deletes it"
            if will_do == "delete" else "file already removed",
        )
    if row.action == "modify":
        snap = row.history_version
        if not snap:
            return RevertPreviewEntry(
                path=row.path, action="modify",
                will_do="skip_missing_history",
                reason="no pre-write snapshot was captured",
            )
        snapshot_path = Path(snap)
        if not snapshot_path.is_absolute():
            snapshot_path = (vault_root / snapshot_path).resolve()
        if snapshot_path.is_file():
            return RevertPreviewEntry(
                path=row.path, action="modify",
                will_do="restore",
                reason=f"restore from {snapshot_path.name}",
            )
        return RevertPreviewEntry(
            path=row.path, action="modify",
            will_do="skip_missing_history",
            reason="snapshot file was removed from .history/",
        )
    return RevertPreviewEntry(
        path=row.path, action=row.action,
        will_do="skip_missing_history",
        reason=f"unknown action '{row.action}'",
    )


def compute_hash(content: str) -> str:
    """Short, stable hash for content comparison (matches VaultWriter.)"""
    return hashlib.md5(content.encode("utf-8")).hexdigest()[:12]
