"""Evolution dataclasses — the domain model.

These mirror the SQLite schema declared in ledger.py 1:1. Serialization to
JSON columns uses ``dataclasses.asdict`` + ``json.dumps`` with ``default=str``
so datetime objects and other non-JSON-native types degrade gracefully.

Every field has a default so callers can construct partial records for
tests without enumerating every column. Timestamps default to "" and are
populated at persistence time by the ledger.

Nothing in this module writes to disk or hits the DB — it is pure data.
"""

from __future__ import annotations

import uuid
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from typing import Any

# ── Synthetic policy identifiers ──
#
# Two special policy_id values are always eligible, never mined from runs,
# never promoted past their current state. They anchor the selector's
# uncertainty model.
#
# - INCUMBENT_DEFAULT: the current deterministic routing behavior that
#   existed BEFORE the evolution layer. Posterior-tracked like any real
#   reflex so we can detect incumbent regressions.
# - BASELINE_RANDOM: deterministic fallback anchor — never preferred by
#   Thompson sampling (sampled score = 0.5 × utility_ema, flat prior).
#   Exists so the eligible set always has something to compare against
#   if mining hasn't produced any real candidates yet.
POLICY_INCUMBENT_DEFAULT: str = "incumbent_default"
POLICY_BASELINE_RANDOM: str = "baseline_random"


def _now_iso() -> str:
    return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ")


def new_run_id() -> str:
    """Generate a short run id suitable for DB + vault artifact naming."""
    return f"run-{uuid.uuid4().hex[:12]}"


def new_reflex_id(kind: str = "reflex") -> str:
    """Generate a reflex id. ``kind`` is 'reflex' or 'anti'."""
    prefix = "anti" if kind == "anti" else "reflex"
    return f"{prefix}-{uuid.uuid4().hex[:10]}"


def new_promotion_id() -> str:
    return f"prom-{uuid.uuid4().hex[:10]}"


@dataclass
class RunManifest:
    """Reproducible state snapshot for one invoke_skill run.

    UNIQUE(correlation_id) — nested invoke_skill calls share their parent's
    correlation_id and inherit the parent's policy_id. Only the outermost
    invoke in a causal chain gets its own run_id.

    ``env_fingerprint`` is a dict with the five environment dimensions we
    can cheaply snapshot (vault content sha, graph rev, tool registry sha,
    settings sha, contract version). It is stored as one JSON blob on the
    run row so env drift between organic runs and replays is a single
    dict-compare rather than a five-column SQL join.
    """

    run_id: str = field(default_factory=new_run_id)
    correlation_id: str = ""
    parent_run_id: str = ""
    caller: str = "system"          # watcher|heartbeat|chat|workflow|cron|user|recovery|system
    trigger_type: str = ""          # free-form (usually the skill name)
    skill_name: str = ""
    agent_name: str = "main"
    policy_id: str = POLICY_INCUMBENT_DEFAULT
    model_name: str = ""
    model_tier: str = ""            # local|cloud-small|cloud-large|capable|fast|reasoning
    env_fingerprint: dict[str, str] = field(default_factory=dict)
    started_at: str = field(default_factory=_now_iso)
    completed_at: str = ""
    duration_ms: int = 0

    def as_row(self) -> dict[str, Any]:
        d = asdict(self)
        # env_fingerprint is stored as JSON in the DB; the ledger handles
        # the dumps. Returning the raw dict here keeps the dataclass
        # authoritative.
        return d


@dataclass
class OutcomeVector:
    """Multi-criteria outcome score for a run.

    ``utility`` is computed by outcomes.py from the seven subscores in
    ``components`` using a weighted geometric mean with hard zero-effect
    gate. See evolution/outcomes.py for the formula.

    ``components`` persists every input to the formula (including the p50
    baselines used for cost/latency normalization) so utility can be
    recomputed retroactively if the weights change — or debugged if a
    value looks off.

    ``zero_effect`` is redundant with the components['effect'] < 0.5
    (given effect is 0 or 1) but kept as an explicit boolean because it's
    the gate SkillZeroEffect raised on — we want queries to filter
    directly on it without unmarshaling the JSON blob.
    """

    run_id: str = ""
    utility: float = 0.0
    zero_effect: bool = False
    blocked: bool = False
    components: dict[str, float] = field(default_factory=dict)
    computed_at: str = field(default_factory=_now_iso)


@dataclass
class Reflex:
    """A reusable execution policy compiled from successful runs.

    ``kind='reflex'`` — a positive pattern worth repeating.
    ``kind='anti'``   — a failure pattern worth avoiding.

    We store both in one table (discriminated by ``kind``) because they
    share every structural field (scope, evidence, decay, status) and
    differ only in how the selector treats them. Two tables would force
    every reflex query to UNION; one table + an index on ``(kind, status)``
    is cheaper and cleaner.
    """

    reflex_id: str = field(default_factory=lambda: new_reflex_id("reflex"))
    kind: str = "reflex"            # 'reflex' | 'anti'
    name: str = ""
    version: int = 1
    scope: dict[str, Any] = field(default_factory=dict)           # e.g. {"skill_name":"wiki-management","caller_in":["watcher"]}
    preferred_agent: str = ""
    preferred_tier: str = ""
    tools: list[str] = field(default_factory=list)
    triggers: list[str] = field(default_factory=list)
    preconditions: list[str] = field(default_factory=list)
    invariants: list[str] = field(default_factory=list)
    abort_conditions: list[str] = field(default_factory=list)
    expected_mutations: dict[str, int] = field(default_factory=dict)  # e.g. {"vault_writes_min":1}
    evidence_run_ids: list[str] = field(default_factory=list)
    status: str = "draft"           # draft|candidate|active|quarantined|retired
    created_at: str = field(default_factory=_now_iso)
    last_validated_at: str = ""
    decay_half_life_days: float = 30.0
    vault_path: str = ""            # relative path under system/evolution/


@dataclass
class AntiReflex(Reflex):
    """Alias dataclass enforcing ``kind='anti'`` at construction time.

    Callers that want to emphasize they're creating an anti-reflex can use
    this class; the ledger stores both shapes identically and only the
    ``kind`` discriminator matters at query time.
    """

    kind: str = "anti"

    def __post_init__(self) -> None:
        # Enforce the discriminator so accidental kind='' doesn't silently
        # pollute the reflex table.
        if self.kind != "anti":
            self.kind = "anti"


@dataclass
class ReflexPosterior:
    """Bayesian posterior state for one reflex — hot write path.

    Separated from Reflex because the reflex row is ~cold metadata
    (scope, triggers, invariants) while the posterior is updated on
    every run attributed to the reflex. Keeping them in separate tables
    avoids rewriting the cold metadata on every outcome.

    Update rule (see outcomes.py::update_posterior):
        alpha      += utility                  # continuous Beta-like update
        beta_param += (1 - utility)
        utility_ema = (1 - α) * utility_ema + α * utility    (α = 0.1 default)
        utility_var = (1 - α) * utility_var + α * (utility - utility_ema)**2
        n_runs     += 1
    """

    reflex_id: str = ""
    alpha: float = 1.0
    beta_param: float = 1.0
    utility_ema: float = 0.0
    utility_var: float = 0.0
    n_runs: int = 0
    last_run_at: str = ""

    def posterior_mean(self) -> float:
        total = self.alpha + self.beta_param
        return self.alpha / total if total > 0 else 0.5


@dataclass
class ReplayJob:
    """One replay attempt of a candidate reflex against a historical run.

    Ties a candidate evaluation to the original run it's replaying so the
    promotion arena can require ``replay_count >= N`` before a candidate
    can outcompete the incumbent. Without this row, "replayed" and
    "organic" runs would be indistinguishable.
    """

    job_id: int = 0                 # assigned by SQLite AUTOINCREMENT
    incumbent_id: str = POLICY_INCUMBENT_DEFAULT
    candidate_id: str = ""
    correlation_id_original: str = ""
    correlation_id_replay: str = ""
    status: str = "pending"         # pending|running|done|skipped|failed
    utility_delta: float | None = None
    created_at: str = field(default_factory=_now_iso)
    completed_at: str = ""


@dataclass
class Promotion:
    """Auditable promotion decision record.

    Always written — promote, reject, AND defer all produce a row so the
    promotion arena's full reasoning is queryable. ``rollback_target`` is
    the previously-active reflex_id (or POLICY_INCUMBENT_DEFAULT); rollback
    is one UPDATE.
    """

    promotion_id: str = field(default_factory=new_promotion_id)
    candidate_id: str = ""
    incumbent_id: str = POLICY_INCUMBENT_DEFAULT
    baseline_ids: list[str] = field(default_factory=list)
    evidence: dict[str, Any] = field(default_factory=dict)        # gate results, stats, replay deltas
    decision: str = "defer"         # promote|reject|defer
    confidence: float = 0.0         # P(candidate > incumbent)
    rationale: str = ""
    created_at: str = field(default_factory=_now_iso)
    rollback_target: str = ""
    vault_path: str = ""


@dataclass
class SelectorTelemetry:
    """One row per run — records which policies were eligible and why one was chosen.

    Stored separately from RunManifest because most callers querying runs
    don't need the full eligible list. Joining on run_id when needed is
    cheap; denormalizing onto every run bloats the hot table.
    """

    run_id: str = ""
    policy_id: str = POLICY_INCUMBENT_DEFAULT
    eligible_ids: list[str] = field(default_factory=list)
    top_samples: list[dict[str, Any]] = field(default_factory=list)  # [{reflex_id, sampled_score, posterior_mean}, ...]
    chosen_reason: str = ""


@dataclass
class PolicyChoice:
    """Return value of ``evolution.selector.select_policy``.

    Carries the chosen policy_id AND enough context for the caller to:
      - pass ``policy_id`` to ``orchestrator.dispatch(policy_id=...)``
      - log the rationale for the /decisions page
      - decide whether to defer experimentation (``is_fallback=True``)

    ``sampled_score`` is the Thompson-sampled utility estimate for the
    chosen candidate. ``sampled_rank`` is 0 for the chosen one; future
    selectors may return top-N.
    """

    policy_id: str = POLICY_INCUMBENT_DEFAULT
    rationale: str = "fallback:evolution_inactive"
    sampled_score: float = 0.0
    sampled_rank: int = 0
    is_fallback: bool = True

    @classmethod
    def fallback(cls, reason: str) -> PolicyChoice:
        return cls(
            policy_id=POLICY_INCUMBENT_DEFAULT,
            rationale=f"fallback:{reason}",
            sampled_score=0.0,
            sampled_rank=0,
            is_fallback=True,
        )
