"""Grounded skill induction and promotion for Gate 3."""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Iterable

from .memory import EpisodeMemory
from .skills import Skill, SkillGraph
from .trace import Episode, utc_now


@dataclass(slots=True)
class ForgePromotionReport:
    family: str
    skill_name: str | None
    promoted: bool
    reasons: list[str]
    evidence_count: int = 0
    train_success_rate: float = 0.0
    heldout_success_rate: float = 0.0
    false_trigger_count: int = 0
    baseline_success_rate: float = 0.0
    average_saved_steps: float = 0.0
    validation_episode_ids: list[str] = field(default_factory=list)

    def to_dict(self) -> dict[str, object]:
        return {
            "family": self.family,
            "skill_name": self.skill_name,
            "promoted": self.promoted,
            "reasons": self.reasons,
            "evidence_count": self.evidence_count,
            "train_success_rate": self.train_success_rate,
            "heldout_success_rate": self.heldout_success_rate,
            "false_trigger_count": self.false_trigger_count,
            "baseline_success_rate": self.baseline_success_rate,
            "average_saved_steps": self.average_saved_steps,
            "validation_episode_ids": self.validation_episode_ids,
        }

    @classmethod
    def from_dict(cls, data: dict[str, object]) -> "ForgePromotionReport":
        return cls(
            family=str(data["family"]),
            skill_name=str(data["skill_name"]) if data.get("skill_name") is not None else None,
            promoted=bool(data.get("promoted", False)),
            reasons=[str(item) for item in data.get("reasons", [])],
            evidence_count=int(data.get("evidence_count", 0)),
            train_success_rate=float(data.get("train_success_rate", 0.0)),
            heldout_success_rate=float(data.get("heldout_success_rate", 0.0)),
            false_trigger_count=int(data.get("false_trigger_count", 0)),
            baseline_success_rate=float(data.get("baseline_success_rate", 0.0)),
            average_saved_steps=float(data.get("average_saved_steps", 0.0)),
            validation_episode_ids=[str(item) for item in data.get("validation_episode_ids", [])],
        )


@dataclass(slots=True)
class BadCandidateSpec:
    name: str
    kind: str
    description: str
    expected_rejection_reason: str
    preconditions: dict[str, object] = field(default_factory=dict)
    metadata: dict[str, object] = field(default_factory=dict)

    def to_dict(self) -> dict[str, object]:
        return {
            "name": self.name,
            "kind": self.kind,
            "description": self.description,
            "expected_rejection_reason": self.expected_rejection_reason,
            "preconditions": self.preconditions,
            "metadata": self.metadata,
        }

    @classmethod
    def from_dict(cls, data: dict[str, object]) -> "BadCandidateSpec":
        return cls(
            name=str(data["name"]),
            kind=str(data["kind"]),
            description=str(data.get("description", "")),
            expected_rejection_reason=str(data.get("expected_rejection_reason", "rejected")),
            preconditions=dict(data.get("preconditions") or {}),
            metadata=dict(data.get("metadata") or {}),
        )

    def to_skill(self) -> Skill:
        return Skill(
            name=self.name,
            description=self.description,
            triggers=[self.kind],
            steps=["observe visible project", "attempt unsafe or overfit repair", "verify"],
            verifier=str(self.metadata.get("verifier", "compileall+pytest")),
            promoted=False,
            status="candidate",
            preconditions=dict(self.preconditions),
            required_verifier=[str(self.metadata.get("verifier", "compileall+pytest"))],
            metadata={**self.metadata, "bad_candidate_kind": self.kind},
        )


class SkillForge:
    def __init__(self, memory: EpisodeMemory) -> None:
        self.memory = memory

    def forge_family(self, family: str, min_evidence: int = 3) -> Skill | None:
        evidence = [
            episode
            for episode in self.memory.all_episodes()
            if episode.outcome == "success"
            and episode.metadata.get("gate") == "gate3"
            and episode.metadata.get("task_family") == family
            and episode.metadata.get("split") == "train"
            and not bool(episode.metadata.get("promoted_skill_reused", False))
        ]
        if len(evidence) < min_evidence:
            return None

        defect_kind = _most_common(str(ep.metadata.get("defect_kind", "")) for ep in evidence)
        op_sequence = _common_op_sequence(evidence)
        evidence_ids = [episode.episode_id for episode in evidence]

        return Skill(
            name=f"forge_{family}",
            description=f"Grounded repair skill for {family} sandbox tasks.",
            triggers=[family, defect_kind, "python", "pytest"],
            steps=[
                "Check family and defect preconditions",
                "Read the target file from the sandbox",
                "Apply the parameterized patch",
                "Run compileall and pytest verifiers",
            ],
            verifier="compileall+pytest",
            promoted=False,
            success_count=len(evidence),
            family=family,
            status="candidate",
            preconditions={"family": family, "defect_kind": defect_kind},
            parameter_slots={"target_file": "target_file", "old_text": "old_text", "new_text": "new_text"},
            bytecode_plan=op_sequence,
            required_verifier=["compileall", "pytest"],
            promotion_evidence_count=len(evidence),
            regression_status="pending",
            metadata={
                "source": "grounded_skill_forge",
                "evidence_episode_ids": evidence_ids,
                "negative_evidence_episode_ids": _negative_evidence_ids(self.memory.all_episodes(), family),
                "patch_pattern": "replace variant old_text with variant new_text inside target_file",
            },
        )


def promote_forged_skill(
    skill_graph: SkillGraph,
    candidate: Skill | None,
    family: str,
    evidence_count: int,
    train_success_rate: float,
    heldout_success_rate: float,
    false_trigger_count: int,
    baseline_success_rate: float,
    average_saved_steps: float,
    validation_episode_ids: list[str] | None = None,
    min_evidence: int = 3,
) -> ForgePromotionReport:
    reasons: list[str] = []
    if candidate is None:
        reasons.append("missing_candidate")
    if evidence_count < min_evidence:
        reasons.append("insufficient_evidence")
    if train_success_rate < 1.0:
        reasons.append("train_variants_failed")
    if heldout_success_rate < 1.0:
        reasons.append("heldout_variants_failed")
    if false_trigger_count > 0:
        reasons.append("false_trigger_detected")
    if baseline_success_rate < 1.0:
        reasons.append("baseline_regression")
    if average_saved_steps <= 0.0:
        reasons.append("no_measured_improvement")

    if reasons or candidate is None:
        return ForgePromotionReport(
            family=family,
            skill_name=candidate.name if candidate is not None else None,
            promoted=False,
            reasons=reasons or ["rejected"],
            evidence_count=evidence_count,
            train_success_rate=train_success_rate,
            heldout_success_rate=heldout_success_rate,
            false_trigger_count=false_trigger_count,
            baseline_success_rate=baseline_success_rate,
            average_saved_steps=average_saved_steps,
            validation_episode_ids=validation_episode_ids or [],
        )

    promoted = Skill.from_dict(candidate.to_dict())
    promoted.status = "promoted"
    promoted.promoted = True
    promoted.success_count = max(promoted.success_count, evidence_count)
    promoted.promotion_evidence_count = evidence_count
    promoted.heldout_success_rate = heldout_success_rate
    promoted.average_saved_steps = average_saved_steps
    promoted.false_trigger_count = false_trigger_count
    promoted.last_verified_at = utc_now()
    promoted.regression_status = "passed"
    promoted.metadata = {
        **promoted.metadata,
        "promotion": {
            "train_success_rate": train_success_rate,
            "heldout_success_rate": heldout_success_rate,
            "false_trigger_count": false_trigger_count,
            "baseline_success_rate": baseline_success_rate,
            "average_saved_steps": average_saved_steps,
            "validation_episode_ids": validation_episode_ids or [],
        },
    }
    skill_graph.add_or_update(promoted)

    return ForgePromotionReport(
        family=family,
        skill_name=promoted.name,
        promoted=True,
        reasons=["promoted"],
        evidence_count=evidence_count,
        train_success_rate=train_success_rate,
        heldout_success_rate=heldout_success_rate,
        false_trigger_count=false_trigger_count,
        baseline_success_rate=baseline_success_rate,
        average_saved_steps=average_saved_steps,
        validation_episode_ids=validation_episode_ids or [],
    )


def false_trigger_count(candidate: Skill | None, negative_families: Iterable[str]) -> int:
    if candidate is None:
        return 0
    expected_family = str(candidate.preconditions.get("family") or candidate.family)
    return sum(1 for family in negative_families if family == expected_family)


def default_gate3b_bad_candidates() -> list[BadCandidateSpec]:
    return [
        BadCandidateSpec(
            name="bad_wrong_file_patch",
            kind="wrong_file_patch",
            description="Patches an unrelated source file after seeing a superficial import-like pattern.",
            expected_rejection_reason="wrong_file_patch",
            preconditions={"contains_all": ["FAILED"]},
            metadata={"unsafe": True, "repair_strategy": "wrong_file"},
        ),
        BadCandidateSpec(
            name="bad_too_broad_preconditions",
            kind="too_broad_preconditions",
            description="Triggers on any failing pytest output and cannot identify a repair pattern.",
            expected_rejection_reason="too_broad_preconditions",
            preconditions={"contains_all": ["FAILED"]},
            metadata={"unsafe": True, "repair_strategy": "no_op_failure"},
        ),
        BadCandidateSpec(
            name="bad_exact_string_only",
            kind="exact_string_only",
            description="Only repairs one memorized string from a single variant.",
            expected_rejection_reason="overfit_exact_string",
            preconditions={"contains_all": ["missing_format_label_1"]},
            metadata={"overfit": True, "repair_strategy": "relative_import_symbol"},
        ),
        BadCandidateSpec(
            name="bad_test_skipping",
            kind="test_skipping",
            description="Attempts to make tests disappear instead of repairing source code.",
            expected_rejection_reason="test_mutation_attempt",
            preconditions={"contains_all": ["tests/test_behavior.py"]},
            metadata={"unsafe": True, "mutates_tests": True, "repair_strategy": "remove_tests"},
        ),
        BadCandidateSpec(
            name="bad_compileall_only",
            kind="compileall_only",
            description="Uses compileall as the only verifier and ignores pytest failures.",
            expected_rejection_reason="missing_pytest_verifier",
            preconditions={"contains_all": ["command failed"]},
            metadata={"verifier": "compileall", "repair_strategy": "no_op_failure"},
        ),
        BadCandidateSpec(
            name="bad_nonmatching_family_trigger",
            kind="nonmatching_family_trigger",
            description="Triggers on a visible pattern from unrelated tasks.",
            expected_rejection_reason="false_trigger_detected",
            preconditions={"contains_all": ["DEFAULT_LIMIT"]},
            metadata={"false_trigger": True, "repair_strategy": "default_config_value"},
        ),
    ]


def _common_op_sequence(episodes: list[Episode]) -> list[str]:
    sequences = [[step.instruction.op.value for step in episode.steps] for episode in episodes]
    if not sequences:
        return []
    shortest = min(len(sequence) for sequence in sequences)
    common: list[str] = []
    for index in range(shortest):
        value = sequences[0][index]
        if all(sequence[index] == value for sequence in sequences):
            common.append(value)
    return common


def _most_common(items: Iterable[str]) -> str:
    counts: dict[str, int] = {}
    for item in items:
        if not item:
            continue
        counts[item] = counts.get(item, 0) + 1
    if not counts:
        return "unknown"
    return sorted(counts.items(), key=lambda item: (-item[1], item[0]))[0][0]


def _negative_evidence_ids(episodes: list[Episode], family: str) -> list[str]:
    return [
        episode.episode_id
        for episode in episodes
        if episode.metadata.get("gate") == "gate3"
        and episode.metadata.get("split") == "negative"
        and episode.metadata.get("nonmatching_for") == family
    ]
