#!/usr/bin/env python3
"""Build a deterministic source PK4 from a self-authored Q4 map tree."""

from __future__ import annotations

import argparse
import hashlib
import json
import pathlib
import re
import stat
import tempfile
import zipfile


TIMESTAMP = (1980, 1, 1, 0, 0, 0)
SAFE_PATH = re.compile(r"^[A-Za-z0-9_.\-/]+$")
REQUIRED_SUFFIXES = (".map", ".proc", ".cm", ".aas32")


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


def source_files(source: pathlib.Path) -> list[tuple[pathlib.Path, str]]:
    maps = source / "maps"
    if not maps.is_dir() or maps.is_symlink():
        raise ValueError(f"missing regular maps directory: {maps}")

    files: list[tuple[pathlib.Path, str]] = []
    for entry in sorted(maps.rglob("*"), key=lambda item: item.relative_to(source).as_posix().casefold()):
        if entry.is_symlink():
            raise ValueError(f"map source contains a symlink: {entry}")
        if not entry.is_file():
            continue
        relative = entry.relative_to(source).as_posix()
        parts = relative.split("/")
        if not SAFE_PATH.fullmatch(relative) or any(part in {"", ".", ".."} for part in parts):
            raise ValueError(f"unsafe map source path: {relative}")
        files.append((entry, relative))

    names = {relative.casefold() for _, relative in files}
    stems = {
        relative[: -len(suffix)]
        for relative in names
        for suffix in REQUIRED_SUFFIXES
        if relative.endswith(suffix)
    }
    complete = [stem for stem in stems if all(f"{stem}{suffix}" in names for suffix in REQUIRED_SUFFIXES)]
    if len(complete) != 1:
        raise ValueError("map source must contain exactly one complete .map/.proc/.cm/.aas32 product set")
    if len(files) != len(REQUIRED_SUFFIXES):
        raise ValueError("deterministic map source PK4 accepts only the four canonical map products")
    return files


def sha256_file(path: pathlib.Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        while chunk := stream.read(1024 * 1024):
            digest.update(chunk)
    return digest.hexdigest()


def main() -> int:
    args = parse_args()
    source = args.source.resolve()
    output = args.output.resolve()
    if not source.is_dir() or source.is_symlink():
        raise SystemExit(f"invalid map source root: {source}")

    try:
        files = source_files(source)
    except (OSError, ValueError) as error:
        raise SystemExit(f"deterministic map build failed: {error}") from error

    output.parent.mkdir(parents=True, exist_ok=True)
    with tempfile.NamedTemporaryFile(prefix=f".{output.name}.", 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.create_system = 3
                info.compress_type = zipfile.ZIP_DEFLATED
                info.external_attr = (stat.S_IFREG | 0o644) << 16
                with file.open("rb") as source_file, archive.open(info, "w") as target:
                    while chunk := source_file.read(1024 * 1024):
                        target.write(chunk)
        temporary.replace(output)
        output.chmod(0o644)
    finally:
        temporary.unlink(missing_ok=True)

    print(json.dumps({
        "bytes": output.stat().st_size,
        "entries": [relative for _, relative in files],
        "output": str(output),
        "sha256": sha256_file(output),
    }, indent=2, sort_keys=True))
    return 0


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