"""SQLite-backed EventStore. CONTRACT v0.5 #3 (schema locked).

Schema lives in `_SCHEMA` below; any change requires bumping the
`schema_version` row in the `meta` table.

  events(seq INTEGER PRIMARY KEY AUTOINCREMENT,
         id TEXT NOT NULL,
         type TEXT NOT NULL,
         actor TEXT,
         payload TEXT NOT NULL,    -- JSON
         frame_id TEXT,
         caused_by TEXT,
         timestamp TEXT NOT NULL,
         run_id TEXT NOT NULL,
         UNIQUE(id, run_id))

  runs(run_id TEXT PRIMARY KEY,
       parent_run_id TEXT,
       forked_at_event_id TEXT,
       label TEXT,
       created_at TEXT NOT NULL,
       goal TEXT,
       frame_id TEXT)

  meta(key TEXT PRIMARY KEY, value TEXT NOT NULL)
  -- carries schema_version since day one.

WAL is enabled on every open. `seq` is the projection ordering authority,
not `timestamp` — wall clocks can lie; AUTOINCREMENT cannot.

NOTE on the UNIQUE constraint (CONTRACT v0.5 diff #3): the locked schema
said `id TEXT NOT NULL UNIQUE`. That clashes with decision #12 (logical
IDs are scoped to run_id; a fork preserves the parent's `evt_017`). We
keep the column shape and intent — IDs are unique within a run — but the
constraint is `UNIQUE(id, run_id)`. Stored ids are the logical ids; no
prefixing, no hidden scoping.

A SQLiteEventStore is scoped to ONE `run_id`. Other runs in the same file
are accessed via separate SQLiteEventStore instances pointing at the same
path. Classmethods below (`list_runs`, `most_recent_run_id`, `fork_run`)
are file-level helpers.
"""

from __future__ import annotations

import sqlite3
from typing import Any, Iterator, Optional

from activegraph.core.event import Event
from activegraph.store.base import RunRecord
from activegraph.store.serde import decode_event, encode_event


SCHEMA_VERSION = "1"


_SCHEMA = [
    """
    CREATE TABLE IF NOT EXISTS events (
        seq INTEGER PRIMARY KEY AUTOINCREMENT,
        id TEXT NOT NULL,
        type TEXT NOT NULL,
        actor TEXT,
        payload TEXT NOT NULL,
        frame_id TEXT,
        caused_by TEXT,
        timestamp TEXT NOT NULL,
        run_id TEXT NOT NULL,
        UNIQUE(id, run_id)
    )
    """,
    "CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id, seq)",
    "CREATE INDEX IF NOT EXISTS idx_events_type ON events(type)",
    """
    CREATE TABLE IF NOT EXISTS runs (
        run_id TEXT PRIMARY KEY,
        parent_run_id TEXT,
        forked_at_event_id TEXT,
        label TEXT,
        created_at TEXT NOT NULL,
        goal TEXT,
        frame_id TEXT
    )
    """,
    """
    CREATE TABLE IF NOT EXISTS meta (
        key TEXT PRIMARY KEY,
        value TEXT NOT NULL
    )
    """,
]


def _ensure_schema(conn: sqlite3.Connection) -> None:
    conn.execute("PRAGMA journal_mode=WAL")
    # WAL + synchronous=NORMAL: group-committed fsyncs, no fsync per write.
    # Crash-safe across process crashes; OS crash may lose the last committed
    # transactions but never corrupts the file. Sufficient for an event log
    # and ~25x faster than the default FULL.
    conn.execute("PRAGMA synchronous=NORMAL")
    for stmt in _SCHEMA:
        conn.execute(stmt)
    row = conn.execute(
        "SELECT value FROM meta WHERE key = 'schema_version'"
    ).fetchone()
    if row is None:
        conn.execute(
            "INSERT INTO meta(key, value) VALUES ('schema_version', ?)",
            (SCHEMA_VERSION,),
        )
    elif row[0] != SCHEMA_VERSION:
        from activegraph import __version__ as _aw_version
        from activegraph.store.errors import SchemaVersionMismatch
        raise SchemaVersionMismatch(
            f"sqlite store schema_version {row[0]!r} does not match this build's expected {SCHEMA_VERSION!r}",
            what_failed=(
                f"The SQLite store records schema_version={row[0]!r} in its meta table, "
                f"but activegraph {_aw_version} expects schema_version={SCHEMA_VERSION!r}."
            ),
            why=(
                "The store file format evolves with the framework. The runtime "
                "refuses to read a store with a different schema_version rather "
                "than risk silent data loss — a newer framework might interpret "
                "columns differently than the writer did, and an older framework "
                "might drop fields it doesn't recognize. Either direction would "
                "corrupt the audit trail."
            ),
            how_to_fix=(
                f"One of three actions:\n"
                f"  1. Install the activegraph version that wrote this store\n"
                f"     (whichever shipped schema_version={row[0]!r}).\n"
                f"  2. Migrate the run to a store written by this build:\n"
                f"     activegraph migrate <src-url> <new-dst-url>\n"
                f"     The destination is written with the current schema.\n"
                f"  3. If the store is empty or expendable, delete and re-run.\n"
                f"\n"
                f"Schema version history is documented in CHANGELOG.md."
            ),
            context={
                "found_version": row[0],
                "expected_version": SCHEMA_VERSION,
                "activegraph_version": _aw_version,
                "driver": "sqlite",
            },
        )


def _row_to_event(row: sqlite3.Row) -> Event:
    return decode_event(
        {
            "id": row["id"],
            "type": row["type"],
            "payload": row["payload"],
            "actor": row["actor"],
            "frame_id": row["frame_id"],
            "caused_by": row["caused_by"],
            "timestamp": row["timestamp"],
        }
    )


def _row_to_run(row: sqlite3.Row) -> RunRecord:
    return RunRecord(
        run_id=row["run_id"],
        parent_run_id=row["parent_run_id"],
        forked_at_event_id=row["forked_at_event_id"],
        label=row["label"],
        created_at=row["created_at"],
        goal=row["goal"],
        frame_id=row["frame_id"],
    )


class SQLiteEventStore:
    """Per-run view onto a SQLite-backed event log."""

    def __init__(self, path: str, run_id: str) -> None:
        self.path = path
        self.run_id = run_id
        self._conn = sqlite3.connect(path, isolation_level=None)
        self._conn.row_factory = sqlite3.Row
        _ensure_schema(self._conn)

    # ---------- EventStore protocol ----------

    def append(self, event: Event) -> None:
        row = encode_event(event)
        self._conn.execute(
            """
            INSERT INTO events (id, type, actor, payload, frame_id, caused_by, timestamp, run_id)
            VALUES (:id, :type, :actor, :payload, :frame_id, :caused_by, :timestamp, :run_id)
            """,
            {**row, "run_id": self.run_id},
        )

    def iter_events(
        self,
        after: Optional[str] = None,
        until: Optional[str] = None,
    ) -> Iterator[Event]:
        clauses = ["run_id = ?"]
        params: list[Any] = [self.run_id]
        if after is not None:
            clauses.append("seq > ?")
            params.append(self._seq_of(after))
        if until is not None:
            clauses.append("seq <= ?")
            params.append(self._seq_of(until))
        sql = "SELECT * FROM events WHERE " + " AND ".join(clauses) + " ORDER BY seq"
        for row in self._conn.execute(sql, params):
            yield _row_to_event(row)

    def get_event(self, event_id: str) -> Optional[Event]:
        row = self._conn.execute(
            "SELECT * FROM events WHERE id = ? AND run_id = ?",
            (event_id, self.run_id),
        ).fetchone()
        return _row_to_event(row) if row else None

    def count(self) -> int:
        row = self._conn.execute(
            "SELECT COUNT(*) FROM events WHERE run_id = ?", (self.run_id,)
        ).fetchone()
        return int(row[0])

    def truncate_after(self, event_id: str) -> None:
        seq = self._seq_of(event_id)
        self._conn.execute(
            "DELETE FROM events WHERE run_id = ? AND seq > ?",
            (self.run_id, seq),
        )

    def close(self) -> None:
        try:
            self._conn.close()
        except sqlite3.ProgrammingError:
            pass

    def _seq_of(self, event_id: str) -> int:
        row = self._conn.execute(
            "SELECT seq FROM events WHERE id = ? AND run_id = ?",
            (event_id, self.run_id),
        ).fetchone()
        if row is None:
            from activegraph.store.errors import EventNotFoundError
            raise EventNotFoundError(
                f"event {event_id!r} not found in run {self.run_id!r}",
                what_failed=(
                    f"The SQLite store has no event with id {event_id!r} in "
                    f"run {self.run_id!r}."
                ),
                why=(
                    "Event ids are the framework's addressing primitive. The "
                    "store refuses to return a default for an unknown id — that "
                    "would silently corrupt the audit trail and any downstream "
                    "fork or replay."
                ),
                how_to_fix=(
                    "Check the event id against what's actually in the run:\n"
                    f"    activegraph inspect <store-url> --run-id {self.run_id} --tail 100\n"
                    "\n"
                    "Common causes: typo in a hand-typed id, referencing an id "
                    "from a different run, or a run truncated by an earlier fork."
                ),
                context={
                    "event_id": event_id,
                    "run_id": self.run_id,
                    "driver": "sqlite",
                },
            )
        return int(row["seq"])

    # ---------- v0.5 helpers (per-run) ----------

    def get_run(self) -> Optional[RunRecord]:
        row = self._conn.execute(
            "SELECT * FROM runs WHERE run_id = ?", (self.run_id,)
        ).fetchone()
        return _row_to_run(row) if row else None

    def upsert_run(
        self,
        *,
        parent_run_id: Optional[str] = None,
        forked_at_event_id: Optional[str] = None,
        label: Optional[str] = None,
        created_at: str,
        goal: Optional[str] = None,
        frame_id: Optional[str] = None,
    ) -> None:
        self._conn.execute(
            """
            INSERT INTO runs (run_id, parent_run_id, forked_at_event_id, label, created_at, goal, frame_id)
            VALUES (?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(run_id) DO UPDATE SET
                parent_run_id      = excluded.parent_run_id,
                forked_at_event_id = excluded.forked_at_event_id,
                label              = excluded.label,
                goal               = COALESCE(excluded.goal, runs.goal),
                frame_id           = COALESCE(excluded.frame_id, runs.frame_id)
            """,
            (
                self.run_id,
                parent_run_id,
                forked_at_event_id,
                label,
                created_at,
                goal,
                frame_id,
            ),
        )

    # ---------- file-level helpers ----------

    @classmethod
    def list_runs(cls, path: str) -> list[RunRecord]:
        conn = sqlite3.connect(path, isolation_level=None)
        conn.row_factory = sqlite3.Row
        _ensure_schema(conn)
        try:
            rows = conn.execute("SELECT * FROM runs ORDER BY created_at").fetchall()
            return [_row_to_run(r) for r in rows]
        finally:
            conn.close()

    @classmethod
    def most_recent_run_id(cls, path: str) -> Optional[str]:
        conn = sqlite3.connect(path, isolation_level=None)
        conn.row_factory = sqlite3.Row
        _ensure_schema(conn)
        try:
            row = conn.execute(
                """
                SELECT runs.run_id
                FROM runs
                LEFT JOIN (
                    SELECT run_id, MAX(seq) AS last_seq FROM events GROUP BY run_id
                ) e ON e.run_id = runs.run_id
                ORDER BY e.last_seq IS NULL, e.last_seq DESC, runs.created_at DESC
                LIMIT 1
                """
            ).fetchone()
            return row["run_id"] if row else None
        finally:
            conn.close()

    @classmethod
    def fork_run(
        cls,
        path: str,
        *,
        parent_run_id: str,
        new_run_id: str,
        at_event_id: str,
        label: Optional[str],
        created_at: str,
    ) -> int:
        """Copy events from parent_run_id up to and including at_event_id
        into new_run_id (CONTRACT v0.5 #11: copy rows, no row-sharing).

        Returns the number of events copied.
        """
        conn = sqlite3.connect(path, isolation_level=None)
        conn.row_factory = sqlite3.Row
        _ensure_schema(conn)
        try:
            cut = conn.execute(
                "SELECT seq FROM events WHERE id = ? AND run_id = ?",
                (at_event_id, parent_run_id),
            ).fetchone()
            if cut is None:
                from activegraph.store.errors import EventNotFoundError
                raise EventNotFoundError(
                    f"event {at_event_id!r} not found in run {parent_run_id!r}",
                    what_failed=(
                        f"Cannot fork run {parent_run_id!r} at event "
                        f"{at_event_id!r}: that event does not exist in the run."
                    ),
                    why=(
                        "Forking takes a parent run and copies events up to and "
                        "including --at-event into a new run. The framework "
                        "refuses to fork at an unknown event id rather than "
                        "guess where the user meant — that would produce a "
                        "fork that doesn't share lineage with its claimed parent."
                    ),
                    how_to_fix=(
                        f"List the events in the parent run to find a valid "
                        f"fork point:\n"
                        f"    activegraph inspect <store-url> --run-id {parent_run_id} --tail 100\n"
                        f"\n"
                        f"Then re-issue the fork with a valid event id."
                    ),
                    context={
                        "event_id": at_event_id,
                        "run_id": parent_run_id,
                        "operation": "fork",
                        "driver": "sqlite",
                    },
                )
            parent_row = conn.execute(
                "SELECT goal, frame_id FROM runs WHERE run_id = ?", (parent_run_id,)
            ).fetchone()
            goal = parent_row["goal"] if parent_row else None
            frame_id = parent_row["frame_id"] if parent_row else None
            conn.execute(
                """
                INSERT INTO runs (run_id, parent_run_id, forked_at_event_id, label, created_at, goal, frame_id)
                VALUES (?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    new_run_id,
                    parent_run_id,
                    at_event_id,
                    label,
                    created_at,
                    goal,
                    frame_id,
                ),
            )
            # Same logical event ids; UNIQUE(id, run_id) makes that safe.
            rows = conn.execute(
                "SELECT * FROM events WHERE run_id = ? AND seq <= ? ORDER BY seq",
                (parent_run_id, cut["seq"]),
            ).fetchall()
            n = 0
            for r in rows:
                conn.execute(
                    """
                    INSERT INTO events (id, type, actor, payload, frame_id, caused_by, timestamp, run_id)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                    """,
                    (
                        r["id"],
                        r["type"],
                        r["actor"],
                        r["payload"],
                        r["frame_id"],
                        r["caused_by"],
                        r["timestamp"],
                        new_run_id,
                    ),
                )
                n += 1
            return n
        finally:
            conn.close()
