"""
fw_server/session.py — Session ledger for the Hayat Bridge.

Channel 3 of the four-channel enforcement architecture.  Tracks tool
calls, grades, gate verdicts, and kavaid within a session so that
downstream tools can see upstream state.

Governing axioms:
  AX5:   Integration prerequisite — the ledger IS the integration layer
         that activates inter-tool awareness.
  AX52:  Multiplicative gate — zero in ANY dimension collapses the whole.
         The ledger was a zero channel; filling it enables AX52 compliance.
  KV₃:   Observer non-interference — ledger records but never blocks.
         A ledger failure MUST NOT crash the tool that called it.
         Persistence is fire-and-forget: save/restore failures are logged,
         never propagated.
  KV₇:   Independence — ledger has no shared mutable state with lenses.
         It only records post-computation results.
  AX23:  Cascade structure — ledger enables cascade feedback (Channel 4)
         by providing the sequential history downstream tools need.

Design decisions:
  - In-memory with optional checkpoint persistence.  The primary
    data structure lives in RAM; persistence (save/restore to JSON)
    is an optional transparent layer managed by fw_server.persistence.
    KV₃ compliance: all I/O is fire-and-forget, failures are swallowed.
  - Module-level singleton via get_ledger(): one ledger per process.
  - All record_*() calls are fire-and-forget: exceptions are swallowed
    and logged, never propagated.
  - Grade ceiling: lowest grade seen so far becomes the ceiling for
    subsequent tools (grade can only go DOWN within a session, never UP).
  - Score ledger is capped at MAX_SCORE_LEDGER_SIZE on export to
    prevent unbounded file growth across sessions.
"""

from __future__ import annotations

import logging
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional

logger = logging.getLogger("fw_server.session")

# ── Persistence schema ───────────────────────────────────────────────

_SCHEMA_VERSION = 1
_SUPPORTED_SCHEMA_VERSIONS = {1}

# Maximum score-ledger entries to persist (prevents unbounded growth).
MAX_SCORE_LEDGER_SIZE = 200

# ── Grade ordering (ascending) ───────────────────────────────────────

GRADE_ORDER: List[str] = ["Tasavvur", "Tasdik", "İlmelyakîn"]


def _grade_index(grade: str) -> int:
    """Return ordinal position of *grade* (0-based), or -1 if unknown."""
    try:
        return GRADE_ORDER.index(grade)
    except ValueError:
        return -1


# ── Call record (one per tool invocation) ────────────────────────────

@dataclass
class CallRecord:
    """Immutable record of a single tool invocation."""

    tool_name: str
    grade: Optional[str] = None
    gate: Optional[str] = None           # PASS / WARN / FAIL
    composite_score: Optional[float] = None
    kavaid_pass_count: Optional[int] = None
    anomalies: List[str] = field(default_factory=list)
    extra: Dict[str, Any] = field(default_factory=dict)


# ── Session Ledger ───────────────────────────────────────────────────

class SessionLedger:
    """In-memory session-scoped tool-call ledger.

    Thread safety: not required — MCP stdio is single-threaded.
    """

    def __init__(self) -> None:
        self._calls: List[CallRecord] = []
        self._grade_ceiling: Optional[str] = None  # lowest grade seen
        self._gate_history: List[str] = []          # PASS/WARN/FAIL sequence
        self._kavaid_failures: List[str] = []       # KV ids that ever failed
        self._score_ledger: List[Dict[str, float]] = []  # Phase 5: per-call lens scores

    # ── Recording API ────────────────────────────────────────────────

    def record_call(
        self,
        tool_name: str,
        *,
        grade: Optional[str] = None,
        gate: Optional[str] = None,
        composite_score: Optional[float] = None,
        kavaid_pass_count: Optional[int] = None,
        anomalies: Optional[List[str]] = None,
        **extra: Any,
    ) -> None:
        """Record a tool invocation.  Fire-and-forget; never raises."""
        try:
            rec = CallRecord(
                tool_name=tool_name,
                grade=grade,
                gate=gate,
                composite_score=composite_score,
                kavaid_pass_count=kavaid_pass_count,
                anomalies=anomalies or [],
                extra=extra,
            )
            self._calls.append(rec)

            # Update grade ceiling (lowest grade wins)
            if grade and _grade_index(grade) >= 0:
                if self._grade_ceiling is None:
                    self._grade_ceiling = grade
                elif _grade_index(grade) < _grade_index(self._grade_ceiling):
                    self._grade_ceiling = grade

            # Track gate history
            if gate:
                self._gate_history.append(gate)

        except Exception:
            logger.debug("SessionLedger.record_call swallowed error", exc_info=True)

    def record_kavaid_failure(self, kv_id: str) -> None:
        """Record that a kavaid constraint failed.  Fire-and-forget."""
        try:
            if kv_id not in self._kavaid_failures:
                self._kavaid_failures.append(kv_id)
        except Exception:
            logger.debug("SessionLedger.record_kavaid_failure swallowed", exc_info=True)

    def record_lens_scores(self, scores: Dict[str, float]) -> None:
        """Append per-lens scores from a dusun() call to the score ledger.

        Phase 5 (Cross-Lens Correlation): the score ledger accumulates
        lens-score snapshots across multiple dusun() calls within a session.
        When the ledger has ≥ 10 entries, cross-validation computes
        pairwise Pearson correlations to verify KV₇ (independence).

        Fire-and-forget; never raises.
        """
        try:
            if isinstance(scores, dict):
                self._score_ledger.append(dict(scores))
        except Exception:
            logger.debug("SessionLedger.record_lens_scores swallowed", exc_info=True)

    def get_score_history(self) -> List[Dict[str, float]]:
        """Return a copy of all accumulated lens-score snapshots."""
        return list(self._score_ledger)

    # ── Query API ────────────────────────────────────────────────────

    @property
    def call_count(self) -> int:
        """Total number of recorded tool calls."""
        return len(self._calls)

    @property
    def calls(self) -> List[CallRecord]:
        """All recorded calls (read-only copy)."""
        return list(self._calls)

    def get_grade_ceiling(self) -> Optional[str]:
        """Return the lowest grade observed so far, or None if no grades recorded.

        The ceiling can only go DOWN.  If dusun returned İlmelyakîn but
        validate_stage returned Tasavvur, the ceiling is Tasavvur.
        """
        return self._grade_ceiling

    def get_last_gate(self) -> Optional[str]:
        """Return the most recent gate verdict, or None."""
        return self._gate_history[-1] if self._gate_history else None

    def has_failure(self) -> bool:
        """True if ANY gate returned FAIL in this session."""
        return "FAIL" in self._gate_history

    def has_warning(self) -> bool:
        """True if ANY gate returned WARN in this session."""
        return "WARN" in self._gate_history

    @property
    def kavaid_failures(self) -> List[str]:
        """KV ids that failed at any point during the session."""
        return list(self._kavaid_failures)

    def get_anomaly_history(self) -> List[str]:
        """Return all anomalies from all recorded calls, in order.

        Enables Channel 4 cascade forward: downstream tools can see
        exactly which anomalies occurred upstream.
        """
        result: List[str] = []
        for call in self._calls:
            result.extend(call.anomalies)
        return result

    def get_cumulative_context(self) -> Dict[str, Any]:
        """Build a summary dict suitable for embedding in tool returns.

        This is the payload that Channel 2 (_framework_context) reads.
        """
        return {
            "session_call_count": self.call_count,
            "grade_ceiling": self._grade_ceiling,
            "last_gate": self.get_last_gate(),
            "has_failure": self.has_failure(),
            "has_warning": self.has_warning(),
            "kavaid_failures": list(self._kavaid_failures),
            "gate_history": list(self._gate_history),
            "tool_sequence": [c.tool_name for c in self._calls],
            "anomaly_history": self.get_anomaly_history(),
            "score_ledger_size": len(self._score_ledger),
        }

    def get_cascade_status(self) -> Dict[str, Any]:
        """Compute cascade degradation state for Channel 4.

        Returns a dict with degradation_level (NOMINAL / DEGRADED / CRITICAL)
        and the reason.
        """
        if not self._calls:
            return {"degradation_level": "NOMINAL", "reason": "no prior calls"}

        if self.has_failure():
            return {
                "degradation_level": "CRITICAL",
                "reason": "FAIL gate in session — downstream must remediate",
            }

        if self.has_warning() and len([g for g in self._gate_history if g == "WARN"]) >= 2:
            return {
                "degradation_level": "DEGRADED",
                "reason": "multiple WARN gates — pattern suggests structural gap",
            }

        if self.has_warning():
            return {
                "degradation_level": "DEGRADED",
                "reason": "WARN gate in session — investigate before proceeding",
            }

        if self._kavaid_failures:
            return {
                "degradation_level": "DEGRADED",
                "reason": f"kavaid failures: {', '.join(self._kavaid_failures)}",
            }

        # High anomaly count without explicit gate failure still degrades
        anomaly_count = len(self.get_anomaly_history())
        if anomaly_count >= 3:
            return {
                "degradation_level": "DEGRADED",
                "reason": f"{anomaly_count} anomalies across session — pattern warrants attention",
            }

        return {"degradation_level": "NOMINAL", "reason": "all gates PASS, no kavaid failures"}

    def get_cascade_forward(self) -> Dict[str, Any]:
        """Build forward-looking cascade guidance for the NEXT tool call.

        Channel 4 (AX23+T8+AX63): this method translates session history
        into actionable directives that downstream tools embed.  It provides:

          - ``prior_anomalies``: all anomalies seen so far (context)
          - ``prior_gate_summary``: gate history for the LLM to read
          - ``forward_directives``: list of enforcement strings
          - ``degradation_level``: NOMINAL / DEGRADED / CRITICAL

        The forward directives use MUST/MUST NOT/NEVER to enforce cascade
        consequences across pipeline stages.
        """
        try:
            status = self.get_cascade_status()
            level = status.get("degradation_level", "NOMINAL")
            anomalies = self.get_anomaly_history()
            kv_failures = list(self._kavaid_failures)

            forward: Dict[str, Any] = {
                "degradation_level": level,
                "reason": status.get("reason", ""),
                "prior_anomalies": anomalies,
                "prior_anomaly_count": len(anomalies),
                "prior_gate_summary": list(self._gate_history),
                "prior_kavaid_failures": kv_failures,
                "forward_directives": [],
            }

            directives: List[str] = []

            if level == "CRITICAL":
                directives.append(
                    "CRITICAL cascade: a FAIL gate occurred earlier in this "
                    "session.  You MUST address the underlying issue before "
                    "continuing.  NEVER mark subsequent stages as PASS when "
                    "a prior stage FAILed."
                )
            elif level == "DEGRADED":
                directives.append(
                    "DEGRADED cascade: warnings or kavaid failures exist in "
                    "this session.  You MUST acknowledge prior issues in your "
                    "AX57 disclosure.  Do not treat this stage in isolation."
                )

            if anomalies:
                directives.append(
                    f"{len(anomalies)} anomalies from prior stages: "
                    f"{', '.join(anomalies[:5])}"
                    f"{'...' if len(anomalies) > 5 else ''}.  "
                    "These MUST be carried forward — NEVER suppress "
                    "upstream signals."
                )

            if kv_failures:
                directives.append(
                    f"Kavaid failures from prior tools: {', '.join(kv_failures)}.  "
                    "These constraints MUST NOT be ignored in subsequent analysis."
                )

            if self._grade_ceiling is not None:
                directives.append(
                    f"Session grade ceiling is '{self._grade_ceiling}'.  "
                    "You MUST NOT claim a higher grade.  "
                    "The ceiling can only go DOWN, never UP."
                )

            forward["forward_directives"] = directives
            return forward

        except Exception:
            logger.debug("SessionLedger.get_cascade_forward swallowed error", exc_info=True)
            return {
                "degradation_level": "NOMINAL",
                "reason": "cascade_forward_error",
                "prior_anomalies": [],
                "prior_anomaly_count": 0,
                "prior_gate_summary": [],
                "prior_kavaid_failures": [],
                "forward_directives": [],
            }

    # ── Persistence API ────────────────────────────────────────────────

    def export_state(self) -> Dict[str, Any]:
        """Export full ledger state as a JSON-serializable dict.

        The score ledger is truncated to ``MAX_SCORE_LEDGER_SIZE`` most
        recent entries to prevent unbounded file growth across sessions.

        Returns a dict with ``schema_version``, ``saved_at``, and all
        ledger fields serialized to primitives.
        """
        # Truncate score ledger to most recent N entries
        score_ledger = self._score_ledger[-MAX_SCORE_LEDGER_SIZE:]

        # Serialize CallRecords via dataclasses.asdict, with a
        # defensive fallback for non-serializable extra values
        calls: List[Dict[str, Any]] = []
        for rec in self._calls:
            try:
                calls.append(asdict(rec))
            except Exception:
                # Fallback: manually build dict if asdict fails
                calls.append({
                    "tool_name": rec.tool_name,
                    "grade": rec.grade,
                    "gate": rec.gate,
                    "composite_score": rec.composite_score,
                    "kavaid_pass_count": rec.kavaid_pass_count,
                    "anomalies": list(rec.anomalies),
                    "extra": {},
                })

        return {
            "schema_version": _SCHEMA_VERSION,
            "saved_at": datetime.now(timezone.utc).isoformat(),
            "calls": calls,
            "grade_ceiling": self._grade_ceiling,
            "gate_history": list(self._gate_history),
            "kavaid_failures": list(self._kavaid_failures),
            "score_ledger": score_ledger,
        }

    def import_state(self, data: Dict[str, Any]) -> None:
        """Restore ledger state from a dict produced by ``export_state()``.

        Validates schema version.  Raises ``ValueError`` for unsupported
        schemas.  KV₃ compliance: callers should wrap this in try/except
        and degrade gracefully on failure.
        """
        version = data.get("schema_version")
        if version not in _SUPPORTED_SCHEMA_VERSIONS:
            raise ValueError(
                f"Unsupported session schema version {version!r} "
                f"(supported: {_SUPPORTED_SCHEMA_VERSIONS})"
            )

        # Reconstruct CallRecords
        raw_calls = data.get("calls", [])
        calls: List[CallRecord] = []
        for raw in raw_calls:
            calls.append(CallRecord(
                tool_name=raw.get("tool_name", "unknown"),
                grade=raw.get("grade"),
                gate=raw.get("gate"),
                composite_score=raw.get("composite_score"),
                kavaid_pass_count=raw.get("kavaid_pass_count"),
                anomalies=raw.get("anomalies", []),
                extra=raw.get("extra", {}),
            ))

        # Apply to self (atomic swap — either all fields update or none)
        self._calls = calls
        self._grade_ceiling = data.get("grade_ceiling")
        self._gate_history = list(data.get("gate_history", []))
        self._kavaid_failures = list(data.get("kavaid_failures", []))
        self._score_ledger = list(data.get("score_ledger", []))

        logger.info(
            "Session state imported: %d calls, ceiling=%s, "
            "score_ledger=%d entries",
            len(calls),
            self._grade_ceiling,
            len(self._score_ledger),
        )

    # ── Lifecycle ────────────────────────────────────────────────────

    def reset(self) -> None:
        """Clear all session state.  Called between sessions if needed.

        Defensive: re-initialises lists from scratch so corrupted state
        (e.g. ``_calls`` set to ``None`` during KV₃ resilience testing)
        never causes a secondary crash.
        """
        self._calls: list[CallRecord] = []
        self._grade_ceiling = None
        self._gate_history: list[str] = []
        self._kavaid_failures: list[str] = []
        self._score_ledger: list[dict[str, float]] = []


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

_ledger: Optional[SessionLedger] = None


def get_ledger() -> SessionLedger:
    """Return the process-wide SessionLedger singleton.

    Creates it on first call.  Thread-safe for single-threaded MCP stdio.
    """
    global _ledger
    if _ledger is None:
        _ledger = SessionLedger()
    return _ledger


def reset_ledger() -> None:
    """Reset the singleton ledger (useful for tests)."""
    global _ledger
    if _ledger is not None:
        _ledger.reset()
    _ledger = None
