"""Three-Strike Detector for Repeated Manual Work.

Tracks recurring manual actions by category and key. When the same action
is performed three or more times, it blocks further attempts and requires
an automation artifact to be registered first.

    Strike 1 (count=1): discovery  — action proceeds normally
    Strike 2 (count=2): warning    — action proceeds with a logged warning
    Strike 3 (count≥3): blocked    — raises ThreeStrikeError; caller must
                                      register an automation artifact first

Governing principle: "If you do the same thing manually three times,
you have failed to crystallise."

Categories tracked:
  - vlm_prompt_edit          VLM prompt edits for the same UI element
  - game_bug_review          Manual game-bug reviews for the same bug type
  - parameter_tuning         Manual parameter tuning for the same parameter
  - portal_adapter_creation  Manual portal-adapter creation for same pattern
  - deployment_step          Manual deployment steps

The Falsework Checklist is enforced before cloud API calls via
:func:`falsework_check`.

Refs: #962
"""

from __future__ import annotations

import json
import logging
import sqlite3
from contextlib import closing
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

from config import settings

logger = logging.getLogger(__name__)

# ── Constants ────────────────────────────────────────────────────────────────

DB_PATH = Path(settings.repo_root) / "data" / "three_strike.db"

CATEGORIES = frozenset(
    {
        "vlm_prompt_edit",
        "game_bug_review",
        "parameter_tuning",
        "portal_adapter_creation",
        "deployment_step",
    }
)

STRIKE_WARNING = 2
STRIKE_BLOCK = 3

_SCHEMA = """
CREATE TABLE IF NOT EXISTS strikes (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    category    TEXT    NOT NULL,
    key         TEXT    NOT NULL,
    count       INTEGER NOT NULL DEFAULT 0,
    blocked     INTEGER NOT NULL DEFAULT 0,
    automation  TEXT    DEFAULT NULL,
    first_seen  TEXT    NOT NULL,
    last_seen   TEXT    NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_strikes_cat_key ON strikes(category, key);
CREATE INDEX        IF NOT EXISTS idx_strikes_blocked  ON strikes(blocked);

CREATE TABLE IF NOT EXISTS strike_events (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    category    TEXT    NOT NULL,
    key         TEXT    NOT NULL,
    strike_num  INTEGER NOT NULL,
    metadata    TEXT    DEFAULT '{}',
    timestamp   TEXT    NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_se_cat_key ON strike_events(category, key);
CREATE INDEX IF NOT EXISTS idx_se_ts      ON strike_events(timestamp);
"""


# ── Exceptions ────────────────────────────────────────────────────────────────


class ThreeStrikeError(RuntimeError):
    """Raised when a manual action has reached the third strike.

    Attributes:
        category:   The action category (e.g. ``"vlm_prompt_edit"``).
        key:        The specific action key (e.g. a UI element name).
        count:      Total number of times this action has been recorded.
    """

    def __init__(self, category: str, key: str, count: int) -> None:
        self.category = category
        self.key = key
        self.count = count
        super().__init__(
            f"Three-strike block: '{category}/{key}' has been performed manually "
            f"{count} time(s). Register an automation artifact before continuing. "
            f"Run the Falsework Checklist (see three_strike.falsework_check)."
        )


# ── Data classes ──────────────────────────────────────────────────────────────


@dataclass
class StrikeRecord:
    """State for one (category, key) pair."""

    category: str
    key: str
    count: int
    blocked: bool
    automation: str | None
    first_seen: str
    last_seen: str


@dataclass
class FalseworkChecklist:
    """Pre-cloud-API call checklist — must be completed before making
    expensive external calls.

    Instantiate and call :meth:`validate` to ensure all answers are provided.
    """

    durable_artifact: str = ""
    artifact_storage_path: str = ""
    local_rule_or_cache: str = ""
    will_repeat: bool | None = None
    elimination_strategy: str = ""
    sovereignty_delta: str = ""

    # ── internal ──
    _errors: list[str] = field(default_factory=list, init=False, repr=False)

    def validate(self) -> list[str]:
        """Return a list of unanswered questions.  Empty list → checklist passes."""
        self._errors = []
        if not self.durable_artifact.strip():
            self._errors.append("Q1: What durable artifact will this call produce?")
        if not self.artifact_storage_path.strip():
            self._errors.append("Q2: Where will the artifact be stored locally?")
        if not self.local_rule_or_cache.strip():
            self._errors.append("Q3: What local rule or cache will this populate?")
        if self.will_repeat is None:
            self._errors.append("Q4: After this call, will I need to make it again?")
        if self.will_repeat and not self.elimination_strategy.strip():
            self._errors.append("Q5: If yes, what would eliminate the repeat?")
        if not self.sovereignty_delta.strip():
            self._errors.append("Q6: What is the sovereignty delta of this call?")
        return self._errors

    @property
    def passed(self) -> bool:
        """True when :meth:`validate` found no unanswered questions."""
        return len(self.validate()) == 0


# ── Store ─────────────────────────────────────────────────────────────────────


class ThreeStrikeStore:
    """SQLite-backed three-strike store.

    Thread-safe: creates a new connection per operation.
    """

    def __init__(self, db_path: Path | None = None) -> None:
        self._db_path = db_path or DB_PATH
        self._init_db()

    # ── setup ─────────────────────────────────────────────────────────────

    def _init_db(self) -> None:
        try:
            self._db_path.parent.mkdir(parents=True, exist_ok=True)
            with closing(sqlite3.connect(str(self._db_path))) as conn:
                conn.execute("PRAGMA journal_mode=WAL")
                conn.execute(f"PRAGMA busy_timeout={settings.db_busy_timeout_ms}")
                conn.executescript(_SCHEMA)
                conn.commit()
        except Exception as exc:
            logger.warning("Failed to initialise three-strike DB: %s", exc)

    def _connect(self) -> sqlite3.Connection:
        conn = sqlite3.connect(str(self._db_path))
        conn.row_factory = sqlite3.Row
        conn.execute(f"PRAGMA busy_timeout={settings.db_busy_timeout_ms}")
        return conn

    # ── record ────────────────────────────────────────────────────────────

    def record(
        self,
        category: str,
        key: str,
        metadata: dict[str, Any] | None = None,
    ) -> StrikeRecord:
        """Record a manual action and return the updated :class:`StrikeRecord`.

        Raises :exc:`ThreeStrikeError` when the action is already blocked
        (count ≥ STRIKE_BLOCK) and no automation has been registered.

        Args:
            category:  Action category; must be in :data:`CATEGORIES`.
            key:       Specific identifier within the category.
            metadata:  Optional context stored alongside the event.

        Returns:
            The updated :class:`StrikeRecord`.

        Raises:
            ValueError: If *category* is not in :data:`CATEGORIES`.
            ThreeStrikeError: On the third (or later) strike with no automation.
        """
        if category not in CATEGORIES:
            raise ValueError(f"Unknown category '{category}'. Valid: {sorted(CATEGORIES)}")

        now = datetime.now(UTC).isoformat()
        meta_json = json.dumps(metadata or {})

        try:
            with closing(self._connect()) as conn:
                # Upsert the aggregate row
                conn.execute(
                    """
                    INSERT INTO strikes (category, key, count, blocked, first_seen, last_seen)
                    VALUES (?, ?, 1, 0, ?, ?)
                    ON CONFLICT(category, key) DO UPDATE SET
                        count    = count + 1,
                        last_seen = excluded.last_seen
                    """,
                    (category, key, now, now),
                )

                row = conn.execute(
                    "SELECT * FROM strikes WHERE category=? AND key=?",
                    (category, key),
                ).fetchone()
                count = row["count"]
                blocked = bool(row["blocked"])
                automation = row["automation"]

                # Record the individual event
                conn.execute(
                    "INSERT INTO strike_events (category, key, strike_num, metadata, timestamp) "
                    "VALUES (?, ?, ?, ?, ?)",
                    (category, key, count, meta_json, now),
                )

                # Mark as blocked once threshold reached
                if count >= STRIKE_BLOCK and not blocked:
                    conn.execute(
                        "UPDATE strikes SET blocked=1 WHERE category=? AND key=?",
                        (category, key),
                    )
                    blocked = True

                conn.commit()

        except ThreeStrikeError:
            raise
        except Exception as exc:
            logger.warning("Three-strike DB error during record: %s", exc)
            # Re-raise DB errors so callers are aware
            raise

        record = StrikeRecord(
            category=category,
            key=key,
            count=count,
            blocked=blocked,
            automation=automation,
            first_seen=row["first_seen"],
            last_seen=now,
        )

        self._emit_log(record)

        if blocked and not automation:
            raise ThreeStrikeError(category=category, key=key, count=count)

        return record

    def _emit_log(self, record: StrikeRecord) -> None:
        """Log a warning or info message based on strike number."""
        if record.count == STRIKE_WARNING:
            logger.warning(
                "Three-strike WARNING: '%s/%s' has been performed manually %d times. "
                "Consider writing an automation.",
                record.category,
                record.key,
                record.count,
            )
        elif record.count >= STRIKE_BLOCK:
            logger.warning(
                "Three-strike BLOCK: '%s/%s' reached %d strikes — automation required.",
                record.category,
                record.key,
                record.count,
            )
        else:
            logger.info(
                "Three-strike discovery: '%s/%s' — strike %d.",
                record.category,
                record.key,
                record.count,
            )

    # ── automation registration ───────────────────────────────────────────

    def register_automation(
        self,
        category: str,
        key: str,
        artifact_path: str,
    ) -> None:
        """Unblock a (category, key) pair by registering an automation artifact.

        Once registered, future calls to :meth:`record` will proceed normally
        and the strike counter resets to zero.

        Args:
            category:      Action category.
            key:           Specific identifier within the category.
            artifact_path: Path or identifier of the automation artifact.
        """
        try:
            with closing(self._connect()) as conn:
                conn.execute(
                    "UPDATE strikes SET automation=?, blocked=0, count=0 "
                    "WHERE category=? AND key=?",
                    (artifact_path, category, key),
                )
                conn.commit()
            logger.info(
                "Three-strike: automation registered for '%s/%s' → %s",
                category,
                key,
                artifact_path,
            )
        except Exception as exc:
            logger.warning("Failed to register automation: %s", exc)

    # ── queries ───────────────────────────────────────────────────────────

    def get(self, category: str, key: str) -> StrikeRecord | None:
        """Return the :class:`StrikeRecord` for (category, key), or None."""
        try:
            with closing(self._connect()) as conn:
                row = conn.execute(
                    "SELECT * FROM strikes WHERE category=? AND key=?",
                    (category, key),
                ).fetchone()
            if row is None:
                return None
            return StrikeRecord(
                category=row["category"],
                key=row["key"],
                count=row["count"],
                blocked=bool(row["blocked"]),
                automation=row["automation"],
                first_seen=row["first_seen"],
                last_seen=row["last_seen"],
            )
        except Exception as exc:
            logger.warning("Failed to query strike record: %s", exc)
            return None

    def list_blocked(self) -> list[StrikeRecord]:
        """Return all currently-blocked (category, key) pairs."""
        try:
            with closing(self._connect()) as conn:
                rows = conn.execute(
                    "SELECT * FROM strikes WHERE blocked=1 ORDER BY last_seen DESC"
                ).fetchall()
            return [
                StrikeRecord(
                    category=r["category"],
                    key=r["key"],
                    count=r["count"],
                    blocked=True,
                    automation=r["automation"],
                    first_seen=r["first_seen"],
                    last_seen=r["last_seen"],
                )
                for r in rows
            ]
        except Exception as exc:
            logger.warning("Failed to query blocked strikes: %s", exc)
            return []

    def list_all(self) -> list[StrikeRecord]:
        """Return all strike records ordered by last seen (most recent first)."""
        try:
            with closing(self._connect()) as conn:
                rows = conn.execute("SELECT * FROM strikes ORDER BY last_seen DESC").fetchall()
            return [
                StrikeRecord(
                    category=r["category"],
                    key=r["key"],
                    count=r["count"],
                    blocked=bool(r["blocked"]),
                    automation=r["automation"],
                    first_seen=r["first_seen"],
                    last_seen=r["last_seen"],
                )
                for r in rows
            ]
        except Exception as exc:
            logger.warning("Failed to list strike records: %s", exc)
            return []

    def get_events(self, category: str, key: str, limit: int = 50) -> list[dict]:
        """Return the individual strike events for (category, key)."""
        try:
            with closing(self._connect()) as conn:
                rows = conn.execute(
                    "SELECT * FROM strike_events WHERE category=? AND key=? "
                    "ORDER BY timestamp DESC LIMIT ?",
                    (category, key, limit),
                ).fetchall()
            return [
                {
                    "strike_num": r["strike_num"],
                    "timestamp": r["timestamp"],
                    "metadata": json.loads(r["metadata"]) if r["metadata"] else {},
                }
                for r in rows
            ]
        except Exception as exc:
            logger.warning("Failed to query strike events: %s", exc)
            return []


# ── Falsework checklist helper ────────────────────────────────────────────────


def falsework_check(checklist: FalseworkChecklist) -> None:
    """Enforce the Falsework Checklist before a cloud API call.

    Raises :exc:`ValueError` listing all unanswered questions if the checklist
    does not pass.

    Usage::

        checklist = FalseworkChecklist(
            durable_artifact="embedding vectors for UI element foo",
            artifact_storage_path="data/vlm/foo_embeddings.json",
            local_rule_or_cache="vlm_cache",
            will_repeat=False,
            sovereignty_delta="eliminates repeated VLM call",
        )
        falsework_check(checklist)  # raises ValueError if incomplete
    """
    errors = checklist.validate()
    if errors:
        raise ValueError(
            "Falsework Checklist incomplete — answer all questions before "
            "making a cloud API call:\n" + "\n".join(f"  • {e}" for e in errors)
        )


# ── Module-level singleton ────────────────────────────────────────────────────

_detector: ThreeStrikeStore | None = None


def get_detector() -> ThreeStrikeStore:
    """Return the module-level :class:`ThreeStrikeStore`, creating it once."""
    global _detector
    if _detector is None:
        _detector = ThreeStrikeStore()
    return _detector
