#!/usr/bin/env python3
"""Create a deterministic, self-contained V3 verifier bundle.

The normal Git archive path is intentionally not used as the final artifact:
Git archives represent submodules as gitlinks and therefore omit their
contents.  This tool expands every initialized submodule recursively and
ships both its checked-out contents and a Git bundle that can prove its object
IDs without network access.
"""

from __future__ import annotations

import argparse
import configparser
import gzip
import hashlib
import json
import os
import re
import shutil
import stat
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"
TOP_LEVEL_DEPENDENCIES = {
    "v3-src/lib/forge-std": "680ee6692649dcc7c617e05b2144932618264a83",
    "v3-src/lib/openzeppelin-contracts": "dbb6104ce834628e473d2173bbc9d47f81a9eec3",
}
REQUIRED_PATHS = [
    "source/v3-src/foundry.toml",
    "source/v3-src/contracts/bonds/AccountabilityBondV3Safe.sol",
    "source/v3-src/lib/forge-std/src/Test.sol",
    "source/v3-src/lib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol",
]
FORBIDDEN_PATH_PARTS = {".git", ".env", "secrets", "private-keys", "private_keys"}
FORBIDDEN_SUFFIXES = (".pem", ".key", ".p12", ".pfx")
SECRET_PATTERNS = (
    re.compile(rb"-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----"),
    re.compile(rb"(?i)(?:api[_-]?key|secret|private[_-]?key)\s*[:=]\s*['\"]?[A-Za-z0-9+/=_-]{24,}"),
)


class BundleError(RuntimeError):
    """A source tree cannot safely be placed in a verifier bundle."""


def run_git(repo: Path, *args: str, text: bool = True) -> str | bytes:
    completed = subprocess.run(
        ["git", "-C", str(repo), *args],
        check=False,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=text,
    )
    if completed.returncode:
        stderr = completed.stderr.decode() if isinstance(completed.stderr, bytes) else completed.stderr
        raise BundleError(f"git -C {repo} {' '.join(args)} failed: {stderr.strip()}")
    return completed.stdout


def git_output(repo: Path, *args: str) -> str:
    return str(run_git(repo, *args)).strip()


def ensure_relative(path: str) -> None:
    parsed = PurePosixPath(path)
    if parsed.is_absolute() or ".." in parsed.parts or path in {"", "."}:
        raise BundleError(f"unsafe path from Git metadata: {path!r}")


def parse_gitmodules(repo: Path, commit: str) -> dict[str, str]:
    try:
        contents = run_git(repo, "show", f"{commit}:.gitmodules")
    except BundleError:
        return {}
    parser = configparser.ConfigParser()
    parser.read_string(str(contents))
    entries: dict[str, str] = {}
    for section in parser.sections():
        if not section.startswith("submodule "):
            continue
        path = parser.get(section, "path", fallback="")
        url = parser.get(section, "url", fallback="")
        ensure_relative(path)
        if not url:
            raise BundleError(f"{repo}@{commit} has submodule {path!r} without a URL")
        entries[path] = url
    return entries


def gitlinks(repo: Path, commit: str) -> dict[str, str]:
    entries: dict[str, str] = {}
    for line in str(run_git(repo, "ls-tree", "-r", "-z", commit)).split("\0"):
        if not line:
            continue
        header, path = line.split("\t", 1)
        mode, kind, object_id = header.split()
        if mode == "160000":
            ensure_relative(path)
            if kind != "commit":
                raise BundleError(f"unexpected gitlink kind for {path}: {kind}")
            entries[path] = object_id
    return entries


def assert_clean_dependency(repo: Path, commit: str) -> None:
    head = git_output(repo, "rev-parse", "HEAD")
    if head != commit:
        raise BundleError(f"dependency checkout {repo} is {head}, expected pinned {commit}")
    status = git_output(repo, "status", "--porcelain", "--untracked-files=no")
    if status:
        raise BundleError(f"dependency checkout {repo} has tracked modifications:\n{status}")


def copy_git_archive(repo: Path, commit: str, destination: Path, *paths: str) -> None:
    archive = run_git(repo, "archive", "--format=tar", commit, *paths, text=False)
    destination.mkdir(parents=True, exist_ok=True)
    with tarfile.open(fileobj=__import__("io").BytesIO(archive), mode="r:") as tar:
        for member in tar.getmembers():
            ensure_relative(member.name)
            if member.issym() or member.islnk() or not (member.isfile() or member.isdir()):
                raise BundleError(f"unsafe Git archive member {member.name!r} from {repo}")
        tar.extractall(destination, filter="data")


def create_git_bundle(repo: Path, commit: str, destination: Path, base_commit: str | None = None) -> None:
    destination.parent.mkdir(parents=True, exist_ok=True)
    # `git bundle create` advertises refs, not a detached object ID.  Create a
    # stable, temporary ref so a verifier can clone the exact requested
    # commit even when it is not the checkout's HEAD.
    temporary_ref = f"refs/verifier-bundle/target-{commit}"
    previous = subprocess.run(
        ["git", "-C", str(repo), "rev-parse", "--verify", "--quiet", temporary_ref],
        check=False,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
    )
    if previous.returncode not in (0, 1):
        raise BundleError(f"could not inspect temporary bundle ref in {repo}: {previous.stderr.strip()}")
    previous_value = previous.stdout.strip() if previous.returncode == 0 else None
    try:
        if previous_value not in (None, commit):
            raise BundleError(f"temporary bundle ref already points to another commit in {repo}")
        if previous_value is None:
            run_git(repo, "update-ref", temporary_ref, commit)
        revisions = [temporary_ref] if base_commit is None else [f"^{base_commit}", temporary_ref]
        completed = subprocess.run(
            ["git", "-C", str(repo), "bundle", "create", str(destination), *revisions],
            check=False,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
        )
        if completed.returncode:
            raise BundleError(f"could not make Git bundle for {repo}@{commit}: {completed.stderr.strip()}")
    finally:
        if previous_value is None:
            subprocess.run(
                ["git", "-C", str(repo), "update-ref", "-d", temporary_ref],
                check=False,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )


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 assert_no_secrets(root: Path) -> None:
    for path in sorted(root.rglob("*")):
        if not path.is_file():
            continue
        relative = path.relative_to(root)
        lowered = {part.lower() for part in relative.parts}
        if lowered & FORBIDDEN_PATH_PARTS or path.suffix.lower() in FORBIDDEN_SUFFIXES:
            raise BundleError(f"refusing potential secret-bearing path: {relative}")
        contents = path.read_bytes()
        for pattern in SECRET_PATTERNS:
            if pattern.search(contents):
                raise BundleError(f"refusing potential secret material in: {relative}")


def write_checksums(root: Path) -> None:
    checksum_file = root / "manifest" / "files.sha256"
    records: list[str] = []
    for path in sorted(root.rglob("*")):
        if path == checksum_file or not path.is_file():
            continue
        records.append(f"{sha256(path)}  {path.relative_to(root).as_posix()}")
    checksum_file.write_text("\n".join(records) + "\n", encoding="utf-8")


def add_deterministic_tar_member(tar: tarfile.TarFile, path: Path, arcname: str) -> None:
    info = tar.gettarinfo(str(path), arcname=arcname)
    info.uid = 0
    info.gid = 0
    info.uname = ""
    info.gname = ""
    info.mtime = 0
    if info.isfile():
        with path.open("rb") as handle:
            tar.addfile(info, handle)
    else:
        tar.addfile(info)


def write_deterministic_tar(stage: Path, output: Path) -> None:
    output.parent.mkdir(parents=True, exist_ok=True)
    with output.open("wb") as raw:
        with gzip.GzipFile(filename="", mode="wb", fileobj=raw, compresslevel=9, mtime=0) as compressed:
            with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as tar:
                for path in [stage, *sorted(stage.rglob("*"))]:
                    relative = path.relative_to(stage)
                    arcname = ROOT_NAME if relative == Path(".") else f"{ROOT_NAME}/{relative.as_posix()}"
                    add_deterministic_tar_member(tar, path, arcname)


def make_readme() -> str:
    return """# Vaultfire V3 source-and-evidence verifier bundle

This bundle is intentionally self-contained. A plain `git archive` records
submodules only as gitlinks, so it omits dependency source such as
`SafeERC20.sol`. This artifact expands every pinned submodule recursively and
contains offline Git bundles for two exact top-level commits:

* the reviewed source commit; and
* the evidence commit, whose sole direct parent is cryptographically checked to
  be the reviewed source commit.

The separate bundles make both identifiers and the evidence-to-source parent
relationship reproducible without network access.

## Offline verification

After extracting this archive, run:

```sh
python3 verify_verifier_bundle.py /path/to/vaultfire-v3-verifier-bundle.tar.gz
```

The command checks archive safety, every SHA-256 entry, both top-level Git
bundles, the evidence commit's direct-parent relation, Git object and tree IDs,
every submodule pin and vendored file, and the required `SafeERC20.sol` path.
It runs Forge formatting/build checks only when Forge is available. An
unavailable Forge is reported as **NOT RUN**, never as a pass; use
`--allow-incomplete` only when accepting structural verification without the
optional local compiler check.

No credentials, private keys, `.env` files, or key-like files are allowed in
this artifact. See `requirements.txt` and `manifest/manifest.json`.
"""


def requirements() -> str:
    return """Verifier bundle requirements
============================

Mandatory for structural offline verification:
* Python 3.9 or newer (standard library only)
* Git 2.30 or newer (Git bundle, archive, fsck, and tree inspection)

Optional strict compiler checks:
* Foundry/Forge with Solidity compiler 0.8.24 already installed locally
  (`forge fmt --check` and `forge build --offline` are attempted).

The verifier never downloads dependencies or compiler binaries.  If Forge is
not installed, or cannot run offline with solc 0.8.24, the outcome is
INCOMPLETE / NOT RUN rather than PASS.  Use `--require-all-tools` to turn this
condition into an explicit failure.
"""


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--repo", type=Path, default=Path.cwd(), help="top-level Vaultfire Git checkout")
    parser.add_argument("--output", type=Path, required=True, help="output .tar.gz path (kept outside Git)")
    parser.add_argument("--source-commit", required=True, help="exact reviewed source commit")
    parser.add_argument("--evidence-commit", required=True, help="exact evidence commit with source as sole direct parent")
    args = parser.parse_args()

    repo = args.repo.resolve()
    try:
        if git_output(repo, "rev-parse", "--show-toplevel") != str(repo):
            raise BundleError(f"--repo must be a Git worktree root: {repo}")
        source_commit = git_output(repo, "rev-parse", f"{args.source_commit}^{{commit}}")
        evidence_commit = git_output(repo, "rev-parse", f"{args.evidence_commit}^{{commit}}")
        evidence_parents = git_output(repo, "show", "-s", "--format=%P", evidence_commit).split()
        if evidence_parents != [source_commit]:
            raise BundleError(
                "evidence commit must have the source commit as its sole direct parent: "
                f"evidence={evidence_commit}, parents={evidence_parents}, source={source_commit}"
            )
        source_tree = git_output(repo, "rev-parse", f"{source_commit}:v3-src")
        top_links = gitlinks(repo, source_commit)
        for path, expected in TOP_LEVEL_DEPENDENCIES.items():
            if top_links.get(path) != expected:
                raise BundleError(f"{source_commit} pin for {path} is {top_links.get(path)!r}, expected {expected}")

        verifier = Path(__file__).with_name("verify_verifier_bundle.py")
        if not verifier.is_file():
            raise BundleError(f"companion verifier is missing: {verifier}")

        with tempfile.TemporaryDirectory(prefix="vaultfire-verifier-bundle-") as temporary:
            stage = Path(temporary) / ROOT_NAME
            (stage / "manifest").mkdir(parents=True)
            copy_git_archive(repo, source_commit, stage / "source", "v3-src")
            create_git_bundle(repo, source_commit, stage / "git-bundles" / "source.bundle")
            # This bundle is incremental over source.bundle. Together, the two
            # Git bundles are self-contained and avoid duplicating source history.
            create_git_bundle(
                repo, evidence_commit, stage / "git-bundles" / "evidence.bundle", base_commit=source_commit
            )
            source_root = stage / "source"
            dependencies: list[dict[str, Any]] = []

            def expand_submodules(
                git_repo: Path, pinned_commit: str, destination: Path, bundle_label: str, source_path: str
            ) -> None:
                assert_clean_dependency(git_repo, pinned_commit)
                tree = git_output(git_repo, "rev-parse", f"{pinned_commit}^{{tree}}")
                bundle_path = f"git-bundles/{bundle_label}.bundle"
                create_git_bundle(git_repo, pinned_commit, stage / bundle_path)
                record: dict[str, Any] = {
                    "path": source_path,
                    "commit": pinned_commit,
                    "tree": tree,
                    "git_bundle": bundle_path,
                    "submodules": [],
                }
                dependencies.append(record)
                copy_git_archive(git_repo, pinned_commit, destination)
                gitmodule_urls = parse_gitmodules(git_repo, pinned_commit)
                links = gitlinks(git_repo, pinned_commit)
                if set(gitmodule_urls) != set(links):
                    raise BundleError(
                        f"{git_repo}@{pinned_commit} .gitmodules/gitlink mismatch: "
                        f"{sorted(gitmodule_urls)} != {sorted(links)}"
                    )
                for relative, child_commit in sorted(links.items()):
                    child_repo = git_repo / relative
                    if not child_repo.is_dir():
                        raise BundleError(
                            f"submodule checkout missing for {source_path}/{relative}; run git submodule update --init --recursive"
                        )
                    child_destination = destination / relative
                    shutil.rmtree(child_destination, ignore_errors=True)
                    child_label = f"{bundle_label}--{relative.replace('/', '--')}"
                    child_source_path = f"{source_path}/{relative}"
                    record["submodules"].append(
                        {"path": child_source_path, "commit": child_commit, "url": gitmodule_urls[relative]}
                    )
                    expand_submodules(child_repo, child_commit, child_destination, child_label, child_source_path)

            for relative, pinned in sorted(TOP_LEVEL_DEPENDENCIES.items()):
                dependency_repo = repo / relative
                dependency_destination = source_root / relative
                shutil.rmtree(dependency_destination, ignore_errors=True)
                expand_submodules(
                    dependency_repo,
                    pinned,
                    dependency_destination,
                    relative.replace("/", "--"),
                    f"source/{relative}",
                )

            for required in REQUIRED_PATHS:
                if not (stage / required).is_file():
                    raise BundleError(f"required verifier source is absent after vendoring: {required}")
            (stage / "README.md").write_text(make_readme(), encoding="utf-8")
            (stage / "requirements.txt").write_text(requirements(), encoding="utf-8")
            shutil.copy2(verifier, stage / "verify_verifier_bundle.py")
            manifest = {
                "format": FORMAT,
                "source": {
                    "repository_commit": source_commit,
                    "repository_tree": git_output(repo, "rev-parse", f"{source_commit}^{{tree}}"),
                    "snapshot_path": "v3-src",
                    "snapshot_bundle_path": "source/v3-src",
                    "snapshot_tree": source_tree,
                    "git_bundle": "git-bundles/source.bundle",
                },
                "evidence": {
                    "repository_commit": evidence_commit,
                    "repository_tree": git_output(repo, "rev-parse", f"{evidence_commit}^{{tree}}"),
                    "direct_parent": source_commit,
                    "git_bundle": "git-bundles/evidence.bundle",
                },
                "top_level_dependency_pins": TOP_LEVEL_DEPENDENCIES,
                "dependencies": dependencies,
                "required_paths": REQUIRED_PATHS,
                "strict_checks": [
                    "git fsck --no-dangling for every Git bundle",
                    "Forge: forge fmt --check (when available)",
                    "Forge: forge build --offline (when available)",
                ],
                "secret_policy": "No .env, key-like files, private-key paths, or private-key/API-key literals.",
            }
            (stage / "manifest" / "manifest.json").write_text(
                json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
            )
            assert_no_secrets(stage)
            write_checksums(stage)
            write_deterministic_tar(stage, args.output.resolve())
        print(f"created {args.output.resolve()}")
        print(f"sha256 {sha256(args.output.resolve())}")
        return 0
    except BundleError as error:
        print(f"error: {error}", file=sys.stderr)
        return 1


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