#!/usr/bin/env python3
"""Refuse an iOS binary Apple's delivery would refuse, before it is uploaded.

App Store Connect validates a binary AFTER the upload finishes and answers hours
later by email, so a refusal like ITMS-90023 (a device family with no icons) or
ITMS-90362 (an extension key in the wrong place) costs a whole release cycle.
Every one of those verdicts is a fact about bytes this job already has on disk:
the exported `.app`, its compiled asset catalog, and the Info.plist of every
nested bundle.

The checks are a TABLE (:data:`CHECKS`). A new ITMS code is one row: a `run` that
returns the exact refusals and stays silent where it does not apply. Nothing here
touches the network, and only a handful of small files are read out of the
archive, so the cost does not scale with the binary.

The asset catalog is read with ``/usr/bin/assetutil``, Apple's own inventory tool,
present on every macOS runner. It reports the renditions `actool` actually
emitted, which is what ITMS-90023 reads. The flattened ``AppIcon*.png`` files in
the bundle root are only a partial mirror of it — measured on shipped,
Apple-ACCEPTED IPAs, Xcode writes 120 and 152 as files and keeps 167 and 180 in
the catalog alone — so they are a fallback that reports what it could not judge.
"""
from __future__ import annotations

import argparse
from fnmatch import fnmatch
import json
from pathlib import Path
import plistlib
import re
import struct
import subprocess
import sys
import zipfile

from delivery_icons import single_size_idioms

SCHEMA = "gowalk-cicd/apple-delivery-preflight.v1"

#: Apple's `assetutil` lives outside the selected Xcode and needs no developer dir.
ASSETUTIL = "/usr/bin/assetutil"

#: UIDeviceFamily member -> the primary app-icon renditions Apple requires of a
#: bundle that declares it, as the catalog idiom `assetutil` reports and the exact
#: pixel square Apple names in ITMS-90023.
REQUIRED_ICONS = {
    1: ("phone", ((120, "60x60@2x"), (180, "60x60@3x"))),
    2: ("pad", ((152, "76x76@2x"), (167, "83.5x83.5@2x"))),
}

#: The only icon squares Xcode ALSO writes as flat PNGs beside Assets.car. Without
#: the catalog, the absence of a flat 167 proves nothing and must never be reported
#: as a missing icon — that would refuse every correct build.
FLATTENED_ICON_SIZES = frozenset({120, 152})

#: Xcode 14+ lets one `universal` 1024 entry stand for every device family.
#: Older compiled catalogs can retain a universal rendition. Newer Xcode also
#: emits indexed phone/pad single-size records, recognized by delivery_icons.
#: Demanding `pad` renditions of such a catalog would refuse a correct app, so a
#: universal app-icon rendition satisfies every family.
UNIVERSAL_IDIOMS = frozenset({"universal"})

#: NSExtensionPointIdentifier -> the Info.plist keys Apple's delivery validator
#: reads for that extension point, WHERE it reads them, and the closed value set.
#: `path` is relative to the appex's NSExtension dictionary; () is the dictionary
#: itself. RPBroadcastProcessMode is the trap this table exists for: Apple's
#: runtime documentation nests it under NSExtensionAttributes and the delivery
#: validator reads it directly under NSExtension, so a bundle that satisfies only
#: the documented location is delivered and then refused as "not specified".
#: A `forbidden` row names a key the validator refuses to SEE. A WidgetKit
#: extension has no principal class: it enters through its @main WidgetBundle,
#: Xcode's own template ships NSExtensionPointIdentifier alone, and a bundle
#: that adds NSExtensionPrincipalClass is delivered and then refused with
#: "Validation failed (409) Unexpected Info.plist key. Unexpected key
#: NSExtensionPrincipalClass found in extension Info.plist for
#: Payload/<App>.app/PlugIns/<Widget>.appex". Demanding the key here refused
#: every correct WidgetKit build (Sunnah 1.0.1 (16)) for a binary Apple accepts.
EXTENSION_KEYS = {
    "com.apple.broadcast-services-upload": (
        {"key": "RPBroadcastProcessMode", "path": (),
         "values": ("RPBroadcastProcessModeSampleBuffer", "RPBroadcastProcessModeMP4Clip"),
         "itms": "ITMS-90362"},
    ),
    "com.apple.broadcast-services-setupui": (
        {"key": "NSExtensionPrincipalClass", "path": (), "values": (), "itms": "ITMS-90362"},
    ),
    "com.apple.widgetkit-extension": (
        {"key": "NSExtensionPrincipalClass", "path": (), "forbidden": True,
         "reason": "a WidgetKit extension enters through its @main WidgetBundle",
         "itms": "Unexpected Info.plist key"},
    ),
}

#: Never shipped inside a Payload, and all of them are a directory listing away.
#: Scoped to what is genuinely disallowed: a vendored framework's own Modules/
#: directory is normal and must not be swept up here.
FORBIDDEN_PAYLOAD = (
    {"glob": ".DS_Store", "itms": "ITMS-90049", "what": "a Finder metadata file"},
    {"glob": "*.dSYM", "itms": "ITMS-90049", "what": "a debug symbol bundle"},
    {"glob": "*.storekit", "itms": "ITMS-90049", "what": "a StoreKit test configuration"},
)


class Refused(Exception):
    """The bundle could not be read well enough to judge it."""


# ── reading the bundle ──────────────────────────────────────────────────────

def _ihdr(data: bytes) -> tuple[int, int, int] | None:
    """(width, height, colour type) from a PNG's IHDR chunk.

    Xcode runs shipped icons through its own PNG optimizer, which inserts a
    `CgBI` chunk BEFORE `IHDR`. Reading IHDR at a fixed offset therefore returns
    garbage for exactly the builds that matter, so the chunk stream is walked.
    """
    if not data.startswith(b"\x89PNG\r\n\x1a\n"):
        return None
    offset = 8
    while offset + 8 <= len(data):
        length = struct.unpack(">I", data[offset:offset + 4])[0]
        if data[offset + 4:offset + 8] == b"IHDR" and offset + 18 <= len(data):
            width, height = struct.unpack(">II", data[offset + 8:offset + 16])
            return width, height, data[offset + 17]
        offset += 12 + length
    return None


def png_size(data: bytes) -> tuple[int, int] | None:
    header = _ihdr(data)
    return (header[0], header[1]) if header else None


def png_has_alpha(data: bytes) -> bool | None:
    header = _ihdr(data)
    return header[2] in (4, 6) if header else None


#: The only bundle files whose BYTES a check reads. Everything else is judged from
#: the archive's own name list, so an .ipa costs the same whether it is 30 MB or
#: 300 MB — a preflight that scaled with the binary would tax every release for
#: the sake of a handful of plists.
def _wanted(relative: str) -> bool:
    name = relative.rsplit("/", 1)[-1]
    return (name in ("Info.plist", "Assets.car", "PrivacyInfo.xcprivacy")
            or (name.startswith("AppIcon") and name.endswith(".png")
                and relative.count("/") == 0))


def unpack_ipa(ipa: Path, into: Path) -> tuple[Path, set[str]]:
    """Extract the few files the checks parse; return the app and EVERY path in it."""
    with zipfile.ZipFile(ipa) as archive:
        names = [name for name in archive.namelist()
                 if name.startswith("Payload/") and not name.startswith("/") and ".." not in name]
        roots = {name.split("/")[1] for name in names
                 if name.count("/") >= 2 and name.split("/")[1].endswith(".app")}
        if len(roots) != 1:
            raise Refused("ipa_payload_unreadable")
        prefix = f"Payload/{roots.pop()}/"
        inside = {name[len(prefix):] for name in names
                  if name.startswith(prefix) and len(name) > len(prefix)}
        archive.extractall(into, members=[prefix + item for item in inside
                                          if _wanted(item) and not item.endswith("/")])
    app = into / prefix.rstrip("/")
    app.mkdir(parents=True, exist_ok=True)
    return app, {item.rstrip("/") for item in inside if item}


def walk_names(app: Path) -> set[str]:
    return {str(path.relative_to(app)) for path in app.rglob("*")}


def find_app(target: Path, scratch: Path | None) -> tuple[Path, set[str]]:
    """The one `.app` to judge and every path inside it, from an .app, an
    .xcarchive, an export directory or an .ipa."""
    target = target.resolve()
    if target.is_dir() and target.suffix == ".app":
        return target, walk_names(target)
    if target.is_dir() and target.suffix == ".xcarchive":
        apps = sorted((target / "Products" / "Applications").glob("*.app"))
        if len(apps) != 1:
            raise Refused("xcarchive_application_unreadable")
        return apps[0], walk_names(apps[0])
    if target.is_file() and target.suffix == ".ipa":
        if scratch is None:
            raise Refused("ipa_scratch_required")
        return unpack_ipa(target, scratch)
    if target.is_dir():
        candidates = sorted(target.glob("*.app")) or sorted(target.glob("*.ipa"))
        if len(candidates) == 1:
            return find_app(candidates[0], scratch)
    raise Refused("bundle_not_found")


def read_plist(path: Path) -> dict:
    try:
        value = plistlib.loads(path.read_bytes())
    except (OSError, plistlib.InvalidFileException, ValueError) as exc:
        raise Refused("info_plist_unreadable") from exc
    if not isinstance(value, dict):
        raise Refused("info_plist_unreadable")
    return value


def catalog_renditions(app: Path) -> list[dict] | None:
    """Every rendition `actool` emitted, or None when the catalog cannot be read.

    None is NOT a pass: the caller falls back to the flattened icons and says so,
    so an unreadable catalog never silently certifies an icon set.
    """
    car = app / "Assets.car"
    if not car.is_file() or not Path(ASSETUTIL).is_file():
        return None
    try:
        done = subprocess.run([ASSETUTIL, "--info", str(car)], capture_output=True,
                              text=True, timeout=120, check=False)
    except (OSError, subprocess.SubprocessError):
        return None
    if done.returncode != 0:
        return None
    try:
        parsed = json.loads(done.stdout)
    except ValueError:
        return None
    return [item for item in parsed if isinstance(item, dict)]


def flattened_icon_sizes(app: Path) -> set[int]:
    sizes = set()
    for png in app.glob("AppIcon*.png"):
        try:
            measured = png_size(png.read_bytes())
        except OSError:
            continue
        if measured and measured[0] == measured[1]:
            sizes.add(measured[0])
    return sizes


def app_extensions(app: Path, names: set[str]) -> list[tuple[str, dict]]:
    plugins = sorted({item.split("/")[1] for item in names
                      if item.startswith("PlugIns/") and item.count("/") >= 1
                      and item.split("/")[1].endswith(".appex")})
    found = []
    for appex in plugins:
        info = app / "PlugIns" / appex / "Info.plist"
        if not info.is_file():
            found.append((appex, {}))
            continue
        try:
            found.append((appex, read_plist(info)))
        except Refused:
            found.append((appex, {}))
    return found


# ── the checks ──────────────────────────────────────────────────────────────

def _icon_renditions(renditions: list[dict]) -> list[dict]:
    return [item for item in renditions if str(item.get("Name") or "").startswith("AppIcon")]


def check_app_icons(ctx: dict) -> list[dict]:
    """Every primary icon the declared device families require.

    The compiled catalog is the authority, because it is what ITMS-90023 reads.
    Without it only the two squares Xcode also flattens can be judged, and the
    rest are reported as unverified rather than as missing.
    """
    families, renditions, flattened = ctx["families"], ctx["renditions"], ctx["flattened"]
    failures, unverified = [], []
    if renditions is not None and any(
            str(item.get("Idiom") or "") in UNIVERSAL_IDIOMS for item in _icon_renditions(renditions)):
        # One universal icon covers every family; per-idiom renditions do not exist
        # for it and demanding them would refuse a correct Xcode 14+ catalog.
        return []
    single_size = single_size_idioms(_icon_renditions(renditions or []))
    for family in sorted(families):
        if family not in REQUIRED_ICONS:
            continue
        idiom, required = REQUIRED_ICONS[family]
        if idiom in single_size:
            continue
        device = "iPad" if family == 2 else "iPhone"
        for pixels, label in required:
            if renditions is not None:
                present = any(item.get("Idiom") == idiom and item.get("PixelWidth") == pixels
                              and item.get("PixelHeight") == pixels
                              for item in _icon_renditions(renditions))
                source = "the compiled asset catalog"
            elif pixels in FLATTENED_ICON_SIZES:
                present, source = pixels in flattened, "the bundle's flattened icons"
            else:
                unverified.append(f"{pixels}x{pixels} ({device})")
                continue
            if not present:
                failures.append({
                    "itms": "ITMS-90023",
                    "message": f"no {pixels}x{pixels} app icon for {device} in {source}",
                    "next": (f"Add the {label} {device} entry to AppIcon.appiconset (or a "
                             f"`universal` entry with `platform: ios` that covers it), or drop "
                             f"{family} from TARGETED_DEVICE_FAMILY if the app does not ship "
                             f"for that device."),
                })
    if unverified:
        ctx["unverified_icons"] = unverified
    return failures


def check_marketing_icon(ctx: dict) -> list[dict]:
    """The 1024 App Store icon, accepting either catalog style.

    Apple takes it from `ios-marketing` or from the Xcode 14+ single-size
    `universal` 1024 entry; treating the latter as absent would refuse shipping
    apps, and treating it as not-applicable would exempt them silently.
    """
    renditions = ctx["renditions"]
    if renditions is None:
        return []
    icons = _icon_renditions(renditions)
    if not icons:
        return []
    present = any(item.get("PixelWidth") == 1024 and item.get("PixelHeight") == 1024
                  and str(item.get("Idiom") or "") in UNIVERSAL_IDIOMS | {"marketing"}
                  for item in icons)
    if present or single_size_idioms(icons):
        return []
    return [{"itms": "ITMS-90704",
             "message": "no 1024x1024 App Store marketing icon in the compiled asset catalog",
             "next": "Add the 1024x1024 `ios-marketing` entry to AppIcon.appiconset."}]


def check_extension_keys(ctx: dict) -> list[dict]:
    failures = []
    for name, info in ctx["extensions"]:
        extension = info.get("NSExtension")
        if not isinstance(extension, dict):
            failures.append({"itms": "ITMS-90206",
                             "message": f"{name} has no NSExtension dictionary",
                             "next": "Give the extension a valid NSExtension Info.plist entry."})
            continue
        point = extension.get("NSExtensionPointIdentifier")
        for rule in EXTENSION_KEYS.get(str(point), ()):
            node = extension
            for step in rule["path"]:
                node = node.get(step) if isinstance(node, dict) else None
            value = node.get(rule["key"]) if isinstance(node, dict) else None
            where = "NSExtension" + "".join("." + step for step in rule["path"])
            if rule.get("forbidden"):
                if value is None:
                    continue
                failures.append({
                    "itms": rule["itms"],
                    "message": (f"{name}: {where}.{rule['key']} is present; Apple's delivery "
                                f"validator refuses it for {point} as an unexpected Info.plist key."),
                    "next": (f"Remove {where}.{rule['key']} from the extension's Info.plist; "
                             f"{rule['reason']}."),
                })
                continue
            legal = rule["values"]
            if value is not None and (not legal or value in legal):
                continue
            elsewhere = ""
            attributes = extension.get("NSExtensionAttributes")
            if isinstance(attributes, dict) and rule["key"] in attributes and not rule["path"]:
                elsewhere = (" The key is present under NSExtension.NSExtensionAttributes, which "
                             "is where the runtime documentation puts it; Apple's delivery "
                             "validator does not read it there.")
            failures.append({
                "itms": rule["itms"],
                "message": (f"{name}: {where}.{rule['key']} is "
                            f"{'absent' if value is None else 'not an accepted value'}." + elsewhere),
                "next": (f"Set {where}.{rule['key']} in the extension's Info.plist"
                         + (f" to one of {', '.join(legal)}." if legal else ".")
                         + (" Keep the NSExtensionAttributes copy as well." if elsewhere else "")),
            })
    return failures


def check_bundle_identifiers(ctx: dict) -> list[dict]:
    """Nested APP EXTENSION identity only.

    Scoped to PlugIns on purpose: CocoaPods legitimately ships frameworks and
    resource bundles whose identifiers repeat or sit outside the app's namespace,
    so applying this to Frameworks/* would refuse ordinary builds.
    """
    app_id = str(ctx["info"].get("CFBundleIdentifier") or "")
    failures, seen = [], {app_id}
    for name, info in ctx["extensions"]:
        nested = str(info.get("CFBundleIdentifier") or "")
        if not nested or "$(" in nested:
            failures.append({"itms": "ITMS-90007",
                             "message": f"{name} has no resolved CFBundleIdentifier",
                             "next": "Give the extension a literal bundle identifier."})
            continue
        if not nested.startswith(app_id + "."):
            failures.append({
                "itms": "ITMS-90046",
                "message": f"{name} bundle id {nested!r} is not a child of the app id {app_id!r}",
                "next": f"Rename the extension's bundle id to {app_id}.<suffix>."})
        if nested in seen:
            failures.append({"itms": "ITMS-90885",
                             "message": f"{name} repeats bundle id {nested!r}",
                             "next": "Give every nested bundle its own identifier."})
        seen.add(nested)
    return failures


def check_version_strings(ctx: dict) -> list[dict]:
    """Version legality and app/extension agreement. Cheap and defensive: it did
    not cause any observed refusal, and it costs one regex per bundle."""
    info = ctx["info"]
    short, build = info.get("CFBundleShortVersionString"), info.get("CFBundleVersion")
    failures = []
    if not isinstance(short, str) or not re.fullmatch(r"\d+(\.\d+){0,2}", short):
        failures.append({"itms": "ITMS-90060",
                         "message": f"CFBundleShortVersionString {short!r} is not a legal version",
                         "next": "Use up to three dot-separated numbers, e.g. 2.0.0."})
    if not isinstance(build, str) or not re.fullmatch(r"\d+(\.\d+){0,2}", build):
        failures.append({"itms": "ITMS-90059",
                         "message": f"CFBundleVersion {build!r} is not a legal build number",
                         "next": "Use up to three dot-separated numbers, e.g. 190."})
    for name, nested in ctx["extensions"]:
        if nested.get("CFBundleShortVersionString") not in (None, short):
            failures.append({
                "itms": "ITMS-90473",
                "message": (f"{name} CFBundleShortVersionString "
                            f"{nested.get('CFBundleShortVersionString')!r} != the app's {short!r}"),
                "next": "Drive every nested bundle's MARKETING_VERSION from the app target."})
        if nested.get("CFBundleVersion") not in (None, build):
            failures.append({
                "itms": "ITMS-90473",
                "message": (f"{name} CFBundleVersion {nested.get('CFBundleVersion')!r} "
                            f"!= the app's {build!r}"),
                "next": "Drive every nested bundle's CURRENT_PROJECT_VERSION from the app target."})
    return failures


def check_payload_hygiene(ctx: dict) -> list[dict]:
    names, failures = ctx["names"], []
    for rule in FORBIDDEN_PAYLOAD:
        hits = [item for item in sorted(names) if fnmatch(item.rsplit("/", 1)[-1], rule["glob"])]
        for found in hits[:8]:
            failures.append({
                "itms": rule["itms"],
                "message": f"the payload contains {rule['what']}: {found}",
                "next": "Remove it from the bundle's copied resources."})
    platforms = ctx["info"].get("CFBundleSupportedPlatforms")
    if isinstance(platforms, list) and "iPhoneSimulator" in platforms:
        failures.append({
            "itms": "ITMS-90085",
            "message": "the bundle was built for the simulator, not for a device",
            "next": ("Archive with -destination 'generic/platform=iOS'; a simulator build is "
                     "never deliverable.")})
    for name, info in ctx["extensions"]:
        executable = info.get("CFBundleExecutable")
        if not executable or f"PlugIns/{name}/{executable}" not in names:
            failures.append({
                "itms": "ITMS-90562",
                "message": f"{name} declares no readable executable",
                "next": "Ensure the extension target produces and embeds its binary."})
        nested = info.get("CFBundleSupportedPlatforms")
        if isinstance(nested, list) and "iPhoneSimulator" in nested:
            failures.append({
                "itms": "ITMS-90085",
                "message": f"{name} was built for the simulator",
                "next": "Rebuild the extension for a device destination."})
    return failures


#: id -> (title, run). One row per delivery refusal.
CHECKS = (
    {"id": "app_icons", "title": "required app icons", "run": check_app_icons},
    {"id": "marketing_icon", "title": "App Store marketing icon", "run": check_marketing_icon},
    {"id": "extension_keys", "title": "app extension Info.plist keys", "run": check_extension_keys},
    {"id": "bundle_identifiers", "title": "nested bundle identifiers",
     "run": check_bundle_identifiers},
    {"id": "version_strings", "title": "version strings", "run": check_version_strings},
    {"id": "payload_hygiene", "title": "payload contents", "run": check_payload_hygiene},
)


def context(app: Path, names: set[str] | None = None) -> dict:
    if names is None:
        names = walk_names(app)
    info = read_plist(app / "Info.plist")
    families = info.get("UIDeviceFamily")
    families = ({int(item) for item in families if isinstance(item, int)}
                if isinstance(families, list) else {1})
    return {"app": app, "names": names, "info": info, "families": families,
            "renditions": catalog_renditions(app),
            "flattened": flattened_icon_sizes(app),
            "extensions": app_extensions(app, names)}


def inspect(app: Path, *, only: set[str] | None = None, names: set[str] | None = None) -> dict:
    ctx = context(app, names)
    failures, advisories, ran = [], [], []
    for check in CHECKS:
        if only and check["id"] not in only:
            continue
        ran.append(check["id"])
        for item in check["run"](ctx):
            item["check"] = check["id"]
            failures.append(item)
    if ctx.get("unverified_icons"):
        # Say what could not be judged. Silence here would read as "icons fine".
        advisories.append({
            "check": "app_icons", "itms": "ITMS-90023",
            "message": (f"Assets.car could not be read, so {', '.join(ctx['unverified_icons'])} "
                        f"could not be verified (Xcode keeps those sizes in the catalog only)"),
            "next": "Run this on a macOS runner where /usr/bin/assetutil can read the catalog."})
    return {"schema": SCHEMA, "ok": not failures, "bundle": app.name,
            "bundle_id": ctx["info"].get("CFBundleIdentifier"),
            "version": ctx["info"].get("CFBundleShortVersionString"),
            "build": ctx["info"].get("CFBundleVersion"),
            "device_families": sorted(ctx["families"]),
            "asset_catalog": "read" if ctx["renditions"] is not None else "unreadable",
            "checks": ran, "failures": failures, "advisories": advisories}


def report(answer: dict) -> None:
    for item in answer.get("advisories", []):
        print(f"::warning title=apple_delivery_preflight::{item['itms']}: {item['message']}")
    for item in answer.get("failures", []):
        print(f"::error title=apple_delivery_preflight::{item['itms']}: {item['message']} "
              f"-- {item['next']}")


def main(argv: list[str]) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("target", type=Path,
                        help="the exported .app, .ipa, .xcarchive, or the export directory")
    parser.add_argument("--scratch", type=Path, default=None,
                        help="writable directory used to unpack an .ipa")
    parser.add_argument("--only", default="", help="comma-separated check ids to run")
    args = parser.parse_args(argv)
    try:
        app, names = find_app(args.target, args.scratch)
        answer = inspect(app, only={item for item in args.only.split(",") if item} or None,
                         names=names)
    except Refused as exc:
        answer = {"schema": SCHEMA, "ok": False, "code": str(exc), "failures": [],
                  "next": "Point the preflight at the exported .app, .ipa or .xcarchive."}
        print(f"::error title=apple_delivery_preflight::{exc}")
    print(json.dumps(answer, indent=2, sort_keys=True))
    if answer.get("ok"):
        return 0
    report(answer)
    return 1


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
