#!/usr/bin/env python3
"""Verify a Vaultfire V3 verifier bundle without network access."""

from __future__ import annotations

import argparse
import hashlib
import io
import json
import os
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
from pathlib import Path, PurePosixPath
from typing import Any


FORMAT = "vaultfire-verifier-bundle/v2"
ROOT_NAME = "vaultfire-verifier-bundle"
MINIMUM_GIT = (2, 30)
MINIMUM_PYTHON = (3, 9)


class VerificationError(RuntimeError):
    """A verifier bundle failed a mandatory integrity check."""


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


def run(command: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
    return subprocess.run(command, cwd=cwd, text=True, capture_output=True, check=False)


def git(repo: Path, *args: str) -> str:
    completed = run(["git", "-C", str(repo), *args])
    if completed.returncode:
        raise VerificationError(f"git -C {repo} {' '.join(args)} failed: {completed.stderr.strip()}")
    return completed.stdout.strip()


def version_tuple(text: str) -> tuple[int, ...]:
    matched = re.search(r"(\d+)\.(\d+)(?:\.(\d+))?", text)
    if not matched:
        return ()
    return tuple(int(part or "0") for part in matched.groups())


def safe_extract(artifact: Path, destination: Path) -> None:
    with tarfile.open(artifact, "r:gz") as tar:
        names: set[str] = set()
        for member in tar.getmembers():
            member_path = PurePosixPath(member.name)
            if (
                member_path.is_absolute()
                or ".." in member_path.parts
                or member.name in names
                or not (member.name == ROOT_NAME or member.name.startswith(f"{ROOT_NAME}/"))
                or member.issym()
                or member.islnk()
                or not (member.isfile() or member.isdir())
            ):
                raise VerificationError(f"unsafe or duplicate archive member: {member.name!r}")
            names.add(member.name)
        tar.extractall(destination, filter="data")


def verify_checksums(root: Path) -> None:
    checksum_file = root / "manifest" / "files.sha256"
    if not checksum_file.is_file():
        raise VerificationError("manifest/files.sha256 is missing")
    records: dict[str, str] = {}
    for line in checksum_file.read_text(encoding="utf-8").splitlines():
        try:
            digest, relative = line.split("  ", 1)
        except ValueError as error:
            raise VerificationError(f"malformed checksum line: {line!r}") from error
        if not re.fullmatch(r"[0-9a-f]{64}", digest):
            raise VerificationError(f"malformed SHA-256 value for {relative!r}")
        parsed = PurePosixPath(relative)
        if parsed.is_absolute() or ".." in parsed.parts or relative in records:
            raise VerificationError(f"unsafe or duplicate checksum path: {relative!r}")
        records[relative] = digest
    actual = {
        path.relative_to(root).as_posix()
        for path in root.rglob("*")
        if path.is_file() and path != checksum_file
    }
    if set(records) != actual:
        raise VerificationError("checksum manifest does not describe exactly the bundle files")
    for relative, expected in records.items():
        observed = sha256(root / relative)
        if observed != expected:
            raise VerificationError(f"SHA-256 mismatch for {relative}: {observed} != {expected}")


def compare_trees(expected: Path, actual: Path, label: str) -> None:
    expected_paths = {
        path.relative_to(expected).as_posix(): path
        for path in expected.rglob("*")
        if path.is_file()
    }
    actual_paths = {
        path.relative_to(actual).as_posix(): path
        for path in actual.rglob("*")
        if path.is_file()
    }
    if set(expected_paths) != set(actual_paths):
        missing = sorted(set(expected_paths) - set(actual_paths))
        extra = sorted(set(actual_paths) - set(expected_paths))
        raise VerificationError(f"{label} file set mismatch; missing={missing[:3]}, extra={extra[:3]}")
    for relative in sorted(expected_paths):
        if sha256(expected_paths[relative]) != sha256(actual_paths[relative]):
            raise VerificationError(f"{label} content mismatch: {relative}")


def verify_incremental_bundle(repo: Path, bundle: Path, expected_commit: str) -> None:
    checked = run(["git", "-C", str(repo), "bundle", "verify", str(bundle)])
    if checked.returncode:
        raise VerificationError(f"Git bundle verification failed for {bundle.name}: {checked.stderr.strip()}")
    temporary_ref = f"refs/verifier-bundle/target-{expected_commit}"
    destination_ref = f"refs/verifier-bundle/evidence-{expected_commit}"
    checked = run(["git", "-C", str(repo), "fetch", "-q", str(bundle), f"{temporary_ref}:{destination_ref}"])
    if checked.returncode:
        raise VerificationError(f"Git bundle fetch failed for {bundle.name}: {checked.stderr.strip()}")
    observed = git(repo, "rev-parse", f"{expected_commit}^{{commit}}")
    if observed != expected_commit:
        raise VerificationError(f"{bundle.name} does not contain expected commit {expected_commit}")
    checked = run(["git", "-C", str(repo), "fsck", "--no-dangling", "--no-reflogs"])
    if checked.returncode:
        raise VerificationError(f"Git fsck failed after loading {bundle.name}: {checked.stderr.strip()}")


def bundle_commit(repo: Path, bundle: Path, expected_commit: str) -> Path:
    verification_repo = repo / "verification-repo"
    verification_repo.mkdir(parents=True)
    git(verification_repo, "init", "-q")
    checked = run(["git", "-C", str(verification_repo), "bundle", "verify", str(bundle)])
    if checked.returncode:
        raise VerificationError(f"Git bundle verification failed for {bundle.name}: {checked.stderr.strip()}")
    clone = repo / "clone"
    checked = run(["git", "clone", "--mirror", "-q", str(bundle), str(clone)])
    if checked.returncode:
        raise VerificationError(f"Git bundle clone failed for {bundle.name}: {checked.stderr.strip()}")
    observed = git(clone, "rev-parse", f"{expected_commit}^{{commit}}")
    if observed != expected_commit:
        raise VerificationError(f"{bundle.name} does not contain expected commit {expected_commit}")
    checked = run(["git", "-C", str(clone), "fsck", "--no-dangling", "--no-reflogs"])
    if checked.returncode:
        raise VerificationError(f"Git fsck failed for {bundle.name}: {checked.stderr.strip()}")
    return clone


def gitlinks(repo: Path, commit: str) -> dict[str, str]:
    output = git(repo, "ls-tree", "-r", commit)
    entries: dict[str, str] = {}
    for line in output.splitlines():
        header, path = line.split("\t", 1)
        mode, kind, object_id = header.split()
        if mode == "160000":
            if kind != "commit":
                raise VerificationError(f"unexpected gitlink kind at {path}")
            entries[path] = object_id
    return entries


def archive_git_tree(repo: Path, commit: str, destination: Path, *paths: str) -> None:
    completed = subprocess.run(
        ["git", "-C", str(repo), "archive", "--format=tar", commit, *paths],
        check=False,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )
    if completed.returncode:
        raise VerificationError(f"could not archive {commit}: {completed.stderr.decode().strip()}")
    with tarfile.open(fileobj=io.BytesIO(completed.stdout), mode="r:") as tar:
        tar.extractall(destination, filter="data")


def verify_git_provenance(root: Path, manifest: dict[str, Any]) -> None:
    source = manifest.get("source", {})
    required_source_keys = {
        "repository_commit",
        "repository_tree",
        "snapshot_path",
        "snapshot_bundle_path",
        "snapshot_tree",
        "git_bundle",
    }
    if set(source) != required_source_keys:
        raise VerificationError("source manifest shape is invalid")
    evidence = manifest.get("evidence", {})
    required_evidence_keys = {"repository_commit", "repository_tree", "direct_parent", "git_bundle"}
    if set(evidence) != required_evidence_keys:
        raise VerificationError("evidence manifest shape is invalid")
    if evidence["direct_parent"] != source["repository_commit"]:
        raise VerificationError("evidence manifest direct parent does not equal source commit")
    dependencies = manifest.get("dependencies")
    if not isinstance(dependencies, list) or not dependencies:
        raise VerificationError("dependency manifest is missing")
    by_path = {entry.get("path"): entry for entry in dependencies if isinstance(entry, dict)}
    if len(by_path) != len(dependencies):
        raise VerificationError("dependency manifest contains duplicate or malformed paths")
    with tempfile.TemporaryDirectory(prefix="vaultfire-verifier-check-") as temporary:
        temp = Path(temporary)
        main_bundle = root / source["git_bundle"]
        if not main_bundle.is_file():
            raise VerificationError("main source Git bundle is missing")
        main_clone = bundle_commit(temp / "main", main_bundle, source["repository_commit"])
        if git(main_clone, "rev-parse", f"{source['repository_commit']}^{{tree}}") != source["repository_tree"]:
            raise VerificationError("main repository tree ID differs from manifest")
        if git(main_clone, "rev-parse", f"{source['repository_commit']}:{source['snapshot_path']}") != source["snapshot_tree"]:
            raise VerificationError("V3 source subtree ID differs from manifest")

        evidence_bundle = root / evidence["git_bundle"]
        if not evidence_bundle.is_file():
            raise VerificationError("evidence Git bundle is missing")
        verify_incremental_bundle(main_clone, evidence_bundle, evidence["repository_commit"])
        if git(main_clone, "rev-parse", f"{evidence['repository_commit']}^{{tree}}") != evidence["repository_tree"]:
            raise VerificationError("evidence repository tree ID differs from manifest")
        evidence_parents = git(main_clone, "show", "-s", "--format=%P", evidence["repository_commit"]).split()
        if evidence_parents != [source["repository_commit"]]:
            raise VerificationError("evidence commit does not have source commit as its sole direct parent")
        if git(main_clone, "rev-parse", f"{source['repository_commit']}^{{commit}}") != source["repository_commit"]:
            raise VerificationError("evidence Git bundle does not contain the exact source commit")

        source_archive = temp / "source-archive"
        archive_git_tree(main_clone, source["repository_commit"], source_archive, "v3-src")
        source_snapshot = root / source["snapshot_bundle_path"]
        if not source_snapshot.is_dir():
            raise VerificationError(f"source snapshot missing: {source['snapshot_bundle_path']}")

        top_pins = manifest.get("top_level_dependency_pins")
        if not isinstance(top_pins, dict):
            raise VerificationError("top_level_dependency_pins is missing")
        main_links = gitlinks(main_clone, source["repository_commit"])
        for relative, pinned in top_pins.items():
            if main_links.get(relative) != pinned:
                raise VerificationError(f"main Git bundle pin mismatch for {relative}")
            if f"source/{relative}" not in by_path:
                raise VerificationError(f"dependency manifest missing top-level path source/{relative}")

        def verify_dependency(entry: dict[str, Any]) -> None:
            for key in ("path", "commit", "tree", "git_bundle", "submodules"):
                if key not in entry:
                    raise VerificationError(f"dependency entry missing {key}")
            bundled = root / entry["git_bundle"]
            if not bundled.is_file():
                raise VerificationError(f"dependency Git bundle missing: {entry['git_bundle']}")
            clone = bundle_commit(temp / ("dep-" + hashlib.sha256(entry["path"].encode()).hexdigest()[:12]), bundled, entry["commit"])
            if git(clone, "rev-parse", f"{entry['commit']}^{{tree}}") != entry["tree"]:
                raise VerificationError(f"tree ID mismatch for {entry['path']}")
            snapshot = root / entry["path"]
            if not snapshot.is_dir():
                raise VerificationError(f"vendored dependency directory missing: {entry['path']}")
            archived = temp / ("archive-" + hashlib.sha256(entry["path"].encode()).hexdigest()[:12])
            archive_git_tree(clone, entry["commit"], archived)
            links = gitlinks(clone, entry["commit"])
            children = entry["submodules"]
            if not isinstance(children, list):
                raise VerificationError(f"submodules must be a list for {entry['path']}")
            child_paths = {child.get("path"): child for child in children if isinstance(child, dict)}
            if len(child_paths) != len(children):
                raise VerificationError(f"duplicate/malformed child records for {entry['path']}")
            if {path.removeprefix(entry["path"] + "/") for path in child_paths} != set(links):
                raise VerificationError(f"submodule manifest does not match Git links for {entry['path']}")
            # Restore nested children to the expected archive before comparing.
            for child_path, child in child_paths.items():
                relative = child_path.removeprefix(entry["path"] + "/")
                child_entry = by_path.get(child_path)
                if child_entry is None or child_entry.get("commit") != links[relative]:
                    raise VerificationError(f"nested dependency pin mismatch at {child_path}")
                nested = archived / relative
                shutil.rmtree(nested, ignore_errors=True)
                verify_dependency(child_entry)
                shutil.copytree(root / child_path, nested)
            compare_trees(archived, snapshot, entry["path"])

        for top_path in sorted(top_pins):
            verify_dependency(by_path[f"source/{top_path}"])

        # The main Git archive contains the V3 source except its top-level gitlinks.
        expected_v3 = temp / "expected-v3"
        observed_v3 = temp / "observed-v3"
        shutil.copytree(source_archive / "v3-src", expected_v3)
        shutil.copytree(source_snapshot, observed_v3)
        for top_path in top_pins:
            relative = Path(top_path).relative_to("v3-src")
            shutil.rmtree(expected_v3 / relative, ignore_errors=True)
            shutil.rmtree(observed_v3 / relative, ignore_errors=True)
        compare_trees(expected_v3, observed_v3, "main V3 source")


def verify_required_paths(root: Path, manifest: dict[str, Any]) -> None:
    paths = manifest.get("required_paths")
    if not isinstance(paths, list) or not paths:
        raise VerificationError("required_paths is missing")
    for relative in paths:
        if not isinstance(relative, str) or not (root / relative).is_file():
            raise VerificationError(f"required verifier file is missing: {relative}")


def run_optional_strict_checks(root: Path) -> tuple[bool, list[str]]:
    forge = shutil.which("forge")
    if forge is None:
        return False, ["Forge checks: NOT RUN (forge is not installed)"]
    source = root / "source" / "v3-src"
    messages: list[str] = []
    commands = (
        [forge, "fmt", "--check"],
        [
            forge,
            "build",
            "--offline",
            "--force",
            "--build-info",
            "--skip",
            "test",
            "--skip",
            "script",
        ],
    )
    for command in commands:
        completed = run(command, cwd=source)
        label = " ".join(Path(part).name if index == 0 else part for index, part in enumerate(command))
        if completed.returncode:
            detail = (completed.stderr or completed.stdout).strip().splitlines()
            raise VerificationError(f"strict check failed ({label}): {detail[-1] if detail else 'no output'}")
        messages.append(f"{label}: PASS")
    return True, messages


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("bundle", type=Path, help="generated verifier bundle (.tar.gz)")
    parser.add_argument(
        "--allow-incomplete",
        action="store_true",
        help="return success when optional Forge checks cannot run; output still says NOT RUN",
    )
    parser.add_argument(
        "--require-all-tools",
        action="store_true",
        help="fail when Forge strict checks cannot run (the default already returns 2)",
    )
    args = parser.parse_args()
    try:
        if sys.version_info < MINIMUM_PYTHON:
            raise VerificationError(f"Python {MINIMUM_PYTHON[0]}.{MINIMUM_PYTHON[1]}+ is required")
        git_version = run(["git", "--version"])
        if git_version.returncode or version_tuple(git_version.stdout) < MINIMUM_GIT:
            raise VerificationError(f"Git {MINIMUM_GIT[0]}.{MINIMUM_GIT[1]}+ is required")
        artifact = args.bundle.resolve()
        if not artifact.is_file():
            raise VerificationError(f"bundle does not exist: {artifact}")
        with tempfile.TemporaryDirectory(prefix="vaultfire-verifier-extract-") as temporary:
            destination = Path(temporary)
            safe_extract(artifact, destination)
            root = destination / ROOT_NAME
            manifest_path = root / "manifest" / "manifest.json"
            if not manifest_path.is_file():
                raise VerificationError("manifest/manifest.json is missing")
            manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
            if manifest.get("format") != FORMAT:
                raise VerificationError("unrecognized verifier bundle format")
            verify_checksums(root)
            verify_required_paths(root, manifest)
            verify_git_provenance(root, manifest)
            complete, messages = run_optional_strict_checks(root)
            print("structural verification: PASS")
            for message in messages:
                print(message)
            if not complete:
                print("overall verification: INCOMPLETE (unavailable strict checks are not a pass)")
                return 0 if args.allow_incomplete else 2
            print("overall verification: PASS")
            return 0
    except (VerificationError, json.JSONDecodeError, tarfile.TarError) as error:
        print(f"verification failed: {error}", file=sys.stderr)
        return 1


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