#!/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 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"


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_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) -> 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")

    sums_sha = validate_engine_checksums(engine_dir)
    return {
        "schema": 1,
        "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"],
        },
    }


def write_manifest(args: argparse.Namespace) -> None:
    root = args.root.resolve()
    release = current_release_manifest(root)
    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 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)
    if archived_release != current_release:
        raise ValueError("source archive release manifest does not match the current frozen release")
    print(
        "Source offer release identity verified: "
        f"{current_release['engine']['build']} ({actual_archive_sha})"
    )


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)
    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)
    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()
