#!/usr/bin/env python3
"""Fail-closed structural, Mach-O, symbol and dSYM audit for Apple VLCKit."""

from __future__ import annotations

import argparse
import hashlib
import json
import plistlib
import re
import subprocess
import sys
from pathlib import Path, PurePosixPath

from audit_static_modules import (
    GPL_MARKER,
    contains_exact_c_string,
    reject_absolute_symlink_components,
)
from macho_audit import run_xcrun

ENTRY_RE = re.compile(r"^_vlc_entry__[A-Za-z0-9_]+$")
PLATFORM = {
    ("ios", None): ({"IOS", "2"}, "LC_VERSION_MIN_IPHONEOS"),
    ("ios", "simulator"): ({"IOSSIMULATOR", "7"}, None),
    ("tvos", None): ({"TVOS", "3"}, "LC_VERSION_MIN_TVOS"),
    ("tvos", "simulator"): ({"TVOSSIMULATOR", "8"}, None),
}
PRIVATE_HEADERS = {
    "VLCCustomDialogProvider.h",
    "VLCEmbeddedDialogProvider.h",
    "VLCEventsHandler.h",
    "VLCFilter+Internal.h",
    "VLCHelperCode.h",
    "VLCiOSLegacyDialogProvider.h",
    "VLCLibVLCBridging.h",
    "VLCMediaPlayer+Internal.h",
}
DSYM_RELOCATION_ARCH = {"arm64": "aarch64", "x86_64": "x86_64"}
FORBIDDEN_SYMBOL_PREFIXES = ("_vbi_",)


def run(*args: str) -> str:
    return subprocess.run(
        args, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
    ).stdout


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


def approved_entries(path: Path) -> tuple[set[str], dict[str, str]]:
    rows = path.read_text(encoding="utf-8").splitlines()
    if not rows or rows[0] != "archive\tentry\tlicense_entry\tclassification":
        raise ValueError(f"invalid approved module inventory: {path}")
    entries: set[str] = set()
    archives: dict[str, str] = {}
    for row in rows[1:]:
        archive, entry, _license_entry, classification = row.split("\t")
        if classification == "lgpl":
            if entry in entries:
                raise ValueError(f"duplicate approved module entry {entry} ({archive})")
            entries.add(entry)
            archives[archive] = entry
    if not entries:
        raise ValueError("approved module entry set is empty")
    return entries | {"vlc_entry__core"}, archives


def audit_generated_builtins(inventory: Path, approved: dict[str, str]) -> None:
    builtins = inventory.parent / "builtins.tsv"
    rows = builtins.read_text(encoding="utf-8").splitlines()
    if not rows or rows[0] != "archive\tentry_symbol":
        raise ValueError(f"invalid generated builtins inventory: {builtins}")
    generated: dict[str, str] = {}
    for row in rows[1:]:
        archive, entry = row.split("\t")
        if archive in generated:
            raise ValueError(f"duplicate generated built-in archive: {archive}")
        generated[archive] = entry
    if generated != approved:
        raise ValueError("generated VLC builtins do not exactly match the approved LGPL archives")
    headers = sorted(inventory.parent.glob("vlc-plugins-*.h"))
    if len(headers) != 1:
        raise ValueError(f"expected one generated plugin header beside {inventory}")
    header_entries = set(re.findall(r"\b(vlc_entry__[A-Za-z0-9_]+)\b", headers[0].read_text()))
    if header_entries != set(approved.values()):
        raise ValueError("generated VLC plugin header does not match approved built-in entries")


def macho_platform(load_commands: str, platform: str, variant: str | None, minimum: str) -> None:
    accepted, legacy = PLATFORM[(platform, variant)]
    build = re.search(
        r"cmd LC_BUILD_VERSION.*?\n\s*platform\s+(\S+).*?\n\s*minos\s+(\S+)",
        load_commands,
        re.DOTALL,
    )
    if build:
        actual_platform, minos = build.groups()
        if actual_platform.upper() not in accepted or minos != minimum:
            raise ValueError(
                f"Mach-O platform/minos {actual_platform}/{minos}, expected {accepted}/{minimum}"
            )
        return
    if legacy:
        old = re.search(
            rf"cmd {legacy}.*?\n\s*cmdsize\s+\d+.*?\n\s*version\s+(\S+)",
            load_commands,
            re.DOTALL,
        )
        if old and old.group(1) == minimum:
            return
    raise ValueError(f"Mach-O lacks the expected {platform}/{variant or 'device'} minimum {minimum}")


def uuids(path: Path) -> dict[str, str]:
    output = run_xcrun("dwarfdump", "--uuid", str(path))
    records = re.findall(r"UUID: ([0-9A-Fa-f-]+) \(([^)]+)\)", output)
    if not records:
        raise ValueError(f"no Mach-O UUIDs found in {path}")
    arches = [arch for _value, arch in records]
    if len(arches) != len(set(arches)):
        raise ValueError(f"duplicate Mach-O UUID architecture records in {path}: {arches}")
    parsed = {arch: value.upper() for value, arch in records}
    return parsed


def dependencies(binary: Path, architecture: str, framework_name: str) -> None:
    lines = run_xcrun("otool", "-L", "-arch", architecture, str(binary)).splitlines()[1:]
    names = [line.strip().split(" (compatibility", 1)[0] for line in lines if line.strip()]
    expected_id = f"@rpath/{framework_name}.framework/{framework_name}"
    if not names or names[0] != expected_id:
        raise ValueError(f"unexpected install name for {binary}: {names[:1]}")
    for dependency in names[1:]:
        if not dependency.startswith(("/System/Library/Frameworks/", "/usr/lib/")):
            raise ValueError(f"non-system dynamic dependency in {binary}: {dependency}")


def reject_forbidden_symbols(
    raw_symbols: list[str], binary: Path, architecture: str
) -> None:
    forbidden_symbols = sorted(
        symbol
        for symbol in raw_symbols
        if symbol.startswith(FORBIDDEN_SYMBOL_PREFIXES)
    )
    if forbidden_symbols:
        raise ValueError(
            f"forbidden zVBI symbols survived final link in {binary} "
            f"({architecture}): {forbidden_symbols}"
        )


def validate_private_headers(framework: Path) -> None:
    directory = framework / "PrivateHeaders"
    if directory.is_symlink() or not directory.is_dir():
        raise ValueError(f"missing or symlinked PrivateHeaders directory: {directory}")
    entries = list(directory.iterdir())
    names = {entry.name for entry in entries}
    if names != PRIVATE_HEADERS:
        raise ValueError(
            f"unexpected private header set in {directory}: {sorted(names ^ PRIVATE_HEADERS)}"
        )
    if any(entry.is_symlink() or not entry.is_file() or entry.stat().st_size == 0 for entry in entries):
        raise ValueError(f"PrivateHeaders must contain only nonempty regular files: {directory}")


def safe_child(root: Path, relative: str, description: str) -> Path:
    value = PurePosixPath(relative)
    if value.is_absolute() or not value.parts or any(part in ("", ".", "..") for part in value.parts):
        raise ValueError(f"unsafe {description} path: {relative}")
    current = root
    if current.is_symlink():
        raise ValueError(f"symlinked {description} root: {current}")
    for part in value.parts:
        current = current / part
        if current.is_symlink():
            raise ValueError(f"symlinked {description} component: {current}")
    return current


def slice_debug_binary(
    slice_dir: Path,
    library: dict[str, object],
    product: str,
    architectures: set[str],
) -> tuple[Path, Path]:
    debug_path = library.get("DebugSymbolsPath")
    if not isinstance(debug_path, str):
        raise ValueError(f"XCFramework slice has no DebugSymbolsPath: {slice_dir}")
    debug_root = safe_child(slice_dir, debug_path, "DebugSymbolsPath")
    expected_bundle = debug_root / f"{product}.framework.dSYM"
    if debug_root.is_symlink() or not debug_root.is_dir():
        raise ValueError(f"missing or symlinked dSYM root: {debug_root}")
    if {path.name for path in debug_root.iterdir()} != {expected_bundle.name}:
        raise ValueError(f"expected exactly {expected_bundle.name} beneath {debug_root}")
    if expected_bundle.is_symlink() or not expected_bundle.is_dir():
        raise ValueError(f"dSYM must be a regular directory: {expected_bundle}")

    contents = expected_bundle / "Contents"
    resources = contents / "Resources"
    dwarf = resources / "DWARF"
    if {path.name for path in expected_bundle.iterdir()} != {"Contents"}:
        raise ValueError(f"unexpected dSYM bundle payload: {expected_bundle}")
    if contents.is_symlink() or not contents.is_dir() or {
        path.name for path in contents.iterdir()
    } != {"Info.plist", "Resources"}:
        raise ValueError(f"unexpected dSYM Contents payload: {contents}")
    info = contents / "Info.plist"
    if info.is_symlink() or not info.is_file() or info.stat().st_size == 0:
        raise ValueError(f"dSYM Info.plist must be a nonempty regular file: {info}")
    if resources.is_symlink() or not resources.is_dir():
        raise ValueError(f"missing or symlinked dSYM Resources: {resources}")
    resource_names = {path.name for path in resources.iterdir()}
    if not {"DWARF"} <= resource_names <= {"DWARF", "Relocations"}:
        raise ValueError(f"unexpected dSYM Resources payload: {sorted(resource_names)}")
    if dwarf.is_symlink() or not dwarf.is_dir():
        raise ValueError(f"missing or symlinked dSYM DWARF directory: {dwarf}")
    dwarf_files = sorted(dwarf.iterdir())
    if (
        len(dwarf_files) != 1
        or dwarf_files[0].name != product
        or dwarf_files[0].is_symlink()
        or not dwarf_files[0].is_file()
    ):
        raise ValueError(f"unexpected dSYM DWARF payload: {dwarf_files}")

    relocations = resources / "Relocations"
    if relocations.exists():
        if relocations.is_symlink() or not relocations.is_dir():
            raise ValueError(f"dSYM Relocations must be a regular directory: {relocations}")
        relocation_arches = {path.name for path in relocations.iterdir()}
        try:
            expected_relocation_arches = {
                DSYM_RELOCATION_ARCH[architecture] for architecture in architectures
            }
        except KeyError as error:
            raise ValueError(f"unsupported dSYM relocation architecture: {error.args[0]}") from error
        if relocation_arches != expected_relocation_arches:
            raise ValueError(
                f"unexpected dSYM relocation architectures: {sorted(relocation_arches)}; "
                f"expected {sorted(expected_relocation_arches)}"
            )
        for architecture in relocation_arches:
            arch_dir = relocations / architecture
            expected_yaml = arch_dir / f"{product}.yml"
            if arch_dir.is_symlink() or not arch_dir.is_dir() or {
                path.name for path in arch_dir.iterdir()
            } != {expected_yaml.name}:
                raise ValueError(f"unexpected dSYM relocation payload: {arch_dir}")
            if (
                expected_yaml.is_symlink()
                or not expected_yaml.is_file()
                or expected_yaml.stat().st_size == 0
            ):
                raise ValueError(f"invalid dSYM relocation file: {expected_yaml}")
    return dwarf_files[0], expected_bundle


def scan_dsym_payloads(bundle: Path, forbidden_prefix: str) -> None:
    for payload in sorted(bundle.rglob("*")):
        if payload.is_dir():
            continue
        if payload.is_symlink() or not payload.is_file():
            raise ValueError(f"special or symlinked dSYM payload: {payload}")
        data = payload.read_bytes()
        if forbidden_prefix.encode() in data or b"/Users/" in data:
            raise ValueError(f"build workspace path survived in dSYM payload: {payload}")


def audit(
    xcframework: Path,
    product: str,
    platform: str,
    minimum: str,
    approved_inventory: Path,
    forbidden_prefix: str,
    report_path: Path,
) -> None:
    if (product, platform) not in {("MobileVLCKit", "ios"), ("TVVLCKit", "tvos")}:
        raise ValueError(f"invalid product/platform pairing: {product}/{platform}")
    reject_absolute_symlink_components(xcframework)
    if xcframework.is_symlink() or not xcframework.is_dir():
        raise ValueError(f"missing or symlinked XCFramework: {xcframework}")
    symlinks = [path for path in xcframework.rglob("*") if path.is_symlink()]
    if symlinks:
        raise ValueError(f"XCFramework contains symlinked payload: {symlinks[:5]}")
    plist_path = xcframework / "Info.plist"
    with plist_path.open("rb") as source:
        info = plistlib.load(source)
    libraries = info.get("AvailableLibraries")
    if not isinstance(libraries, list) or len(libraries) != 2:
        raise ValueError("XCFramework must contain exactly one device and one simulator library")

    expected_slices = {
        (platform, None): {"arm64"},
        (platform, "simulator"): {"arm64", "x86_64"},
    }
    actual_slices: dict[tuple[str, str | None], set[str]] = {}
    if approved_inventory.is_symlink() or not approved_inventory.is_file():
        raise ValueError(f"missing or symlinked approved inventory: {approved_inventory}")
    expected_entries, approved_archives = approved_entries(approved_inventory)
    audit_generated_builtins(approved_inventory, approved_archives)
    entry_digest = hashlib.sha256(
        "".join(f"{entry}\n" for entry in sorted(expected_entries)).encode()
    ).hexdigest()
    report: list[dict[str, object]] = []

    for library in libraries:
        if not isinstance(library, dict):
            raise ValueError("invalid XCFramework library record")
        supported_platform = library.get("SupportedPlatform")
        variant = library.get("SupportedPlatformVariant")
        if not isinstance(supported_platform, str) or variant not in (None, "simulator"):
            raise ValueError(f"invalid XCFramework platform record: {library}")
        architecture_list = library.get("SupportedArchitectures")
        if not isinstance(architecture_list, list) or not all(
            isinstance(architecture, str) for architecture in architecture_list
        ):
            raise ValueError(f"invalid SupportedArchitectures: {architecture_list}")
        architectures = set(architecture_list)
        if len(architectures) != len(architecture_list):
            raise ValueError(f"duplicate SupportedArchitectures: {architecture_list}")
        identity = (supported_platform, variant)
        actual_slices[identity] = architectures
        identifier = library.get("LibraryIdentifier")
        library_path = library.get("LibraryPath")
        if not isinstance(identifier, str) or not isinstance(library_path, str):
            raise ValueError(f"invalid XCFramework library paths: {library}")
        slice_dir = safe_child(xcframework, identifier, "LibraryIdentifier")
        framework = safe_child(slice_dir, library_path, "LibraryPath")
        if framework.is_symlink() or not framework.is_dir():
            raise ValueError(f"missing or symlinked framework slice: {framework}")
        framework_plist = framework / "Info.plist"
        with framework_plist.open("rb") as source:
            bundle_info = plistlib.load(source)
        executable_name = bundle_info.get("CFBundleExecutable")
        if executable_name != product:
            raise ValueError(f"unexpected CFBundleExecutable in {framework_plist}: {executable_name}")
        binary = framework / product
        if binary.is_symlink() or not binary.is_file():
            raise ValueError(f"missing or symlinked framework executable: {binary}")
        framework_entries = {path.name for path in framework.iterdir()}
        expected_framework_entries = {
            product,
            "Headers",
            "PrivateHeaders",
            "Modules",
            "Info.plist",
        }
        if framework_entries != expected_framework_entries:
            raise ValueError(
                f"unexpected framework payload in {framework}: "
                f"{sorted(framework_entries ^ expected_framework_entries)}"
            )
        if not (framework / "Headers").is_dir() or not (framework / "Modules/module.modulemap").is_file():
            raise ValueError(f"framework headers/module map are incomplete: {framework}")
        validate_private_headers(framework)
        binary_bytes = binary.read_bytes()
        if contains_exact_c_string(binary_bytes, GPL_MARKER):
            raise ValueError(f"GPL VLC marker survived final link: {binary}")
        if forbidden_prefix.encode() in binary_bytes or b"/Users/" in binary_bytes:
            raise ValueError(f"build workspace path survived final link: {binary}")

        lipo_arches = set(run_xcrun("lipo", "-archs", str(binary)).split())
        if lipo_arches != architectures:
            raise ValueError(f"plist/Mach-O architecture mismatch in {binary}")
        framework_uuids = uuids(binary)
        debug_binary, debug_bundle = slice_debug_binary(
            slice_dir, library, product, architectures
        )
        debug_path = library["DebugSymbolsPath"]
        allowed_slice_entries = {PurePosixPath(library_path).parts[0], PurePosixPath(str(debug_path)).parts[0]}
        actual_slice_entries = {path.name for path in slice_dir.iterdir()}
        if actual_slice_entries != allowed_slice_entries:
            raise ValueError(
                f"unreferenced XCFramework slice payload in {slice_dir}: "
                f"{sorted(actual_slice_entries - allowed_slice_entries)}"
            )
        scan_dsym_payloads(debug_bundle, forbidden_prefix)
        debug_uuids = uuids(debug_binary)
        if framework_uuids != debug_uuids or set(framework_uuids) != architectures:
            raise ValueError(f"framework/dSYM UUID mismatch for {binary}")

        for architecture in sorted(architectures):
            headers = run_xcrun("otool", "-hv", "-arch", architecture, str(binary))
            header_rows = [line.split() for line in headers.splitlines() if line.lstrip().startswith("MH_MAGIC")]
            if len(header_rows) != 1 or len(header_rows[0]) < 5 or header_rows[0][4] != "DYLIB":
                raise ValueError(f"{binary} ({architecture}) is not exactly one MH_DYLIB")
            load_commands = run_xcrun("otool", "-l", "-arch", architecture, str(binary))
            macho_platform(load_commands, platform, variant, minimum)
            if "LC_CODE_SIGNATURE" in load_commands or "segname __LLVM" in load_commands:
                raise ValueError(f"signed or bitcode payload found in {binary} ({architecture})")
            raw_symbols = [
                line.strip()
                for line in run_xcrun("nm", "-arch", architecture, "-Uj", str(binary)).splitlines()
            ]
            reject_forbidden_symbols(raw_symbols, binary, architecture)
            module_symbols = [symbol.removeprefix("_") for symbol in raw_symbols if ENTRY_RE.fullmatch(symbol)]
            if len(module_symbols) != len(set(module_symbols)):
                raise ValueError(f"duplicate final module entries in {binary} ({architecture})")
            if set(module_symbols) != expected_entries:
                raise ValueError(
                    f"final module entry set differs in {binary} ({architecture}); "
                    f"missing={sorted(expected_entries - set(module_symbols))}, "
                    f"extra={sorted(set(module_symbols) - expected_entries)}"
                )
            if "_vlc_static_modules" not in raw_symbols:
                raise ValueError(f"_vlc_static_modules is missing from {binary} ({architecture})")
            dependencies(binary, architecture, product)

        report.append(
            {
                "identifier": identifier,
                "platform": supported_platform,
                "variant": variant or "device",
                "architectures": sorted(architectures),
                "binary_sha256": sha256_file(binary),
                "entry_set_sha256": entry_digest,
                "uuids": framework_uuids,
            }
        )

    if actual_slices != expected_slices:
        raise ValueError(f"unexpected XCFramework slices: {actual_slices} != {expected_slices}")
    allowed_root_entries = {"Info.plist"} | {
        str(library["LibraryIdentifier"]) for library in libraries
    }
    actual_root_entries = {path.name for path in xcframework.iterdir()}
    if actual_root_entries != allowed_root_entries:
        raise ValueError(
            f"unreferenced XCFramework root payload: {sorted(actual_root_entries - allowed_root_entries)}"
        )
    report_path.parent.mkdir(parents=True, exist_ok=True)
    report_path.write_text(
        json.dumps(
            sorted(report, key=lambda row: str(row["identifier"])), indent=2, sort_keys=True
        )
        + "\n"
    )


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--xcframework", required=True, type=Path)
    parser.add_argument("--product", required=True, choices=("MobileVLCKit", "TVVLCKit"))
    parser.add_argument("--platform", required=True, choices=("ios", "tvos"))
    parser.add_argument("--minimum-os", required=True)
    parser.add_argument("--approved-inventory", required=True, type=Path)
    parser.add_argument("--forbidden-prefix", required=True)
    parser.add_argument("--report", required=True, type=Path)
    args = parser.parse_args()
    try:
        audit(
            args.xcframework.absolute(),
            args.product,
            args.platform,
            args.minimum_os,
            args.approved_inventory.absolute(),
            args.forbidden_prefix,
            args.report.resolve(),
        )
    except (OSError, ValueError, subprocess.CalledProcessError, plistlib.InvalidFileException) as error:
        print(f"ERROR: {error}", file=sys.stderr)
        return 1
    return 0


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