#!/usr/bin/env python3
"""Apply an approved adaptation patch with allowlisted targets and regression evidence."""

import argparse
import json
import os
import shlex
import subprocess
import sys
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any

from adaptation_patch_safety import patch_target_files, sha256_file, target_file_sha256, validate_target_file_sha256
from adaptation_report_contracts import APPLY_CONTRACT, APPROVAL_CONTRACT, decorate_approval_ledger, decorate_regression_report


ROOT = Path(__file__).resolve().parent.parent
SCRIPT_INTERFACE = "cli"
SCRIPT_INTERFACE_REASON = "Approval-gated adaptive patch application with dry-run, allowlist, regression, and rollback evidence."


def utc_now() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")


def display_path(path: Path, skill_dir: Path) -> str:
    try:
        return str(path.resolve().relative_to(skill_dir.resolve()))
    except ValueError:
        return str(path)


def resolve_path(skill_dir: Path, value: str) -> Path:
    path = Path(value)
    return path if path.is_absolute() else skill_dir / path


def load_json(path: Path) -> dict[str, Any]:
    if not path.exists():
        return {}
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError:
        return {}
    return payload if isinstance(payload, dict) else {}


def approved_entries(ledger: dict[str, Any]) -> list[dict[str, Any]]:
    entries = ledger.get("entries")
    return [item for item in entries if isinstance(item, dict)] if isinstance(entries, list) else []


def find_proposal(proposals: dict[str, Any], proposal_id: str) -> dict[str, Any]:
    for item in proposals.get("proposals", []):
        if isinstance(item, dict) and item.get("proposal_id") == proposal_id:
            return item
    return {}


def find_approval(ledger: dict[str, Any], proposal_id: str) -> dict[str, Any]:
    for item in approved_entries(ledger):
        if item.get("proposal_id") == proposal_id and item.get("decision") == "approved":
            return item
    return {}


def refresh_ledger_summary(ledger: dict[str, Any]) -> None:
    entries = approved_entries(ledger)
    approved = [item for item in entries if item.get("decision") == "approved"]
    pending = [item for item in entries if item.get("decision") == "pending-review"]
    ledger["summary"] = {
        "approval_count": len(approved),
        "active_approval_count": len(approved),
        "pending_review_count": len(pending),
        "applied_count": 0,
        "rollback_count": 0,
    }
    decorate_approval_ledger(ledger)


def upsert_ledger_entry(ledger: dict[str, Any], entry: dict[str, Any]) -> None:
    entries = approved_entries(ledger)
    proposal_id = entry.get("proposal_id")
    kept = [item for item in entries if item.get("proposal_id") != proposal_id]
    ledger["entries"] = [*kept, entry]
    refresh_ledger_summary(ledger)


def parse_date(value: str) -> date | None:
    if not value:
        return None
    try:
        return date.fromisoformat(value[:10])
    except ValueError:
        return None


def validate_approval(approval: dict[str, Any], today: date) -> list[str]:
    failures = []
    required = [
        "reviewer",
        "reason",
        "approved_at",
        "patch_sha256",
        "target_files",
        "target_file_sha256",
        "verification_commands",
        "rollback_plan",
    ]
    for key in required:
        if not approval.get(key):
            failures.append(f"Approval entry missing required field: {key}")
    expires_at = parse_date(str(approval.get("expires_at", "")))
    if expires_at and expires_at < today:
        failures.append(f"Approval entry is expired: {approval.get('expires_at')}")
    if not isinstance(approval.get("target_files"), list):
        failures.append("Approval target_files must be a list.")
    if not isinstance(approval.get("target_file_sha256"), dict):
        failures.append("Approval target_file_sha256 must be an object.")
    if not isinstance(approval.get("verification_commands"), list):
        failures.append("Approval verification_commands must be a list.")
    return failures


def safe_command(command: str) -> tuple[bool, list[str], str]:
    try:
        parts = shlex.split(command)
    except ValueError as exc:
        return False, [], f"cannot parse command: {exc}"
    if not parts:
        return False, [], "empty command"
    if parts[0] == "make" and len(parts) == 2 and all(ch.isalnum() or ch in {"-", "_"} for ch in parts[1]):
        return True, parts, "make target"
    if parts[0] in {"python3", sys.executable} and len(parts) >= 2:
        script = Path(parts[1])
        if not script.is_absolute() and script.parts and script.parts[0] in {"tests", "scripts"} and script.suffix == ".py":
            return True, parts, "local python verifier"
    return False, parts, "command is not in the safe regression allowlist"


def run_command(command: str, skill_dir: Path) -> dict[str, Any]:
    allowed, parts, reason = safe_command(command)
    if not allowed:
        return {"command": command, "ok": False, "returncode": None, "stdout": "", "stderr": reason}
    proc = subprocess.run(parts, cwd=skill_dir, capture_output=True, text=True)
    return {
        "command": command,
        "ok": proc.returncode == 0,
        "returncode": proc.returncode,
        "stdout": proc.stdout[-4000:],
        "stderr": proc.stderr[-4000:],
    }


def git_apply_check(skill_dir: Path, patch_file: Path) -> dict[str, Any]:
    env = dict(os.environ)
    env["GIT_CEILING_DIRECTORIES"] = str(skill_dir.parent)
    proc = subprocess.run(["git", "apply", "--check", str(patch_file)], cwd=skill_dir, capture_output=True, text=True, env=env)
    return {"ok": proc.returncode == 0, "returncode": proc.returncode, "stdout": proc.stdout, "stderr": proc.stderr}


def git_apply(skill_dir: Path, patch_file: Path) -> dict[str, Any]:
    env = dict(os.environ)
    env["GIT_CEILING_DIRECTORIES"] = str(skill_dir.parent)
    proc = subprocess.run(["git", "apply", str(patch_file)], cwd=skill_dir, capture_output=True, text=True, env=env)
    return {"ok": proc.returncode == 0, "returncode": proc.returncode, "stdout": proc.stdout, "stderr": proc.stderr}


def git_apply_reverse(skill_dir: Path, patch_file: Path) -> dict[str, Any]:
    env = dict(os.environ)
    env["GIT_CEILING_DIRECTORIES"] = str(skill_dir.parent)
    proc = subprocess.run(["git", "apply", "-R", str(patch_file)], cwd=skill_dir, capture_output=True, text=True, env=env)
    return {"ok": proc.returncode == 0, "returncode": proc.returncode, "stdout": proc.stdout, "stderr": proc.stderr}


def empty_approval_ledger(generated_at: str) -> dict[str, Any]:
    return decorate_approval_ledger({
        "schema_version": "1.0",
        "ok": True,
        "generated_at": generated_at,
        "summary": {
            "approval_count": 0,
            "active_approval_count": 0,
            "pending_review_count": 0,
            "applied_count": 0,
            "rollback_count": 0,
        },
        "approval_contract": dict(APPROVAL_CONTRACT),
        "entries": [],
    })


def empty_regression_report(skill_dir: Path, generated_at: str) -> dict[str, Any]:
    return decorate_regression_report({
        "schema_version": "1.0",
        "ok": True,
        "generated_at": generated_at,
        "skill_dir": display_path(skill_dir, skill_dir),
        "summary": {
            "apply_supported": True,
            "attempt_count": 0,
            "approval_draft_count": 0,
            "applied_count": 0,
            "dry_run_count": 0,
            "rollback_count": 0,
            "regression_run_count": 0,
            "regression_pass_count": 0,
            "failure_count": 0,
        },
        "apply_contract": dict(APPLY_CONTRACT),
        "attempts": [],
        "failures": [],
        "artifacts": {
            "json": "reports/adaptation_regression_report.json",
            "approval_ledger": "reports/adaptation_approval_ledger.json",
        },
    })


def write_json(path: Path, payload: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def render_markdown(report: dict[str, Any]) -> str:
    summary = report["summary"]
    lines = [
        "# Adaptation Regression Report",
        "",
        f"- generated_at: `{report['generated_at']}`",
        f"- apply_supported: `{str(summary['apply_supported']).lower()}`",
        f"- attempts: `{summary['attempt_count']}`",
        f"- applied: `{summary['applied_count']}`",
        f"- dry runs: `{summary['dry_run_count']}`",
        f"- rollbacks: `{summary.get('rollback_count', 0)}`",
        f"- failures: `{summary['failure_count']}`",
        "",
        "This report proves the adaptive apply harness behavior. It does not count proposals as applied changes.",
        "",
    ]
    for attempt in report.get("attempts", []):
        lines.extend(
            [
                f"## {attempt['proposal_id']}",
                "",
                f"- mode: `{attempt['mode']}`",
                f"- status: `{attempt['status']}`",
                f"- patch: `{attempt['patch']}`",
                f"- patch sha256: `{attempt['patch_sha256']}`",
                f"- targets: `{', '.join(attempt['target_files'])}`",
                f"- rollback: {attempt['rollback']['plan']}",
            ]
        )
        if attempt.get("regression_runs"):
            lines.append("- regression:")
            lines.extend(f"  - `{item['command']}`: `{str(item['ok']).lower()}`" for item in attempt["regression_runs"])
        lines.append("")
    if report.get("failures"):
        lines.extend(["## Failures", ""])
        lines.extend(f"- {failure}" for failure in report["failures"])
    draft = report.get("approval_draft")
    if isinstance(draft, dict):
        lines.extend(
            [
                "",
                "## Approval Draft",
                "",
                f"- proposal: `{draft.get('proposal_id', '')}`",
                f"- decision: `{draft.get('decision', '')}`",
                f"- patch sha256: `{draft.get('patch_sha256', '')}`",
                f"- targets: `{', '.join(draft.get('target_files', []))}`",
                "",
                "A human reviewer must set `decision` to `approved` and fill reviewer, reason, and approval date before apply.",
            ]
        )
    return "\n".join(lines).rstrip() + "\n"


def build_report(args: argparse.Namespace) -> dict[str, Any]:
    skill_dir = Path(args.skill_dir).resolve()
    generated_at = args.generated_at or utc_now()
    ledger_path = resolve_path(skill_dir, args.approval_ledger)
    output_json = resolve_path(skill_dir, args.output_json)
    output_md = resolve_path(skill_dir, args.output_md)
    if args.write_template:
        ledger = empty_approval_ledger(generated_at)
        report = empty_regression_report(skill_dir, generated_at)
        write_json(ledger_path, ledger)
        write_json(output_json, report)
        output_md.parent.mkdir(parents=True, exist_ok=True)
        output_md.write_text(render_markdown(report), encoding="utf-8")
        return report

    failures: list[str] = []
    proposals_path = resolve_path(skill_dir, args.proposals_json)
    patch_path = resolve_path(skill_dir, args.patch_file) if args.patch_file else Path("")
    proposals = load_json(proposals_path)
    ledger = load_json(ledger_path)
    if not proposals:
        failures.append(f"Proposal report missing or invalid: {display_path(proposals_path, skill_dir)}")
    if args.prepare_approval and not ledger and not ledger_path.exists():
        ledger = empty_approval_ledger(generated_at)
    if not args.prepare_approval and not ledger:
        failures.append(f"Approval ledger missing or invalid: {display_path(ledger_path, skill_dir)}")
    if args.prepare_approval and ledger_path.exists() and not ledger:
        failures.append(f"Approval ledger exists but is invalid: {display_path(ledger_path, skill_dir)}")
    if not args.proposal_id:
        failures.append("--proposal-id is required unless --write-template is used.")
    if not args.patch_file or not patch_path.exists():
        failures.append("--patch-file must point to an existing unified diff.")

    today = parse_date(str(args.today or date.today().isoformat()))
    if today is None:
        failures.append("--today must be an ISO date when provided.")

    proposal = find_proposal(proposals, args.proposal_id) if not failures else {}
    if not failures and not proposal:
        failures.append(f"Proposal id is not present in proposal report: {args.proposal_id}")

    patch_sha = sha256_file(patch_path) if patch_path.exists() else ""
    target_files: list[str] = []
    observed_target_file_sha256: dict[str, str] = {}
    if not failures:
        try:
            target_files = patch_target_files(patch_path.read_text(encoding="utf-8", errors="replace"))
        except ValueError as exc:
            failures.append(str(exc))

    if args.prepare_approval:
        if not failures:
            proposal_targets = set(str(item) for item in proposal.get("target_files", []))
            patch_targets = set(target_files)
            if not patch_targets:
                failures.append("Patch does not declare any target files.")
            if not patch_targets <= proposal_targets:
                failures.append("Patch touches files outside proposal target_files.")
        check = git_apply_check(skill_dir, patch_path) if not failures else {"ok": False, "returncode": None, "stdout": "", "stderr": ""}
        if not failures and not check["ok"]:
            failures.append(f"git apply --check failed: {check['stderr'].strip()}")
        draft: dict[str, Any] = {}
        if not failures:
            observed_target_file_sha256 = target_file_sha256(skill_dir, target_files)
            draft = {
                "proposal_id": args.proposal_id,
                "decision": "pending-review",
                "reviewer": "",
                "reason": "",
                "approved_at": "",
                "expires_at": "",
                "created_at": generated_at,
                "patch": display_path(patch_path, skill_dir),
                "patch_sha256": patch_sha,
                "target_files": target_files,
                "target_file_sha256": observed_target_file_sha256,
                "verification_commands": [
                    str(command) for command in proposal.get("verification_commands", []) if command
                ],
                "rollback_plan": proposal.get("rollback_plan", f"git apply -R {display_path(patch_path, skill_dir)}"),
            }
            upsert_ledger_entry(ledger, draft)
            write_json(ledger_path, ledger)
        report = decorate_regression_report({
            "schema_version": "1.0",
            "ok": not failures,
            "generated_at": generated_at,
            "skill_dir": display_path(skill_dir, skill_dir),
            "summary": {
                "apply_supported": True,
                "attempt_count": 0,
                "approval_draft_count": 1 if draft else 0,
                "applied_count": 0,
                "dry_run_count": 0,
                "rollback_count": 0,
                "regression_run_count": 0,
                "regression_pass_count": 0,
                "failure_count": len(failures),
            },
            "apply_contract": dict(APPLY_CONTRACT),
            "approval_draft": draft,
            "attempts": [],
            "failures": failures,
            "artifacts": {
                "json": display_path(output_json, skill_dir),
                "markdown": display_path(output_md, skill_dir),
                "approval_ledger": display_path(ledger_path, skill_dir),
            },
        })
        write_json(output_json, report)
        output_md.parent.mkdir(parents=True, exist_ok=True)
        output_md.write_text(render_markdown(report), encoding="utf-8")
        return report

    approval = find_approval(ledger, args.proposal_id) if not failures else {}
    if not failures and not approval:
        failures.append(f"Proposal id is not approved in the ledger: {args.proposal_id}")
    if approval:
        refresh_ledger_summary(ledger)
        write_json(ledger_path, ledger)
        failures.extend(validate_approval(approval, today or date.today()))

    if not failures:
        approved_targets = set(str(item) for item in approval.get("target_files", []))
        proposal_targets = set(str(item) for item in proposal.get("target_files", []))
        patch_targets = set(target_files)
        if patch_sha != approval.get("patch_sha256"):
            failures.append("Patch sha256 does not match approval ledger.")
        if not patch_targets:
            failures.append("Patch does not declare any target files.")
        if not patch_targets <= approved_targets:
            failures.append("Patch touches files outside approval target_files.")
        if not patch_targets <= proposal_targets:
            failures.append("Patch touches files outside proposal target_files.")
        if not failures:
            baseline_failures, observed_target_file_sha256 = validate_target_file_sha256(
                skill_dir,
                target_files,
                approval.get("target_file_sha256", {}),
            )
            failures.extend(baseline_failures)

    check = git_apply_check(skill_dir, patch_path) if not failures else {"ok": False, "returncode": None, "stdout": "", "stderr": ""}
    if not failures and not check["ok"]:
        failures.append(f"git apply --check failed: {check['stderr'].strip()}")

    mode = "apply" if args.apply else "dry-run"
    applied = False
    apply_result = {"ok": False, "returncode": None, "stdout": "", "stderr": "not requested"}
    if not failures and args.apply:
        apply_result = git_apply(skill_dir, patch_path)
        applied = bool(apply_result["ok"])
        if not applied:
            failures.append(f"git apply failed: {apply_result['stderr'].strip()}")

    regression_runs: list[dict[str, Any]] = []
    if not failures and args.run_verification:
        for command in approval.get("verification_commands", []):
            regression_runs.append(run_command(str(command), skill_dir))
        if any(not item["ok"] for item in regression_runs):
            failures.append("One or more regression commands failed or were not allowed.")

    rollback_result = {"ok": None, "returncode": None, "stdout": "", "stderr": "not needed"}
    rolled_back = False
    if applied and failures and args.rollback_on_failure:
        rollback_result = git_apply_reverse(skill_dir, patch_path)
        rolled_back = bool(rollback_result["ok"])
        if not rolled_back:
            failures.append(f"Automatic rollback failed: {rollback_result['stderr'].strip()}")

    attempt = {
        "proposal_id": args.proposal_id or "",
        "mode": mode,
        "status": "failed-rolled-back" if rolled_back else ("failed" if failures else ("applied" if applied else "dry-run-pass")),
        "patch": display_path(patch_path, skill_dir) if args.patch_file else "",
        "patch_sha256": patch_sha,
        "target_files": target_files,
        "approval": {
            "reviewer": approval.get("reviewer", ""),
            "approved_at": approval.get("approved_at", ""),
            "expires_at": approval.get("expires_at", ""),
        },
        "target_file_sha256": {
            "expected": {
                target: str(approval.get("target_file_sha256", {}).get(target, ""))
                for target in target_files
            },
            "observed": observed_target_file_sha256,
        },
        "git_apply_check": check,
        "git_apply": apply_result,
        "regression_runs": regression_runs,
        "rollback_result": rollback_result,
        "rollback": {
            "plan": approval.get("rollback_plan", ""),
            "command": f"git apply -R {display_path(patch_path, skill_dir)}" if args.patch_file else "",
        },
    }
    report = decorate_regression_report({
        "schema_version": "1.0",
        "ok": not failures,
        "generated_at": generated_at,
        "skill_dir": display_path(skill_dir, skill_dir),
        "summary": {
            "apply_supported": True,
            "attempt_count": 1,
            "approval_draft_count": 0,
            "applied_count": 1 if applied and not rolled_back else 0,
            "dry_run_count": 0 if args.apply else 1,
            "rollback_count": 1 if rolled_back else 0,
            "regression_run_count": len(regression_runs),
            "regression_pass_count": sum(1 for item in regression_runs if item["ok"]),
            "failure_count": len(failures),
        },
        "apply_contract": dict(APPLY_CONTRACT),
        "attempts": [attempt],
        "failures": failures,
        "artifacts": {
            "json": display_path(output_json, skill_dir),
            "markdown": display_path(output_md, skill_dir),
            "approval_ledger": display_path(ledger_path, skill_dir),
        },
    })
    write_json(output_json, report)
    output_md.parent.mkdir(parents=True, exist_ok=True)
    output_md.write_text(render_markdown(report), encoding="utf-8")
    return report


def main() -> None:
    parser = argparse.ArgumentParser(description="Apply an approved adaptation patch with review and regression evidence.")
    parser.add_argument("skill_dir", nargs="?", default=".")
    parser.add_argument("--proposal-id")
    parser.add_argument("--patch-file")
    parser.add_argument("--proposals-json", default="reports/adaptation_proposals.json")
    parser.add_argument("--approval-ledger", default="reports/adaptation_approval_ledger.json")
    parser.add_argument("--output-json", default="reports/adaptation_regression_report.json")
    parser.add_argument("--output-md", default="reports/adaptation_regression_report.md")
    parser.add_argument("--generated-at")
    parser.add_argument("--today")
    parser.add_argument("--write-template", action="store_true")
    parser.add_argument(
        "--prepare-approval",
        action="store_true",
        help="Create or update a pending approval ledger entry with patch and target baseline hashes.",
    )
    parser.add_argument("--apply", action="store_true", help="Write the patch after every approval and allowlist check passes.")
    parser.add_argument("--run-verification", action="store_true")
    parser.add_argument(
        "--no-rollback-on-failure",
        dest="rollback_on_failure",
        action="store_false",
        help="Leave an applied patch in place if verification fails. Default is to reverse the patch.",
    )
    parser.set_defaults(rollback_on_failure=True)
    args = parser.parse_args()

    report = build_report(args)
    print(json.dumps(report, ensure_ascii=False, indent=2))
    raise SystemExit(0 if report["ok"] else 2)


if __name__ == "__main__":
    main()
