#!/usr/bin/env python3
"""Bind a staged source offer to the exact frozen browser release."""

from __future__ import annotations

import argparse
import hashlib
import json
import pathlib
import re
import subprocess
import sys
import tarfile


SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
GIT_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
SOURCE_MANIFEST_MEMBER = "q4-web-source/SOURCE_MANIFEST.json"
NATIVE_IDENTITY_KEYS = (
    "patchManifestSha256",
    "openq4PatchedDiffSha256",
    "gamelibsPatchedDiffSha256",
)


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


def read_json_object(raw: bytes, label: str) -> dict:
    try:
        value = json.loads(raw)
    except (UnicodeDecodeError, json.JSONDecodeError) as error:
        raise ValueError(f"{label} is not valid UTF-8 JSON: {error}") from error
    if not isinstance(value, dict):
        raise ValueError(f"{label} must be a JSON object")
    return value


def read_build_info(path: pathlib.Path) -> dict[str, str]:
    info: dict[str, str] = {}
    for line in path.read_text(encoding="utf-8").splitlines():
        key, separator, value = line.partition("=")
        if not separator or not key or key in info:
            raise ValueError(f"invalid frozen engine build-info line: {line!r}")
        info[key] = value
    return info


def validate_native_identity(value: dict) -> dict:
    if value.get("schema") != 1:
        raise ValueError("native source identity schema is invalid")
    for key in ("openq4", "gamelibs"):
        if not GIT_SHA_RE.fullmatch(value.get(key, "")):
            raise ValueError(f"native source identity has an invalid {key}")
    for key in NATIVE_IDENTITY_KEYS:
        if not SHA256_RE.fullmatch(value.get(key, "")):
            raise ValueError(f"native source identity has an invalid {key}")

    patches = value.get("patches")
    if not isinstance(patches, list):
        raise ValueError("native source identity patches must be an array")
    seen: set[str] = set()
    manifest = bytearray()
    for entry in patches:
        if not isinstance(entry, dict) or set(entry) != {"tree", "path", "sha256"}:
            raise ValueError("native source identity patch entry is invalid")
        tree = entry.get("tree")
        patch_path = entry.get("path")
        digest = entry.get("sha256")
        if tree not in {"openq4", "gamelibs"}:
            raise ValueError("native source identity patch tree is invalid")
        if not isinstance(patch_path, str) or not re.fullmatch(
            r"port/native/patches/[0-9]{4}-[A-Za-z0-9._-]+\.patch", patch_path
        ):
            raise ValueError("native source identity patch path is invalid")
        if patch_path in seen or not SHA256_RE.fullmatch(digest or ""):
            raise ValueError("native source identity patch digest is invalid or duplicated")
        seen.add(patch_path)
        manifest.extend(f"{pathlib.PurePosixPath(patch_path).name} {digest}\n".encode("ascii"))
    if patches and hashlib.sha256(manifest).hexdigest() != value["patchManifestSha256"]:
        raise ValueError("native source identity patch manifest digest is inconsistent")
    return value


def explicit_native_identity(args: argparse.Namespace, openq4: str, gamelibs: str) -> dict | None:
    values = [getattr(args, name) for name in (
        "native_patch_manifest", "native_openq4_diff", "native_gamelibs_diff"
    )]
    if not any(values):
        return None
    if not all(values):
        raise ValueError("all native source identity overrides must be supplied together")
    return validate_native_identity({
        "schema": 1,
        "openq4": openq4,
        "gamelibs": gamelibs,
        "patchManifestSha256": values[0],
        "openq4PatchedDiffSha256": values[1],
        "gamelibsPatchedDiffSha256": values[2],
        "patches": [],
    })


def current_native_identity(root: pathlib.Path, args: argparse.Namespace, openq4: str, gamelibs: str) -> dict:
    identity = explicit_native_identity(args, openq4, gamelibs)
    if identity is None:
        tool = root / "tools/native-source-identity.py"
        if tool.is_symlink() or not tool.is_file():
            raise ValueError("native source identity verifier is missing or is not regular")
        try:
            completed = subprocess.run(
                [sys.executable, str(tool), "--root", str(root), "--json"],
                check=True,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
            )
        except subprocess.CalledProcessError as error:
            detail = error.stderr.decode("utf-8", errors="replace").strip()
            raise ValueError(f"native source identity verification failed: {detail}") from error
        identity = validate_native_identity(read_json_object(
            completed.stdout, "native source verifier output"
        ))
    if identity["openq4"] != openq4 or identity["gamelibs"] != gamelibs:
        raise ValueError("native source identity pins differ from the frozen browser engine")
    return identity


def validate_native_build_info(root: pathlib.Path, browser_info: dict[str, str], native: dict) -> None:
    path = root / "runtime/native/build-info/build.txt"
    if path.is_symlink() or not path.is_file():
        raise ValueError("native runtime build-info is missing or is not a regular file")
    info = read_build_info(path)
    expected = {
        "openq4": browser_info["openq4"],
        "gamelibs": browser_info["gamelibs"],
        "native_patch_manifest_sha256": native["patchManifestSha256"],
    }
    for key, value in expected.items():
        if info.get(key) != value:
            raise ValueError(f"native runtime build-info {key} does not match its verified source identity")


def validate_engine_checksums(engine_dir: pathlib.Path) -> str:
    sums_path = engine_dir / "SHA256SUMS"
    if sums_path.is_symlink() or not sums_path.is_file():
        raise ValueError("frozen engine SHA256SUMS is missing or is not a regular file")

    entries: dict[str, str] = {}
    pattern = re.compile(r"^([0-9a-f]{64})  ([A-Za-z0-9][A-Za-z0-9._-]*)$")
    for line in sums_path.read_text(encoding="ascii").splitlines():
        match = pattern.fullmatch(line)
        if not match or match.group(2) in entries:
            raise ValueError(f"invalid frozen engine SHA256SUMS line: {line!r}")
        entries[match.group(2)] = match.group(1)

    for required in ("engine-manifest.json", "build-info.txt"):
        if required not in entries:
            raise ValueError(f"frozen engine SHA256SUMS does not cover {required}")

    for filename, expected in entries.items():
        artifact = engine_dir / filename
        if artifact.is_symlink() or not artifact.is_file():
            raise ValueError(f"frozen engine artifact is missing or is not regular: {filename}")
        actual = sha256_file(artifact)
        if actual != expected:
            raise ValueError(
                f"frozen engine artifact checksum mismatch: {filename} "
                f"(expected {expected}, got {actual})"
            )
    return sha256_file(sums_path)


def current_release_manifest(root: pathlib.Path, args: argparse.Namespace) -> dict:
    engine_dir = root / "engine"
    manifest_path = engine_dir / "engine-manifest.json"
    info_path = engine_dir / "build-info.txt"
    for path in (manifest_path, info_path):
        if path.is_symlink() or not path.is_file():
            raise ValueError(f"frozen engine identity file is missing or is not regular: {path.name}")

    manifest = read_json_object(manifest_path.read_bytes(), "frozen engine manifest")
    info = read_build_info(info_path)
    required = {
        "openq4": GIT_SHA_RE,
        "gamelibs": GIT_SHA_RE,
        "patch_manifest_sha256": SHA256_RE,
        "runtime_source_sha256": SHA256_RE,
    }
    for key, pattern in required.items():
        if not pattern.fullmatch(info.get(key, "")):
            raise ValueError(f"frozen engine build-info has an invalid or missing {key}")
    if info.get("linkage") != "emscripten-main-module-plus-side-module":
        raise ValueError("frozen engine build-info linkage is invalid")

    expected_build = (
        f"openq4-{info['openq4'][:12]}-gamelibs-{info['gamelibs'][:12]}"
        f"-p{info['patch_manifest_sha256'][:12]}-r{info['runtime_source_sha256'][:12]}"
    )
    if manifest.get("schema") != 2:
        raise ValueError("source offer requires a schema-2 frozen engine manifest")
    if info.get("build") != expected_build or manifest.get("build") != expected_build:
        raise ValueError("frozen engine build id does not match its source identifiers")

    native = current_native_identity(root, args, info["openq4"], info["gamelibs"])
    validate_native_build_info(root, info, native)
    sums_sha = validate_engine_checksums(engine_dir)
    return {
        "schema": 2,
        "engine": {
            "build": expected_build,
            "manifestSha256": sha256_file(manifest_path),
            "buildInfoSha256": sha256_file(info_path),
            "sha256SumsSha256": sums_sha,
        },
        "source": {
            "openq4": info["openq4"],
            "gamelibs": info["gamelibs"],
            "patchManifestSha256": info["patch_manifest_sha256"],
            "runtimeSourceSha256": info["runtime_source_sha256"],
        },
        "nativeSource": native,
    }


def write_manifest(args: argparse.Namespace) -> None:
    root = args.root.resolve()
    release = current_release_manifest(root, args)
    expected_source = {
        "openq4": args.openq4,
        "gamelibs": args.gamelibs,
        "patchManifestSha256": args.patch_manifest,
        "runtimeSourceSha256": args.runtime_source,
    }
    if release["source"] != expected_source:
        raise ValueError("staged source identifiers do not match frozen engine build-info")

    output = args.output
    if output.is_symlink():
        raise ValueError("refusing to replace a symlinked source release manifest")
    output.write_text(
        json.dumps(release, indent=2, sort_keys=True) + "\n",
        encoding="ascii",
    )


def read_archive_manifest(archive: pathlib.Path) -> dict:
    try:
        with tarfile.open(archive, mode="r:xz") as source:
            matches = [member for member in source.getmembers() if member.name == SOURCE_MANIFEST_MEMBER]
            if len(matches) != 1 or not matches[0].isfile() or matches[0].size > 16 * 1024:
                raise ValueError("source archive must contain one small regular release manifest")
            extracted = source.extractfile(matches[0])
            if extracted is None:
                raise ValueError("source archive release manifest cannot be read")
            return read_json_object(extracted.read(), "source archive release manifest")
    except (tarfile.TarError, OSError) as error:
        raise ValueError(f"source archive cannot be read: {error}") from error


def validate_archived_native_patches(archive: pathlib.Path, native: dict) -> None:
    patches = native.get("patches", [])
    if not patches:
        return
    try:
        with tarfile.open(archive, mode="r:xz") as source:
            members: dict[str, list[tarfile.TarInfo]] = {}
            for member in source.getmembers():
                members.setdefault(member.name, []).append(member)
            for entry in patches:
                name = f"q4-web-source/{entry['path']}"
                matches = members.get(name, [])
                if len(matches) != 1 or not matches[0].isfile() or matches[0].size > 16 * 1024 * 1024:
                    raise ValueError(f"source archive native patch is missing, duplicated, or invalid: {name}")
                extracted = source.extractfile(matches[0])
                if extracted is None:
                    raise ValueError(f"source archive native patch cannot be read: {name}")
                digest = hashlib.sha256()
                for chunk in iter(lambda: extracted.read(1024 * 1024), b""):
                    digest.update(chunk)
                if digest.hexdigest() != entry["sha256"]:
                    raise ValueError(f"source archive native patch checksum mismatch: {name}")
    except (tarfile.TarError, OSError) as error:
        raise ValueError(f"source archive native patch set cannot be read: {error}") from error


def verify_archive(args: argparse.Namespace) -> None:
    root = args.root.resolve()
    archive = (args.archive or root / "public/source/openq4-web-source.tar.xz").absolute()
    checksum = pathlib.Path(f"{archive}.sha256")
    for path, label in ((archive, "source archive"), (checksum, "source archive checksum")):
        if path.is_symlink() or not path.is_file():
            raise ValueError(f"{label} is missing or is not a regular file: {path}")

    lines = checksum.read_text(encoding="ascii").splitlines()
    pattern = re.compile(rf"^([0-9a-f]{{64}})  {re.escape(archive.name)}$")
    match = pattern.fullmatch(lines[0]) if len(lines) == 1 else None
    if not match:
        raise ValueError("source archive checksum sidecar is malformed or names another file")
    actual_archive_sha = sha256_file(archive)
    if match.group(1) != actual_archive_sha:
        raise ValueError(
            "source archive checksum mismatch "
            f"(expected {match.group(1)}, got {actual_archive_sha})"
        )

    archived_release = read_archive_manifest(archive)
    current_release = current_release_manifest(root, args)
    if archived_release != current_release:
        raise ValueError("source archive release manifest does not match the current frozen release")
    validate_archived_native_patches(archive, current_release["nativeSource"])
    print(
        "Source offer release identity verified: "
        f"{current_release['engine']['build']} ({actual_archive_sha})"
    )


def add_native_overrides(parser: argparse.ArgumentParser) -> None:
    # Explicit values keep fixture tests small. Production callers omit these so
    # the trusted local verifier recomputes the complete native patch identity.
    parser.add_argument("--native-patch-manifest")
    parser.add_argument("--native-openq4-diff")
    parser.add_argument("--native-gamelibs-diff")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    subparsers = parser.add_subparsers(dest="command", required=True)

    write = subparsers.add_parser("write", help="write a release manifest into a staged tree")
    write.add_argument("--root", type=pathlib.Path, required=True)
    write.add_argument("--output", type=pathlib.Path, required=True)
    write.add_argument("--openq4", required=True)
    write.add_argument("--gamelibs", required=True)
    write.add_argument("--patch-manifest", required=True)
    write.add_argument("--runtime-source", required=True)
    add_native_overrides(write)
    write.set_defaults(handler=write_manifest)

    verify = subparsers.add_parser("verify", help="verify an archive against the frozen release")
    verify.add_argument("--root", type=pathlib.Path, required=True)
    verify.add_argument("--archive", type=pathlib.Path)
    add_native_overrides(verify)
    verify.set_defaults(handler=verify_archive)
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    try:
        args.handler(args)
    except (OSError, UnicodeError, ValueError) as error:
        raise SystemExit(f"source-offer-identity: {error}") from error


if __name__ == "__main__":
    main()
