#!/usr/bin/env python3
"""Verify a packaged Apple VLCKit candidate without trusting its manifests."""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import re
import tarfile
from pathlib import Path, PurePosixPath

PLATFORM_PRODUCTS = {
    "ios": ("MobileVLCKit", "iPhone"),
    "tvos": ("TVVLCKit", "AppleTV"),
}
MODULE_AUDIT_BASE_FILES = {
    "approved-module-entries.tsv",
    "excluded-gpl-modules.tsv",
    "included-lgpl-modules.tsv",
    "module-inventory-digests.txt",
    "module-inventory.tsv",
}


def parse_platforms(value: str) -> tuple[str, ...]:
    if value == "ios":
        return ("ios",)
    if value == "ios,tvos":
        return ("ios", "tvos")
    raise ValueError("candidate platforms must be exactly ios or ios,tvos")


def artifact_scope(platforms: tuple[str, ...]) -> str:
    return "ios" if platforms == ("ios",) else "apple"


def expected_markers(platforms: tuple[str, ...]) -> set[str]:
    return {"VERIFIED", *(f"{platform}/VERIFIED" for platform in platforms)}
LINK_INPUT_AUDIT_FILES = {
    "contrib-archives.tsv",
    "contrib-platformless-members.tsv",
    "core-archives.tsv",
}


def module_audit_files(os_style: str) -> set[str]:
    return MODULE_AUDIT_BASE_FILES | {
        "builtins.tsv",
        f"vlc-plugins-{os_style}.h",
        f"vlc-plugins-{os_style}.xcconfig",
    }


def sha256(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 safe_relative(value: str) -> str:
    normalized = value.removeprefix("./")
    path = PurePosixPath(normalized)
    if path.is_absolute() or not path.parts or ".." in path.parts:
        raise ValueError(f"unsafe relative path: {value}")
    return path.as_posix()


def safe_link_target(member_name: str, link_name: str, *, symbolic: bool) -> None:
    target = PurePosixPath(link_name)
    if target.is_absolute():
        raise ValueError(f"absolute archive link target: {member_name} -> {link_name}")
    combined = (PurePosixPath(member_name).parent / target) if symbolic else target
    depth = 0
    for part in combined.parts:
        if part in ("", "."):
            continue
        if part == "..":
            depth -= 1
        else:
            depth += 1
        if depth < 0:
            raise ValueError(f"archive link escapes root: {member_name} -> {link_name}")


def exact_directory(root: Path, relative: str, expected: set[str]) -> None:
    directory = root / relative
    if directory.is_symlink() or not directory.is_dir():
        raise ValueError(f"missing or symlinked candidate directory: {relative}")
    actual = {path.name for path in directory.iterdir()}
    if actual != expected:
        raise ValueError(
            f"unexpected candidate topology beneath {relative}: "
            f"missing={sorted(expected - actual)}, extra={sorted(actual - expected)}"
        )


def verify_topology(
    root: Path,
    version: str,
    platforms: tuple[str, ...],
    *,
    verified: bool,
) -> None:
    scope = artifact_scope(platforms)
    root_entries = {
        "artifacts",
        "audit",
        "BUILD-MANIFEST.txt",
        "frameworks",
        "logs",
        "SHA256SUMS",
        "SOURCE-MANIFEST.txt",
        *platforms,
    }
    if verified:
        root_entries.add("VERIFIED")
    exact_directory(root, ".", root_entries)
    exact_directory(
        root,
        "artifacts",
        {
            *(
                f"beam-{PLATFORM_PRODUCTS[platform][0].lower()}-{platform}-{version}.tar.xz"
                for platform in platforms
            ),
            *(f"{PLATFORM_PRODUCTS[platform][0]}.podspec.json" for platform in platforms),
            f"beam-vlckit-{scope}-audit-{version}.tar.xz",
            f"beam-vlckit-{scope}-corresponding-source-{version}.tar.xz",
        },
    )
    exact_directory(
        root,
        "frameworks",
        {f"{PLATFORM_PRODUCTS[platform][0]}.xcframework" for platform in platforms},
    )
    for platform in platforms:
        product, _os_style = PLATFORM_PRODUCTS[platform]
        entries = {"COPYING.txt", f"{product}.xcframework"}
        if verified:
            entries.add("VERIFIED")
        exact_directory(root, platform, entries)

    exact_directory(
        root,
        "audit",
        {
            "link-inputs",
            "modules",
            "source-inputs-sha512.tsv",
            *(f"{platform}-xcframework.json" for platform in platforms),
        },
    )
    exact_directory(root, "audit/modules", set(platforms))
    exact_directory(root, "audit/link-inputs", set(platforms))
    for platform in platforms:
        _product, os_style = PLATFORM_PRODUCTS[platform]
        exact_directory(root, f"audit/modules/{platform}", {os_style})
        exact_directory(
            root,
            f"audit/modules/{platform}/{os_style}",
            module_audit_files(os_style),
        )
        exact_directory(root, f"audit/link-inputs/{platform}", LINK_INPUT_AUDIT_FILES)
    exact_directory(root, "logs", {f"build-{platform}.log" for platform in platforms})


def verified_marker_state(root: Path, platforms: tuple[str, ...]) -> bool:
    required = expected_markers(platforms)
    actual = {
        path.relative_to(root).as_posix()
        for path in root.rglob("*")
        if path.name == "VERIFIED"
    }
    if actual not in (set(), required):
        raise ValueError(f"candidate has an invalid VERIFIED marker set: {sorted(actual)}")
    for relative in actual:
        marker = root / relative
        if marker.is_symlink() or not marker.is_file() or marker.stat().st_size != 0:
            raise ValueError(f"VERIFIED marker must be an empty regular file: {relative}")
    return actual == required


def verify_sha_manifest(root: Path, platforms: tuple[str, ...]) -> None:
    manifest = root / "SHA256SUMS"
    rows: dict[str, str] = {}
    for raw in manifest.read_text(encoding="utf-8").splitlines():
        match = re.fullmatch(r"([0-9a-f]{64})  (.+)", raw)
        if not match:
            raise ValueError(f"invalid SHA256SUMS row: {raw}")
        digest, relative = match.groups()
        relative = safe_relative(relative)
        if relative in rows:
            raise ValueError(f"duplicate SHA256SUMS path: {relative}")
        rows[relative] = digest
    actual_files = {
        path.relative_to(root).as_posix()
        for path in root.rglob("*")
        if path.is_file()
        and path.relative_to(root).as_posix()
        not in expected_markers(platforms) | {"SHA256SUMS"}
    }
    if set(rows) != actual_files:
        raise ValueError(
            f"SHA256SUMS file set mismatch: missing={sorted(actual_files - set(rows))}, "
            f"extra={sorted(set(rows) - actual_files)}"
        )
    for relative, expected in sorted(rows.items()):
        actual = sha256(root / relative)
        if actual != expected:
            raise ValueError(f"SHA-256 mismatch for {relative}: {actual} != {expected}")


def tree_hashes(root: Path) -> dict[str, str]:
    return {
        path.relative_to(root).as_posix(): sha256(path)
        for path in sorted(root.rglob("*"))
        if path.is_file()
    }


def validate_tar(path: Path, *, allow_symlinks: bool) -> list[tarfile.TarInfo]:
    seen: set[str] = set()
    with tarfile.open(path, "r:xz") as archive:
        members = archive.getmembers()
        for member in members:
            name = safe_relative(member.name)
            if name in seen:
                raise ValueError(f"duplicate member in {path.name}: {name}")
            seen.add(name)
            if member.islnk() or member.issym():
                if not allow_symlinks:
                    raise ValueError(f"link member forbidden in {path.name}: {name}")
                safe_link_target(member.name, member.linkname, symbolic=member.issym())
            elif not (member.isfile() or member.isdir()):
                raise ValueError(f"special archive member forbidden in {path.name}: {name}")
            if member.isfile():
                extracted = archive.extractfile(member)
                if extracted is None:
                    raise ValueError(f"cannot read archive member: {name}")
                while extracted.read(1024 * 1024):
                    pass
    return members


def payload_hashes(path: Path, product: str) -> dict[str, str]:
    prefix = f"{product}.xcframework/"
    hashes: dict[str, str] = {}
    with tarfile.open(path, "r:xz") as archive:
        for member in archive.getmembers():
            if member.isfile() and member.name.startswith(prefix):
                source = archive.extractfile(member)
                if source is None:
                    raise ValueError(f"cannot read {member.name}")
                digest = hashlib.sha256()
                for block in iter(lambda: source.read(1024 * 1024), b""):
                    digest.update(block)
                hashes[member.name[len(prefix) :]] = digest.hexdigest()
    return hashes


def tar_member_sha256(path: Path, name: str) -> str:
    with tarfile.open(path, "r:xz") as archive:
        try:
            member = archive.getmember(name)
        except KeyError as error:
            raise ValueError(f"{path.name} lacks {name}") from error
        source = archive.extractfile(member)
        if source is None:
            raise ValueError(f"cannot read {name} from {path.name}")
        digest = hashlib.sha256()
        for block in iter(lambda: source.read(1024 * 1024), b""):
            digest.update(block)
        return digest.hexdigest()


def verify_podspec(
    path: Path,
    asset: Path,
    product: str,
    platform: str,
    version: str,
    minimum: str,
) -> None:
    content = json.loads(path.read_text(encoding="utf-8"))
    source = content.get("source", {})
    if (
        content.get("name") != product
        or content.get("version") != version
        or source.get("sha256") != sha256(asset)
    ):
        raise ValueError(f"podspec identity/hash mismatch: {path}")
    if not str(source.get("http", "")).endswith("/" + asset.name):
        raise ValueError(f"podspec asset URL mismatch: {path}")
    if content.get("platforms") != {platform: minimum}:
        raise ValueError(f"podspec deployment platform mismatch: {path}")


def verify(root: Path) -> None:
    if root.is_symlink() or not root.is_dir():
        raise ValueError(f"candidate root is missing or symlinked: {root}")
    symlinks = [path for path in root.rglob("*") if path.is_symlink()]
    if symlinks:
        raise ValueError(f"candidate contains symlinks: {symlinks[:5]}")
    manifest = (root / "BUILD-MANIFEST.txt").read_text(encoding="utf-8")
    if "PENDING_" in manifest:
        raise ValueError("candidate manifest contains an unreviewed pending pin")

    platforms_matches = re.findall(r"^platforms=(.+)$", manifest, re.MULTILINE)
    scope_matches = re.findall(r"^artifact_scope=(.+)$", manifest, re.MULTILINE)
    if len(platforms_matches) != 1 or len(scope_matches) != 1:
        raise ValueError("candidate build manifest lacks one platform/scope identity")
    platforms = parse_platforms(platforms_matches[0])
    scope = artifact_scope(platforms)
    if scope_matches[0] != scope:
        raise ValueError("candidate artifact scope does not match selected platforms")

    minimums: dict[str, str] = {}
    for platform in PLATFORM_PRODUCTS:
        sdk_matches = re.findall(rf"^{platform}_sdk=(.+)$", manifest, re.MULTILINE)
        minimum_matches = re.findall(
            rf"^{platform}_minimum=(.+)$", manifest, re.MULTILINE
        )
        if platform in platforms:
            if len(sdk_matches) != 1 or len(minimum_matches) != 1:
                raise ValueError(f"candidate manifest lacks one {platform} SDK/minimum")
            if not re.fullmatch(r"[0-9]+(?:\.[0-9]+){1,2}", sdk_matches[0]):
                raise ValueError(f"candidate manifest has an invalid {platform} SDK")
            if not re.fullmatch(r"[0-9]+(?:\.[0-9]+){1,2}", minimum_matches[0]):
                raise ValueError(f"candidate manifest has an invalid {platform} minimum")
            minimums[platform] = minimum_matches[0]
        elif sdk_matches or minimum_matches:
            raise ValueError(f"candidate manifest contains unselected {platform} settings")

    verified = verified_marker_state(root, platforms)
    version_match = re.search(r"^vlckit_version=(.+)$", manifest, re.MULTILINE)
    revision_match = re.search(r"^candidate_revision=(.+)$", manifest, re.MULTILINE)
    if not version_match or not revision_match:
        raise ValueError("candidate build manifest lacks version identity")
    if not re.fullmatch(r"[0-9]+(?:\.[0-9]+){2}", version_match.group(1)):
        raise ValueError("candidate manifest has an invalid VLCKit version")
    if not re.fullmatch(r"beam\.[0-9]+", revision_match.group(1)):
        raise ValueError("candidate manifest has an invalid revision")
    version = f"{version_match.group(1)}-{revision_match.group(1)}"
    verify_topology(root, version, platforms, verified=verified)
    verify_sha_manifest(root, platforms)
    artifacts = root / "artifacts"
    for platform in platforms:
        product, _os_style = PLATFORM_PRODUCTS[platform]
        asset = artifacts / f"beam-{product.lower()}-{platform}-{version}.tar.xz"
        validate_tar(asset, allow_symlinks=False)
        packaged = payload_hashes(asset, product)
        framework = root / f"frameworks/{product}.xcframework"
        if packaged != tree_hashes(framework):
            raise ValueError(f"{asset.name} framework bytes differ from audited staging bytes")
        if tree_hashes(root / platform / f"{product}.xcframework") != tree_hashes(framework):
            raise ValueError(f"consumer {platform} framework bytes differ from audited staging bytes")
        license_copy = root / f"{platform}/COPYING.txt"
        if sha256(license_copy) != tar_member_sha256(asset, "COPYING.txt"):
            raise ValueError(f"consumer {platform} license differs from packaged payload")
        verify_podspec(
            artifacts / f"{product}.podspec.json",
            asset,
            product,
            platform,
            version,
            minimums[platform],
        )

    source_asset = artifacts / f"beam-vlckit-{scope}-corresponding-source-{version}.tar.xz"
    audit_asset = artifacts / f"beam-vlckit-{scope}-audit-{version}.tar.xz"
    validate_tar(source_asset, allow_symlinks=True)
    validate_tar(audit_asset, allow_symlinks=False)


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("candidate", type=Path)
    args = parser.parse_args()
    verify(args.candidate.absolute())
    print(f"Candidate package verification passed: {args.candidate}")
    return 0


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