#!/usr/bin/env python3
"""Validate FriiDump native report fixtures against the v1 contract."""

from __future__ import annotations

import argparse
import json
import subprocess
import sys
import uuid
from pathlib import Path
from typing import Any


def load_json(path: Path) -> Any:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except (OSError, UnicodeError, json.JSONDecodeError) as exc:
        raise RuntimeError(f"Could not decode {path}: {exc}") from exc


def require(condition: bool, message: str) -> None:
    if not condition:
        raise RuntimeError(message)


def semantic_fixture_checks(path: Path, report: dict[str, Any]) -> None:
    run = report["run"]
    dump = report["dump"]
    notes = report["notes"]

    parsed_uuid = uuid.UUID(run["run_id"])
    require(parsed_uuid.int != 0, f"{path.name}: nil run UUID")

    scope_notes = [
        note for note in notes
        if isinstance(note, str) and note.lower().startswith("measurement scope:")
    ]

    if dump["attempted"]:
        require(len(scope_notes) == 1,
                f"{path.name}: attempted dump must have exactly one measurement scope")

    artifacts = report["artifacts"]
    dump_artifacts = [
        artifact for artifact in artifacts
        if isinstance(artifact, dict) and artifact.get("type") == "dump_output"
    ]

    if dump["attempted"] and dump.get("output_path"):
        require(len(dump_artifacts) == 1,
                f"{path.name}: attempted dump with output must have one dump_output artifact")
        artifact = dump_artifacts[0]
        require(artifact["path"] == dump["output_path"],
                f"{path.name}: dump_output path mismatch")
        if dump.get("byte_count") is not None:
            require(artifact["bytes"] == dump["byte_count"],
                    f"{path.name}: dump_output byte count mismatch")
        if report["hashes"].get("sha256") is not None:
            require(artifact["sha256"] == report["hashes"]["sha256"],
                    f"{path.name}: dump_output SHA-256 mismatch")

    if path.name == "success-gamecube.friidump.json":
        require(run == {**run, "test_type": "full_dump", "result": "pass"},
                f"{path.name}: success outcome mismatch")
        require("Measurement scope: full_optical_payload." in notes,
                f"{path.name}: optical scope missing")

    if path.name == "success-xbox-assembled.friidump.json":
        require("Measurement scope: assembled_output." in notes,
                f"{path.name}: assembled-output scope missing")
        require(report["drive"]["firmware_modified"] is True,
                f"{path.name}: modified-firmware identity missing")
        require(report["media"]["region"] == "North America",
                f"{path.name}: Xbox XBE region mismatch")

    if path.name == "modified-firmware.friidump.json":
        require("UTF-8 preservation fixture: Pokémon." in notes,
                f"{path.name}: UTF-8 text was not preserved")

    if path.name == "partial-cancelled.friidump.json":
        require(run["test_type"] == "partial_dump" and run["result"] == "partial",
                f"{path.name}: cancellation outcome mismatch")
        require(dump["result"] == "partial" and
                dump["failure_stage"] == "user_cancelled",
                f"{path.name}: cancellation dump state mismatch")
        require("Measurement scope: partial_progress." in notes,
                f"{path.name}: cancellation scope missing")

    if path.name == "seed-failure.friidump.json":
        require(run["test_type"] == "seed_only" and run["result"] == "fail",
                f"{path.name}: seed-failure outcome mismatch")
        require(dump["result"] == "not_attempted",
                f"{path.name}: seed failure must not claim a dump")

    if path.name == "no-media.friidump.json":
        require(run["test_type"] == "diagnostic_no_media" and
                run["result"] == "not_applicable",
                f"{path.name}: no-media outcome mismatch")


def run_php_validator(php_validator: Path, report_path: Path) -> tuple[int, int]:
    result = subprocess.run(
        ["php", str(php_validator), str(report_path)],
        check=False,
        capture_output=True,
        text=True,
    )
    if result.returncode not in (0, 1):
        raise RuntimeError(
            f"PHP validator failed for {report_path}:\n{result.stdout}\n{result.stderr}"
        )
    try:
        decoded = json.loads(result.stdout)
    except json.JSONDecodeError as exc:
        raise RuntimeError(
            f"PHP validator returned invalid JSON for {report_path}: {exc}"
        ) from exc
    if not decoded.get("valid"):
        raise RuntimeError(
            f"PHP validator rejected {report_path}: {decoded.get('errors')}"
        )
    return len(decoded.get("errors", [])), len(decoded.get("warnings", []))


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--fixtures", type=Path, required=True)
    parser.add_argument(
        "--schema",
        type=Path,
        default=Path(__file__).parent / "contracts" /
                "friidump-test-result.v1.schema.json",
    )
    parser.add_argument("--php-validator", type=Path)
    parser.add_argument(
        "--allow-arbitrary",
        action="store_true",
        help="Validate any report set without requiring the built-in fixture filenames",
    )
    args = parser.parse_args()

    try:
        from jsonschema import Draft202012Validator
    except ImportError as exc:
        raise RuntimeError(
            "The Python 'jsonschema' package is required for strict contract validation"
        ) from exc

    schema = load_json(args.schema)
    validator = Draft202012Validator(schema)
    reports = sorted(args.fixtures.rglob("*.friidump.json"))
    require(bool(reports), f"No .friidump.json fixtures found under {args.fixtures}")

    seen_run_ids: set[str] = set()
    php_warning_total = 0

    for path in reports:
        report = load_json(path)
        errors = sorted(validator.iter_errors(report), key=lambda item: list(item.path))
        if errors:
            rendered = "\n".join(
                f"  {list(error.path)}: {error.message}" for error in errors
            )
            raise RuntimeError(f"JSON Schema rejected {path}:\n{rendered}")

        run_id = report["run"]["run_id"]
        require(run_id not in seen_run_ids, f"Duplicate fixture run UUID: {run_id}")
        seen_run_ids.add(run_id)
        semantic_fixture_checks(path, report)

        php_summary = ""
        if args.php_validator:
            errors_count, warnings_count = run_php_validator(args.php_validator, path)
            php_warning_total += warnings_count
            php_summary = f", php errors={errors_count}, warnings={warnings_count}"

        print(f"PASS {path.relative_to(args.fixtures)}: schema + fixture semantics{php_summary}")

    if not args.allow_arbitrary:
        expected_names = {
            "success-gamecube.friidump.json",
            "success-xbox-assembled.friidump.json",
            "partial-gamecube.friidump.json",
            "partial-cancelled.friidump.json",
            "seed-failure.friidump.json",
            "no-media.friidump.json",
            "modified-firmware.friidump.json",
            "identity-no-overwrite.friidump.json",
        }
        observed_names = {path.name for path in reports}
        missing = expected_names - observed_names
        require(not missing, f"Required fixture reports are missing: {sorted(missing)}")

    print()
    print(f"Native report validation: PASS ({len(reports)} reports)")
    if args.php_validator:
        print(f"Database semantic validator warnings: {php_warning_total}")
        print("The single expected warning is the honest seed-failure fixture without a disc title.")
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except RuntimeError as exc:
        print(f"FAIL: {exc}", file=sys.stderr)
        raise SystemExit(1)
