"""Sandboxed execution for approved learned-recommended skills.

Gate 5F-S allows a learned controller to recommend only a deterministic skill
candidate ID. This module keeps execution authority deterministic: a separate
approver revalidates the recommendation and an executor may mutate only a
disposable sandbox, with verifier and rollback accounting recorded explicitly.
"""

from __future__ import annotations

import base64
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from .bytecode import CognitiveOp
from .ledger import stable_hash
from .shadow_controller import model_input_from_workspace
from .skill_recommendation import (
    LearnedSkillRecommendation,
    SkillRecommendationApproval,
    SkillRecommendationCandidate,
    validate_skill_recommendation_candidate,
)
from .skills import Skill, SkillGraph
from .tools import SandboxToolRunner, SandboxViolation
from .trace import ToolCallTrace
from .workbench import (
    GeneratedTask,
    VisibleProjectObservation,
    _infer_visible_repair,
    _is_visible_python_file,
    _visible_symptoms,
    assert_no_oracle_leak,
)
from .workspace import Workspace


@dataclass(slots=True)
class SandboxSnapshot:
    sandbox_root: str
    files: dict[str, str]
    content_hash: str

    @classmethod
    def capture(cls, sandbox_root: str | Path) -> "SandboxSnapshot":
        root = Path(sandbox_root).resolve()
        root.mkdir(parents=True, exist_ok=True)
        files: dict[str, str] = {}
        for item in sorted(root.rglob("*")):
            if item.is_symlink():
                raise SandboxViolation(f"symlink is not allowed in execution sandbox: {item}")
            if not item.is_file():
                continue
            relative = item.relative_to(root)
            files[relative.as_posix()] = base64.b64encode(item.read_bytes()).decode("ascii")
        payload_hash = stable_hash({"sandbox_root": str(root), "files": files})
        return cls(sandbox_root=str(root), files=files, content_hash=payload_hash)

    def restore(self) -> "SandboxSnapshot":
        root = Path(self.sandbox_root).resolve()
        root.mkdir(parents=True, exist_ok=True)
        expected = set(self.files)
        for item in sorted(root.rglob("*"), reverse=True):
            if item.is_file() and not item.is_symlink():
                relative = item.relative_to(root).as_posix()
                if relative not in expected:
                    item.unlink()
        for relative, content in self.files.items():
            target = (root / relative).resolve()
            if not target.is_relative_to(root):
                raise SandboxViolation(f"snapshot restore path escapes sandbox: {relative}")
            target.parent.mkdir(parents=True, exist_ok=True)
            target.write_bytes(base64.b64decode(content.encode("ascii")))
        for item in sorted(root.rglob("*"), reverse=True):
            if item.is_dir() and item != root and not any(item.iterdir()):
                item.rmdir()
        return self.capture(root)

    def verify_restored(self) -> bool:
        return self.capture(self.sandbox_root).content_hash == self.content_hash

    def to_dict(self) -> dict[str, Any]:
        return {
            "sandbox_root": self.sandbox_root,
            "files": dict(self.files),
            "content_hash": self.content_hash,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "SandboxSnapshot":
        return cls(
            sandbox_root=str(data["sandbox_root"]),
            files={str(key): str(value) for key, value in dict(data.get("files") or {}).items()},
            content_hash=str(data["content_hash"]),
        )


@dataclass(slots=True)
class SkillExecutionApproval:
    approved: bool
    candidate_id: str
    skill_id: str = ""
    reason_code: str = ""
    source_of_args: str = "deterministic_candidate_generator"
    deterministic_executor: bool = True

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

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "SkillExecutionApproval":
        return cls(
            approved=bool(data["approved"]),
            candidate_id=str(data.get("candidate_id", "")),
            skill_id=str(data.get("skill_id", "")),
            reason_code=str(data.get("reason_code", "")),
            source_of_args=str(data.get("source_of_args", "deterministic_candidate_generator")),
            deterministic_executor=bool(data.get("deterministic_executor", True)),
        )


@dataclass(slots=True)
class SkillExecutionAccounting:
    learned_skill_recommendations_made: int = 0
    learned_skill_recommendations_approved: int = 0
    learned_skill_recommendations_rejected: int = 0
    deterministic_skill_executions_from_learned_recommendation: int = 0
    learned_direct_skill_calls_executed: int = 0
    skill_execution_success_count: int = 0
    skill_execution_failure_count: int = 0
    rollback_count: int = 0
    rollback_success_count: int = 0
    verifier_pass_count: int = 0
    verifier_fail_count: int = 0
    verifier_override_count: int = 0
    real_repo_mutation_count: int = 0
    memory_write_count: int = 0
    skill_graph_write_count: int = 0
    answer_write_count: int = 0
    trace_audit_log_append_count: int = 0

    def record_result(self, result: "SandboxSkillExecutionResult") -> None:
        if result.executed:
            self.deterministic_skill_executions_from_learned_recommendation += 1
            self.skill_execution_success_count += int(result.success)
            self.skill_execution_failure_count += int(not result.success)
            self.verifier_pass_count += int(result.verifier_passed)
            self.verifier_fail_count += int(not result.verifier_passed)
        self.rollback_count += int(result.rollback_performed)
        self.rollback_success_count += int(result.rollback_success)
        self.verifier_override_count += int(result.verifier_override_count)
        self.real_repo_mutation_count += int(result.real_repo_mutation_count)
        self.memory_write_count += int(result.memory_write_count)
        self.skill_graph_write_count += int(result.skill_graph_write_count)
        self.answer_write_count += int(result.answer_write_count)
        self.trace_audit_log_append_count += 1

    @property
    def rollback_success_rate(self) -> float:
        return self.rollback_success_count / self.rollback_count if self.rollback_count else 1.0

    def to_dict(self) -> dict[str, int]:
        return {
            "learned_skill_recommendations_made": self.learned_skill_recommendations_made,
            "learned_skill_recommendations_approved": self.learned_skill_recommendations_approved,
            "learned_skill_recommendations_rejected": self.learned_skill_recommendations_rejected,
            "deterministic_skill_executions_from_learned_recommendation": self.deterministic_skill_executions_from_learned_recommendation,
            "learned_direct_skill_calls_executed": self.learned_direct_skill_calls_executed,
            "skill_execution_success_count": self.skill_execution_success_count,
            "skill_execution_failure_count": self.skill_execution_failure_count,
            "rollback_count": self.rollback_count,
            "rollback_success_count": self.rollback_success_count,
            "verifier_pass_count": self.verifier_pass_count,
            "verifier_fail_count": self.verifier_fail_count,
            "verifier_override_count": self.verifier_override_count,
            "real_repo_mutation_count": self.real_repo_mutation_count,
            "memory_write_count": self.memory_write_count,
            "skill_graph_write_count": self.skill_graph_write_count,
            "answer_write_count": self.answer_write_count,
            "trace_audit_log_append_count": self.trace_audit_log_append_count,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "SkillExecutionAccounting":
        return cls(**{field_name: int(data.get(field_name, 0)) for field_name in cls.__dataclass_fields__})


@dataclass(slots=True)
class SkillExecutionSafetyProof:
    verifier_required_after_execution: bool
    rollback_success_rate: float
    verifier_override_count: int
    real_repo_mutation_count: int
    memory_write_count: int
    skill_graph_write_count: int
    answer_write_count: int
    trace_audit_log_append_count: int = 0
    sandbox_file_mutation_count: int = 0

    def to_dict(self) -> dict[str, Any]:
        return {
            "verifier_required_after_execution": self.verifier_required_after_execution,
            "rollback_success_rate": self.rollback_success_rate,
            "verifier_override_count": self.verifier_override_count,
            "real_repo_mutation_count": self.real_repo_mutation_count,
            "memory_write_count": self.memory_write_count,
            "skill_graph_write_count": self.skill_graph_write_count,
            "answer_write_count": self.answer_write_count,
            "trace_audit_log_append_count": self.trace_audit_log_append_count,
            "sandbox_file_mutation_count": self.sandbox_file_mutation_count,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "SkillExecutionSafetyProof":
        return cls(
            verifier_required_after_execution=bool(data["verifier_required_after_execution"]),
            rollback_success_rate=float(data["rollback_success_rate"]),
            verifier_override_count=int(data.get("verifier_override_count", 0)),
            real_repo_mutation_count=int(data.get("real_repo_mutation_count", 0)),
            memory_write_count=int(data.get("memory_write_count", 0)),
            skill_graph_write_count=int(data.get("skill_graph_write_count", 0)),
            answer_write_count=int(data.get("answer_write_count", 0)),
            trace_audit_log_append_count=int(data.get("trace_audit_log_append_count", 0)),
            sandbox_file_mutation_count=int(data.get("sandbox_file_mutation_count", 0)),
        )


@dataclass(slots=True)
class SandboxSkillExecutionResult:
    candidate_id: str
    skill_id: str
    sandbox_root: str
    executed: bool
    success: bool
    reason_code: str
    verifier_required_after_execution: bool
    verifier_passed: bool
    verifier_override_count: int
    rollback_performed: bool
    rollback_success: bool
    snapshot_before_hash: str
    snapshot_after_execution_hash: str
    snapshot_restored_hash: str = ""
    patch_inferred: bool = False
    patch_success: bool = False
    repair_strategy: str = ""
    post_execution_verifier_call_count: int = 0
    real_repo_mutation_count: int = 0
    memory_write_count: int = 0
    skill_graph_write_count: int = 0
    answer_write_count: int = 0
    tool_calls: list[dict[str, Any]] = field(default_factory=list)

    def to_dict(self) -> dict[str, Any]:
        return {
            "candidate_id": self.candidate_id,
            "skill_id": self.skill_id,
            "sandbox_root": self.sandbox_root,
            "executed": self.executed,
            "success": self.success,
            "reason_code": self.reason_code,
            "verifier_required_after_execution": self.verifier_required_after_execution,
            "verifier_passed": self.verifier_passed,
            "verifier_override_count": self.verifier_override_count,
            "rollback_performed": self.rollback_performed,
            "rollback_success": self.rollback_success,
            "snapshot_before_hash": self.snapshot_before_hash,
            "snapshot_after_execution_hash": self.snapshot_after_execution_hash,
            "snapshot_restored_hash": self.snapshot_restored_hash,
            "patch_inferred": self.patch_inferred,
            "patch_success": self.patch_success,
            "repair_strategy": self.repair_strategy,
            "post_execution_verifier_call_count": self.post_execution_verifier_call_count,
            "real_repo_mutation_count": self.real_repo_mutation_count,
            "memory_write_count": self.memory_write_count,
            "skill_graph_write_count": self.skill_graph_write_count,
            "answer_write_count": self.answer_write_count,
            "tool_calls": [dict(item) for item in self.tool_calls],
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "SandboxSkillExecutionResult":
        return cls(
            candidate_id=str(data["candidate_id"]),
            skill_id=str(data["skill_id"]),
            sandbox_root=str(data["sandbox_root"]),
            executed=bool(data["executed"]),
            success=bool(data["success"]),
            reason_code=str(data["reason_code"]),
            verifier_required_after_execution=bool(data["verifier_required_after_execution"]),
            verifier_passed=bool(data["verifier_passed"]),
            verifier_override_count=int(data.get("verifier_override_count", 0)),
            rollback_performed=bool(data.get("rollback_performed", False)),
            rollback_success=bool(data.get("rollback_success", False)),
            snapshot_before_hash=str(data.get("snapshot_before_hash", "")),
            snapshot_after_execution_hash=str(data.get("snapshot_after_execution_hash", "")),
            snapshot_restored_hash=str(data.get("snapshot_restored_hash", "")),
            patch_inferred=bool(data.get("patch_inferred", False)),
            patch_success=bool(data.get("patch_success", False)),
            repair_strategy=str(data.get("repair_strategy", "")),
            post_execution_verifier_call_count=int(data.get("post_execution_verifier_call_count", 0)),
            real_repo_mutation_count=int(data.get("real_repo_mutation_count", 0)),
            memory_write_count=int(data.get("memory_write_count", 0)),
            skill_graph_write_count=int(data.get("skill_graph_write_count", 0)),
            answer_write_count=int(data.get("answer_write_count", 0)),
            tool_calls=[dict(item) for item in data.get("tool_calls", [])],
        )


class SandboxSkillExecutionApprover:
    def approve(
        self,
        recommendation: LearnedSkillRecommendation,
        recommendation_approval: SkillRecommendationApproval,
        candidates: list[SkillRecommendationCandidate],
        action_mask: dict[str, bool],
        skill_graph: SkillGraph,
    ) -> SkillExecutionApproval:
        if recommendation.decision != "accept":
            return SkillExecutionApproval(False, recommendation.candidate_id, reason_code=recommendation.reason_code)
        if not recommendation_approval.approved:
            return SkillExecutionApproval(False, recommendation.candidate_id, recommendation_approval.skill_id, recommendation_approval.reason_code)
        if recommendation_approval.candidate_id != recommendation.candidate_id:
            return SkillExecutionApproval(False, recommendation.candidate_id, reason_code="approval_candidate_mismatch")
        by_id = {candidate.candidate_id: candidate for candidate in candidates}
        candidate = by_id.get(recommendation.candidate_id)
        if candidate is None:
            return SkillExecutionApproval(False, recommendation.candidate_id, reason_code="candidate_id_not_from_generator")
        valid, reason = validate_skill_recommendation_candidate(candidate)
        if not valid:
            return SkillExecutionApproval(False, candidate.candidate_id, candidate.skill_id, reason)
        if not bool(action_mask.get(CognitiveOp.RETRIEVE_SKILL.value, False)):
            return SkillExecutionApproval(False, candidate.candidate_id, candidate.skill_id, "action_mask_violation")
        skill = _skill_by_id(skill_graph, candidate.skill_id)
        if skill is None:
            return SkillExecutionApproval(False, candidate.candidate_id, candidate.skill_id, "skill_not_in_graph")
        skill_valid, skill_reason = _validate_executable_skill(skill, candidate)
        if not skill_valid:
            return SkillExecutionApproval(False, candidate.candidate_id, candidate.skill_id, skill_reason)
        return SkillExecutionApproval(
            True,
            candidate.candidate_id,
            candidate.skill_id,
            "approved_for_disposable_sandbox_execution",
        )


class SandboxSkillExecutor:
    def __init__(self, *, timeout_seconds: float = 120.0, real_repo_root: str | Path | None = None) -> None:
        self.timeout_seconds = timeout_seconds
        self.real_repo_root = Path(real_repo_root).resolve() if real_repo_root is not None else None

    def execute(
        self,
        task: GeneratedTask,
        skill: Skill,
        candidate: SkillRecommendationCandidate,
    ) -> SandboxSkillExecutionResult:
        root = task.root.resolve()
        empty_hash = stable_hash({"sandbox_root": str(root), "files": {}})
        if self.real_repo_root is not None and root.is_relative_to(self.real_repo_root):
            return SandboxSkillExecutionResult(
                candidate_id=candidate.candidate_id,
                skill_id=candidate.skill_id,
                sandbox_root=str(root),
                executed=False,
                success=False,
                reason_code="real_repo_execution_forbidden",
                verifier_required_after_execution=True,
                verifier_passed=False,
                verifier_override_count=0,
                rollback_performed=False,
                rollback_success=False,
                snapshot_before_hash=empty_hash,
                snapshot_after_execution_hash=empty_hash,
            )

        snapshot = SandboxSnapshot.capture(root)
        runner = SandboxToolRunner(root, timeout_seconds=self.timeout_seconds)
        action = None
        patch_inferred = False
        patch_success = False
        repair_strategy = ""
        try:
            observation = _observe_visible_project(task, runner)
            skill_input = {
                "visible_observation": observation.to_dict(),
                "candidate": candidate.to_dict(),
                "skill_id": skill.skill_id,
                "skill_metadata": skill.metadata,
                "source_of_args": "deterministic_candidate_generator",
            }
            assert_no_oracle_leak(skill_input)
            action = _infer_visible_repair(observation, preferred_strategy=str(skill.metadata.get("repair_strategy", "")))
            if action is not None:
                patch_inferred = True
                repair_strategy = action.strategy
                patch_result = runner.patch_file(action.path, action.old_text, action.new_text)
                patch_success = patch_result.success
            compile_result = runner.run_compileall()
            pytest_result = runner.run_pytest()
            verifier_passed = compile_result.success and pytest_result.success
            post_verifier_count = 2
            after_snapshot = SandboxSnapshot.capture(root)
            rollback_performed = not verifier_passed
            rollback_success = False
            restored_hash = ""
            if rollback_performed:
                restored = snapshot.restore()
                restored_hash = restored.content_hash
                rollback_success = restored.content_hash == snapshot.content_hash
            success = patch_success and verifier_passed
            reason = "verifier_passed" if success else ("verifier_failed_and_rolled_back" if rollback_performed else "patch_failed")
            return SandboxSkillExecutionResult(
                candidate_id=candidate.candidate_id,
                skill_id=candidate.skill_id,
                sandbox_root=str(root),
                executed=True,
                success=success,
                reason_code=reason,
                verifier_required_after_execution=True,
                verifier_passed=verifier_passed,
                verifier_override_count=0,
                rollback_performed=rollback_performed,
                rollback_success=rollback_success,
                snapshot_before_hash=snapshot.content_hash,
                snapshot_after_execution_hash=after_snapshot.content_hash,
                snapshot_restored_hash=restored_hash,
                patch_inferred=patch_inferred,
                patch_success=patch_success,
                repair_strategy=repair_strategy,
                post_execution_verifier_call_count=post_verifier_count,
                tool_calls=[call.to_dict() for call in runner.calls],
            )
        except Exception as exc:
            after_snapshot = SandboxSnapshot.capture(root)
            restored = snapshot.restore()
            return SandboxSkillExecutionResult(
                candidate_id=candidate.candidate_id,
                skill_id=candidate.skill_id,
                sandbox_root=str(root),
                executed=True,
                success=False,
                reason_code=f"execution_exception:{type(exc).__name__}",
                verifier_required_after_execution=True,
                verifier_passed=False,
                verifier_override_count=0,
                rollback_performed=True,
                rollback_success=restored.content_hash == snapshot.content_hash,
                snapshot_before_hash=snapshot.content_hash,
                snapshot_after_execution_hash=after_snapshot.content_hash,
                snapshot_restored_hash=restored.content_hash,
                patch_inferred=patch_inferred,
                patch_success=patch_success,
                repair_strategy=repair_strategy,
                post_execution_verifier_call_count=0,
                tool_calls=[call.to_dict() for call in runner.calls],
            )


def execution_proof_from_accounting(
    accounting: SkillExecutionAccounting,
    results: list[SandboxSkillExecutionResult],
) -> SkillExecutionSafetyProof:
    return SkillExecutionSafetyProof(
        verifier_required_after_execution=all(result.verifier_required_after_execution and result.post_execution_verifier_call_count >= 2 for result in results if result.executed),
        rollback_success_rate=accounting.rollback_success_rate,
        verifier_override_count=accounting.verifier_override_count,
        real_repo_mutation_count=accounting.real_repo_mutation_count,
        memory_write_count=accounting.memory_write_count,
        skill_graph_write_count=accounting.skill_graph_write_count,
        answer_write_count=accounting.answer_write_count,
        trace_audit_log_append_count=accounting.trace_audit_log_append_count,
        sandbox_file_mutation_count=sum(1 for result in results if result.snapshot_before_hash != result.snapshot_after_execution_hash),
    )


def sandbox_skill_workspace_input(
    task_text: str,
    observed_paths: list[str],
    skill: Skill,
) -> tuple[Workspace, dict[str, Any], dict[str, bool]]:
    workspace = Workspace.from_task(task_text)
    workspace.set_stage("skill_retrieved")
    workspace.metadata["step"] = 4
    workspace.metadata["observed_paths"] = list(observed_paths)
    workspace.metadata["sandbox_files"] = {path: "visible content" for path in observed_paths}
    model_input = model_input_from_workspace(workspace, previous_action=CognitiveOp.RETRIEVE_SKILL.value)
    model_input["agent_visible_observation"]["observed_paths"] = list(observed_paths)
    model_input["available_skill_cards"] = [
        {
            "skill_id": skill.skill_id,
            "status": skill.status,
            "heldout_success_rate": skill.heldout_success_rate,
            "required_verifier": list(skill.required_verifier),
        }
    ]
    action_mask = {op.value: True for op in CognitiveOp}
    return workspace, model_input, action_mask


def _observe_visible_project(task: GeneratedTask, runner: SandboxToolRunner) -> VisibleProjectObservation:
    task.agent_view().assert_no_oracle_leak()
    list_result = runner.list_files(".")
    files = list_result.files or []
    read_files: dict[str, str] = {}
    for relative_path in files:
        if _is_visible_python_file(relative_path):
            read_result = runner.read_file(relative_path)
            if read_result.success and read_result.content is not None:
                read_files[relative_path] = read_result.content
    compile_result = runner.run_compileall()
    pytest_result = runner.run_pytest()
    observation = VisibleProjectObservation(
        files=files,
        read_files=read_files,
        compileall=_tool_call_dict(compile_result.trace),
        pytest=_tool_call_dict(pytest_result.trace),
        symptoms=_visible_symptoms(_tool_call_dict(compile_result.trace), _tool_call_dict(pytest_result.trace)),
    )
    observation.to_dict()
    return observation


def _tool_call_dict(trace: ToolCallTrace) -> dict[str, Any]:
    return trace.to_dict()


def _skill_by_id(skill_graph: SkillGraph, skill_id: str) -> Skill | None:
    for skill in skill_graph.skills.values():
        if str(skill.skill_id) == str(skill_id):
            return skill
    return None


def _validate_executable_skill(skill: Skill, candidate: SkillRecommendationCandidate) -> tuple[bool, str]:
    if skill.skill_id != candidate.skill_id:
        return False, "skill_id_mismatch"
    if skill.status != "promoted" or not skill.promoted:
        return False, "skill_not_promoted"
    if skill.status in {"quarantined", "deprecated"} or skill.quarantine_reason:
        return False, "skill_not_executable_status"
    if not (skill.required_verifier or skill.verifier):
        return False, "missing_verifier"
    if int(skill.promotion_evidence_count or skill.success_count) < 1:
        return False, "missing_evidence"
    if skill.false_trigger_count:
        return False, "false_trigger_risk"
    if str(skill.metadata.get("repair_strategy", "")) == "":
        return False, "missing_deterministic_repair_strategy"
    return True, "ok"
