#!/usr/bin/env python3
"""Copy-only CARBON failure-injection run state and sandbox operations."""

from __future__ import annotations

import argparse
import datetime as dt
import hashlib
import json
import os
import re
import secrets
import shutil
import stat
import tempfile
from pathlib import Path
from typing import Any, Iterable


SCHEMA = "carbon.failure/v1"
MANIFEST_SCHEMA = "carbon.failure-original-manifest/v1"
IGNORED_DIRECTORIES = {
    ".carbon", ".git", ".hg", ".svn", ".idea", ".vscode", "node_modules",
    "vendor", "venv", ".venv", "env", ".tox", ".nox", "__pycache__",
    ".pytest_cache", ".mypy_cache", ".ruff_cache", ".next", ".nuxt",
    "coverage", ".coverage", "dist", "build", "target",
}
SENSITIVE_NAMES = {
    ".env", ".env.local", ".env.production", ".npmrc", ".pypirc",
    "credentials.json", "service-account.json", "secrets.json",
}
INJECTION_KINDS = {
    "delete", "empty", "truncate", "invalid-json", "corrupt", "malformed-utf8",
    "permission-denied", "replace-text", "rename",
}
SCENARIO_TYPES = {
    "http-400", "http-401", "http-403", "http-404", "http-409", "http-422",
    "http-429", "http-500", "http-502", "http-503", "http-504", "timeout",
    "latency", "connection-reset", "offline", "malformed-response", "partial-response",
    "expired-auth", "dependency-down", "read-only-storage", "quota-exceeded",
    "process-restart", "clock-skew",
}


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


def atomic_json(path: Path, value: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            json.dump(value, handle, indent=2, ensure_ascii=False)
            handle.write("\n")
        os.replace(temporary_name, path)
    finally:
        if os.path.exists(temporary_name):
            os.unlink(temporary_name)


def append_jsonl(path: Path, value: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("a", encoding="utf-8") as handle:
        handle.write(json.dumps(value, ensure_ascii=False) + "\n")


def sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for block in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(block)
    return digest.hexdigest()


def safe_root(value: str) -> Path:
    root = Path(value).expanduser().resolve()
    if not root.is_dir():
        raise SystemExit(f"Project root is not a directory: {root}")
    forbidden = {Path("/").resolve(), Path.home().resolve()}
    if root in forbidden:
        raise SystemExit("Refusing to copy a filesystem or home-directory root. Choose a project directory.")
    return root


def relative_artifacts(root: Path, values: list[str]) -> list[Path]:
    if not values:
        return [Path(".")]
    selected: list[Path] = []
    for value in values:
        candidate = Path(value).expanduser()
        absolute = candidate.resolve() if candidate.is_absolute() else (root / candidate).resolve()
        if not absolute.is_relative_to(root):
            raise SystemExit(f"Artifact escapes the project root: {value}")
        if not absolute.exists() and not absolute.is_symlink():
            raise SystemExit(f"Artifact does not exist: {absolute}")
        relative = absolute.relative_to(root)
        if relative.parts and relative.parts[0] == ".carbon":
            raise SystemExit("Existing .carbon state cannot be selected as a failure artifact.")
        selected.append(relative)
    return sorted(set(selected), key=lambda item: item.as_posix())


def ignored(path: Path, selected_root: Path | None = None) -> bool:
    if path.name in SENSITIVE_NAMES:
        return True
    effective = path
    if selected_root is not None and selected_root != Path("."):
        try:
            effective = path.relative_to(selected_root)
        except ValueError:
            effective = path
    return any(part in IGNORED_DIRECTORIES for part in effective.parts)


def selected_entries(root: Path, selected: Iterable[Path]) -> list[tuple[Path, Path]]:
    entries: dict[str, tuple[Path, Path]] = {}
    for relative in selected:
        source = root / relative
        if source.is_symlink() or source.is_file():
            if not ignored(relative, relative):
                entries[relative.as_posix()] = (relative, source)
            continue
        for current_root, directory_names, file_names in os.walk(source, followlinks=False):
            current = Path(current_root)
            relative_current = current.relative_to(root)
            directory_names[:] = [
                name for name in directory_names
                if not ignored(relative_current / name, relative)
            ]
            for name in directory_names:
                candidate = current / name
                if candidate.is_symlink():
                    rel = candidate.relative_to(root)
                    entries[rel.as_posix()] = (rel, candidate)
            directory_names[:] = [name for name in directory_names if not (current / name).is_symlink()]
            for name in file_names:
                candidate = current / name
                rel = candidate.relative_to(root)
                if not ignored(rel, relative):
                    entries[rel.as_posix()] = (rel, candidate)
    return [entries[key] for key in sorted(entries)]


def manifest(root: Path, selected: list[Path]) -> tuple[dict[str, Any], list[tuple[Path, Path]]]:
    entries = selected_entries(root, selected)
    rows: list[dict[str, Any]] = []
    total_bytes = 0
    for relative, source in entries:
        if source.is_symlink():
            rows.append({"path": relative.as_posix(), "type": "symlink", "target": os.readlink(source)})
            continue
        size = source.stat().st_size
        total_bytes += size
        rows.append({
            "path": relative.as_posix(),
            "type": "file",
            "size": size,
            "sha256": sha256(source),
        })
    return ({
        "schema": MANIFEST_SCHEMA,
        "root": str(root),
        "artifacts": [path.as_posix() for path in selected],
        "files": rows,
        "fileCount": sum(row["type"] == "file" for row in rows),
        "symlinkCount": sum(row["type"] == "symlink" for row in rows),
        "totalBytes": total_bytes,
        "createdAt": now(),
    }, entries)


def copy_entries(entries: list[tuple[Path, Path]], destination: Path, max_files: int, max_bytes: int) -> dict[str, Any]:
    regular = [(relative, source) for relative, source in entries if not source.is_symlink()]
    total_bytes = sum(source.stat().st_size for _, source in regular)
    if len(regular) > max_files:
        raise SystemExit(f"Copy would exceed --max-files ({len(regular)} > {max_files}). Narrow --artifact scope.")
    if total_bytes > max_bytes:
        raise SystemExit(f"Copy would exceed --max-bytes ({total_bytes} > {max_bytes}). Narrow --artifact scope.")
    destination.mkdir(parents=True, exist_ok=False)
    for relative, source in regular:
        target = destination / relative
        target.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(source, target)
    skipped = [relative.as_posix() for relative, source in entries if source.is_symlink()]
    return {"copiedFiles": len(regular), "copiedBytes": total_bytes, "skippedSymlinks": skipped}


def load_run(value: str) -> tuple[Path, dict[str, Any]]:
    run_dir = Path(value).expanduser().resolve()
    state_path = run_dir / "run.json"
    if not state_path.is_file():
        raise SystemExit(f"Failure run not found: {state_path}")
    state = json.loads(state_path.read_text(encoding="utf-8"))
    if state.get("schema") != SCHEMA:
        raise SystemExit(f"Unsupported failure run schema: {state.get('schema')}")
    root = Path(state["root"]).resolve()
    expected = (root / ".carbon" / "failure").resolve()
    if not run_dir.is_relative_to(expected):
        raise SystemExit("Failure run is outside the owning project's .carbon/failure directory.")
    return run_dir, state


def sandbox_target(run_dir: Path, value: str, must_exist: bool = True) -> Path:
    sandbox = (run_dir / "sandbox").resolve()
    candidate = (sandbox / value).resolve()
    if not candidate.is_relative_to(sandbox):
        raise SystemExit(f"Injection path escapes the sandbox: {value}")
    if candidate.is_symlink():
        raise SystemExit("Refusing to inject through a symlink.")
    if must_exist and not candidate.exists():
        raise SystemExit(f"Sandbox target does not exist: {candidate}")
    return candidate


def save_state(run_dir: Path, state: dict[str, Any]) -> None:
    state["updatedAt"] = now()
    atomic_json(run_dir / "run.json", state)


def command_prepare(args: argparse.Namespace) -> None:
    root = safe_root(args.root)
    selected = relative_artifacts(root, args.artifact)
    original_manifest, entries = manifest(root, selected)
    run_id = args.id or f"failure-{dt.datetime.now(dt.timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{secrets.token_hex(3)}"
    if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}", run_id):
        raise SystemExit("Failure run id contains unsafe characters.")
    run_dir = root / ".carbon" / "failure" / run_id
    if run_dir.exists():
        raise SystemExit(f"Failure run already exists: {run_dir}")
    run_dir.mkdir(parents=True)
    try:
        copy_result = copy_entries(entries, run_dir / "baseline", args.max_files, args.max_bytes)
        shutil.copytree(run_dir / "baseline", run_dir / "sandbox")
        atomic_json(run_dir / "original-manifest.json", original_manifest)
        created = now()
        state = {
            "schema": SCHEMA,
            "id": run_id,
            "root": str(root),
            "scope": str(args.scope).strip()[:2000],
            "artifacts": original_manifest["artifacts"],
            "status": "ready",
            "createdAt": created,
            "updatedAt": created,
            "baselineDir": str((run_dir / "baseline").resolve()),
            "sandboxDir": str((run_dir / "sandbox").resolve()),
            "originalManifest": str((run_dir / "original-manifest.json").resolve()),
            "copy": copy_result,
            "excludedDirectories": sorted(IGNORED_DIRECTORIES),
            "excludedSensitiveNames": sorted(SENSITIVE_NAMES),
            "injections": [],
            "scenarios": [],
            "observations": [],
            "originalIntegrity": {"status": "not-yet-verified", "changedPaths": []},
            "safetyPolicy": "copy-only; inject only inside sandboxDir; never modify original artifacts",
        }
        save_state(run_dir, state)
    except Exception:
        shutil.rmtree(run_dir, ignore_errors=True)
        raise
    print(json.dumps({"runDir": str(run_dir), **{key: state[key] for key in ("baselineDir", "sandboxDir", "copy", "safetyPolicy")}}, indent=2))


def command_reset(args: argparse.Namespace) -> None:
    run_dir, state = load_run(args.run_dir)
    baseline = (run_dir / "baseline").resolve()
    sandbox = (run_dir / "sandbox").resolve()
    if not baseline.is_dir() or baseline.parent != run_dir or sandbox.parent != run_dir:
        raise SystemExit("Failure run baseline or sandbox boundary is invalid.")
    if sandbox.exists():
        shutil.rmtree(sandbox)
    shutil.copytree(baseline, sandbox)
    state["status"] = "ready"
    state["lastResetAt"] = now()
    save_state(run_dir, state)
    print(json.dumps({"sandboxDir": str(sandbox), "reset": True}, indent=2))


def command_inject(args: argparse.Namespace) -> None:
    run_dir, state = load_run(args.run_dir)
    kind = args.kind
    target = sandbox_target(run_dir, args.path)
    injection_id = f"F{len(state['injections']) + 1:03d}"
    detail: dict[str, Any] = {"id": injection_id, "kind": kind, "path": args.path, "injectedAt": now()}

    if kind == "delete":
        if target.is_dir():
            shutil.rmtree(target)
        else:
            target.unlink()
    elif kind == "empty":
        if not target.is_file():
            raise SystemExit("empty requires a regular file target")
        target.write_bytes(b"")
    elif kind == "truncate":
        if not target.is_file():
            raise SystemExit("truncate requires a regular file target")
        keep = max(0, args.bytes)
        target.write_bytes(target.read_bytes()[:keep])
        detail["bytesKept"] = keep
    elif kind == "invalid-json":
        if not target.is_file():
            raise SystemExit("invalid-json requires a regular file target")
        target.write_text('{"carbon_failure":', encoding="utf-8")
    elif kind == "corrupt":
        if not target.is_file():
            raise SystemExit("corrupt requires a regular file target")
        data = bytearray(target.read_bytes())
        if data:
            data[0] ^= 0xFF
        else:
            data.extend(b"CARBON_FAILURE_CORRUPTION")
        target.write_bytes(bytes(data))
    elif kind == "malformed-utf8":
        if not target.is_file():
            raise SystemExit("malformed-utf8 requires a regular file target")
        target.write_bytes(b"\xff\xfe\xfaCARBON_FAILURE")
    elif kind == "permission-denied":
        if not target.is_file():
            raise SystemExit("permission-denied requires a regular file target")
        target.chmod(0)
    elif kind == "replace-text":
        if not target.is_file() or args.search is None:
            raise SystemExit("replace-text requires a regular file and --search")
        source = target.read_text(encoding="utf-8")
        if args.search not in source:
            raise SystemExit("--search text was not found in the sandbox target")
        target.write_text(source.replace(args.search, args.replacement, 1), encoding="utf-8")
        detail["replacementApplied"] = True
    elif kind == "rename":
        if not args.destination:
            raise SystemExit("rename requires --destination")
        destination = sandbox_target(run_dir, args.destination, must_exist=False)
        destination.parent.mkdir(parents=True, exist_ok=True)
        if destination.exists():
            raise SystemExit(f"Rename destination already exists: {destination}")
        target.rename(destination)
        detail["destination"] = args.destination
    else:
        raise SystemExit(f"Unsupported injection kind: {kind}")

    state["status"] = "injected"
    state["injections"].append(detail)
    append_jsonl(run_dir / "injections.jsonl", detail)
    save_state(run_dir, state)
    print(json.dumps(detail, indent=2))


def command_scenario(args: argparse.Namespace) -> None:
    run_dir, state = load_run(args.run_dir)
    scenario = {
        "id": f"S{len(state['scenarios']) + 1:03d}",
        "type": args.type,
        "target": str(args.target).strip()[:1000],
        "method": str(args.method).strip()[:2000],
        "expected": str(args.expected).strip()[:3000],
        "status": "planned",
        "createdAt": now(),
    }
    state["scenarios"].append(scenario)
    save_state(run_dir, state)
    print(json.dumps(scenario, indent=2))


def command_observe(args: argparse.Namespace) -> None:
    run_dir, state = load_run(args.run_dir)
    scenario = next((row for row in state["scenarios"] if row["id"] == args.scenario_id), None)
    if not scenario:
        raise SystemExit(f"Unknown failure scenario: {args.scenario_id}")
    observation = {
        "scenarioId": args.scenario_id,
        "status": args.status,
        "actual": str(args.actual).strip()[:5000],
        "evidence": str(args.evidence).strip()[:2000],
        "observedAt": now(),
    }
    scenario["status"] = args.status
    scenario["actual"] = observation["actual"]
    scenario["evidence"] = observation["evidence"]
    state["observations"].append(observation)
    append_jsonl(run_dir / "observations.jsonl", observation)
    save_state(run_dir, state)
    print(json.dumps(observation, indent=2))


def compare_original(state: dict[str, Any], run_dir: Path) -> dict[str, Any]:
    stored = json.loads((run_dir / "original-manifest.json").read_text(encoding="utf-8"))
    root = Path(state["root"]).resolve()
    selected = [Path(value) for value in stored["artifacts"]]
    current, _ = manifest(root, selected)
    stored_rows = {row["path"]: row for row in stored["files"]}
    current_rows = {row["path"]: row for row in current["files"]}
    changed = sorted(
        path for path in set(stored_rows) | set(current_rows)
        if stored_rows.get(path) != current_rows.get(path)
    )
    return {
        "status": "verified-unchanged" if not changed else "changed",
        "verifiedAt": now(),
        "changedPaths": changed,
        "originalFileCount": stored["fileCount"],
        "currentFileCount": current["fileCount"],
    }


def command_verify(args: argparse.Namespace) -> None:
    run_dir, state = load_run(args.run_dir)
    result = compare_original(state, run_dir)
    state["originalIntegrity"] = result
    if result["status"] == "changed":
        state["status"] = "unsafe"
    save_state(run_dir, state)
    print(json.dumps(result, indent=2))
    if result["status"] != "verified-unchanged":
        raise SystemExit(3)


def command_complete(args: argparse.Namespace) -> None:
    run_dir, state = load_run(args.run_dir)
    result = compare_original(state, run_dir)
    state["originalIntegrity"] = result
    if result["status"] != "verified-unchanged":
        state["status"] = "unsafe"
        save_state(run_dir, state)
        print(json.dumps({"status": "unsafe", "originalIntegrity": result}, indent=2))
        raise SystemExit(3)
    state["status"] = "completed"
    state["completedAt"] = now()
    save_state(run_dir, state)
    print(json.dumps({
        "status": "completed",
        "runDir": str(run_dir),
        "originalIntegrity": result,
        "injections": len(state["injections"]),
        "scenarios": len(state["scenarios"]),
        "observations": len(state["observations"]),
        "unexecutedScenarios": [row["id"] for row in state["scenarios"] if row["status"] == "planned"],
    }, indent=2))


def command_status(args: argparse.Namespace) -> None:
    _, state = load_run(args.run_dir)
    print(json.dumps(state, indent=2))


def parser() -> argparse.ArgumentParser:
    root = argparse.ArgumentParser(description=__doc__)
    sub = root.add_subparsers(dest="command", required=True)

    prepare = sub.add_parser("prepare", help="Create immutable baseline and working sandbox copies.")
    prepare.add_argument("--root", required=True)
    prepare.add_argument("--artifact", action="append", default=[])
    prepare.add_argument("--scope", default="current project failure handling")
    prepare.add_argument("--id")
    prepare.add_argument("--max-files", type=int, default=20_000)
    prepare.add_argument("--max-bytes", type=int, default=2_000_000_000)
    prepare.set_defaults(func=command_prepare)

    reset = sub.add_parser("reset", help="Replace the working sandbox from the immutable baseline copy.")
    reset.add_argument("--run-dir", required=True)
    reset.set_defaults(func=command_reset)

    inject = sub.add_parser("inject", help="Inject a deterministic file failure inside the sandbox only.")
    inject.add_argument("--run-dir", required=True)
    inject.add_argument("--kind", required=True, choices=sorted(INJECTION_KINDS))
    inject.add_argument("--path", required=True)
    inject.add_argument("--bytes", type=int, default=0)
    inject.add_argument("--search")
    inject.add_argument("--replacement", default="")
    inject.add_argument("--destination")
    inject.set_defaults(func=command_inject)

    scenario = sub.add_parser("scenario", help="Register a runtime or dependency failure scenario.")
    scenario.add_argument("--run-dir", required=True)
    scenario.add_argument("--type", required=True, choices=sorted(SCENARIO_TYPES))
    scenario.add_argument("--target", required=True)
    scenario.add_argument("--method", required=True)
    scenario.add_argument("--expected", default="")
    scenario.set_defaults(func=command_scenario)

    observe = sub.add_parser("observe", help="Record observed behavior for a registered scenario.")
    observe.add_argument("--run-dir", required=True)
    observe.add_argument("--scenario-id", required=True)
    observe.add_argument("--status", required=True, choices=("passed", "failed", "blocked", "partial"))
    observe.add_argument("--actual", required=True)
    observe.add_argument("--evidence", default="")
    observe.set_defaults(func=command_observe)

    verify = sub.add_parser("verify", help="Verify selected original artifacts still match their hashes.")
    verify.add_argument("--run-dir", required=True)
    verify.set_defaults(func=command_verify)

    complete = sub.add_parser("complete", help="Verify originals and close the failure run.")
    complete.add_argument("--run-dir", required=True)
    complete.set_defaults(func=command_complete)

    status = sub.add_parser("status", help="Print durable failure-run state.")
    status.add_argument("--run-dir", required=True)
    status.set_defaults(func=command_status)
    return root


if __name__ == "__main__":
    arguments = parser().parse_args()
    arguments.func(arguments)
