#!/usr/bin/env python3
"""Shared Mach-O archive validation for Apple VLCKit link inputs."""

from __future__ import annotations

import re
import subprocess
from pathlib import Path

CPU_FAMILY = {"arm64": "ARM64", "x86_64": "X86_64"}
BUILD_PLATFORM = {
    ("iPhone", "device"): {"IOS", "2"},
    ("iPhone", "simulator"): {"IOSSIMULATOR", "7"},
    ("AppleTV", "device"): {"TVOS", "3"},
    ("AppleTV", "simulator"): {"TVOSSIMULATOR", "8"},
}
LEGACY_COMMAND = {
    ("iPhone", "device"): "LC_VERSION_MIN_IPHONEOS",
    ("AppleTV", "device"): "LC_VERSION_MIN_TVOS",
    ("iPhone", "simulator"): "LC_VERSION_MIN_IPHONEOS",
    ("AppleTV", "simulator"): "LC_VERSION_MIN_TVOS",
}
MEMBER_RE = re.compile(r"^.+\(([^()]+)\):$", re.MULTILINE)


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


def split_members(output: str) -> list[str]:
    matches = list(MEMBER_RE.finditer(output))
    return [
        output[match.end() : matches[index + 1].start() if index + 1 < len(matches) else None]
        for index, match in enumerate(matches)
    ]


def archive_member_records(
    path: Path, *, architecture: str
) -> list[tuple[int, str, str]]:
    """Return every object member after enforcing archive and CPU identity."""
    if path.is_symlink() or not path.is_file():
        raise ValueError(f"archive must be a regular, non-symlink file: {path}")
    if architecture not in CPU_FAMILY:
        raise ValueError(f"unsupported Apple architecture: {architecture}")

    archs = run_xcrun("lipo", "-archs", str(path)).split()
    if archs != [architecture]:
        raise ValueError(f"{path} has architectures {archs}; expected [{architecture}]")

    headers = run_xcrun("otool", "-hv", str(path))
    header_rows = [
        line.split()
        for line in headers.splitlines()
        if line.lstrip().startswith("MH_MAGIC")
    ]
    if not header_rows:
        raise ValueError(f"{path} contains no Mach-O object members")
    expected_cpu = CPU_FAMILY[architecture]
    wrong_cpu = [row[1] for row in header_rows if len(row) < 2 or row[1] != expected_cpu]
    if wrong_cpu:
        raise ValueError(f"{path} contains non-{expected_cpu} object members: {wrong_cpu}")

    load_commands = run_xcrun("otool", "-l", str(path))
    matches = list(MEMBER_RE.finditer(load_commands))
    members = split_members(load_commands)
    if len(members) != len(header_rows):
        raise ValueError(
            f"{path} member count differs between otool headers/load commands: "
            f"{len(header_rows)} != {len(members)}"
        )
    return [
        (index, match.group(1), members[index])
        for index, match in enumerate(matches)
    ]


def validate_member_platform(
    path: Path,
    member: str,
    *,
    index: int,
    architecture: str,
    os_style: str,
    kind: str,
    minimum_os: str,
    allow_platformless: bool,
) -> bool:
    """Validate a declared platform; return False only for an allowed omission."""
    build_matches = re.findall(
        r"cmd LC_BUILD_VERSION.*?\n\s*platform\s+(\S+).*?\n\s*minos\s+(\S+)",
        member,
        re.DOTALL,
    )
    legacy_matches = re.findall(
        r"cmd (LC_VERSION_MIN_[A-Z0-9_]+).*?\n\s*cmdsize\s+\d+"
        r".*?\n\s*version\s+(\S+)",
        member,
        re.DOTALL,
    )
    if len(build_matches) + len(legacy_matches) > 1:
        raise ValueError(f"{path} member {index} declares multiple Apple platforms")

    if build_matches:
        platform, minos = build_matches[0]
        expected_platforms = BUILD_PLATFORM[(os_style, kind)]
        if platform.upper() not in expected_platforms or minos != minimum_os:
            raise ValueError(
                f"{path} member {index} has platform/minos {platform}/{minos}; "
                f"expected {sorted(expected_platforms)}/{minimum_os}"
            )
        return True

    if legacy_matches:
        command, minos = legacy_matches[0]
        expected_legacy = LEGACY_COMMAND.get((os_style, kind))
        legacy_allowed = kind == "device" or (
            kind == "simulator" and architecture == "x86_64"
        )
        if not legacy_allowed or command != expected_legacy or minos != minimum_os:
            raise ValueError(
                f"{path} member {index} has legacy platform/minos {command}/{minos}; "
                f"expected {expected_legacy}/{minimum_os}"
            )
        return True

    if allow_platformless:
        return False
    raise ValueError(
        f"{path} member {index} lacks the expected Apple platform/minimum {minimum_os}"
    )


def audit_archive_identity(
    path: Path, *, architecture: str, os_style: str, kind: str, minimum_os: str
) -> None:
    records = archive_member_records(path, architecture=architecture)
    for index, _name, member in records:
        validate_member_platform(
            path,
            member,
            index=index,
            architecture=architecture,
            os_style=os_style,
            kind=kind,
            minimum_os=minimum_os,
            allow_platformless=False,
        )


def audit_contrib_archive_identity(
    path: Path, *, architecture: str, os_style: str, kind: str, minimum_os: str
) -> tuple[int, list[tuple[int, str]]]:
    """Audit contrib members while inventorying platformless assembly objects.

    Hand-written assembly and generated objects in projects such as FFmpeg and
    libvpx can omit Apple platform load commands. Every declared command must
    still match the requested slice and deployment floor, and an archive must
    contain at least one declared member so its slice provenance is observable.
    """
    records = archive_member_records(path, architecture=architecture)
    platformless: list[tuple[int, str]] = []
    declared = 0
    for index, name, member in records:
        if validate_member_platform(
            path,
            member,
            index=index,
            architecture=architecture,
            os_style=os_style,
            kind=kind,
            minimum_os=minimum_os,
            allow_platformless=True,
        ):
            declared += 1
        else:
            platformless.append((index, name))
    if not declared:
        raise ValueError(f"{path} has no member declaring its Apple platform/minimum")
    return declared, platformless
