"""PromotionArena — Phase 4: evaluate candidates, promote or reject with evidence.

The arena runs periodically (hourly heartbeat) AND on-demand via
``POST /api/evolution/promote/{reflex_id}``. It evaluates every
``status='candidate'`` reflex against six gates:

    1. ``n_runs >= min_runs`` (default 10)
    2. ``replay_count >= min_replays`` (default 3)
    3. ``candidate.utility_ema - incumbent.utility_ema >= utility_uplift_min`` (default 0.05)
    4. ``P(candidate > incumbent | posteriors) >= confidence_min`` (default 0.90)
    5. ``candidate.safety_ema >= max(incumbent.safety_ema, safety_ema_floor)`` (default 0.95)
    6. No open ``kind='anti'`` reflex matching the candidate's scope

ALL gates must pass. Any failure → decision='defer' (or 'reject' if the
candidate has been repeatedly deferred and the evidence clearly shows
it won't succeed). A passing candidate transitions to ``status='active'``
with ``rollback_target`` set to the previously-active reflex's ID (or
``incumbent_default`` when there's no prior active in this scope).

Every transition — promote, reject, or defer — writes:
  1. A row in ``evolution_promotions``
  2. A readable markdown artifact under ``system/evolution/promotions/``
  3. A decision_log entry (``action_type='evolution_promote'``)

Rollback: a single SQL UPDATE (``WHERE status='active' SET status='candidate'``)
on the currently-active reflex + a second UPDATE restoring the rollback_target.
Rollback doesn't re-evaluate — it's a "restore to last good state" operation.
"""

from __future__ import annotations

import logging
import time
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any

from agntspace.evolution.bayes import posterior_mean, prob_greater
from agntspace.evolution.kernel import validate_reflex_respects_kernel
from agntspace.evolution.schemas import (
    POLICY_INCUMBENT_DEFAULT,
    Promotion,
    Reflex,
    new_promotion_id,
)
from agntspace.evolution.storage import promotion_artifact_path

if TYPE_CHECKING:
    from agntspace.evolution.ledger import EvolutionLedger

log = logging.getLogger(__name__)


class PromotionArena:
    """Evaluates candidate reflexes against the promotion gates."""

    def __init__(
        self,
        *,
        ledger: EvolutionLedger,
        config: dict[str, Any],
        writer: Any | None = None,
        decision_logger: Any | None = None,
        activity_logger: Any | None = None,
    ) -> None:
        self._ledger = ledger
        self._config = config or {}
        self._writer = writer
        self._decisions = decision_logger
        self._activity = activity_logger

    async def evaluate_all(self) -> dict[str, Any]:
        """Score every candidate, persist decisions. Returns a summary.

        Safe to call as often as the WorkQueue handler fires — the gates
        are deterministic, so re-running on stale data produces the same
        answer. A candidate that recently DEFER'd isn't re-evaluated
        until the ``re_evaluation_interval_hours`` has passed.
        """
        candidates = await self._ledger.list_reflexes(kind="reflex", status="candidate")
        summary = {
            "candidates": len(candidates),
            "promoted": 0,
            "deferred": 0,
            "rejected": 0,
            "errors": 0,
        }
        for c in candidates:
            try:
                dec = await self.evaluate_one(c)
                if dec.decision == "promote":
                    summary["promoted"] += 1
                elif dec.decision == "reject":
                    summary["rejected"] += 1
                else:
                    summary["deferred"] += 1
            except Exception:
                log.exception("promotion evaluation failed for %s", c.reflex_id)
                summary["errors"] += 1
        return summary

    async def evaluate_one(self, candidate: Reflex) -> Promotion:
        """Run all gates for a single candidate, persist the decision.

        Returns the Promotion record. If ``decision == "promote"``, the
        candidate's status has been flipped to 'active' and any prior
        active reflex in the same scope has been demoted to 'candidate'.
        """
        promo_cfg = self._config.get("promotion", {}) if isinstance(self._config, dict) else {}
        min_runs = int(promo_cfg.get("min_runs", 10))
        min_replays = int(promo_cfg.get("min_replays", 3))
        utility_uplift_min = float(promo_cfg.get("utility_uplift_min", 0.05))
        confidence_min = float(promo_cfg.get("confidence_min", 0.90))
        safety_ema_floor = float(promo_cfg.get("safety_ema_floor", 0.95))

        # Gather all the evidence up front — gates are cheap to evaluate once
        # we have the numbers, and gathering them all together makes the
        # decision rationale self-explanatory.
        cand_post = await self._ledger.get_posterior(candidate.reflex_id)
        inc_post = await self._ledger.get_posterior(POLICY_INCUMBENT_DEFAULT)
        cand_safety = await self._ledger.recent_safety_ema(candidate.reflex_id)
        inc_safety = await self._ledger.recent_safety_ema(POLICY_INCUMBENT_DEFAULT)
        replay_count = await self._ledger.count_replays(candidate.reflex_id, status="done")

        # Search for blocking anti-reflexes that match this candidate's scope.
        anti_reflexes = await self._ledger.list_reflexes(kind="anti")
        blocking_anti = next(
            (a for a in anti_reflexes
             if _scopes_overlap(a.scope, candidate.scope) and a.status in ("draft", "candidate", "active")),
            None,
        )

        # Run all 6 gates. Each returns (pass: bool, reason: str).
        gates = [
            _gate_min_runs(cand_post.n_runs, min_runs),
            _gate_min_replays(replay_count, min_replays),
            _gate_utility_uplift(cand_post.utility_ema, inc_post.utility_ema, utility_uplift_min),
            _gate_confidence(cand_post, inc_post, confidence_min),
            _gate_safety(cand_safety, inc_safety, safety_ema_floor),
            _gate_no_blocking_anti(blocking_anti),
            _gate_kernel(candidate),
        ]
        passed = all(g[0] for g in gates)
        reasons = [g[1] for g in gates]

        # Confidence score for the decision = P(candidate > incumbent)
        confidence = prob_greater(
            cand_post.alpha, cand_post.beta_param,
            inc_post.alpha, inc_post.beta_param,
            samples=2000,
        )

        # Decide: promote if all gates pass; reject if the candidate has
        # clearly failed on a load-bearing gate (safety regression);
        # defer otherwise (gather more evidence).
        if passed:
            decision = "promote"
        elif cand_safety < safety_ema_floor - 0.10 and cand_post.n_runs >= min_runs:
            # Safety regression isn't just "defer" — it's a hard reject.
            decision = "reject"
        else:
            decision = "defer"

        # Derive the rollback_target: the currently-active reflex (if any)
        # in the same scope will be demoted on promote. If none exists,
        # rollback means "revert to incumbent_default".
        rollback_target = POLICY_INCUMBENT_DEFAULT
        if decision == "promote":
            current_active = await self._current_active_in_scope(candidate.scope)
            if current_active:
                rollback_target = current_active.reflex_id

        promotion = Promotion(
            promotion_id=new_promotion_id(),
            candidate_id=candidate.reflex_id,
            incumbent_id=POLICY_INCUMBENT_DEFAULT,
            baseline_ids=[POLICY_INCUMBENT_DEFAULT],
            evidence={
                "gates": [{"passed": p, "reason": r} for p, r in gates],
                "candidate_posterior": {
                    "alpha": cand_post.alpha,
                    "beta": cand_post.beta_param,
                    "n_runs": cand_post.n_runs,
                    "utility_ema": cand_post.utility_ema,
                    "posterior_mean": posterior_mean(cand_post.alpha, cand_post.beta_param),
                },
                "incumbent_posterior": {
                    "alpha": inc_post.alpha,
                    "beta": inc_post.beta_param,
                    "n_runs": inc_post.n_runs,
                    "utility_ema": inc_post.utility_ema,
                    "posterior_mean": posterior_mean(inc_post.alpha, inc_post.beta_param),
                },
                "replay_count": replay_count,
                "candidate_safety_ema": cand_safety,
                "incumbent_safety_ema": inc_safety,
                "blocking_anti": getattr(blocking_anti, "reflex_id", None),
            },
            decision=decision,
            confidence=confidence,
            rationale="; ".join(reasons),
            rollback_target=rollback_target,
        )

        # Write the decision FIRST so the promotion is auditable even if
        # the status transition fails.
        await self._ledger.save_promotion(promotion)
        await self._write_promotion_artifact(promotion, candidate)
        await self._record_decision(promotion, candidate)

        # Apply the state transition on promote
        if decision == "promote":
            # Demote any currently-active reflex in this scope
            current_active = await self._current_active_in_scope(candidate.scope)
            if current_active and current_active.reflex_id != candidate.reflex_id:
                await self._ledger.update_reflex_status(
                    current_active.reflex_id, "candidate",
                )
            # Promote the candidate to active
            await self._ledger.update_reflex_status(
                candidate.reflex_id, "active",
                last_validated_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
            )
            self._emit_activity(
                "reflex_promoted",
                f"Reflex {candidate.name} promoted candidate→active "
                f"(confidence={confidence:.3f})",
                {
                    "reflex_id": candidate.reflex_id,
                    "promotion_id": promotion.promotion_id,
                    "confidence": confidence,
                    "rollback_target": rollback_target,
                },
                importance="high",
            )
        elif decision == "reject":
            await self._ledger.update_reflex_status(candidate.reflex_id, "retired")
            self._emit_activity(
                "reflex_rejected",
                f"Reflex {candidate.name} rejected by promotion arena",
                {
                    "reflex_id": candidate.reflex_id,
                    "promotion_id": promotion.promotion_id,
                    "reason": promotion.rationale,
                },
                importance="high",
            )
        # defer: no status change; candidate stays at 'candidate' for re-evaluation

        return promotion

    async def rollback(self, promotion_id: str) -> dict[str, Any]:
        """Revert a prior promotion. Returns the rollback summary."""
        proms = await self._ledger.list_promotions(limit=500)
        target_prom = next((p for p in proms if p.promotion_id == promotion_id), None)
        if target_prom is None:
            return {"status": "not_found", "promotion_id": promotion_id}
        if target_prom.decision != "promote":
            return {"status": "not_applicable", "reason": f"decision was {target_prom.decision}"}

        # Demote the currently-active candidate back to 'candidate'
        await self._ledger.update_reflex_status(target_prom.candidate_id, "candidate")

        # Restore the rollback_target to 'active' unless it's the incumbent_default
        # (which is synthetic and doesn't have a row).
        if target_prom.rollback_target and target_prom.rollback_target != POLICY_INCUMBENT_DEFAULT:
            await self._ledger.update_reflex_status(target_prom.rollback_target, "active")

        self._emit_activity(
            "reflex_rolled_back",
            f"Promotion {promotion_id} rolled back (candidate={target_prom.candidate_id})",
            {
                "promotion_id": promotion_id,
                "candidate_id": target_prom.candidate_id,
                "rollback_target": target_prom.rollback_target,
            },
            importance="high",
        )

        return {
            "status": "rolled_back",
            "promotion_id": promotion_id,
            "candidate_id": target_prom.candidate_id,
            "rollback_target": target_prom.rollback_target,
        }

    # ── Internals ──

    async def _current_active_in_scope(self, scope: dict[str, Any]) -> Reflex | None:
        actives = await self._ledger.list_reflexes(kind="reflex", status="active")
        for r in actives:
            if _scopes_overlap(r.scope, scope):
                return r
        return None

    async def _write_promotion_artifact(
        self, promotion: Promotion, candidate: Reflex,
    ) -> None:
        if self._writer is None:
            return
        path = promotion_artifact_path(
            promotion.promotion_id, candidate.reflex_id, promotion.created_at,
        )
        promotion.vault_path = path
        body = _render_promotion_markdown(promotion, candidate)
        try:
            try:
                await self._writer.write(
                    path, body,
                    frontmatter={
                        "title": f"Promotion decision: {candidate.name}",
                        "description": (
                            f"Promotion arena evaluated {candidate.name} — "
                            f"decision: {promotion.decision}"
                        ),
                        "date": time.strftime("%Y-%m-%d"),
                        "author": "agent",
                        "agent": "evolution",
                        "promotion_id": promotion.promotion_id,
                        "reflex_id": candidate.reflex_id,
                        "decision": promotion.decision,
                    },
                    trust_reason="evolution_persist",
                )
            except TypeError:
                await self._writer.write(path, body)
        except Exception:
            log.debug("promotion artifact write failed", exc_info=True)

    async def _record_decision(self, promotion: Promotion, candidate: Reflex) -> None:
        if self._decisions is None:
            return
        try:
            await self._decisions.record(
                actor="evolution",
                action_type="evolution_promote",
                action_target=candidate.reflex_id,
                contract_action="evolution_persist",
                contract_result="allowed",
                triggered_by="promotion_arena",
                rationale=promotion.rationale,
                details={
                    "promotion_id": promotion.promotion_id,
                    "decision": promotion.decision,
                    "confidence": promotion.confidence,
                    "rollback_target": promotion.rollback_target,
                    "evidence": promotion.evidence,
                },
            )
        except Exception:
            log.debug("promotion decision record failed", exc_info=True)

    def _emit_activity(
        self, action: str, summary: str, details: dict, importance: str = "medium",
    ) -> None:
        if self._activity is None:
            return
        try:
            self._activity.log(
                "evolution", action, summary, details, importance=importance,
            )
        except Exception:
            log.debug("promotion activity log failed", exc_info=True)


# ── Gate functions (pure, testable in isolation) ──


def _gate_min_runs(n_runs: int, threshold: int) -> tuple[bool, str]:
    ok = n_runs >= threshold
    return ok, f"min_runs: {n_runs} {'>=' if ok else '<'} {threshold}"


def _gate_min_replays(replay_count: int, threshold: int) -> tuple[bool, str]:
    ok = replay_count >= threshold
    return ok, f"min_replays: {replay_count} {'>=' if ok else '<'} {threshold}"


def _gate_utility_uplift(
    cand_ema: float, inc_ema: float, threshold: float,
) -> tuple[bool, str]:
    delta = cand_ema - inc_ema
    ok = delta >= threshold
    return ok, (
        f"utility_uplift: {delta:.4f} "
        f"{'>=' if ok else '<'} {threshold} "
        f"(cand_ema={cand_ema:.3f}, inc_ema={inc_ema:.3f})"
    )


def _gate_confidence(
    cand_post, inc_post, threshold: float,
) -> tuple[bool, str]:
    confidence = prob_greater(
        cand_post.alpha, cand_post.beta_param,
        inc_post.alpha, inc_post.beta_param,
        samples=2000,
    )
    ok = confidence >= threshold
    return ok, f"confidence: P(cand>inc)={confidence:.3f} {'>=' if ok else '<'} {threshold}"


def _gate_safety(cand_safety: float, inc_safety: float, floor: float) -> tuple[bool, str]:
    required = max(inc_safety, floor)
    ok = cand_safety >= required
    return ok, (
        f"safety: cand_ema={cand_safety:.3f} "
        f"{'>=' if ok else '<'} max(inc_ema={inc_safety:.3f}, floor={floor:.3f})"
    )


def _gate_no_blocking_anti(blocking_anti: Reflex | None) -> tuple[bool, str]:
    if blocking_anti is None:
        return True, "no_blocking_anti: clear"
    return False, f"no_blocking_anti: blocked by anti-reflex {blocking_anti.reflex_id}"


def _gate_kernel(candidate: Reflex) -> tuple[bool, str]:
    """Re-check kernel invariants at promotion time — protects against
    edits that happened after the miner originally created the candidate."""
    violation = validate_reflex_respects_kernel(candidate)
    if violation is None:
        return True, "kernel: clean"
    return False, f"kernel_violation: {violation.reason}"


def _scopes_overlap(a: dict[str, Any], b: dict[str, Any]) -> bool:
    """Two scopes overlap iff they match on ``skill_name`` AND at least
    one caller from each side is common (or neither declares callers)."""
    a_skill = str((a or {}).get("skill_name", ""))
    b_skill = str((b or {}).get("skill_name", ""))
    if a_skill != b_skill:
        return False
    a_callers = set((a or {}).get("caller_in") or [])
    b_callers = set((b or {}).get("caller_in") or [])
    if not a_callers and not b_callers:
        return True
    if not a_callers or not b_callers:
        return True  # one side is "any caller"
    return bool(a_callers & b_callers)


def _render_promotion_markdown(promotion: Promotion, candidate: Reflex) -> str:
    """Readable promotion decision record for the vault."""
    gate_rows = "\n".join(
        f"- {'✓' if g['passed'] else '✗'} {g['reason']}"
        for g in promotion.evidence.get("gates", [])
    )
    cand_p = promotion.evidence.get("candidate_posterior", {})
    inc_p = promotion.evidence.get("incumbent_posterior", {})

    decision_emoji = {"promote": "✅", "defer": "⏸", "reject": "❌"}.get(
        promotion.decision, "❔",
    )

    return f"""# Promotion decision: {candidate.name}

{decision_emoji} **Decision**: `{promotion.decision}`
**Confidence**: {promotion.confidence:.3f} (P(candidate > incumbent))
**Promotion ID**: `{promotion.promotion_id}`
**Candidate ID**: `{promotion.candidate_id}`
**Rollback target**: `{promotion.rollback_target}`

## Gate results

{gate_rows}

## Candidate posterior

- α = {cand_p.get('alpha', 0):.3f}, β = {cand_p.get('beta', 0):.3f}
- n_runs = {cand_p.get('n_runs', 0)}
- utility_ema = {cand_p.get('utility_ema', 0):.3f}
- posterior mean = {cand_p.get('posterior_mean', 0):.3f}

## Incumbent posterior

- α = {inc_p.get('alpha', 0):.3f}, β = {inc_p.get('beta', 0):.3f}
- n_runs = {inc_p.get('n_runs', 0)}
- utility_ema = {inc_p.get('utility_ema', 0):.3f}
- posterior mean = {inc_p.get('posterior_mean', 0):.3f}

## Evidence summary

- Replays completed: {promotion.evidence.get('replay_count', 0)}
- Candidate safety EMA: {promotion.evidence.get('candidate_safety_ema', 1.0):.3f}
- Incumbent safety EMA: {promotion.evidence.get('incumbent_safety_ema', 1.0):.3f}
- Blocking anti-reflex: {promotion.evidence.get('blocking_anti') or '(none)'}

## Rollback

If this promotion turns out to regress behavior, rollback restores
`{promotion.rollback_target}` as the active policy for this scope via
`POST /api/evolution/rollback/{promotion.promotion_id}`. Rollback doesn't
re-evaluate — it's a "restore to last good state" operation.

---
_Auto-generated by the promotion arena. Do not edit by hand — edits are
overwritten on the next arena pass._
"""
