"""ReflexMiner + AntiReflexMiner — Phase 2 pattern extraction.

Mining produces *candidate* reflexes only. The promotion arena (Phase 4)
decides whether any candidate actually earns the right to drive behavior.
Mining is cheap and errs on the side of producing candidates — the arena
is where statistical rigor kicks in.

Algorithm (both miners share it):
  1. Fetch recent runs + outcomes from the ledger.
  2. Group by ``(skill_name, caller, agent_name, model_tier)``.
  3. For each cluster with ≥ ``min_run_count_for_candidate`` runs:
       - average utility ≥ ``success_utility_threshold`` → Reflex candidate
       - average utility ≤ ``failure_utility_threshold`` → AntiReflex candidate
  4. Validate each candidate against the Protected Kernel — rejections
     stay at ``status='draft'`` and emit a high-importance activity event.
  5. Persist surviving candidates via the ledger + write a readable
     markdown artifact under ``system/evolution/{reflexes,anti-reflexes}/``.

The algorithm is pure, deterministic, and safe to run on stale data —
re-running on the same window produces the same candidate set (idempotent
by construction because candidate IDs are derived from the cluster key).
"""

from __future__ import annotations

import hashlib
import logging
import time
from typing import TYPE_CHECKING, Any

from agntspace.evolution.kernel import validate_reflex_respects_kernel
from agntspace.evolution.schemas import (
    AntiReflex,
    Reflex,
)
from agntspace.evolution.storage import reflex_artifact_path

if TYPE_CHECKING:
    from agntspace.evolution.ledger import EvolutionLedger

log = logging.getLogger(__name__)


# ── Public API ──


class ReflexMiner:
    """Mines successful + failure run clusters into Reflex / AntiReflex candidates.

    Wired into the WorkQueue via ``evolution_mine_reflexes`` and scheduled
    daily from the heartbeat (see ``Heartbeat._maybe_schedule_evolution_mining``).
    Safe to run manually via ``POST /api/evolution/mine`` (Phase 4 only;
    Phase 1 exposes this as a no-op placeholder).
    """

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

    async def mine(self, *, limit: int = 500) -> dict[str, Any]:
        """Entry point — scan recent runs and persist candidate reflexes.

        Returns a summary dict suitable as a WorkQueue handler result::

            {
              "status": "done",
              "runs_scanned": 120,
              "clusters": 18,
              "reflex_candidates": 3,
              "anti_reflex_candidates": 1,
              "rejected_by_kernel": 0,
            }
        """
        mining_cfg = self._config.get("mining", {}) if isinstance(self._config, dict) else {}
        if not bool(mining_cfg.get("enabled", True)):
            return {"status": "skipped", "reason": "mining_disabled"}

        min_runs = int(mining_cfg.get("min_run_count_for_candidate", 5))
        success_threshold = float(mining_cfg.get("success_utility_threshold", 0.75))
        failure_threshold = float(mining_cfg.get("failure_utility_threshold", 0.25))

        # Pull recent runs + joined outcomes. Only rows with an outcome
        # are mine-able — ongoing runs are skipped.
        runs = await self._ledger.list_runs(limit=limit)
        runs = [r for r in runs if r.get("outcome")]

        clusters = _group_runs_by_scope(runs)
        summary = {
            "status": "done",
            "runs_scanned": len(runs),
            "clusters": len(clusters),
            "reflex_candidates": 0,
            "anti_reflex_candidates": 0,
            "rejected_by_kernel": 0,
        }

        for cluster_key, cluster_runs in clusters.items():
            if len(cluster_runs) < min_runs:
                continue
            utilities = [float(r["outcome"].get("utility", 0.0)) for r in cluster_runs]
            avg_u = sum(utilities) / len(utilities) if utilities else 0.0

            if avg_u >= success_threshold:
                kind = "reflex"
            elif avg_u <= failure_threshold:
                kind = "anti"
            else:
                continue  # ambiguous zone — not worth a candidate

            candidate = _build_candidate_from_cluster(
                cluster_key=cluster_key,
                cluster_runs=cluster_runs,
                avg_utility=avg_u,
                kind=kind,
            )

            # Kernel validation — reject BEFORE it ever reaches candidate status
            violation = validate_reflex_respects_kernel(candidate)
            if violation is not None:
                summary["rejected_by_kernel"] += 1
                self._emit_activity(
                    "kernel_violation_proposed",
                    f"Reflex candidate {candidate.name} rejected by kernel: {violation.reason}",
                    {
                        "reflex_id": candidate.reflex_id,
                        "kind": candidate.kind,
                        "violation": violation.as_dict(),
                    },
                    importance="high",
                )
                continue

            # ── Draft → candidate auto-promotion ──
            # Fresh runs always land at status='draft'. But if the SAME
            # cluster key was already at status='draft' (from a previous
            # mining pass) AND the evidence has doubled, promote to
            # 'candidate' — ready for the PromotionArena to evaluate.
            existing = await self._ledger.get_reflex(candidate.reflex_id)
            promote_threshold = max(min_runs * 2, int(mining_cfg.get(
                "draft_to_candidate_min_runs", min_runs * 2,
            )))
            if (
                existing is not None
                and existing.status == "draft"
                and len(cluster_runs) >= promote_threshold
                and kind == "reflex"   # anti-reflexes stay draft; they don't promote
            ):
                candidate.status = "candidate"
                self._emit_activity(
                    "reflex_draft_promoted",
                    f"Reflex {candidate.name} promoted draft→candidate "
                    f"(n={len(cluster_runs)} ≥ {promote_threshold})",
                    {
                        "reflex_id": candidate.reflex_id,
                        "n_runs": len(cluster_runs),
                        "avg_utility": avg_u,
                    },
                    importance="medium",
                )

            await self._ledger.save_reflex(candidate)
            await self._write_reflex_artifact(candidate, cluster_runs, avg_u)

            if kind == "reflex":
                summary["reflex_candidates"] += 1
            else:
                summary["anti_reflex_candidates"] += 1

            self._emit_activity(
                "reflex_candidate_created" if kind == "reflex" else "anti_reflex_candidate_created",
                f"{'Reflex' if kind == 'reflex' else 'Anti-reflex'} candidate {candidate.name} "
                f"(n={len(cluster_runs)}, avg_utility={avg_u:.3f})",
                {
                    "reflex_id": candidate.reflex_id,
                    "kind": kind,
                    "cluster": cluster_key,
                    "n_runs": len(cluster_runs),
                    "avg_utility": avg_u,
                },
                importance="medium",
            )

        return summary

    async def _write_reflex_artifact(
        self, reflex: Reflex | AntiReflex, cluster_runs: list[dict], avg_u: float,
    ) -> None:
        """Write a human-readable markdown artifact for the candidate.

        The artifact lives under ``system/evolution/{reflexes,anti-reflexes}/``
        so users can inspect WHY a candidate exists and WHAT evidence backs
        it. Uses ``trust_reason='evolution_persist'`` on the VaultWriter so
        the runtime guard passes.

        VaultWriter.write() is SYNC and does not accept a ``frontmatter=``
        kwarg — frontmatter is embedded as YAML inside the content body
        (the format every other vault file uses). Previously this
        ``await self._writer.write(..., frontmatter=...)`` both awaited a
        non-awaitable Path AND passed an unsupported kwarg, so every reflex
        artifact write was silently dropped (evolution/reflex.py:212 crash
        in today's log).
        """
        if self._writer is None:
            return
        path = reflex_artifact_path(reflex.reflex_id, kind=reflex.kind)
        reflex.vault_path = path
        body = _render_reflex_markdown(reflex, cluster_runs, avg_u)
        frontmatter_yaml = (
            "---\n"
            f"title: \"{reflex.kind.capitalize()}: {reflex.name}\"\n"
            f"description: \"Auto-mined {reflex.kind} candidate — status: {reflex.status}\"\n"
            f"date: {time.strftime('%Y-%m-%d')}\n"
            "author: agent\n"
            "agent: evolution\n"
            f"reflex_id: {reflex.reflex_id}\n"
            f"kind: {reflex.kind}\n"
            f"status: {reflex.status}\n"
            "---\n\n"
        )
        try:
            # Sync call — VaultWriter.write() is not async.
            self._writer.write(
                path,
                frontmatter_yaml + body,
                trust_reason="evolution_persist",
            )
        except Exception:
            log.debug("reflex artifact write 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("evolution activity log failed", exc_info=True)


# ── Internals (pure functions, easy to unit-test) ──


def _group_runs_by_scope(runs: list[dict]) -> dict[tuple[str, ...], list[dict]]:
    """Group runs by (skill_name, caller, agent_name, model_tier).

    Empty fields degrade to ``""`` so runs from older schemas cluster
    together instead of producing one-element clusters.
    """
    clusters: dict[tuple[str, ...], list[dict]] = {}
    for r in runs:
        key = (
            str(r.get("skill_name", "")),
            str(r.get("caller", "")),
            str(r.get("agent_name", "")),
            str(r.get("model_tier", "")),
        )
        clusters.setdefault(key, []).append(r)
    return clusters


def _candidate_id_from_cluster(cluster_key: tuple[str, ...], kind: str) -> str:
    """Deterministic ID from cluster key + kind — re-mining produces stable IDs.

    This is the load-bearing property that makes the miner idempotent: the
    same cluster always yields the same reflex_id, so ``save_reflex`` is an
    upsert (INSERT OR REPLACE) rather than duplicating rows across runs.
    """
    key_str = "|".join(cluster_key) + f"::{kind}"
    digest = hashlib.sha256(key_str.encode("utf-8", "replace")).hexdigest()[:10]
    prefix = "anti" if kind == "anti" else "reflex"
    return f"{prefix}-{digest}"


def _build_candidate_from_cluster(
    *,
    cluster_key: tuple[str, ...],
    cluster_runs: list[dict],
    avg_utility: float,
    kind: str,
) -> Reflex | AntiReflex:
    """Construct a Reflex or AntiReflex from a cluster of runs.

    The candidate records the cluster's dominant characteristics
    (skill, caller, preferred agent, preferred tier) plus a sampled set
    of evidence run IDs (capped at 20).
    """
    skill_name, caller, agent_name, model_tier = cluster_key

    reflex_id = _candidate_id_from_cluster(cluster_key, kind)
    evidence = [r.get("run_id", "") for r in cluster_runs[:20] if r.get("run_id")]

    # Mutation expectations: use the median counts across the cluster so
    # outliers don't over-constrain the reflex.
    vault_counts = sorted(
        int((r.get("outcome", {}).get("components", {}) or {}).get("_vault_writes_raw", 0))
        for r in cluster_runs
    )
    # Best-effort only — fall back to 1 if we don't have signal
    expected_vault = vault_counts[len(vault_counts) // 2] if vault_counts and vault_counts[-1] > 0 else 0

    name = (
        f"{skill_name}-{kind}-{caller}" if skill_name else f"{kind}-candidate"
    ).replace("_", "-")

    scope = {
        "skill_name": skill_name,
        "caller_in": [caller] if caller else [],
    }

    payload = {
        "reflex_id": reflex_id,
        "name": name,
        "scope": scope,
        "preferred_agent": agent_name,
        "preferred_tier": model_tier,
        "evidence_run_ids": evidence,
        "status": "draft",
        "expected_mutations": (
            {"vault_writes_min": 1} if expected_vault >= 1 else {}
        ),
    }

    if kind == "anti":
        return AntiReflex(**payload)
    return Reflex(**payload)


def _render_reflex_markdown(
    reflex: Reflex | AntiReflex,
    cluster_runs: list[dict],
    avg_utility: float,
) -> str:
    """Render the readable artifact body.

    Every line that a user might care about — evidence runs, current
    status, why it exists, how to disable — is in-document. The
    promotion arena rewrites this file on every status change so the
    vault copy always reflects DB state.
    """
    scope = reflex.scope or {}
    kind_header = "Reflex" if reflex.kind == "reflex" else "Anti-reflex"
    verb = "worth repeating" if reflex.kind == "reflex" else "worth avoiding"

    rows = [f"- `{r.get('run_id', '?')[:16]}` utility={r.get('outcome', {}).get('utility', 0.0):.3f}"
            for r in cluster_runs[:10]]

    lines = [
        f"# {kind_header}: {reflex.name}",
        "",
        f"**Status**: `{reflex.status}` · **Kind**: `{reflex.kind}` · "
        f"**Version**: {reflex.version} · **ID**: `{reflex.reflex_id}`",
        "",
        f"Auto-mined from **{len(cluster_runs)} runs** with **avg utility "
        f"{avg_utility:.3f}** — a pattern {verb}.",
        "",
        "## Scope",
        "",
        f"- **Skill**: `{scope.get('skill_name', '')}`",
        f"- **Caller(s)**: `{', '.join(scope.get('caller_in', []) or ['any'])}`",
        f"- **Preferred agent**: `{reflex.preferred_agent or '(any)'}`",
        f"- **Preferred tier**: `{reflex.preferred_tier or '(any)'}`",
    ]
    if reflex.expected_mutations:
        lines.extend([
            "",
            "## Expected mutations",
            "",
            "```yaml",
            *[f"{k}: {v}" for k, v in reflex.expected_mutations.items()],
            "```",
        ])
    lines.extend([
        "",
        "## Evidence (top 10 runs)",
        "",
        *rows,
        "",
        "## Lifecycle",
        "",
        "- `draft` → `candidate` → `active` → `quarantined` → `retired`",
        "- New candidates land at **draft** for human review.",
        "- Promotion to `active` requires ≥10 runs, ≥3 replays, and "
        "P(candidate > incumbent) ≥ 0.90 at the Phase 4 arena.",
        "- A `quarantined` reflex auto-retires after 7 days without recovery.",
        "",
        "## How to disable this reflex",
        "",
        "1. Direct: set `selection.kill_switch: true` in "
        "`defaults/skills/_meta/evolution/config/evolution.yaml` (global).",
        "2. Scoped: mark this specific reflex retired via "
        "`POST /api/evolution/rollback` (Phase 4 endpoint).",
        "",
        "---",
        "_This artifact is auto-generated by the evolution miner. "
        "Do not edit by hand — edits are overwritten on the next mining pass._",
    ])
    return "\n".join(lines) + "\n"
