#!/usr/bin/env python3
"""Refuse app artwork and icon coverage the stores reject, before the build runs.

Two failures cost a whole release each and are both visible in the checked-out
source in under a second:

* the app still ships its framework's PLACEHOLDER icon. Apple refuses that under
  guideline 2.3.8 ("the app icons appear to be placeholder icons") after a full
  review cycle, and the finished artwork is usually already in the repository,
  unused — `hearingaid` 2.0.0 was rejected for exactly this while its own
  `assets/brand/app-icon-1024.jpg` sat beside the stock set.
* the asset catalog does not cover a device family the target declares. Apple
  refuses that at DELIVERY (ITMS-90023), after the archive, the signing and the
  upload have all been paid for.

Both checks read only what the app actually SHIPS. An appiconset routinely keeps
files nothing references, and `flutter create` leaves its stock icons behind
after real artwork is installed under another name; hashing a directory instead
of the declared set convicts apps that ship correctly.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import sys

SCHEMA = "gowalk-cicd/app-source-preflight.v1"

#: Where a Flutter SDK keeps the stock icons `flutter create` installs.
#:
#: The SDK's own `templates/app/ios.tmpl` tree holds ZERO-BYTE `*.png.img.tmpl`
#: markers, not artwork: the real bytes live in the `flutter_template_images` pub
#: package, which :func:`template_image_roots` resolves from the tool's
#: `package_config.json`. The SDK trees are still read because some layouts do
#: carry real bytes; an empty file is skipped rather than matched.
FLUTTER_ICON_TEMPLATES = (
    "packages/flutter_tools/templates/app/ios.tmpl/Runner/Assets.xcassets/AppIcon.appiconset",
    "packages/flutter_tools/templates/module/ios/host_app_ephemeral/Runner.tmpl/Assets.xcassets/AppIcon.appiconset",
    "packages/flutter_tools/templates/app/android.tmpl/app/src/main/res",
    "packages/flutter_tools/templates/module/android/host_app_common/app.tmpl/src/main/res",
)

#: Subtrees of `flutter_template_images` that hold the artwork.
TEMPLATE_IMAGE_SUBTREES = ("templates/app/ios.tmpl", "templates/app/android.tmpl",
                           "templates/module")

#: Fallback digests for the stock Flutter artwork, used only when no SDK is on
#: disk. Being out of date here degrades to "not detected", never to a false
#: refusal, because a match is always an exact byte equality.
KNOWN_PLACEHOLDERS = {
    "7770183009e914112de7d8ef1d235a6a30c5834424858e0d2f8253f6b8d31926": "flutter stock Icon-App-1024x1024@1x",
    "809abfe75c440770c13c4e2e46d09603c22053e9d4e0e2275dcb9c1dcfbb2c75": "flutter stock Icon-App-60x60@2x",
    "3db08cb79e7b01b9e81f956e3a0ae10148d0fafde3ea7aa70048df905aa52cfd": "flutter stock Icon-App-60x60@3x",
    "6db7726530d71d3f52cb59793eb691313baa04796e129e1e043c0a58a1bffd2f": "flutter stock Icon-App-76x76@2x",
    "cee565f5e62116569b64980b209fd5ca4b3d3739011f5343250b33a1a2c6eb65": "flutter stock Icon-App-83.5x83.5@2x",
    "3c34e1f298d0c9ea3455d46db6b7759c8211a49e9ec6e44b635fc5c87dfb4180": "flutter stock ic_launcher xxxhdpi",
    "4d470bf22d5c17d84edc5f82516d1ba8a1c09559cd761cefb792f86d9f52b540": "flutter stock ic_launcher xxhdpi",
    "e14aa40904929bf313fded22cf7e7ffcbf1d1aac4263b5ef1be8bfce650397aa": "flutter stock ic_launcher xhdpi",
    "6a7c8f0d703e3682108f9662f813302236240d3f8f638bb391e32bfb96055fef": "flutter stock ic_launcher hdpi",
    "c7c0c0189145e4e32a401c61c9bdc615754b0264e7afae24e834bb81049eaf81": "flutter stock ic_launcher mdpi",
}

#: sha256 of zero bytes — an empty template file matches nothing.
EMPTY = hashlib.sha256(b"").hexdigest()

#: Where finished artwork tends to wait while the placeholder ships. Named so the
#: refusal can point at the file the app already has instead of asking for art.
ARTWORK_HINTS = ("assets/brand/app-icon*", "assets/brand/icon*", "assets/icon/*",
                 "design/app-icon*", "design/icon*", ".factory/design/app-icon*")

#: TARGETED_DEVICE_FAMILY member -> the catalog coverage Apple requires.
FAMILY_ICONS = {
    "1": ("iphone", (("60x60", "2x"), ("60x60", "3x"))),
    "2": ("ipad", (("76x76", "2x"), ("83.5x83.5", "2x"))),
}

#: Directories a checked-out app repository fills with other people's copies of
#: the same files. Walking them costs seconds on a multi-gigabyte checkout and
#: can only produce findings about code this app does not ship.
PRUNED = frozenset({".git", ".factory", "build", "Pods", "DerivedData", "node_modules",
                    ".dart_tool", ".symlinks", "legacy-preserve"})


def digest(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def template_image_roots(flutter_root: Path) -> list[Path]:
    """The `flutter_template_images` package directory, from the tool's own
    package graph. That package — not the SDK's template tree — carries the
    stock artwork bytes."""
    config = flutter_root / "packages/flutter_tools/.dart_tool/package_config.json"
    try:
        parsed = json.loads(config.read_text())
    except (OSError, ValueError):
        return []
    roots = []
    for package in parsed.get("packages") or []:
        if not isinstance(package, dict) or package.get("name") != "flutter_template_images":
            continue
        uri = str(package.get("rootUri") or "")
        if uri.startswith("file://"):
            base = Path(uri[7:])
        elif uri:
            base = (config.parent / uri).resolve()
        else:
            continue
        roots.extend(base / sub for sub in TEMPLATE_IMAGE_SUBTREES)
    return [root for root in roots if root.is_dir()]


def placeholder_digests(flutter_root: Path | None) -> dict[str, str]:
    """Stock-artwork digests, from whatever this machine actually has."""
    found = dict(KNOWN_PLACEHOLDERS)
    if not flutter_root or not flutter_root.is_dir():
        return found
    bases = template_image_roots(flutter_root)
    bases += [flutter_root / relative for relative in FLUTTER_ICON_TEMPLATES]
    for base in bases:
        if not base.is_dir():
            continue
        for candidate in base.rglob("*"):
            if not candidate.is_file():
                continue
            name = candidate.name
            if not (name.endswith(".png") or name.endswith(".png.img.tmpl")):
                continue
            try:
                value = digest(candidate)
            except OSError:
                continue
            if value == EMPTY:
                continue
            found.setdefault(value, f"flutter stock {name.split('.png')[0]}")
    return found


def android_icon_names(root: Path) -> set[str]:
    """The launcher resources the manifest actually POINTS AT.

    `flutter create` leaves its stock `ic_launcher.png` behind even after an app
    generates real artwork under another name, so hashing every mipmap file
    convicts apps that ship correctly: three of the four Android hits in a fleet
    sweep declared `@mipmap/launcher_icon` and were shipping their own icon.
    """
    names = set()
    for manifest in sorted(root.glob("android/app/src/main/AndroidManifest.xml")):
        try:
            text = manifest.read_text(errors="ignore")
        except OSError:
            continue
        for attribute in ("icon", "roundIcon"):
            for value in re.findall(
                    rf'android:{attribute}="@(?:mipmap|drawable)/([A-Za-z0-9_]+)"', text):
                names.add(value)
    return names


def declared_icon_files(contents: Path) -> list[Path]:
    """Only the PNGs this catalog actually REFERENCES.

    ios-tv-remote carries all fifteen stock Flutter icons beside the eleven it
    really ships, because the template's files were never deleted when a
    hand-rolled Contents.json replaced the template's. Hashing the directory
    would convict an app whose shipped icon is its own.
    """
    try:
        parsed = json.loads(contents.read_text())
    except (OSError, ValueError):
        return []
    named = {str(image.get("filename")) for image in parsed.get("images") or []
             if isinstance(image, dict) and image.get("filename")}
    return [contents.parent / name for name in sorted(named)
            if (contents.parent / name).is_file()]


def find_catalogs(base: Path) -> list[Path]:
    """Every AppIcon catalog under `base`, without descending into vendored trees."""
    found, pending = [], [base]
    while pending and len(found) < 64:
        current = pending.pop()
        try:
            entries = list(current.iterdir())
        except OSError:
            continue
        for entry in entries:
            if not entry.is_dir() or entry.is_symlink() or entry.name in PRUNED:
                continue
            if entry.name == "AppIcon.appiconset":
                contents = entry / "Contents.json"
                if contents.is_file():
                    found.append(contents)
                continue
            pending.append(entry)
    return sorted(found)


def icon_sources(root: Path) -> list[Path]:
    paths = []
    for pattern in ("ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json",
                    "*/Assets.xcassets/AppIcon.appiconset/Contents.json",
                    "*/Assets/Assets.xcassets/AppIcon.appiconset/Contents.json"):
        for contents in sorted(root.glob(pattern)):
            paths.extend(declared_icon_files(contents))
    for name in sorted(android_icon_names(root)):
        paths.extend(sorted(root.glob(f"android/app/src/main/res/mipmap-*/{name}.png")))
        paths.extend(sorted(root.glob(f"android/app/src/main/res/drawable-*/{name}.png")))
    return [path for path in paths if ".factory" not in path.parts and "build" not in path.parts]


def unused_artwork(root: Path, shipped: set[str]) -> list[str]:
    """Finished artwork in the repository that is not the artwork being shipped."""
    waiting = []
    for pattern in ARTWORK_HINTS:
        for candidate in sorted(root.glob(pattern)):
            if not candidate.is_file() or candidate.stat().st_size < 2048:
                continue
            try:
                if digest(candidate) in shipped:
                    continue
            except OSError:
                continue
            waiting.append(str(candidate.relative_to(root)))
    return waiting[:8]


def check_placeholder_icons(root: Path, flutter_root: Path | None) -> list[dict]:
    stock = placeholder_digests(flutter_root)
    sources = icon_sources(root)
    if not sources:
        return []
    shipped, offenders = set(), []
    for path in sources:
        try:
            value = digest(path)
        except OSError:
            continue
        shipped.add(value)
        if value in stock:
            offenders.append((str(path.relative_to(root)), stock[value]))
    if not offenders:
        return []
    waiting = unused_artwork(root, shipped)
    nudge = (f" Finished artwork is already in this repository and is not what ships: "
             f"{', '.join(waiting)}." if waiting else "")
    return [{
        "guideline": "2.3.8",
        "message": (f"{len(offenders)} SHIPPED app icon file(s) are the framework's placeholder "
                    f"artwork, including {offenders[0][0]} ({offenders[0][1]})." + nudge),
        "next": ("Install the app's real icon into every icon slot the catalog and the Android "
                 "manifest reference, then rebuild. Apple refuses a placeholder icon under "
                 "guideline 2.3.8 after a full review cycle."),
        "files": [name for name, _ in offenders[:12]],
    }]


def device_families(root: Path) -> dict[str, set[str]]:
    families = {}
    for pbxproj in sorted(root.glob("*/*.xcodeproj/project.pbxproj")) + \
            sorted(root.glob("*.xcodeproj/project.pbxproj")):
        if ".factory" in pbxproj.parts or "build" in pbxproj.parts:
            continue
        try:
            text = pbxproj.read_text(errors="ignore")
        except OSError:
            continue
        found = set()
        for value in re.findall(r'TARGETED_DEVICE_FAMILY = "?([0-9,\s]+)"?;', text):
            found.update(item.strip() for item in value.split(",") if item.strip())
        if found:
            families[str(pbxproj.relative_to(root))] = found
    return families


def catalog_coverage(contents: Path) -> tuple[set[tuple[str, str]], bool]:
    """Declared (idiom, size|scale) pairs, and whether a `universal` iOS entry
    covers every device family on its own (the Xcode 14+ unified icon)."""
    try:
        parsed = json.loads(contents.read_text())
    except (OSError, ValueError):
        return set(), False
    images = parsed.get("images")
    if not isinstance(images, list):
        return set(), False
    declared, universal = set(), False
    for image in images:
        if not isinstance(image, dict) or not image.get("filename"):
            continue
        idiom, size, scale = image.get("idiom"), image.get("size"), image.get("scale")
        if idiom == "universal" and image.get("platform") in (None, "ios"):
            universal = True
        declared.add((str(idiom), f"{size}|{scale}"))
    return declared, universal


def check_icon_coverage(root: Path) -> list[dict]:
    failures = []
    for project, families in device_families(root).items():
        for contents in find_catalogs((root / project).parent.parent):
            declared, universal = catalog_coverage(contents)
            if universal or not declared:
                continue
            for family in sorted(families):
                if family not in FAMILY_ICONS:
                    continue
                idiom, required = FAMILY_ICONS[family]
                missing = [f"{size}@{scale}" for size, scale in required
                           if (idiom, f"{size}|{scale}") not in declared]
                if not missing:
                    continue
                failures.append({
                    "guideline": "ITMS-90023",
                    "message": (f"{contents.relative_to(root)} declares no {idiom} entry for "
                                f"{', '.join(missing)} while {project} builds for "
                                f"TARGETED_DEVICE_FAMILY {','.join(sorted(families))}."),
                    "next": (f"Add the missing {idiom} entries to the asset catalog, or remove "
                             f"{family} from TARGETED_DEVICE_FAMILY. Apple refuses the upload "
                             f"with ITMS-90023 after the archive is built and signed."),
                    "files": [str(contents.relative_to(root))],
                })
    return failures


CHECKS = (
    {"id": "placeholder_icons",
     "run": lambda ctx: check_placeholder_icons(ctx["root"], ctx["flutter"])},
    {"id": "icon_coverage", "run": lambda ctx: check_icon_coverage(ctx["root"])},
)


def inspect(root: Path, flutter_root: Path | None, only: set[str] | None = None) -> dict:
    ctx = {"root": root, "flutter": flutter_root}
    failures, 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)
    return {"schema": SCHEMA, "ok": not failures, "root": str(root),
            "flutter_root": str(flutter_root) if flutter_root else "",
            "checks": ran, "failures": failures}


def main(argv: list[str]) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("root", type=Path, nargs="?", default=Path.cwd())
    parser.add_argument("--flutter-root", type=Path,
                        default=(Path(os.environ["FLUTTER_ROOT"])
                                 if os.environ.get("FLUTTER_ROOT") else None))
    parser.add_argument("--only", default="")
    args = parser.parse_args(argv)
    answer = inspect(args.root.resolve(), args.flutter_root,
                     {item for item in args.only.split(",") if item} or None)
    print(json.dumps(answer, indent=2, sort_keys=True))
    if answer["ok"]:
        return 0
    for item in answer["failures"]:
        print(f"::error title=app_source_preflight::{item['guideline']}: {item['message']} "
              f"-- {item['next']}")
    return 1


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