"""Local, project-scoped run storage — `.abrade/runs/<id>.json` next to
wherever the user runs the CLI from, the same way `.git/` works. Nothing
here ever leaves the local filesystem (hard constraint #4).
"""

import json
from dataclasses import asdict
from pathlib import Path

from abrade_cli.models import Posture, ResultRow, RunRecord, SuiteMode, Verdict
from abrade_cli.safeio import (
    create_secret_file_exclusively,
    secure_dir,
    write_secret_text,
)

RUNS_DIR = Path(".abrade") / "runs"


def _run_path(run_id: str, base: Path | None = None) -> Path:
    root = base or Path.cwd()
    return root / RUNS_DIR / f"{run_id}.json"


def save_run(run: RunRecord, base: Path | None = None) -> Path:
    """Write a run's evidence, readable only by its owner.

    These files hold the agent's raw replies, the injected telemetry, and
    the planted canaries — the sensitive material this tool exists to
    detect. Default permissions put that at 0644 under a typical 022
    umask, readable by every account on the machine, which on a shared CI
    runner or a developer workstation is a real disclosure.

    The `.gitignore` matters for the same reason. This repository has one
    covering `.abrade/`; a customer's does not, so `git add -A` after a
    run would commit the evidence. Writing it alongside the directory
    makes that safe by default rather than by documentation.
    """
    path = _run_path(run.id, base)
    secure_dir(path.parent)
    _ensure_gitignore(path.parent)

    # Atomic, 0600 from creation, and refuses to write through a symlink.
    # The previous version opened the real path with O_TRUNC, so a crash
    # mid-write left a truncated file that still looked like the run — and
    # crash corruption, unlike symlink redirection, needs no attacker.
    return write_secret_text(path, json.dumps(asdict(run), indent=2, default=str))


def _ensure_gitignore(runs_dir: Path) -> None:
    """Keep run evidence out of the customer's repository.

    Exclusive-create rather than exists()-then-write: those are two
    operations, and between them another process can create the file, at
    which point this one silently truncates whatever was written. It also
    means an existing .gitignore is never overwritten, which was the
    intent of the original `exists()` check.
    """
    create_secret_file_exclusively(
        runs_dir.parent / ".gitignore",
        "# Run evidence contains raw agent replies, injected telemetry\n"
        "# and planted canaries. Not for version control.\n"
        "*\n",
    )


def load_run(run_id: str, base: Path | None = None) -> RunRecord:
    path = _run_path(run_id, base)
    if not path.exists():
        raise FileNotFoundError(f"No local run found with id {run_id!r} ({path}).")
    data = json.loads(path.read_text())
    results = [
        ResultRow(
            injection_id=r["injection_id"],
            status=r["status"],
            verdict=Verdict(r["verdict"]),
            reason=r["reason"],
            tell=r["tell"],
            agent_reply=r["agent_reply"],
            injected_telemetry=r["injected_telemetry"],
            # .get() with a fallback: runs saved before these fields existed
            # (this session's own earlier local runs, for instance) should
            # still load — abrade list/report keep working either way, only
            # abrade push loses category info for pre-existing files.
            injection_name=r.get("injection_name", ""),
            injection_vector=r.get("injection_vector", ""),
            injection_severity=r.get("injection_severity", ""),
            # Canary evidence was written to disk by save_run (asdict
            # captures every field) and then silently dropped here, so a
            # later `abrade push` uploaded a run with no deterministic
            # evidence at all — the strongest part of the result, lost
            # between saving it and sending it.
            canary_token=r.get("canary_token"),
            canary_exfiltrated=r.get("canary_exfiltrated"),
            canary_type=r.get("canary_type"),
        )
        for r in data["results"]
    ]
    return RunRecord(
        id=data["id"],
        agent_name=data["agent_name"],
        posture=Posture(data["posture"]),
        suite_mode=SuiteMode(data["suite_mode"]),
        model=data["model"],
        started_at=data["started_at"],
        completed_at=data["completed_at"],
        status=data["status"],
        containment=data["containment"],
        results=results,
    )


def latest_run_id(base: Path | None = None) -> str | None:
    """The most recently written run, or None if there are none.

    Ordered by write time rather than by id. Run ids are UUIDs, so sorting
    them alphabetically returns an arbitrary run — and `abrade push
    --latest` picking the wrong one would upload the wrong evidence
    silently, which is worse than failing.
    """
    root = base or Path.cwd()
    runs_dir = root / RUNS_DIR
    if not runs_dir.exists():
        return None
    runs = sorted(runs_dir.glob("*.json"), key=lambda p: p.stat().st_mtime)
    return runs[-1].stem if runs else None


def list_runs(base: Path | None = None) -> list[str]:
    root = base or Path.cwd()
    runs_dir = root / RUNS_DIR
    if not runs_dir.exists():
        return []
    return sorted(p.stem for p in runs_dir.glob("*.json"))
