"""Local snapshot store for diagnosed executions.

Cloud retention for execution history is **90 days** — after that, the
Control Room silently drops the record and a failure becomes
undiagnosable through the API. There is no history→audit backup path
(CL AUDE.md §retention). This store keeps a local copy of every
execution we diagnose — execution detail, the parsed bot-logic surface
at the time, and the diagnosis result — so the data survives the cloud
cutoff.

Layout::

    {state_dir()}/snapshots/{account_key}/exec_{execution_id}.json

One file per execution, keyed by account so two accounts on the same
Control Room never cross-read. Writes are atomic (temp + ``os.replace``)
so a partial write never corrupts a previous snapshot.

Two modes of operation:

* **First diagnosis.** ``save_diagnosis`` writes the snapshot; the
  diagnosis flow returns normally.
* **Repeat diagnosis.** ``get_diagnosis`` returns the cached result and
  the flow skips the Control Room entirely — a performance win, and it
  survives CR data loss. When the original execution has aged past the
  90-day retention window, the cached result carries
  ``original_expired=True`` and a ``source_note`` explaining the data is
  local-only.

This module never raises on I/O — a corrupt or missing snapshot just
means "diagnose fresh from CR". ``A360AuthError`` is never seen here
(the store does no HTTP). The raw bot JSON is **never** placed in the
cached result — only the parsed action surface that was already part of
the diagnosis dict.
"""

from __future__ import annotations

import logging
import re
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING

from ..auth._paths import account_key, state_dir
from .atomic_io import read_json, write_json_atomic

if TYPE_CHECKING:
    # Type-only imports: the store uses duck typing at runtime (getattr) but
    # mypy needs the concrete types to verify Protocol compatibility. These
    # are string annotations under ``from __future__ import annotations`` so
    # no runtime import occurs (avoids utils→services layering concern).
    from ..auth.auth_manager import AuthManager
    from ..services.bot import BotNode
    from ..services.execution import ExecutionSummary

logger = logging.getLogger(__name__)

# --------------------------------------------------------------------------- #
# Constants
# --------------------------------------------------------------------------- #

# Cloud retention limit (CLAUDE.md §retention). Past this, the original
# execution is gone from CR and only the local snapshot remains.
CR_EXECUTION_RETENTION_DAYS = 90

# Schema version for the snapshot file format. Bump when the shape changes;
# readers reject unknown versions loudly rather than guessing.
SNAPSHOT_SCHEMA_VERSION = 1

# Subdirectory under state_dir() for all snapshots.
SNAPSHOTS_SUBDIR = "snapshots"

# File name template for a single execution snapshot.
SNAPSHOT_FILE_TEMPLATE = "exec_{execution_id}.json"

# Characters in execution ids that are not filesystem-safe. Replaced with '-'.
_UNSAFE_ID_CHARS = re.compile(r"[^A-Za-z0-9._-]+")

# Fields from ExecutionSummary that we persist. ``raw`` is dropped — it can
# be large and the parsed fields are what diagnosis needs.
_EXECUTION_PERSIST_FIELDS: tuple[str, ...] = (
    "id",
    "status",
    "message",
    "file_id",
    "deployment_id",
    "bot_name",
    "started_on",
    "completed_on",
)

# Fields from BotNode that we persist.
_BOT_NODE_PERSIST_FIELDS: tuple[str, ...] = (
    "order",
    "path",
    "command",
    "name",
    "line_number",
    "index",
    "kind",
    "depth",
    "has_children",
)

# Keys that may carry an execution timestamp, checked in priority order.
_EXECUTION_TIMESTAMP_KEYS: tuple[str, ...] = (
    "completed_on",
    "completedOn",
    "started_on",
    "startedOn",
)


# --------------------------------------------------------------------------- #
# Pure helpers
# --------------------------------------------------------------------------- #
def _utcnow() -> datetime:
    return datetime.now(UTC)


def _iso(dt: datetime) -> str:
    return dt.isoformat()


def _parse_iso(text: str | None) -> datetime | None:
    """Parse an ISO-8601 timestamp, tolerating a trailing ``Z``."""
    if not text:
        return None
    try:
        return datetime.fromisoformat(text.replace("Z", "+00:00"))
    except ValueError:
        return None


def _execution_timestamp(payload: dict) -> str | None:
    """Best-effort execution timestamp from a snapshot payload.

    Prefers the execution's own ``completed_on``/``started_on``; falls back
    to ``saved_at`` (the snapshot write time) as a proxy when the execution
    record carried no timestamps.
    """
    execution_dict = payload.get("execution") or {}
    if isinstance(execution_dict, dict):
        for key in _EXECUTION_TIMESTAMP_KEYS:
            value = execution_dict.get(key)
            if isinstance(value, str) and value.strip():
                return value.strip()
    saved_at = payload.get("saved_at")
    if isinstance(saved_at, str) and saved_at.strip():
        return saved_at.strip()
    return None


def is_payload_expired(payload: dict, *, now: datetime | None = None) -> bool:
    """True if the original execution has aged past CR retention.

    Uses the execution timestamp (or ``saved_at`` as a proxy). Returns
    ``False`` when no timestamp is available — never claim expired on
    missing data. A ``force_expired`` flag (set by ``mark_expired``) is
    honoured as an override.
    """
    if payload.get("force_expired"):
        return True
    timestamp_text = _execution_timestamp(payload)
    if not timestamp_text:
        return False
    parsed = _parse_iso(timestamp_text)
    if parsed is None:
        return False
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=UTC)
    cutoff = (now or _utcnow()) - timedelta(days=CR_EXECUTION_RETENTION_DAYS)
    return parsed < cutoff


def _serialize_execution(execution: ExecutionSummary) -> dict:
    """Project an ``ExecutionSummary`` to a JSON-safe dict (drops ``raw``)."""
    return {field: getattr(execution, field, None) for field in _EXECUTION_PERSIST_FIELDS}


def _serialize_bot_nodes(bot_nodes: list[BotNode]) -> list[dict]:
    """Project a list of ``BotNode`` to JSON-safe dicts.

    ``path`` is a tuple on the dataclass; JSON has no tuple, so it becomes
    a list. Round-tripping a snapshot therefore yields lists, which the
    diagnosis consumers already accept.
    """
    result: list[dict] = []
    for node in bot_nodes:
        item = {field: getattr(node, field, None) for field in _BOT_NODE_PERSIST_FIELDS}
        path_value = item.get("path")
        if isinstance(path_value, tuple):
            item["path"] = list(path_value)
        result.append(item)
    return result


def _sanitize_execution_id(execution_id: str) -> str:
    """Make an execution id safe for use as a filename component."""
    safe = _UNSAFE_ID_CHARS.sub("-", execution_id).strip("-")
    return safe or "unknown"


def _source_note(*, expired: bool, saved_at: str | None) -> str:
    """Human-readable provenance note for a cached diagnosis."""
    when = (saved_at or "an earlier session")[:10]  # date portion only
    if expired:
        return (
            f"Original has aged out of Control Room "
            f"({CR_EXECUTION_RETENTION_DAYS}-day retention). "
            f"This is a local snapshot saved {when}."
        )
    return f"Served from local snapshot (saved {when}). Original still in Control Room."


# --------------------------------------------------------------------------- #
# Store
# --------------------------------------------------------------------------- #
class LocalSnapshotStore:
    """Filesystem-backed snapshot store, keyed by account.

    Implements the ``SnapshotStore`` Protocol from ``services.diagnose``.
    All I/O is best-effort: a read failure returns ``None`` (diagnose
    fresh from CR), a write failure logs and returns ``None`` (the
    diagnosis itself is still returned to the caller).
    """

    def __init__(
        self,
        *,
        control_room_url: str,
        username: str,
        base_dir: Path | None = None,
    ) -> None:
        self._account_key = account_key(control_room_url, username)
        root = base_dir if base_dir is not None else state_dir()
        self._dir = root / SNAPSHOTS_SUBDIR / self._account_key

    @property
    def account_dir(self) -> Path:
        """Per-account directory holding this account's snapshots."""
        return self._dir

    def snapshot_path(self, execution_id: str) -> Path:
        """Resolve the on-disk path for a given execution id (no I/O)."""
        safe_id = _sanitize_execution_id(execution_id)
        return self._dir / SNAPSHOT_FILE_TEMPLATE.format(execution_id=safe_id)

    # -- Protocol: read -------------------------------------------------- #
    def get_diagnosis(self, execution_id: str) -> dict | None:
        """Return the cached diagnosis for ``execution_id``, or ``None``.

        The returned dict is the original diagnosis augmented with snapshot
        metadata: ``from_snapshot``, ``snapshot_path``,
        ``snapshot_saved_at``, ``original_expired``, ``source_note``.
        """
        path = self.snapshot_path(execution_id)
        payload = read_json(path)
        if not isinstance(payload, dict):
            return None
        if payload.get("schema_version") != SNAPSHOT_SCHEMA_VERSION:
            logger.warning(
                "snapshot %s: unknown schema_version %r; ignoring",
                path,
                payload.get("schema_version"),
            )
            return None
        diagnosis = payload.get("diagnosis")
        if not isinstance(diagnosis, dict):
            return None
        expired = is_payload_expired(payload)
        saved_at = payload.get("saved_at")
        result = dict(diagnosis)  # shallow copy — never mutate the cached blob
        result["from_snapshot"] = True
        result["snapshot_path"] = str(path)
        result["snapshot_saved_at"] = saved_at
        result["original_expired"] = expired
        result["source_note"] = _source_note(expired=expired, saved_at=saved_at)
        return result

    # -- Protocol: write ------------------------------------------------- #
    def save_diagnosis(
        self,
        *,
        auth: AuthManager,  # unused (store is local-only), kept for Protocol match
        execution: ExecutionSummary,
        bot_nodes: list[BotNode],
        diagnosis: dict,
    ) -> str | None:
        """Persist the diagnosis snapshot. Returns the file path, or ``None``.

        Idempotent: re-saving the same execution overwrites the previous
        snapshot atomically. A write failure is logged and swallowed —
        the diagnosis itself has already been computed and is returned to
        the caller regardless.
        """
        path = self.snapshot_path(getattr(execution, "id", "unknown"))
        payload = {
            "schema_version": SNAPSHOT_SCHEMA_VERSION,
            "saved_at": _iso(_utcnow()),
            "execution_id": getattr(execution, "id", None),
            "account_key": self._account_key,
            "execution": _serialize_execution(execution),
            "bot_nodes": _serialize_bot_nodes(bot_nodes),
            "diagnosis": diagnosis,
        }
        try:
            write_json_atomic(path, payload)
        except OSError:
            logger.warning(
                "snapshot save failed for execution %s",
                getattr(execution, "id", "?"),
                exc_info=True,
            )
            return None
        return str(path)

    # -- Management ------------------------------------------------------ #
    def mark_expired(self, execution_id: str) -> bool:
        """Force-set the expired flag on a snapshot (e.g. after a CR 404).

        Best-effort: returns ``False`` if no snapshot exists or the write
        fails.
        """
        path = self.snapshot_path(execution_id)
        payload = read_json(path)
        if not isinstance(payload, dict):
            return False
        payload["force_expired"] = True
        try:
            write_json_atomic(path, payload)
        except OSError:
            logger.warning("mark_expired failed for %s", execution_id, exc_info=True)
            return False
        return True

    def list_snapshots(self) -> list[dict]:
        """Return lightweight metadata for every snapshot in this account dir.

        Intended for debugging and operator inspection — not used by the
        diagnosis flow itself.
        """
        if not self._dir.exists():
            return []
        results: list[dict] = []
        for entry in sorted(self._dir.glob("exec_*.json")):
            payload = read_json(entry)
            if not isinstance(payload, dict):
                continue
            results.append(
                {
                    "execution_id": payload.get("execution_id"),
                    "saved_at": payload.get("saved_at"),
                    "path": str(entry),
                    "expired": is_payload_expired(payload),
                }
            )
        return results
