#!/usr/bin/env python3
"""Verify an extracted Vaultfire candidate without Git metadata or workspace state."""
from __future__ import annotations

import argparse
import hashlib
import json
import stat
import tarfile
from pathlib import Path, PurePosixPath


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 safe_relative(value: str) -> bool:
    path = PurePosixPath(value)
    return (
        value == path.as_posix()
        and not path.is_absolute()
        and value not in {"", "."}
        and ".." not in path.parts
    )


def extracted_inventory(root: Path) -> tuple[set[str], list[str]]:
    files: set[str] = set()
    forbidden: list[str] = []
    for path in root.rglob("*"):
        relative = path.relative_to(root).as_posix()
        if path.is_symlink():
            forbidden.append(relative)
        elif path.is_file():
            files.add(relative)
        elif not path.is_dir():
            forbidden.append(relative)
    return files, sorted(forbidden)


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--root", type=Path, required=True)
    parser.add_argument("--manifest", type=Path, required=True)
    parser.add_argument("--archive", type=Path)
    args = parser.parse_args()

    root = args.root.resolve()
    document = json.loads(args.manifest.read_text())
    errors: list[str] = []
    if document.get("schema") != "vaultfire.unsigned-release-candidate.v1":
        errors.append("unsupported candidate manifest schema")
    if document.get("signatureStatus") not in {"unsigned", "signed"}:
        errors.append("invalid signature status")
    if args.archive is not None:
        actual_archive = sha256(args.archive)
        if actual_archive != document.get("archiveSha256"):
            errors.append("archive SHA-256 differs from candidate manifest")
        with tarfile.open(args.archive, "r:*") as archive:
            unsafe = [
                member.name
                for member in archive.getmembers()
                if not member.isfile() or member.issym() or member.islnk()
            ]
            if unsafe:
                errors.append("archive contains non-regular members: " + ", ".join(unsafe[:20]))

    records = document.get("files")
    if not isinstance(records, list):
        errors.append("candidate manifest files inventory is absent")
        records = []
    expected: dict[str, dict] = {}
    for record in records:
        path = record.get("path") if isinstance(record, dict) else None
        if not isinstance(path, str) or not safe_relative(path):
            errors.append(f"unsafe inventory path: {path!r}")
            continue
        if path in expected:
            errors.append(f"duplicate inventory path: {path}")
            continue
        if record.get("mode") not in {"100644", "100755"}:
            errors.append(f"invalid inventory mode: {path}")
            continue
        expected[path] = record

    actual, forbidden_entries = extracted_inventory(root)
    if forbidden_entries:
        errors.append("extracted tree contains non-regular entries: " + ", ".join(forbidden_entries[:20]))
    missing = sorted(set(expected) - actual)
    extra = sorted(actual - set(expected))
    if missing:
        errors.append("missing extracted files: " + ", ".join(missing[:20]))
    if extra:
        errors.append("unexpected extracted files: " + ", ".join(extra[:20]))
    for relative in sorted(set(expected) & actual):
        path = root / relative
        record = expected[relative]
        if path.stat().st_size != record.get("bytes"):
            errors.append(f"byte length mismatch: {relative}")
            continue
        if sha256(path) != record.get("sha256"):
            errors.append(f"SHA-256 mismatch: {relative}")
        actual_mode = "100755" if stat.S_IMODE(path.stat().st_mode) & 0o111 else "100644"
        if actual_mode != record.get("mode"):
            errors.append(f"mode mismatch: {relative}")

    required_controls = {
        ".github/workflows/full-v3-ci.yml",
        ".github/CODEOWNERS",
        "COMPONENT_OWNERSHIP.json",
        "Makefile",
        "dependency-policy.json",
        "generator-discovery.json",
        "generation-registry.json",
        "release-control/source-candidate-20260826/inherited-evidence-decisions.json",
        "release-control/source-candidate-20260826/hygiene-purge-report.json",
        "full-v3/release/archive-verification-modes.json",
        "scripts/check_dependency_policy.py",
        "scripts/validate_generation_registry.py",
        "full-v3/release/build_candidate.py",
        "full-v3/release/verify_extracted_candidate.py",
        "full-v3/manifest/protocol/modules.manifest.json",
    }
    absent_controls = sorted(required_controls - set(expected))
    if absent_controls:
        errors.append("required source-review controls absent: " + ", ".join(absent_controls))
    # VF-R22-02: every immutable V2 input bound by the portable byte-preservation
    # gate must ship in the archive, or the compatibility gate cannot run.
    preservation = root / "pr7/compat/oct20-v2-byte-preservation.json"
    if preservation.is_file():
        try:
            ledger = json.loads(preservation.read_text())
            absent_ledger = sorted(
                entry["path"] for entry in ledger.get("files", [])
                if entry.get("path") not in expected
            )
        except (json.JSONDecodeError, OSError, KeyError, TypeError):
            absent_ledger = ["<malformed pr7/compat/oct20-v2-byte-preservation.json>"]
        if absent_ledger:
            errors.append("V2 byte-preservation inputs absent from archive: " + ", ".join(absent_ledger))
    mode_path = root / "full-v3/release/archive-verification-modes.json"
    if mode_path.is_file():
        try:
            modes = json.loads(mode_path.read_text())
            if modes.get("schema") != "vaultfire.archive-verification-modes.v1":
                errors.append("unsupported archive verification-modes schema")
            if not modes.get("archiveLocal") or not modes.get("gitHistoryRequired"):
                errors.append("archive verification modes do not separate local and history checks")
        except (json.JSONDecodeError, OSError):
            errors.append("archive verification modes are malformed")
    if len(expected) != document.get("fileCount"):
        errors.append("candidate file count does not match inventory")

    result = {
        "schema": "vaultfire.extracted-candidate-verification.v1",
        "status": "passed" if not errors else "failed",
        "revision": document.get("revision"),
        "sourceTreeIdentity": document.get("sourceTreeIdentity"),
        "archiveSha256": document.get("archiveSha256"),
        "filesVerified": len(expected) - len(missing),
        "missingFiles": len(missing),
        "unexpectedFiles": len(extra),
        "errors": errors,
        "gitMetadataRequired": False,
        "ambientWorkspaceFilesRequired": False,
        "historyAuthenticationPerformed": False,
        "publicationAuthenticationPerformed": False,
    }
    print(json.dumps(result, indent=2))
    return 1 if errors else 0


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