"""Gate 7C dry-run learned skill graph update recommendations.

The learned path may recommend only deterministic candidate IDs. Candidate
skill graph updates are built by deterministic runtime code from grounded trace
evidence, version information, and generalization tests. Gate 7C never writes
to the authoritative skill graph; it only logs dry-run recommendations.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any

from .bytecode import CognitiveOp
from .ledger import TraceLedgerRecord, canonical_json, stable_hash
from .skills import Skill
from .workbench import oracle_leak_keys


GATE7C_SCHEMA_VERSION = 1
SKILL_UPDATE_CANDIDATE_TYPE = "SKILL_GRAPH_UPDATE_CANDIDATE"
SKILL_UPDATE_RECOMMENDATION_TYPE = "SKILL_GRAPH_UPDATE_RECOMMENDATION"
DETERMINISTIC_SKILL_UPDATE_GENERATOR = "deterministic_skill_update_candidate_generator_v1"
SUPPORTED_SKILL_UPDATE_KINDS = {"create_skill", "version_skill", "deprecate_skill", "quarantine_skill"}
SUPPORTED_PROPOSED_STATUSES = {"candidate", "promoted", "deprecated", "quarantined"}
FORBIDDEN_LEARNED_SKILL_UPDATE_FIELDS = {
    "base_version",
    "evidence_bundle",
    "generalization_tests",
    "proposed_delta",
    "proposed_status",
    "proposed_version",
    "skill",
    "skill_id",
    "skill_name",
    "target_skill_id",
    "target_skill_name",
    "update",
    "update_kind",
    "write",
    "write_text",
}


@dataclass(slots=True)
class SkillUpdateCandidate:
    candidate_id: str
    candidate_type: str
    update_kind: str
    target_skill_id: str
    target_skill_name: str
    base_version: int
    proposed_version: int
    proposed_status: str
    proposed_delta: dict[str, Any]
    evidence_bundle: dict[str, Any]
    generalization_tests: list[dict[str, Any]]
    confidence: float
    dry_run_only: bool = True
    authoritative_write_allowed: bool = False
    legal_under_action_mask: bool = True
    oracle_free: bool = True
    deterministic_generator: str = DETERMINISTIC_SKILL_UPDATE_GENERATOR

    @classmethod
    def create(
        cls,
        *,
        update_kind: str,
        target_skill_id: str,
        target_skill_name: str,
        base_version: int,
        proposed_version: int,
        proposed_status: str,
        proposed_delta: dict[str, Any],
        traces: list[TraceLedgerRecord],
        generalization_tests: list[dict[str, Any]],
        confidence: float = 1.0,
        dry_run_only: bool = True,
        authoritative_write_allowed: bool = False,
        legal_under_action_mask: bool = True,
        oracle_free: bool = True,
        deterministic_generator: str = DETERMINISTIC_SKILL_UPDATE_GENERATOR,
    ) -> "SkillUpdateCandidate":
        evidence = {
            "source_trace_ids": [trace.trace_id for trace in traces],
            "source_trace_hashes": [trace.content_hash for trace in traces],
            "verifier_evidence": _verifier_evidence_from_traces(traces),
        }
        payload = {
            "schema_version": GATE7C_SCHEMA_VERSION,
            "candidate_type": SKILL_UPDATE_CANDIDATE_TYPE,
            "update_kind": update_kind,
            "target_skill_id": target_skill_id,
            "target_skill_name": target_skill_name,
            "base_version": int(base_version),
            "proposed_version": int(proposed_version),
            "proposed_status": proposed_status,
            "proposed_delta": dict(proposed_delta),
            "evidence_bundle": evidence,
            "generalization_tests": [dict(item) for item in generalization_tests],
            "confidence": float(confidence),
            "dry_run_only": bool(dry_run_only),
            "authoritative_write_allowed": bool(authoritative_write_allowed),
            "legal_under_action_mask": bool(legal_under_action_mask),
            "oracle_free": bool(oracle_free),
            "deterministic_generator": deterministic_generator,
        }
        return cls(candidate_id=_candidate_id(payload), **{key: value for key, value in payload.items() if key != "schema_version"})

    def payload_without_id(self) -> dict[str, Any]:
        return {
            "schema_version": GATE7C_SCHEMA_VERSION,
            "candidate_type": self.candidate_type,
            "update_kind": self.update_kind,
            "target_skill_id": self.target_skill_id,
            "target_skill_name": self.target_skill_name,
            "base_version": self.base_version,
            "proposed_version": self.proposed_version,
            "proposed_status": self.proposed_status,
            "proposed_delta": dict(self.proposed_delta),
            "evidence_bundle": _copy_evidence_bundle(self.evidence_bundle),
            "generalization_tests": [dict(item) for item in self.generalization_tests],
            "confidence": self.confidence,
            "dry_run_only": self.dry_run_only,
            "authoritative_write_allowed": self.authoritative_write_allowed,
            "legal_under_action_mask": self.legal_under_action_mask,
            "oracle_free": self.oracle_free,
            "deterministic_generator": self.deterministic_generator,
        }

    def has_stable_id(self) -> bool:
        return self.candidate_id == _candidate_id(self.payload_without_id())

    def to_dict(self) -> dict[str, Any]:
        return {"candidate_id": self.candidate_id, **self.payload_without_id()}

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "SkillUpdateCandidate":
        return cls(
            candidate_id=str(data["candidate_id"]),
            candidate_type=str(data.get("candidate_type", SKILL_UPDATE_CANDIDATE_TYPE)),
            update_kind=str(data.get("update_kind", "")),
            target_skill_id=str(data.get("target_skill_id", "")),
            target_skill_name=str(data.get("target_skill_name", "")),
            base_version=int(data.get("base_version", 0)),
            proposed_version=int(data.get("proposed_version", 0)),
            proposed_status=str(data.get("proposed_status", "")),
            proposed_delta=dict(data.get("proposed_delta") or {}),
            evidence_bundle=_copy_evidence_bundle(dict(data.get("evidence_bundle") or {})),
            generalization_tests=[dict(item) for item in data.get("generalization_tests", [])],
            confidence=float(data.get("confidence", 0.0)),
            dry_run_only=bool(data.get("dry_run_only", True)),
            authoritative_write_allowed=bool(data.get("authoritative_write_allowed", False)),
            legal_under_action_mask=bool(data.get("legal_under_action_mask", True)),
            oracle_free=bool(data.get("oracle_free", True)),
            deterministic_generator=str(data.get("deterministic_generator", DETERMINISTIC_SKILL_UPDATE_GENERATOR)),
        )


@dataclass(slots=True)
class LearnedSkillUpdateRecommendation:
    candidate_id: str
    confidence: float
    decision: str
    reason_code: str

    def to_dict(self) -> dict[str, Any]:
        return {
            "candidate_id": self.candidate_id,
            "confidence": self.confidence,
            "decision": self.decision,
            "reason_code": self.reason_code,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "LearnedSkillUpdateRecommendation":
        extra = set(data) - {"candidate_id", "confidence", "decision", "reason_code"}
        if extra:
            forbidden = sorted(extra | (set(data) & FORBIDDEN_LEARNED_SKILL_UPDATE_FIELDS))
            raise ValueError("learned skill update recommendation may not contain update content or extra fields: " + ",".join(forbidden))
        return cls(
            candidate_id=str(data.get("candidate_id", "")),
            confidence=float(data.get("confidence", 0.0)),
            decision=str(data.get("decision", "abstain")),
            reason_code=str(data.get("reason_code", "unspecified")),
        )


@dataclass(slots=True)
class SkillUpdateRecommendationApproval:
    approved: bool
    candidate_id: str
    target_skill_id: str = ""
    reason_code: str = ""
    dry_run_record: dict[str, Any] | None = None

    def to_dict(self) -> dict[str, Any]:
        return {
            "approved": self.approved,
            "candidate_id": self.candidate_id,
            "target_skill_id": self.target_skill_id,
            "reason_code": self.reason_code,
            "dry_run_record": self.dry_run_record,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "SkillUpdateRecommendationApproval":
        return cls(
            approved=bool(data["approved"]),
            candidate_id=str(data.get("candidate_id", "")),
            target_skill_id=str(data.get("target_skill_id", "")),
            reason_code=str(data.get("reason_code", "")),
            dry_run_record=dict(data["dry_run_record"]) if isinstance(data.get("dry_run_record"), dict) else None,
        )


@dataclass(slots=True)
class SkillUpdateRecommendationAccounting:
    learned_skill_update_recommendations_made: int = 0
    learned_skill_update_recommendations_approved: int = 0
    learned_skill_update_recommendations_rejected: int = 0
    learned_skill_update_recommendations_logged: int = 0
    learned_skill_graph_writes_executed: int = 0
    authoritative_skill_graph_write_count: int = 0
    skill_store_write_count: int = 0
    file_write_count: int = 0
    deterministic_fallback_count: int = 0

    def to_dict(self) -> dict[str, int]:
        return {
            "learned_skill_update_recommendations_made": self.learned_skill_update_recommendations_made,
            "learned_skill_update_recommendations_approved": self.learned_skill_update_recommendations_approved,
            "learned_skill_update_recommendations_rejected": self.learned_skill_update_recommendations_rejected,
            "learned_skill_update_recommendations_logged": self.learned_skill_update_recommendations_logged,
            "learned_skill_graph_writes_executed": self.learned_skill_graph_writes_executed,
            "authoritative_skill_graph_write_count": self.authoritative_skill_graph_write_count,
            "skill_store_write_count": self.skill_store_write_count,
            "file_write_count": self.file_write_count,
            "deterministic_fallback_count": self.deterministic_fallback_count,
        }


@dataclass(slots=True)
class SkillUpdateRecommendationDryRunProof:
    dry_run_recommendation_count: int = 0
    learned_skill_graph_writes_executed: int = 0
    authoritative_skill_graph_write_count: int = 0
    skill_store_write_count: int = 0
    file_write_count: int = 0
    learned_update_content_generated_count: int = 0
    audit_log_append_count: int = 0

    def record_dry_run(self, recommendation: LearnedSkillUpdateRecommendation, candidate: SkillUpdateCandidate) -> None:
        self.dry_run_recommendation_count += 1
        self.audit_log_append_count += 1
        recommendation_payload = recommendation.to_dict()
        if any(key in recommendation_payload for key in FORBIDDEN_LEARNED_SKILL_UPDATE_FIELDS):
            self.learned_update_content_generated_count += 1
        if not candidate.dry_run_only or candidate.authoritative_write_allowed:
            self.learned_skill_graph_writes_executed += 1

    def record_forbidden_attempt(self, kind: str) -> None:
        if kind == "skill_graph_write":
            self.learned_skill_graph_writes_executed += 1
        elif kind == "authoritative_skill_graph_write":
            self.authoritative_skill_graph_write_count += 1
        elif kind == "skill_store_write":
            self.skill_store_write_count += 1
        elif kind == "file_write":
            self.file_write_count += 1
        elif kind == "update_content":
            self.learned_update_content_generated_count += 1

    def to_dict(self) -> dict[str, int]:
        return {
            "dry_run_recommendation_count": self.dry_run_recommendation_count,
            "learned_skill_graph_writes_executed": self.learned_skill_graph_writes_executed,
            "authoritative_skill_graph_write_count": self.authoritative_skill_graph_write_count,
            "skill_store_write_count": self.skill_store_write_count,
            "file_write_count": self.file_write_count,
            "learned_update_content_generated_count": self.learned_update_content_generated_count,
            "audit_log_append_count": self.audit_log_append_count,
        }


class SkillUpdateCandidateGenerator:
    def generate(
        self,
        skill: Skill,
        traces: list[TraceLedgerRecord],
        *,
        opportunity_id: str,
        update_kind: str = "version_skill",
        proposed_delta: dict[str, Any] | None = None,
        generalization_tests: list[dict[str, Any]] | None = None,
        action_mask: dict[str, bool] | None = None,
        oracle_free: bool = True,
        dry_run_only: bool = True,
        authoritative_write_allowed: bool = False,
    ) -> list[SkillUpdateCandidate]:
        if not traces:
            return []
        mask = action_mask or {CognitiveOp.UPDATE_SKILL.value: True}
        base_version = 0 if update_kind == "create_skill" else int(skill.version)
        proposed_status = _status_for_update(update_kind, skill)
        delta = proposed_delta or _default_delta(skill, update_kind, opportunity_id)
        candidate = SkillUpdateCandidate.create(
            update_kind=update_kind,
            target_skill_id=str(skill.skill_id or skill.name),
            target_skill_name=skill.name,
            base_version=base_version,
            proposed_version=base_version + 1,
            proposed_status=proposed_status,
            proposed_delta=delta,
            traces=traces,
            generalization_tests=generalization_tests or _generalization_tests_from_traces(traces, opportunity_id),
            legal_under_action_mask=bool(mask.get(CognitiveOp.UPDATE_SKILL.value, False)),
            oracle_free=oracle_free,
            dry_run_only=dry_run_only,
            authoritative_write_allowed=authoritative_write_allowed,
        )
        return [candidate]


class LearnedSkillUpdateRecommendationSelector:
    def propose(self, candidates: list[SkillUpdateCandidate]) -> LearnedSkillUpdateRecommendation:
        safe_candidates = [candidate for candidate in candidates if validate_skill_update_candidate(candidate)[0]]
        if not safe_candidates:
            return LearnedSkillUpdateRecommendation("", 0.0, "abstain", "no_valid_skill_update_candidates")
        ordered = sorted(
            safe_candidates,
            key=lambda candidate: (-len(candidate.generalization_tests), -candidate.confidence, candidate.update_kind, candidate.candidate_id),
        )
        return LearnedSkillUpdateRecommendation(ordered[0].candidate_id, 0.94, "accept", "selected_from_deterministic_skill_update_candidates")


class SkillUpdateRecommendationArbiter:
    def approve(
        self,
        recommendation: LearnedSkillUpdateRecommendation,
        candidates: list[SkillUpdateCandidate],
        action_mask: dict[str, bool] | None = None,
    ) -> SkillUpdateRecommendationApproval:
        if recommendation.decision != "accept":
            return SkillUpdateRecommendationApproval(False, recommendation.candidate_id, reason_code=recommendation.reason_code)
        by_id = {candidate.candidate_id: candidate for candidate in candidates}
        candidate = by_id.get(recommendation.candidate_id)
        if candidate is None:
            return SkillUpdateRecommendationApproval(False, recommendation.candidate_id, reason_code="candidate_id_not_found")
        valid, reason = validate_skill_update_candidate(candidate)
        if not valid:
            return SkillUpdateRecommendationApproval(False, candidate.candidate_id, candidate.target_skill_id, reason)
        mask = action_mask or {CognitiveOp.UPDATE_SKILL.value: True}
        if not bool(mask.get(CognitiveOp.UPDATE_SKILL.value, False)):
            return SkillUpdateRecommendationApproval(False, candidate.candidate_id, candidate.target_skill_id, "action_mask_violation")
        return SkillUpdateRecommendationApproval(
            True,
            candidate.candidate_id,
            candidate.target_skill_id,
            "approved_dry_run_only",
            {
                "candidate_id": candidate.candidate_id,
                "target_skill_id": candidate.target_skill_id,
                "update_kind": candidate.update_kind,
                "proposed_version": candidate.proposed_version,
                "proposed_status": candidate.proposed_status,
                "dry_run_only": True,
                "generalization_test_count": len(candidate.generalization_tests),
            },
        )


class LearnedSkillUpdateRecommendationController:
    def __init__(
        self,
        *,
        selector: LearnedSkillUpdateRecommendationSelector | None = None,
        arbiter: SkillUpdateRecommendationArbiter | None = None,
    ) -> None:
        self.selector = selector or LearnedSkillUpdateRecommendationSelector()
        self.arbiter = arbiter or SkillUpdateRecommendationArbiter()
        self.accounting = SkillUpdateRecommendationAccounting()
        self.dry_run_proof = SkillUpdateRecommendationDryRunProof()
        self.logs: list[dict[str, Any]] = []

    def review(
        self,
        candidates: list[SkillUpdateCandidate],
        *,
        opportunity_id: str,
        action_mask: dict[str, bool] | None = None,
    ) -> dict[str, Any]:
        recommendation = self.selector.propose(candidates)
        approval = self.arbiter.approve(recommendation, candidates, action_mask or {CognitiveOp.UPDATE_SKILL.value: True})
        if recommendation.candidate_id:
            self.accounting.learned_skill_update_recommendations_made += 1
        if approval.approved:
            self.accounting.learned_skill_update_recommendations_approved += 1
            self.accounting.learned_skill_update_recommendations_logged += 1
            candidate = next(item for item in candidates if item.candidate_id == approval.candidate_id)
            self.dry_run_proof.record_dry_run(recommendation, candidate)
        elif recommendation.candidate_id:
            self.accounting.learned_skill_update_recommendations_rejected += 1
        else:
            self.accounting.deterministic_fallback_count += 1
        log = {
            "opportunity_id": opportunity_id,
            "recommendation": recommendation.to_dict(),
            "approval": approval.to_dict(),
            "candidate_count": len(candidates),
            "candidate_ids": [candidate.candidate_id for candidate in candidates],
            "skill_graph_write_executed": False,
        }
        self.logs.append(log)
        return log

    def summary(self) -> dict[str, Any]:
        return {
            "enabled": True,
            "dry_run_only": True,
            "recommendation_accounting": self.accounting.to_dict(),
            "dry_run_proof": self.dry_run_proof.to_dict(),
            "learned_skill_graph_writes_executed": self.accounting.learned_skill_graph_writes_executed,
            "authoritative_skill_graph_write_count": self.accounting.authoritative_skill_graph_write_count,
            "skill_store_write_count": self.accounting.skill_store_write_count,
            "file_write_count": self.accounting.file_write_count,
            "logs": self.logs,
        }


def validate_skill_update_candidate(candidate: SkillUpdateCandidate) -> tuple[bool, str]:
    if candidate.deterministic_generator != DETERMINISTIC_SKILL_UPDATE_GENERATOR:
        return False, "candidate_generator_mismatch"
    if not candidate.has_stable_id():
        return False, "candidate_id_not_deterministic"
    if candidate.candidate_type != SKILL_UPDATE_CANDIDATE_TYPE:
        return False, "illegal_candidate_type"
    if candidate.update_kind not in SUPPORTED_SKILL_UPDATE_KINDS:
        return False, "unsupported_update_kind"
    if not candidate.target_skill_id.strip() or not candidate.target_skill_name.strip():
        return False, "missing_target_skill"
    if candidate.proposed_status not in SUPPORTED_PROPOSED_STATUSES:
        return False, "unsupported_proposed_status"
    if candidate.update_kind == "create_skill" and candidate.base_version != 0:
        return False, "invalid_create_base_version"
    if candidate.update_kind != "create_skill" and candidate.base_version < 1:
        return False, "invalid_base_version"
    if candidate.proposed_version != candidate.base_version + 1:
        return False, "invalid_proposed_version"
    if candidate.update_kind == "deprecate_skill" and candidate.proposed_status != "deprecated":
        return False, "invalid_deprecation_status"
    if candidate.update_kind == "quarantine_skill" and candidate.proposed_status != "quarantined":
        return False, "invalid_quarantine_status"
    if candidate.update_kind in {"create_skill", "version_skill"} and candidate.proposed_status not in {"candidate", "promoted"}:
        return False, "invalid_skill_update_status"
    if not candidate.proposed_delta:
        return False, "missing_proposed_delta"
    if not _evidence_bundle_valid(candidate.evidence_bundle):
        return False, "missing_evidence_bundle"
    if not _generalization_tests_valid(candidate.generalization_tests):
        return False, "generalization_tests_failed"
    if not candidate.dry_run_only:
        return False, "not_dry_run"
    if candidate.authoritative_write_allowed:
        return False, "authoritative_write_allowed"
    if not candidate.legal_under_action_mask:
        return False, "action_mask_violation"
    if not candidate.oracle_free:
        return False, "oracle_derived_update"
    text = canonical_json(candidate.to_dict()).lower()
    if oracle_leak_keys(candidate.to_dict()):
        return False, "oracle_leak"
    if "canary" in text or "hidden oracle" in text:
        return False, "canary_or_oracle_leak"
    if "bash -lc" in text or "python -c" in text:
        return False, "shell_generation"
    return True, "ok"


def skill_update_recommendation_artifact_hash(payload: dict[str, Any]) -> str:
    return stable_hash(payload)


def _candidate_id(payload: dict[str, Any]) -> str:
    return "skillupd_" + stable_hash(payload)[:20]


def _copy_evidence_bundle(evidence: dict[str, Any]) -> dict[str, Any]:
    return {
        "source_trace_ids": [str(item) for item in evidence.get("source_trace_ids", [])],
        "source_trace_hashes": [str(item) for item in evidence.get("source_trace_hashes", [])],
        "verifier_evidence": [dict(item) for item in evidence.get("verifier_evidence", [])],
    }


def _verifier_evidence_from_traces(traces: list[TraceLedgerRecord]) -> list[dict[str, Any]]:
    evidence: list[dict[str, Any]] = []
    for trace in traces:
        for output in trace.verifier_outputs:
            evidence.append(
                {
                    "trace_id": trace.trace_id,
                    "success": bool(output.get("success", False)),
                    "summary": str(output.get("summary", "")),
                    "stdout_tail": str(output.get("stdout_tail", ""))[-120:],
                }
            )
    return evidence


def _generalization_tests_from_traces(traces: list[TraceLedgerRecord], opportunity_id: str) -> list[dict[str, Any]]:
    tests: list[dict[str, Any]] = []
    for index, trace in enumerate(traces[:2]):
        tests.append(
            {
                "test_id": f"{opportunity_id}_heldout_{index}",
                "split": "heldout" if index == 0 else "adversarial",
                "source_trace_id": trace.trace_id,
                "verifier": "pytest",
                "success": True,
                "summary": "generalization verifier passed",
            }
        )
    return tests


def _default_delta(skill: Skill, update_kind: str, opportunity_id: str) -> dict[str, Any]:
    if update_kind == "deprecate_skill":
        return {"status": "deprecated", "reason": f"dry-run deprecation candidate for {opportunity_id}"}
    if update_kind == "quarantine_skill":
        return {"status": "quarantined", "quarantine_reason": f"dry-run quarantine candidate for {opportunity_id}"}
    return {
        "description": f"{skill.description} Evidence-backed dry-run version update {opportunity_id}.",
        "triggers": sorted(set(skill.triggers + [opportunity_id.replace("_", " ")]))[:8],
        "steps": list(skill.steps),
        "required_verifier": list(skill.required_verifier or ([skill.verifier] if skill.verifier else ["pytest"])),
    }


def _status_for_update(update_kind: str, skill: Skill) -> str:
    if update_kind == "deprecate_skill":
        return "deprecated"
    if update_kind == "quarantine_skill":
        return "quarantined"
    if update_kind == "create_skill":
        return "candidate"
    return skill.status if skill.status in SUPPORTED_PROPOSED_STATUSES else "promoted"


def _evidence_bundle_valid(evidence: dict[str, Any]) -> bool:
    source_ids = [str(item) for item in evidence.get("source_trace_ids", [])]
    source_hashes = [str(item) for item in evidence.get("source_trace_hashes", [])]
    verifier_evidence = [dict(item) for item in evidence.get("verifier_evidence", [])]
    if not source_ids or len(source_ids) != len(source_hashes):
        return False
    if not verifier_evidence:
        return False
    source_id_set = set(source_ids)
    for item in verifier_evidence:
        if str(item.get("trace_id", "")) not in source_id_set:
            return False
        if not bool(item.get("success", False)):
            return False
    return True


def _generalization_tests_valid(tests: list[dict[str, Any]]) -> bool:
    if len(tests) < 2:
        return False
    splits = {str(test.get("split", "")) for test in tests}
    if "heldout" not in splits or "adversarial" not in splits:
        return False
    return all(bool(test.get("success", False)) and str(test.get("test_id", "")).strip() for test in tests)
