#!/usr/bin/env python3
"""Verify native and browser baseoq4 overlays expose identical entityDefs."""

from __future__ import annotations

import argparse
import functools
import pathlib
import re
import stat
import sys
import zipfile
from dataclasses import dataclass


NUMBERED_PAK = re.compile(r"^pak([0-9]+)\.pk4$", re.IGNORECASE)


@dataclass(frozen=True)
class Token:
    value: str
    quoted: bool
    line: int


@dataclass(frozen=True)
class DeclFile:
    path: str
    source: pathlib.Path
    contents: str


def regular_archive(path: pathlib.Path, label: str) -> None:
    try:
        mode = path.lstat().st_mode
    except FileNotFoundError as error:
        raise ValueError(f"{label} is missing: {path}") from error
    if path.is_symlink() or not stat.S_ISREG(mode) or path.stat().st_size <= 0:
        raise ValueError(f"{label} is not a non-empty regular file: {path}")


def compare_pack_names(left: pathlib.Path, right: pathlib.Path) -> int:
    left_name = left.name
    right_name = right.name
    left_match = NUMBERED_PAK.fullmatch(left_name)
    right_match = NUMBERED_PAK.fullmatch(right_name)

    if left_match and right_match:
        left_digits = left_match.group(1)
        right_digits = right_match.group(1)
        if len(left_digits) != len(right_digits):
            return len(right_digits) - len(left_digits)
        left_number = int(left_digits)
        right_number = int(right_digits)
        if left_number != right_number:
            return left_number - right_number

    if bool(left_match) != bool(right_match):
        left_key = "pak" if left_match else left_name.casefold()
        right_key = "pak" if right_match else right_name.casefold()
        if left_key != right_key:
            return -1 if left_key < right_key else 1
        return -1 if left_match else 1

    left_key = left_name.casefold()
    right_key = right_name.casefold()
    if left_key == right_key:
        return 0
    return -1 if left_key < right_key else 1


def normalized_def_path(name: str) -> str | None:
    normalized = name.replace("\\", "/").strip("/")
    parts = normalized.split("/")
    if len(parts) < 2 or any(part in {"", ".", ".."} for part in parts):
        return None
    folded = "/".join(part.casefold() for part in parts)
    if not folded.startswith("def/") or not folded.endswith(".def"):
        return None
    return folded


def archive_decl_files(archive_path: pathlib.Path) -> dict[str, DeclFile]:
    result: dict[str, DeclFile] = {}
    try:
        with zipfile.ZipFile(archive_path) as archive:
            for entry in archive.infolist():
                if entry.is_dir():
                    continue
                virtual_path = normalized_def_path(entry.filename)
                if virtual_path is None:
                    continue
                if virtual_path in result:
                    raise ValueError(
                        f"archive contains duplicate case-insensitive def path "
                        f"{virtual_path}: {archive_path}"
                    )
                try:
                    contents = archive.read(entry).decode("utf-8-sig")
                except UnicodeDecodeError as error:
                    raise ValueError(
                        f"def file is not valid UTF-8: {archive_path}:{entry.filename}"
                    ) from error
                result[virtual_path] = DeclFile(virtual_path, archive_path, contents)
    except (OSError, zipfile.BadZipFile) as error:
        raise ValueError(f"invalid PK4 archive {archive_path}: {error}") from error
    return result


def visible_decl_files(archives: list[pathlib.Path], label: str) -> dict[str, DeclFile]:
    names: set[str] = set()
    for archive in archives:
        regular_archive(archive, f"{label} archive")
        name = archive.name.casefold()
        if name in names:
            raise ValueError(f"{label} repeats PK4 basename {archive.name}")
        names.add(name)

    load_order = sorted(archives, key=functools.cmp_to_key(compare_pack_names))
    visible: dict[str, DeclFile] = {}
    # AddGameDirectory inserts each later archive closer to the search-path head.
    for archive in reversed(load_order):
        for virtual_path, decl_file in archive_decl_files(archive).items():
            visible.setdefault(virtual_path, decl_file)
    return visible


def lex_decl(contents: str, source: str) -> list[Token]:
    tokens: list[Token] = []
    index = 0
    line = 1
    length = len(contents)

    while index < length:
        char = contents[index]
        if char.isspace():
            if char == "\n":
                line += 1
            index += 1
            continue
        if contents.startswith("//", index):
            newline = contents.find("\n", index + 2)
            if newline < 0:
                break
            index = newline
            continue
        if contents.startswith("/*", index):
            end = contents.find("*/", index + 2)
            if end < 0:
                raise ValueError(f"unterminated block comment in {source}:{line}")
            line += contents.count("\n", index, end + 2)
            index = end + 2
            continue
        if char == '"':
            token_line = line
            index += 1
            value: list[str] = []
            while index < length:
                char = contents[index]
                if char == '"':
                    index += 1
                    tokens.append(Token("".join(value), True, token_line))
                    break
                if char == "\\" and index + 1 < length:
                    value.append(contents[index + 1])
                    index += 2
                    continue
                if char == "\n":
                    line += 1
                value.append(char)
                index += 1
            else:
                raise ValueError(f"unterminated string in {source}:{token_line}")
            continue
        if char in "{}":
            tokens.append(Token(char, False, line))
            index += 1
            continue

        token_line = line
        start = index
        while index < length:
            char = contents[index]
            if char.isspace() or char in '{}"' or contents.startswith("//", index) or contents.startswith("/*", index):
                break
            index += 1
        if index == start:
            raise ValueError(f"cannot tokenize {source}:{line}")
        tokens.append(Token(contents[start:index], False, token_line))
    return tokens


def entity_defs(decl_file: DeclFile) -> tuple[str, ...]:
    source = f"{decl_file.source}:{decl_file.path}"
    tokens = lex_decl(decl_file.contents, source)
    names: list[str] = []
    depth = 0
    index = 0
    while index < len(tokens):
        token = tokens[index]
        if not token.quoted and token.value == "{":
            depth += 1
        elif not token.quoted and token.value == "}":
            depth -= 1
            if depth < 0:
                raise ValueError(f"unmatched closing brace in {source}:{token.line}")
        elif depth == 0 and not token.quoted and token.value.casefold() == "entitydef":
            if index + 1 >= len(tokens) or tokens[index + 1].value in {"{", "}"}:
                raise ValueError(f"entityDef has no name in {source}:{token.line}")
            names.append(tokens[index + 1].value.casefold())
            index += 1
        index += 1
    if depth != 0:
        raise ValueError(f"unbalanced braces in {source}")
    return tuple(names)


def engine_path_order(paths: set[str]) -> list[str]:
    tree: dict[str, object] = {"files": [], "dirs": {}}
    for virtual_path in paths:
        relative = virtual_path.split("/", 1)[1]
        parts = relative.split("/")
        node = tree
        for directory in parts[:-1]:
            directories = node["dirs"]
            assert isinstance(directories, dict)
            node = directories.setdefault(directory, {"files": [], "dirs": {}})
            assert isinstance(node, dict)
        files = node["files"]
        assert isinstance(files, list)
        files.append(virtual_path)

    ordered: list[str] = []

    def visit(node: dict[str, object]) -> None:
        files = node["files"]
        directories = node["dirs"]
        assert isinstance(files, list)
        assert isinstance(directories, dict)
        ordered.extend(sorted(files, key=str.casefold))
        for name in sorted(directories, key=str.casefold):
            child = directories[name]
            assert isinstance(child, dict)
            visit(child)

    visit(tree)
    return ordered


def profile(archives: list[pathlib.Path], label: str) -> list[tuple[str, tuple[str, ...]]]:
    visible = visible_decl_files(archives, label)
    result = []
    for virtual_path in engine_path_order(set(visible)):
        names = entity_defs(visible[virtual_path])
        if names:
            result.append((virtual_path, names))
    return result


def format_profile(entries: list[tuple[str, tuple[str, ...]]]) -> str:
    if not entries:
        return "  (none)"
    return "\n".join(f"  {path}: {', '.join(names)}" for path, names in entries)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    parser.add_argument("--native-pack", action="append", required=True, type=pathlib.Path)
    parser.add_argument("--browser-pack", action="append", required=True, type=pathlib.Path)
    parser.add_argument("--shared-pack", action="append", default=[], type=pathlib.Path)
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    try:
        shared = [path.resolve() for path in args.shared_pack]
        native = profile([path.resolve() for path in args.native_pack] + shared, "native")
        browser = profile([path.resolve() for path in args.browser_pack] + shared, "browser")
        if native != browser:
            raise ValueError(
                "native/browser entityDef parity mismatch\n"
                f"native:\n{format_profile(native)}\n"
                f"browser:\n{format_profile(browser)}"
            )
    except ValueError as error:
        print(f"entityDef parity check failed: {error}", file=sys.stderr)
        return 1

    declaration_count = sum(len(names) for _, names in native)
    print(
        f"entityDef parity ok: {declaration_count} declarations "
        f"in {len(native)} virtual def files"
    )
    return 0


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