#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12,<3.14"
# dependencies = ["pydantic>=2,<3"]
# ///

# ─── How to run ───
#   uv run scripts/verify_nutricoach_v150_candidate.py --base BASE.json \
#     --successor CANDIDATE_DIR --manifest CANDIDATE_DIR/manifest.json
# ──────────────────

"""Verify the immutable NutriCoach v1.5 combined candidate."""

from __future__ import annotations

import argparse
import json
import stat
import sys
import zipfile
from pathlib import Path
from typing import Final

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

from scripts.verify_nutricoach_v140_candidate_core import (
    CandidateContractError,
    JsonValue,
    canonical,
    inventory_digest,
    load_json,
    require_digest,
    require_list,
    require_object,
    require_string,
    sha256_bytes,
    sha256_file,
    verify_hash_entries,
)
from scripts.verify_nutricoach_v150_candidate_inputs import verify_v17_overlay

_V17_EVIDENCE_MANIFEST_SHA256: Final = (
    "2f799ea17dfad3b71ce3e07ea78b140f50afeae10b16140a3659f74b84e5dfd7"
)
_V17_SANDBOX_DIFF_SHA256: Final = (
    "d03fd62f2d28847c9e31566eb23517211ebc236fdc3af97ffc9388dd2b868777"
)
_V17_SOURCE_SET_SHA256: Final = (
    "05def47bc84eedc249dc424b91e25f0a66963d8b6b381cb3a8dad9d3f288a55d"
)


def _parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    _ = parser.add_argument("--base", required=True, type=Path)
    _ = parser.add_argument("--successor", required=True, type=Path)
    _ = parser.add_argument("--manifest", required=True, type=Path)
    return parser


class CliNamespace(argparse.Namespace):
    """Mutable argparse destination with statically known path fields."""

    def __init__(self) -> None:
        super().__init__()
        self.base: Path = Path()
        self.successor: Path = Path()
        self.manifest: Path = Path()


def verify_capabilities(manifest: dict[str, JsonValue]) -> None:
    capabilities = require_object(manifest.get("capabilities"), "capabilities")
    channel = require_object(
        capabilities.get("nutricoach_channel_inbox_v1"), "channel inbox"
    )
    multi = require_object(
        capabilities.get("nutricoach_multi_customer_v1"), "multi customer"
    )
    if channel != {"compiled": True, "configured": False, "authorized": False}:
        raise CandidateContractError("Channel Inbox is not compiled default-OFF")
    if multi != {
        "compiled": True,
        "configured": False,
        "authorized": False,
        "authorized_capacity": 0,
        "post_migration_capacity": 5,
    }:
        raise CandidateContractError("multi-customer capability contract is invalid")


def _wheel_member_digest(wheel: Path, member: str) -> str:
    with zipfile.ZipFile(wheel) as archive:
        return sha256_bytes(archive.read(member))


def _verify_wheel_sources(
    source_root: Path, hermes_wheel: Path, profile_wheel: Path
) -> None:
    bindings = {
        hermes_wheel: {
            "gateway/platforms/nutrition_coaching.py": "gateway/platforms/nutrition_coaching.py",
            "gateway/platforms/telegram.py": "gateway/platforms/telegram.py",
            "gateway/platforms/telegram_channel_inbox.py": "gateway/platforms/telegram_channel_inbox.py",
            "gateway/platforms/telegram_channel_inbox_config.py": "gateway/platforms/telegram_channel_inbox_config.py",
        },
        profile_wheel: {
            "checkin_cli/channel_inbox_migration.py": "dualcoach/profile/checkin_cli/channel_inbox_migration.py",
            "checkin_cli/channel_inbox_migration_models.py": "dualcoach/profile/checkin_cli/channel_inbox_migration_models.py",
            "checkin_cli/customer_admin.py": "dualcoach/profile/checkin_cli/customer_admin.py",
            "checkin_cli/customer_coaching.py": "dualcoach/profile/checkin_cli/customer_coaching.py",
            "checkin_cli/multi_customer_admission_migration.py": "dualcoach/profile/checkin_cli/multi_customer_admission_migration.py",
            "checkin_cli/multi_customer_admission_migration_models.py": "dualcoach/profile/checkin_cli/multi_customer_admission_migration_models.py",
        },
    }
    for wheel, members in bindings.items():
        for member, relative in members.items():
            if _wheel_member_digest(wheel, member) != sha256_file(
                source_root / relative
            ):
                raise CandidateContractError(f"wheel source byte mismatch: {member}")


def _verify_exact_file_set(
    root: Path,
    manifest: dict[str, JsonValue],
) -> None:
    expected = {"manifest.json", "qualification.json"}
    for name in (
        "source_inventory",
        "input_inventory",
        "wheel_inventory",
        "evidence_inventory",
    ):
        for raw in require_list(manifest.get(name), name):
            entry = require_object(raw, name)
            expected.add(require_string(entry.get("path"), f"{name}.path"))
    actual = {
        path.relative_to(root).as_posix()
        for path in root.rglob("*")
        if path.is_file() and not path.is_symlink()
    }
    if actual != expected:
        raise CandidateContractError("candidate physical inventory mismatch")


def _verify_immutable_files(root: Path) -> None:
    for path in root.rglob("*"):
        if path.is_symlink():
            continue
        mode = stat.S_IMODE(path.stat(follow_symlinks=False).st_mode)
        if mode & 0o222:
            raise CandidateContractError("candidate contains writable entry")


def verify(base_path: Path, root: Path, manifest_path: Path) -> str:
    manifest = require_object(load_json(manifest_path), "manifest")
    if manifest.get("schema") != "nutricoach-v150-combined-candidate-manifest-v1":
        raise CandidateContractError("candidate manifest schema is invalid")
    if manifest.get("status") != "QUALIFIED_PENDING_LIVE_AUTHORIZATION":
        raise CandidateContractError("candidate status is not qualified")
    verify_capabilities(manifest)
    source_entries = verify_hash_entries(
        root, manifest.get("source_inventory"), "source_inventory"
    )
    _ = verify_hash_entries(root, manifest.get("input_inventory"), "input_inventory")
    wheel_entries = verify_hash_entries(
        root, manifest.get("wheel_inventory"), "wheel_inventory"
    )
    evidence_entries = verify_hash_entries(
        root, manifest.get("evidence_inventory"), "evidence_inventory"
    )
    _verify_exact_file_set(root, manifest)
    _verify_immutable_files(root)
    verify_v17_overlay(root, source_entries)

    derivation = require_object(manifest.get("derivation_inputs"), "derivation_inputs")
    base = require_object(load_json(base_path), "base manifest")
    base_identities = require_object(base.get("identities"), "base identities")
    expected: dict[str, JsonValue] = {
        "base_candidate_digest": require_digest(
            base_identities.get("candidate_digest"), "base candidate digest"
        ),
        "base_manifest_sha256": sha256_file(base_path),
        "evidence_digest": inventory_digest(evidence_entries),
        "hermes_wheel_sha256": sha256_file(
            root / require_string(wheel_entries[0].get("path"), "build-1 Hermes")
        ),
        "interpreter_sha256": sha256_file(Path(sys.executable).resolve()),
        "profile_wheel_sha256": sha256_file(
            root / require_string(wheel_entries[1].get("path"), "build-1 profile")
        ),
        "source_tree_digest": inventory_digest(source_entries),
        "successor_overlay_sha256": sha256_file(
            root / "inputs/successor-overlay.sha256"
        ),
        "v17_evidence_manifest_sha256": sha256_file(
            root / "inputs/v17-evidence-files.sha256"
        ),
        "v17_patch_tree_sha256": sha256_file(root / "inputs/v17-patch-tree.sha256"),
        "v17_sandbox_diff_sha256": sha256_file(root / "inputs/v17-sandbox.diff"),
        "v17_source_set_sha256": sha256_file(
            root / "inputs/v17-exact-source-set.sha256"
        ),
    }
    if derivation != expected:
        raise CandidateContractError("canonical product derivation inputs differ")
    if (
        expected["v17_evidence_manifest_sha256"] != _V17_EVIDENCE_MANIFEST_SHA256
        or expected["v17_sandbox_diff_sha256"] != _V17_SANDBOX_DIFF_SHA256
        or expected["v17_source_set_sha256"] != _V17_SOURCE_SET_SHA256
    ):
        raise CandidateContractError("immutable v17 qualification input mismatch")
    candidate = sha256_bytes(canonical(derivation))
    claimed = require_digest(manifest.get("candidate_digest"), "candidate digest")
    component_digests = {
        require_digest(expected["hermes_wheel_sha256"], "derived Hermes wheel digest"),
        require_digest(
            expected["profile_wheel_sha256"], "derived profile wheel digest"
        ),
        require_digest(expected["source_tree_digest"], "derived source digest"),
    }
    if candidate != claimed or candidate in component_digests:
        raise CandidateContractError("full-product candidate identity is invalid")
    if sha256_file(root / "inputs/base-manifest.json") != sha256_file(base_path):
        raise CandidateContractError("snapshot base manifest mismatch")
    if len(wheel_entries) != 4:
        raise CandidateContractError("exactly two detached wheel pairs are required")
    wheels = [
        root / require_string(entry.get("path"), "wheel path")
        for entry in wheel_entries
    ]
    if sha256_file(wheels[0]) != sha256_file(wheels[2]):
        raise CandidateContractError("Hermes wheel builds are not reproducible")
    if sha256_file(wheels[1]) != sha256_file(wheels[3]):
        raise CandidateContractError("profile wheel builds are not reproducible")
    source_root = Path(__file__).resolve().parents[1]
    for entry in source_entries:
        relative = Path(require_string(entry.get("path"), "source path"))
        product_relative = Path(*relative.parts[2:])
        if sha256_file(source_root / product_relative) != require_digest(
            entry.get("sha256"), "source hash"
        ):
            raise CandidateContractError(f"working source mismatch: {product_relative}")
    _verify_wheel_sources(source_root, wheels[0], wheels[1])
    qualification = require_object(
        load_json(root / "qualification.json"), "qualification"
    )
    if qualification != {
        "candidate_digest": candidate,
        "status": "QUALIFIED_PENDING_LIVE_AUTHORIZATION",
    }:
        raise CandidateContractError("qualification binding is invalid")
    return candidate


def main() -> int:
    args = _parser().parse_args(namespace=CliNamespace())
    try:
        candidate = verify(args.base, args.successor, args.manifest)
    except (CandidateContractError, OSError, ValueError, zipfile.BadZipFile) as error:
        print(f"NUTRICOACH_V150_CANDIDATE_FAIL:{error}")
        return 1
    print(json.dumps({"candidate_digest": candidate}, sort_keys=True))
    print("NUTRICOACH_V150_CANDIDATE_PASS")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
