"""Gate 6A dry-run patch candidate recommendations.

Patch text is generated only by deterministic runtime code from visible project
observations. The learned path may recommend only candidate IDs, and the
recommendation is logged as dry-run evidence without applying patches.
"""

from __future__ import annotations

from dataclasses import dataclass, field
import difflib
from pathlib import Path
import re
from typing import Any

from .bytecode import CognitiveOp
from .ledger import canonical_json, stable_hash
from .workbench import VisibleProjectObservation, oracle_leak_keys


GATE6A_SCHEMA_VERSION = 1
PATCH_CANDIDATE_TYPE = "PATCH_CANDIDATE"
PATCH_RECOMMENDATION_TYPE = "PATCH_CANDIDATE_RECOMMENDATION"
DETERMINISTIC_PATCH_GENERATOR = "deterministic_patch_candidate_generator_v1"
ALLOWED_VERIFIER_PLAN = {
    ("python", "-m", "compileall", "src", "tests"),
    ("python", "-m", "pytest", "-q"),
}
FORBIDDEN_LEARNED_PATCH_FIELDS = {
    "diff",
    "diff_text",
    "new_text",
    "old_text",
    "patch",
    "patch_text",
    "target_path",
}


@dataclass(slots=True)
class PatchCandidate:
    candidate_id: str
    candidate_type: str
    target_path: str
    diff_text: str
    patch_provenance: dict[str, Any] = field(default_factory=dict)
    diff_scope: dict[str, Any] = field(default_factory=dict)
    risk_class: str = "low"
    verifier_plan: list[list[str]] = field(default_factory=list)
    dry_run_only: bool = True
    deterministic_generator: str = DETERMINISTIC_PATCH_GENERATOR
    source_observation_hash: str = ""
    legal_under_action_mask: bool = True
    oracle_free: bool = True

    @classmethod
    def create(
        cls,
        *,
        opportunity_id: str,
        target_path: str,
        source_content: str,
        old_text: str,
        new_text: str,
        strategy: str,
        observation: VisibleProjectObservation,
        verifier_plan: list[list[str]] | None = None,
        legal_under_action_mask: bool = True,
        oracle_free: bool = True,
        dry_run_only: bool = True,
    ) -> "PatchCandidate":
        modified = source_content.replace(old_text, new_text, 1)
        diff_text = _unified_diff(target_path, source_content, modified)
        plan = [list(command) for command in (verifier_plan or _default_verifier_plan())]
        observation_hash = stable_hash(_safe_observation_payload(observation))
        provenance = {
            "generator": DETERMINISTIC_PATCH_GENERATOR,
            "opportunity_id": opportunity_id,
            "strategy": strategy,
            "source_observation_hash": observation_hash,
            "observed_file_count": len(observation.read_files),
        }
        diff_scope = _diff_scope(target_path, diff_text)
        risk_class = classify_patch_risk(
            target_path=target_path,
            diff_scope=diff_scope,
            verifier_plan=plan,
            dry_run_only=dry_run_only,
            legal_under_action_mask=legal_under_action_mask,
            oracle_free=oracle_free,
        )
        payload = {
            "schema_version": GATE6A_SCHEMA_VERSION,
            "candidate_type": PATCH_CANDIDATE_TYPE,
            "target_path": target_path,
            "diff_text": diff_text,
            "patch_provenance": provenance,
            "diff_scope": diff_scope,
            "risk_class": risk_class,
            "verifier_plan": plan,
            "dry_run_only": bool(dry_run_only),
            "deterministic_generator": DETERMINISTIC_PATCH_GENERATOR,
            "source_observation_hash": observation_hash,
            "legal_under_action_mask": bool(legal_under_action_mask),
            "oracle_free": bool(oracle_free),
        }
        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": GATE6A_SCHEMA_VERSION,
            "candidate_type": self.candidate_type,
            "target_path": self.target_path,
            "diff_text": self.diff_text,
            "patch_provenance": self.patch_provenance,
            "diff_scope": self.diff_scope,
            "risk_class": self.risk_class,
            "verifier_plan": self.verifier_plan,
            "dry_run_only": self.dry_run_only,
            "deterministic_generator": self.deterministic_generator,
            "source_observation_hash": self.source_observation_hash,
            "legal_under_action_mask": self.legal_under_action_mask,
            "oracle_free": self.oracle_free,
        }

    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]) -> "PatchCandidate":
        return cls(
            candidate_id=str(data["candidate_id"]),
            candidate_type=str(data.get("candidate_type", PATCH_CANDIDATE_TYPE)),
            target_path=str(data["target_path"]),
            diff_text=str(data.get("diff_text", "")),
            patch_provenance=dict(data.get("patch_provenance") or {}),
            diff_scope=dict(data.get("diff_scope") or {}),
            risk_class=str(data.get("risk_class", "high")),
            verifier_plan=[list(command) for command in data.get("verifier_plan", [])],
            dry_run_only=bool(data.get("dry_run_only", True)),
            deterministic_generator=str(data.get("deterministic_generator", DETERMINISTIC_PATCH_GENERATOR)),
            source_observation_hash=str(data.get("source_observation_hash", "")),
            legal_under_action_mask=bool(data.get("legal_under_action_mask", True)),
            oracle_free=bool(data.get("oracle_free", True)),
        )


@dataclass(slots=True)
class LearnedPatchRecommendation:
    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]) -> "LearnedPatchRecommendation":
        extra = set(data) - {"candidate_id", "confidence", "decision", "reason_code"}
        if extra:
            raise ValueError("learned patch recommendation may not contain patch text or raw args: " + ",".join(sorted(extra)))
        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 PatchRecommendationApproval:
    approved: bool
    candidate_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,
            "reason_code": self.reason_code,
            "dry_run_record": self.dry_run_record,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "PatchRecommendationApproval":
        return cls(
            approved=bool(data["approved"]),
            candidate_id=str(data.get("candidate_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 PatchRecommendationAccounting:
    learned_patch_recommendations_made: int = 0
    learned_patch_recommendations_approved: int = 0
    learned_patch_recommendations_rejected: int = 0
    learned_patch_recommendations_logged: int = 0
    learned_patch_text_generated_count: int = 0
    learned_patch_applied_count: int = 0
    file_write_count: int = 0
    deterministic_fallback_count: int = 0

    def to_dict(self) -> dict[str, int]:
        return {
            "learned_patch_recommendations_made": self.learned_patch_recommendations_made,
            "learned_patch_recommendations_approved": self.learned_patch_recommendations_approved,
            "learned_patch_recommendations_rejected": self.learned_patch_recommendations_rejected,
            "learned_patch_recommendations_logged": self.learned_patch_recommendations_logged,
            "learned_patch_text_generated_count": self.learned_patch_text_generated_count,
            "learned_patch_applied_count": self.learned_patch_applied_count,
            "file_write_count": self.file_write_count,
            "deterministic_fallback_count": self.deterministic_fallback_count,
        }


@dataclass(slots=True)
class PatchRecommendationDryRunProof:
    dry_run_recommendation_count: int = 0
    learned_patch_text_generated_count: int = 0
    learned_patch_applied_count: int = 0
    file_write_count: int = 0
    audit_log_append_count: int = 0

    def record_dry_run(self, recommendation: LearnedPatchRecommendation, candidate: PatchCandidate) -> 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_PATCH_FIELDS):
            self.learned_patch_text_generated_count += 1
        if not candidate.dry_run_only:
            self.learned_patch_applied_count += 1

    def record_forbidden_attempt(self, kind: str) -> None:
        if kind == "patch_text":
            self.learned_patch_text_generated_count += 1
        elif kind == "patch_apply":
            self.learned_patch_applied_count += 1
        elif kind == "file_write":
            self.file_write_count += 1

    def to_dict(self) -> dict[str, int]:
        return {
            "dry_run_recommendation_count": self.dry_run_recommendation_count,
            "learned_patch_text_generated_count": self.learned_patch_text_generated_count,
            "learned_patch_applied_count": self.learned_patch_applied_count,
            "file_write_count": self.file_write_count,
            "audit_log_append_count": self.audit_log_append_count,
        }


class PatchCandidateGenerator:
    def generate(
        self,
        observation: VisibleProjectObservation,
        *,
        opportunity_id: str,
        action_mask: dict[str, bool] | None = None,
        include_unsafe: bool = False,
    ) -> list[PatchCandidate]:
        _reject_oracle_leaks(observation.to_dict())
        mask = action_mask or {CognitiveOp.PATCH.value: True}
        candidates: list[PatchCandidate] = []
        for action in _infer_patch_actions(observation):
            source = observation.read_files.get(action["target_path"], "")
            candidate = PatchCandidate.create(
                opportunity_id=opportunity_id,
                target_path=action["target_path"],
                source_content=source,
                old_text=action["old_text"],
                new_text=action["new_text"],
                strategy=action["strategy"],
                observation=observation,
                legal_under_action_mask=bool(mask.get(CognitiveOp.PATCH.value, False)),
            )
            valid, _reason = validate_patch_candidate(candidate)
            if valid or include_unsafe:
                candidates.append(candidate)
        if include_unsafe:
            candidates.extend(_unsafe_candidates(observation, opportunity_id, bool(mask.get(CognitiveOp.PATCH.value, False))))
        return _dedupe_candidates(candidates)


class LearnedPatchRecommendationSelector:
    def propose(self, candidates: list[PatchCandidate]) -> LearnedPatchRecommendation:
        safe_candidates = [candidate for candidate in candidates if validate_patch_candidate(candidate)[0] and candidate.risk_class == "low"]
        if not safe_candidates:
            return LearnedPatchRecommendation("", 0.0, "abstain", "no_low_risk_patch_candidates")
        ordered = sorted(
            safe_candidates,
            key=lambda candidate: (
                int(candidate.diff_scope.get("changed_file_count", 99)),
                int(candidate.diff_scope.get("diff_line_count", 9999)),
                candidate.target_path,
                candidate.candidate_id,
            ),
        )
        return LearnedPatchRecommendation(ordered[0].candidate_id, 0.93, "accept", "selected_from_deterministic_patch_candidates")


class PatchRecommendationArbiter:
    def approve(
        self,
        recommendation: LearnedPatchRecommendation,
        candidates: list[PatchCandidate],
        action_mask: dict[str, bool] | None = None,
    ) -> PatchRecommendationApproval:
        if recommendation.decision != "accept":
            return PatchRecommendationApproval(False, recommendation.candidate_id, 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 PatchRecommendationApproval(False, recommendation.candidate_id, "candidate_id_not_found")
        valid, reason = validate_patch_candidate(candidate)
        if not valid:
            return PatchRecommendationApproval(False, candidate.candidate_id, reason)
        mask = action_mask or {CognitiveOp.PATCH.value: True}
        if not bool(mask.get(CognitiveOp.PATCH.value, False)):
            return PatchRecommendationApproval(False, candidate.candidate_id, "action_mask_violation")
        return PatchRecommendationApproval(
            True,
            candidate.candidate_id,
            "approved_dry_run_only",
            {
                "candidate_id": candidate.candidate_id,
                "dry_run_only": True,
                "risk_class": candidate.risk_class,
                "target_path": candidate.target_path,
                "verifier_plan": [list(command) for command in candidate.verifier_plan],
            },
        )


class LearnedPatchRecommendationController:
    def __init__(
        self,
        *,
        generator: PatchCandidateGenerator | None = None,
        selector: LearnedPatchRecommendationSelector | None = None,
        arbiter: PatchRecommendationArbiter | None = None,
    ) -> None:
        self.generator = generator or PatchCandidateGenerator()
        self.selector = selector or LearnedPatchRecommendationSelector()
        self.arbiter = arbiter or PatchRecommendationArbiter()
        self.accounting = PatchRecommendationAccounting()
        self.dry_run_proof = PatchRecommendationDryRunProof()
        self.logs: list[dict[str, Any]] = []

    def review(
        self,
        observation: VisibleProjectObservation,
        *,
        opportunity_id: str,
        action_mask: dict[str, bool] | None = None,
        include_unsafe: bool = False,
    ) -> dict[str, Any]:
        candidates = self.generator.generate(
            observation,
            opportunity_id=opportunity_id,
            action_mask=action_mask,
            include_unsafe=include_unsafe,
        )
        recommendation = self.selector.propose(candidates)
        approval = self.arbiter.approve(recommendation, candidates, action_mask)
        if recommendation.candidate_id:
            self.accounting.learned_patch_recommendations_made += 1
        if approval.approved:
            self.accounting.learned_patch_recommendations_approved += 1
            self.accounting.learned_patch_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_patch_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],
            "executed_by_learned_patch_recommendation": False,
        }
        self.logs.append(log)
        return log

    def summary(self) -> dict[str, Any]:
        proof = self.dry_run_proof.to_dict()
        accounting = self.accounting.to_dict()
        return {
            "enabled": True,
            "dry_run_only": True,
            "recommendation_accounting": accounting,
            "dry_run_proof": proof,
            "learned_patch_text_generated_count": accounting["learned_patch_text_generated_count"] + proof["learned_patch_text_generated_count"],
            "learned_patch_applied_count": accounting["learned_patch_applied_count"] + proof["learned_patch_applied_count"],
            "file_write_count": accounting["file_write_count"] + proof["file_write_count"],
            "logs": self.logs,
        }


def validate_patch_candidate(candidate: PatchCandidate) -> tuple[bool, str]:
    if not candidate.has_stable_id():
        return False, "candidate_id_not_deterministic"
    if candidate.candidate_type != PATCH_CANDIDATE_TYPE:
        return False, "illegal_candidate_type"
    if candidate.deterministic_generator != DETERMINISTIC_PATCH_GENERATOR:
        return False, "unknown_generator"
    if not candidate.dry_run_only:
        return False, "not_dry_run"
    if not candidate.legal_under_action_mask:
        return False, "action_mask_violation"
    if not candidate.oracle_free:
        return False, "oracle_derived_patch"
    if not _safe_relative_path(candidate.target_path):
        return False, "unsafe_target_path"
    if not candidate.target_path.startswith("src/") or not candidate.target_path.endswith(".py"):
        return False, "unsupported_patch_target"
    if not candidate.diff_text.strip():
        return False, "empty_diff"
    if int(candidate.diff_scope.get("changed_file_count", 0)) != 1:
        return False, "invalid_diff_scope"
    if candidate.risk_class != "low":
        return False, "risk_not_low"
    if not _allowed_verifier_plan(candidate.verifier_plan):
        return False, "verifier_plan_not_allowed"
    if oracle_leak_keys(candidate.patch_provenance) or oracle_leak_keys(candidate.diff_scope):
        return False, "oracle_leak"
    return True, "ok"


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


def classify_patch_risk(
    *,
    target_path: str,
    diff_scope: dict[str, Any],
    verifier_plan: list[list[str]],
    dry_run_only: bool,
    legal_under_action_mask: bool,
    oracle_free: bool,
) -> str:
    if not dry_run_only or not legal_under_action_mask or not oracle_free:
        return "high"
    if not _safe_relative_path(target_path):
        return "high"
    if not _allowed_verifier_plan(verifier_plan):
        return "high"
    if int(diff_scope.get("changed_file_count", 0)) != 1:
        return "high"
    if str(target_path).startswith("tests/"):
        return "medium"
    if int(diff_scope.get("diff_line_count", 0)) > 60:
        return "medium"
    return "low"


def _infer_patch_actions(observation: VisibleProjectObservation) -> list[dict[str, str]]:
    actions: list[dict[str, str]] = []
    for infer in [_infer_relative_import_symbol_fix, _infer_default_config_fix, _infer_shape_fix, _infer_absolute_import_layout_fix]:
        action = infer(observation)
        if action is not None:
            actions.append(action)
    return actions


def _infer_relative_import_symbol_fix(observation: VisibleProjectObservation) -> dict[str, str] | None:
    for path, content in _source_files(observation).items():
        source_dir = str(Path(path).parent)
        for match in re.finditer(r"from \.([A-Za-z_][A-Za-z0-9_]*) import ([A-Za-z0-9_, ]+)", content):
            module_path = f"{source_dir}/{match.group(1)}.py"
            module_content = observation.read_files.get(module_path)
            if module_content is None:
                continue
            definitions = _defined_functions(module_content)
            for symbol in [item.strip() for item in match.group(2).split(",") if item.strip()]:
                if symbol in definitions:
                    continue
                replacement = _best_symbol(symbol, definitions)
                if replacement:
                    patched_content = re.sub(rf"\b{re.escape(symbol)}\b", replacement, content)
                    return {
                        "target_path": path,
                        "old_text": content,
                        "new_text": patched_content,
                        "strategy": "relative_import_symbol",
                    }
    return None


def _infer_absolute_import_layout_fix(observation: VisibleProjectObservation) -> dict[str, str] | None:
    for path, content in _source_files(observation).items():
        source_dir = str(Path(path).parent)
        for match in re.finditer(r"from ([A-Za-z_][A-Za-z0-9_]*) import ([A-Za-z_][A-Za-z0-9_]*)", content):
            if match.group(0).startswith("from ."):
                continue
            if f"{source_dir}/{match.group(1)}.py" in observation.read_files:
                return {
                    "target_path": path,
                    "old_text": match.group(0),
                    "new_text": f"from .{match.group(1)} import {match.group(2)}",
                    "strategy": "absolute_import_layout",
                }
    return None


def _infer_default_config_fix(observation: VisibleProjectObservation) -> dict[str, str] | None:
    expected_values = _expected_ints_from_tests(observation)
    if len(expected_values) != 1:
        return None
    expected = next(iter(expected_values))
    for path, content in _source_files(observation).items():
        match = re.search(r"^DEFAULT_LIMIT\s*=\s*(\d+)\s*$", content, flags=re.MULTILINE)
        if match and int(match.group(1)) != expected:
            return {
                "target_path": path,
                "old_text": match.group(0),
                "new_text": f"DEFAULT_LIMIT = {expected}",
                "strategy": "default_config_value",
            }
    return None


def _infer_shape_fix(observation: VisibleProjectObservation) -> dict[str, str] | None:
    tests_text = "\n".join(_test_files(observation).values())
    if "'name'" not in tests_text or "'score'" not in tests_text:
        return None
    for path, content in _source_files(observation).items():
        old_text = "    return (name, score)\n"
        if old_text in content:
            return {
                "target_path": path,
                "old_text": old_text,
                "new_text": '    return {"name": name, "score": score}\n',
                "strategy": "shape_mismatch",
            }
    return None


def _unsafe_candidates(observation: VisibleProjectObservation, opportunity_id: str, legal_under_action_mask: bool) -> list[PatchCandidate]:
    source_files = _source_files(observation)
    if not source_files:
        return []
    path, content = sorted(source_files.items())[0]
    return [
        PatchCandidate.create(
            opportunity_id=f"{opportunity_id}_unsafe_path",
            target_path="../escape.py",
            source_content=content,
            old_text=content[:1] or "",
            new_text=content[:1] or "",
            strategy="unsafe_path_probe",
            observation=observation,
            legal_under_action_mask=legal_under_action_mask,
        ),
        PatchCandidate.create(
            opportunity_id=f"{opportunity_id}_unsafe_verifier",
            target_path=path,
            source_content=content,
            old_text=content[:1] or "",
            new_text=(content[:1] or "") + "_",
            strategy="unsafe_verifier_probe",
            observation=observation,
            verifier_plan=[["bash", "-lc", "pytest"]],
            legal_under_action_mask=legal_under_action_mask,
        ),
        PatchCandidate.create(
            opportunity_id=f"{opportunity_id}_not_dry_run",
            target_path=path,
            source_content=content,
            old_text=content[:1] or "",
            new_text=(content[:1] or "") + "_",
            strategy="not_dry_run_probe",
            observation=observation,
            dry_run_only=False,
            legal_under_action_mask=legal_under_action_mask,
        ),
    ]


def _safe_observation_payload(observation: VisibleProjectObservation) -> dict[str, Any]:
    return {
        "files": list(observation.files),
        "read_file_hashes": {path: stable_hash({"content": content}) for path, content in sorted(observation.read_files.items())},
        "symptoms": list(observation.symptoms),
    }


def _source_files(observation: VisibleProjectObservation) -> dict[str, str]:
    return {
        path: content
        for path, content in observation.read_files.items()
        if path.startswith("src/") and path.endswith(".py")
    }


def _test_files(observation: VisibleProjectObservation) -> dict[str, str]:
    return {
        path: content
        for path, content in observation.read_files.items()
        if path.startswith("tests/") and path.endswith(".py")
    }


def _defined_functions(content: str) -> list[str]:
    return re.findall(r"^def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(", content, flags=re.MULTILINE)


def _best_symbol(symbol: str, definitions: list[str]) -> str | None:
    if not definitions:
        return None
    if symbol.startswith("missing_") and symbol.removeprefix("missing_") in definitions:
        return symbol.removeprefix("missing_")
    suffix_matches = [definition for definition in definitions if symbol.endswith(definition) or definition.endswith(symbol)]
    if suffix_matches:
        return sorted(suffix_matches, key=len, reverse=True)[0]
    symbol_tokens = set(symbol.split("_"))
    ranked = sorted(
        definitions,
        key=lambda definition: (len(symbol_tokens & set(definition.split("_"))), len(definition)),
        reverse=True,
    )
    return ranked[0] if ranked and symbol_tokens & set(ranked[0].split("_")) else None


def _expected_ints_from_tests(observation: VisibleProjectObservation) -> set[int]:
    expected: set[int] = set()
    for content in _test_files(observation).values():
        for match in re.finditer(r"assert\s+[A-Za-z_][A-Za-z0-9_]*\(\)\s*==\s*(\d+)", content):
            expected.add(int(match.group(1)))
    return expected


def _unified_diff(path: str, before: str, after: str) -> str:
    if before == after:
        return ""
    diff = difflib.unified_diff(
        before.splitlines(),
        after.splitlines(),
        fromfile=f"a/{path}",
        tofile=f"b/{path}",
        lineterm="",
    )
    text = "\n".join(diff)
    return text if text.endswith("\n") else text + "\n"


def _diff_scope(target_path: str, diff_text: str) -> dict[str, Any]:
    return {
        "changed_file_count": int(bool(diff_text.strip())),
        "diff_line_count": len(diff_text.splitlines()),
        "target_paths": [target_path] if diff_text.strip() else [],
        "touches_source": target_path.startswith("src/"),
        "touches_tests": target_path.startswith("tests/"),
    }


def _default_verifier_plan() -> list[list[str]]:
    return [["python", "-m", "compileall", "src", "tests"], ["python", "-m", "pytest", "-q"]]


def _allowed_verifier_plan(verifier_plan: list[list[str]]) -> bool:
    return bool(verifier_plan) and all(tuple(str(part) for part in command) in ALLOWED_VERIFIER_PLAN for command in verifier_plan)


def _safe_relative_path(value: str) -> bool:
    path = Path(value)
    return bool(value) and not path.is_absolute() and ".." not in path.parts


def _dedupe_candidates(candidates: list[PatchCandidate]) -> list[PatchCandidate]:
    by_id: dict[str, PatchCandidate] = {}
    for candidate in candidates:
        by_id.setdefault(candidate.candidate_id, candidate)
    return list(by_id.values())


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


def _reject_oracle_leaks(payload: dict[str, Any]) -> None:
    leaks = oracle_leak_keys(payload)
    text = canonical_json(payload).lower()
    if leaks or any(item in text for item in ["expected_fix", "hidden_verifier_oracle", "defect_kind", "canary"]):
        raise ValueError("patch candidate input contains forbidden oracle fields: " + ",".join(leaks[:5]))
