#!/usr/bin/env python3
"""Build the self-authored, deterministic Q4 Web baseoq4 runtime packs."""

from __future__ import annotations

import argparse
import hashlib
import json
import pathlib
import sys
import tempfile
import zipfile


PACKS = ("pak0.pk4", "pak1.pk4")
PACK_SOURCES = {"pak0.pk4": "pak0", "pak1.pk4": "pak1"}
REQUIRED = {
    "pak0.pk4": {"default.cfg", "license.txt"},
    "pak1.pk4": {"license.txt", "readme.txt"},
}
TIMESTAMP = (1980, 1, 1, 0, 0, 0)
CHUNK_SIZE = 1024 * 1024


def sha256_file(file: pathlib.Path) -> str:
    digest = hashlib.sha256()
    with file.open("rb") as source:
        while chunk := source.read(CHUNK_SIZE):
            digest.update(chunk)
    return digest.hexdigest()


def md5_file(file: pathlib.Path) -> str:
    digest = hashlib.md5()
    with file.open("rb") as source:
        while chunk := source.read(CHUNK_SIZE):
            digest.update(chunk)
    return digest.hexdigest()


def source_files(source_root: pathlib.Path) -> list[tuple[pathlib.Path, str]]:
    result: list[tuple[pathlib.Path, str]] = []
    for file in sorted(source_root.rglob("*"), key=lambda item: item.relative_to(source_root).as_posix().casefold()):
        if file.is_symlink():
            raise ValueError(f"runtime source contains a symlink: {file}")
        if not file.is_file():
            continue
        relative = file.relative_to(source_root).as_posix()
        parts = relative.split("/")
        if any(part in {"", ".", ".."} for part in parts):
            raise ValueError(f"runtime source has an unsafe path: {relative}")
        result.append((file, relative))
    return result


def source_fingerprint(source_root: pathlib.Path) -> str:
    digest = hashlib.sha256()
    for file, relative in source_files(source_root):
        digest.update(relative.encode("utf-8"))
        digest.update(b"\0")
        digest.update(sha256_file(file).encode("ascii"))
        digest.update(b"\n")
    return digest.hexdigest()


def build_pack(source_root: pathlib.Path, output: pathlib.Path, name: str) -> dict[str, object]:
    source = source_root / PACK_SOURCES[name]
    if not source.is_dir() or source.is_symlink():
        raise ValueError(f"missing regular source directory for {name}: {source}")
    files = source_files(source)
    names = {relative.casefold() for _, relative in files}
    missing = sorted(REQUIRED[name] - names)
    if missing:
        raise ValueError(f"{name} source is missing required files: {', '.join(missing)}")
    output.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.NamedTemporaryFile(prefix=f".{name}.", suffix=".tmp", dir=output.parent, delete=False) as handle:
        temporary = pathlib.Path(handle.name)
    try:
        with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
            for file, relative in files:
                info = zipfile.ZipInfo(relative, date_time=TIMESTAMP)
                info.compress_type = zipfile.ZIP_DEFLATED
                info.external_attr = 0o644 << 16
                with file.open("rb") as source_file, archive.open(info, "w") as target:
                    while chunk := source_file.read(CHUNK_SIZE):
                        target.write(chunk)
        temporary.replace(output)
        output.chmod(0o644)
    finally:
        temporary.unlink(missing_ok=True)
    return {
        "name": name,
        "bytes": output.stat().st_size,
        "sha256": sha256_file(output),
        "md5": md5_file(output),
        "files": len(files),
    }


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    parser.add_argument("--source", required=True, type=pathlib.Path)
    parser.add_argument("--output", type=pathlib.Path)
    parser.add_argument("--manifest", type=pathlib.Path)
    parser.add_argument("--fingerprint-only", action="store_true")
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    source = args.source.resolve()
    if not source.is_dir() or source.is_symlink():
        print(f"invalid free runtime source: {source}", file=sys.stderr)
        return 1
    try:
        fingerprint = source_fingerprint(source)
        if args.fingerprint_only:
            if args.output or args.manifest:
                raise ValueError("--fingerprint-only cannot be combined with --output or --manifest")
            print(fingerprint)
            return 0
        if not args.output:
            raise ValueError("--output is required unless --fingerprint-only is used")
        output = args.output.resolve()
        packs = [build_pack(source, output / name, name) for name in PACKS]
        payload = {
            "schema": 1,
            "policy": "free-only-v1",
            "sourceSha256": fingerprint,
            "packs": packs,
        }
    except (OSError, ValueError, zipfile.BadZipFile) as error:
        print(f"free runtime build failed: {error}", file=sys.stderr)
        return 1
    encoded = json.dumps(payload, indent=2, sort_keys=True) + "\n"
    if args.manifest:
        args.manifest.parent.mkdir(parents=True, exist_ok=True)
        args.manifest.write_text(encoded, encoding="utf-8")
    sys.stdout.write(encoded)
    return 0


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