#!/usr/bin/env python3
import argparse
import hashlib
import json
import os
import platform
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any


RIR_FIXTURES = {
    "afrinic": {
        "tal": "tal/afrinic.tal",
        "ta": "ta/afrinic-ta.cer",
    },
    "apnic": {
        "tal": "tal/apnic-rfc7730-https.tal",
        "ta": "ta/apnic-ta.cer",
    },
    "arin": {
        "tal": "tal/arin.tal",
        "ta": "ta/arin-ta.cer",
    },
    "lacnic": {
        "tal": "tal/lacnic.tal",
        "ta": "ta/lacnic-ta.cer",
    },
    "ripe": {
        "tal": "tal/ripe-ncc.tal",
        "ta": "ta/ripe-ncc-ta.cer",
    },
}


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


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


def read_tal_uris(path: Path) -> list[str]:
    uris: list[str] = []
    with path.open("r", encoding="utf-8") as file:
        for line in file:
            item = line.strip()
            if not item or item.startswith("#"):
                if uris:
                    break
                continue
            if item.startswith(("rsync://", "https://", "http://")):
                uris.append(item)
                continue
            if uris:
                break
    return uris


def first_uri(uris: list[str], prefixes: tuple[str, ...]) -> str | None:
    for uri in uris:
        if uri.startswith(prefixes):
            return uri
    return None


def parse_rirs(raw: str) -> list[str]:
    rirs = [item.strip().lower() for item in raw.split(",") if item.strip()]
    if not rirs:
        raise SystemExit("RIR list must not be empty")
    invalid = [item for item in rirs if item not in RIR_FIXTURES]
    if invalid:
        raise SystemExit(
            f"invalid RIR(s): {','.join(invalid)}; allowed: {','.join(RIR_FIXTURES)}"
        )
    return rirs


def rel_or_abs(path: Path, root: Path | None) -> str:
    path = path.resolve()
    if root is not None:
        try:
            return path.relative_to(root.resolve()).as_posix()
        except ValueError:
            pass
    return path.as_posix()


def git_commit(repo_root: Path) -> str | None:
    try:
        return subprocess.check_output(
            ["git", "-C", str(repo_root), "rev-parse", "--short", "HEAD"],
            text=True,
            stderr=subprocess.DEVNULL,
        ).strip()
    except (subprocess.CalledProcessError, FileNotFoundError):
        return None


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


def build_fixture_proof(
    fixture_dir: Path,
    rirs: list[str],
    repo_root: Path | None,
    ta_online_fetch_observed: bool,
) -> dict[str, Any]:
    trust_anchors = []
    for rir in rirs:
        mapping = RIR_FIXTURES[rir]
        tal_path = fixture_dir / mapping["tal"]
        ta_path = fixture_dir / mapping["ta"]
        if not tal_path.is_file():
            raise SystemExit(f"missing TAL fixture for {rir}: {tal_path}")
        if not ta_path.is_file():
            raise SystemExit(f"missing TA fixture for {rir}: {ta_path}")
        tal_uris = read_tal_uris(tal_path)
        trust_anchors.append(
            {
                "rir": rir,
                "talPath": rel_or_abs(tal_path, repo_root),
                "taPath": rel_or_abs(ta_path, repo_root),
                "talUri": first_uri(tal_uris, ("https://", "http://")),
                "taRsyncUri": first_uri(tal_uris, ("rsync://",)),
                "talSha256": sha256_file(tal_path),
                "taCertificateSha256": sha256_file(ta_path),
                "talBytes": tal_path.stat().st_size,
                "taCertificateBytes": ta_path.stat().st_size,
                "taFixturePinned": not ta_online_fetch_observed,
                "taOnlineFetchObserved": ta_online_fetch_observed,
            }
        )
    return {
        "schemaVersion": 1,
        "generatedBy": "feature035-experiment-driver",
        "generatedAtUtc": utc_now(),
        "fixtureDir": rel_or_abs(fixture_dir, repo_root),
        "all5": set(rirs) == set(RIR_FIXTURES),
        "rirs": rirs,
        "trustAnchors": trust_anchors,
    }


def parse_csv(raw: str) -> list[str]:
    if not raw:
        return []
    return [item.strip() for item in raw.split(",") if item.strip()]


def optional_path(raw: str | None, repo_root: Path | None) -> str | None:
    if raw is None:
        return None
    return rel_or_abs(Path(raw), repo_root)


def build_run_meta(args: argparse.Namespace) -> dict[str, Any]:
    rirs = parse_rirs(args.rirs)
    repo_root = Path(args.repo_root).resolve() if args.repo_root else None
    argv = json.loads(args.argv_json) if args.argv_json else []
    env_whitelist = json.loads(args.env_json) if args.env_json else {}
    fixture_proof_summary = (
        json.loads(args.fixture_proof_summary_json)
        if args.fixture_proof_summary_json
        else None
    )
    fixture_proof = None
    if args.fixture_proof:
        fixture_proof_path = Path(args.fixture_proof)
        if fixture_proof_path.is_file():
            fixture_proof = json.loads(fixture_proof_path.read_text(encoding="utf-8"))
    if not isinstance(argv, list):
        raise SystemExit("--argv-json must decode to a JSON array")
    if not isinstance(env_whitelist, dict):
        raise SystemExit("--env-json must decode to a JSON object")
    if fixture_proof_summary is not None and not isinstance(fixture_proof_summary, dict):
        raise SystemExit("--fixture-proof-summary-json must decode to a JSON object")

    return {
        "schemaVersion": 1,
        "generatedBy": "feature035-experiment-driver",
        "generatedAtUtc": utc_now(),
        "experimentId": args.experiment_id,
        "side": args.side,
        "sideLabel": args.side_label,
        "step": args.step,
        "runId": args.run_id,
        "liveRun": not args.replay_used,
        "replayUsed": args.replay_used,
        "rp": {
            "kind": args.rp_kind,
            "binary": args.rp_binary,
            "version": args.rp_version,
            "gitCommit": args.rp_git_commit,
            "mode": args.rp_mode,
            "protocolMode": args.protocol_mode,
            "strictPolicies": parse_csv(args.strict_policies),
        },
        "scope": {
            "rirs": rirs,
            "all5": set(rirs) == set(RIR_FIXTURES),
        },
        "command": {
            "argv": argv,
            "cwd": args.cwd,
            "envWhitelist": env_whitelist,
        },
        "state": {
            "resetBeforeRun": args.reset_before_run,
            "stateRoot": args.state_root,
            "db": args.db,
            "repoBytesDb": args.repo_bytes_db,
            "rawStoreDb": args.raw_store_db,
            "rsyncMirrorRoot": args.rsync_mirror_root,
            "cacheRoot": args.cache_root,
        },
        "artifacts": {
            "ccr": optional_path(args.ccr, repo_root),
            "cir": optional_path(args.cir, repo_root),
            "runMeta": optional_path(str(args.out), repo_root),
            "fixtureProof": optional_path(args.fixture_proof, repo_root),
            "reportJson": optional_path(args.report_json, repo_root),
            "stageTimingJson": optional_path(args.stage_timing_json, repo_root),
            "stdoutLog": optional_path(args.stdout_log, repo_root),
            "stderrLog": optional_path(args.stderr_log, repo_root),
            "processTime": optional_path(args.process_time, repo_root),
            "vrpsCsv": optional_path(args.vrps_csv, repo_root),
            "vapsCsv": optional_path(args.vaps_csv, repo_root),
        },
        "fixtureProof": fixture_proof,
        "fixtureProofSummary": fixture_proof_summary,
        "metrics": {
            "exitCode": args.exit_code,
            "wallMs": args.wall_ms,
            "maxRssKb": args.max_rss_kb,
            "vrps": args.vrps,
            "vaps": args.vaps,
            "publicationPoints": args.publication_points,
            "warnings": args.warnings,
            "cirObjectCount": args.cir_object_count,
            "cirRejectCount": args.cir_reject_count,
            "cirTrustAnchorCount": args.cir_trust_anchor_count,
            "ccrStateDigest": args.ccr_state_digest,
        },
        "environment": {
            "host": args.host or platform.node(),
            "platform": args.platform or platform.platform(),
        },
    }


def command_fixture_proof(args: argparse.Namespace) -> None:
    repo_root = Path(args.repo_root).resolve() if args.repo_root else None
    proof = build_fixture_proof(
        fixture_dir=Path(args.fixture_dir),
        rirs=parse_rirs(args.rirs),
        repo_root=repo_root,
        ta_online_fetch_observed=args.ta_online_fetch_observed,
    )
    write_json(Path(args.out), proof)


def command_run_meta(args: argparse.Namespace) -> None:
    write_json(Path(args.out), build_run_meta(args))


def command_dry_run_bundle(args: argparse.Namespace) -> None:
    out_dir = Path(args.out_dir)
    repo_root = Path(args.repo_root).resolve() if args.repo_root else Path.cwd().resolve()
    fixture_proof = out_dir / "fixture-proof.json"
    command_fixture_proof(
        argparse.Namespace(
            fixture_dir=args.fixture_dir,
            rirs=args.rirs,
            repo_root=str(repo_root),
            out=str(fixture_proof),
            ta_online_fetch_observed=False,
        )
    )
    for side, side_label in (("left", "A"), ("right", "B")):
        run_dir = out_dir / side_label / "snapshot"
        meta_args = argparse.Namespace(
            out=run_dir / "run-meta.json",
            repo_root=str(repo_root),
            experiment_id=args.experiment_id,
            side=side,
            side_label=side_label,
            step="snapshot",
            run_id=f"{side_label}-snapshot-dry-run",
            replay_used=False,
            rp_kind="ours" if side_label == "A" else "rpki-client",
            rp_binary=f"bin/{'rpki' if side_label == 'A' else 'rpki-client'}",
            rp_version="dry-run",
            rp_git_commit=git_commit(repo_root),
            rp_mode="standard",
            protocol_mode="rrdp+rsync",
            strict_policies="",
            rirs=args.rirs,
            argv_json=json.dumps(["dry-run"]),
            env_json=json.dumps({"RPKI_PROGRESS_LOG": "1"}),
            cwd=str(out_dir),
            reset_before_run=True,
            state_root=str(out_dir / side_label / "state"),
            db=str(out_dir / side_label / "state" / "work-db"),
            repo_bytes_db=str(out_dir / side_label / "state" / "repo-bytes.db"),
            raw_store_db=str(out_dir / side_label / "state" / "raw-store.db"),
            rsync_mirror_root=str(out_dir / side_label / "state" / "rsync-mirror"),
            cache_root=str(out_dir / side_label / "state" / "cache"),
            ccr=str(run_dir / "result.ccr"),
            cir=str(run_dir / "result.cir"),
            fixture_proof=str(fixture_proof),
            report_json=str(run_dir / "report.json"),
            stage_timing_json=str(run_dir / "stage-timing.json"),
            stdout_log=str(run_dir / "stdout.log"),
            stderr_log=str(run_dir / "stderr.log"),
            process_time=str(run_dir / "process-time.txt"),
            vrps_csv=str(run_dir / "vrps.csv"),
            vaps_csv=str(run_dir / "vaps.csv"),
            exit_code=0,
            wall_ms=0,
            max_rss_kb=0,
            vrps=0,
            vaps=0,
            publication_points=0,
            warnings=0,
            cir_object_count=0,
            cir_reject_count=0,
            cir_trust_anchor_count=len(parse_rirs(args.rirs)),
            ccr_state_digest=None,
            fixture_proof_summary_json=json.dumps(
                {
                    "taFixturePinned": True,
                    "taOnlineFetchObserved": False,
                    "trustAnchorCount": len(parse_rirs(args.rirs)),
                }
            ),
        )
        command_run_meta(meta_args)


def add_run_meta_args(parser: argparse.ArgumentParser) -> None:
    parser.add_argument("--out", required=True)
    parser.add_argument("--repo-root")
    parser.add_argument("--experiment-id", required=True)
    parser.add_argument("--side", choices=["left", "right"], required=True)
    parser.add_argument("--side-label", choices=["A", "B"], required=True)
    parser.add_argument("--step", choices=["snapshot", "delta"], required=True)
    parser.add_argument("--run-id", required=True)
    parser.add_argument("--replay-used", action="store_true")
    parser.add_argument("--rp-kind", required=True)
    parser.add_argument("--rp-binary", required=True)
    parser.add_argument("--rp-version")
    parser.add_argument("--rp-git-commit")
    parser.add_argument("--rp-mode", default="standard")
    parser.add_argument("--protocol-mode", default="rrdp+rsync")
    parser.add_argument("--strict-policies", default="")
    parser.add_argument("--rirs", default="afrinic,apnic,arin,lacnic,ripe")
    parser.add_argument("--argv-json")
    parser.add_argument("--env-json")
    parser.add_argument("--cwd", default=os.getcwd())
    parser.add_argument("--reset-before-run", action="store_true")
    parser.add_argument("--state-root")
    parser.add_argument("--db")
    parser.add_argument("--repo-bytes-db")
    parser.add_argument("--raw-store-db")
    parser.add_argument("--rsync-mirror-root")
    parser.add_argument("--cache-root")
    parser.add_argument("--ccr")
    parser.add_argument("--cir")
    parser.add_argument("--fixture-proof")
    parser.add_argument("--fixture-proof-summary-json")
    parser.add_argument("--report-json")
    parser.add_argument("--stage-timing-json")
    parser.add_argument("--stdout-log")
    parser.add_argument("--stderr-log")
    parser.add_argument("--process-time")
    parser.add_argument("--vrps-csv")
    parser.add_argument("--vaps-csv")
    parser.add_argument("--exit-code", type=int)
    parser.add_argument("--wall-ms", type=int)
    parser.add_argument("--max-rss-kb", type=int)
    parser.add_argument("--vrps", type=int)
    parser.add_argument("--vaps", type=int)
    parser.add_argument("--publication-points", type=int)
    parser.add_argument("--warnings", type=int)
    parser.add_argument("--cir-object-count", type=int)
    parser.add_argument("--cir-reject-count", type=int)
    parser.add_argument("--cir-trust-anchor-count", type=int)
    parser.add_argument("--ccr-state-digest")
    parser.add_argument("--host")
    parser.add_argument("--platform")


def main() -> None:
    parser = argparse.ArgumentParser(description="Feature #035 CCR/CIR experiment bundle helpers")
    subparsers = parser.add_subparsers(dest="command", required=True)

    fixture = subparsers.add_parser("fixture-proof")
    fixture.add_argument("--fixture-dir", default="tests/fixtures")
    fixture.add_argument("--rirs", default="afrinic,apnic,arin,lacnic,ripe")
    fixture.add_argument("--repo-root")
    fixture.add_argument("--out", required=True)
    fixture.add_argument("--ta-online-fetch-observed", action="store_true")
    fixture.set_defaults(func=command_fixture_proof)

    run_meta = subparsers.add_parser("run-meta")
    add_run_meta_args(run_meta)
    run_meta.set_defaults(func=command_run_meta)

    dry_run = subparsers.add_parser("dry-run-bundle")
    dry_run.add_argument("--out-dir", required=True)
    dry_run.add_argument("--repo-root", default=".")
    dry_run.add_argument("--fixture-dir", default="tests/fixtures")
    dry_run.add_argument("--rirs", default="afrinic,apnic,arin,lacnic,ripe")
    dry_run.add_argument("--experiment-id", default="m2-dry-run")
    dry_run.set_defaults(func=command_dry_run_bundle)

    args = parser.parse_args()
    args.func(args)


if __name__ == "__main__":
    main()
