#!/usr/bin/env python3
"""Sparkle auto-update sanity check (release channel).

Validates that the shipped app bundle is wired for Sparkle and (optionally)
that the public appcast feed is reachable and signed with the embedded public key.

Layers:

1. Config — ``SUFeedURL`` + ``SUPublicEDKey`` in ``MugiApp/project.yml`` and
   ``MugiApp/Mugi/Info.plist`` (canonical XcodeGen sources).
2. Bundle — built ``Mugi.app`` Info.plist contains the same keys (Release preferred).
3. Feed — HTTP fetch of ``SUFeedURL``; XML contains ``sparkle:edSignature`` on at
   least one enclosure (skipped unless ``--require-feed``).

Usage:
    python scripts/sparkle_sanity.py
    python scripts/sparkle_sanity.py --require-feed   # fail if appcast not live yet
    python scripts/sparkle_sanity.py --app /path/to/Mugi.app

Exit 0 + ``SPARKLE_SANITY_OK`` on success.
"""

from __future__ import annotations

import argparse
import base64
import os
import plistlib
import re
import subprocess
import sys
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET

REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROJECT_YML = os.path.join(REPO_ROOT, "MugiApp", "project.yml")
INFO_PLIST = os.path.join(REPO_ROOT, "MugiApp", "Mugi", "Info.plist")
XCODE_PROJECT = os.path.join(REPO_ROOT, "MugiApp", "Mugi.xcodeproj")
DEFAULT_FEED = "https://mugi-ai.com/appcast.xml"
DEFAULT_PUBLIC_KEY = "5EMCk0IiAhNUb1efNdAwu7WMHT4ePHdexaExo3DJQT8="


def _fail(stage: str, msg: str) -> int:
    print(f"FAIL: {stage}: {msg}", file=sys.stderr)
    return 1


def _read_plist_keys(path: str) -> tuple[str | None, str | None]:
    with open(path, "rb") as fh:
        data = plistlib.load(fh)
    return data.get("SUFeedURL"), data.get("SUPublicEDKey")


def _validate_public_key(key: str) -> str | None:
    try:
        raw = base64.b64decode(key, validate=True)
    except Exception as exc:  # noqa: BLE001 — surface decode failure to operator
        return f"SUPublicEDKey is not valid base64: {exc}"
    if len(raw) != 32:
        return f"SUPublicEDKey must decode to 32 bytes (got {len(raw)})"
    return None


def _read_yaml_sparkle_keys() -> tuple[str | None, str | None]:
    feed = None
    public_key = None
    with open(PROJECT_YML, encoding="utf-8") as fh:
        for line in fh:
            stripped = line.strip()
            if stripped.startswith("SUFeedURL:"):
                feed = stripped.split(":", 1)[1].strip().strip('"')
            elif stripped.startswith("SUPublicEDKey:"):
                public_key = stripped.split(":", 1)[1].strip().strip('"')
    return feed, public_key


def _find_built_app(explicit: str | None) -> str | None:
    if explicit:
        return explicit if os.path.isdir(explicit) else None
    try:
        out = subprocess.run(
            [
                "xcodebuild",
                "-project",
                XCODE_PROJECT,
                "-scheme",
                "Mugi Release",
                "-configuration",
                "Release",
                "-showBuildSettings",
            ],
            check=True,
            capture_output=True,
            text=True,
        )
    except (subprocess.CalledProcessError, FileNotFoundError):
        out = None
    if out and out.stdout:
        products_dir = None
        for line in out.stdout.splitlines():
            if "BUILT_PRODUCTS_DIR" in line and "=" in line:
                products_dir = line.split("=", 1)[1].strip()
                break
        if products_dir:
            candidate = os.path.join(products_dir, "Mugi.app")
            if os.path.isdir(candidate):
                return candidate
    derived_glob = os.path.expanduser(
        "~/Library/Developer/Xcode/DerivedData/Mugi-*/Build/Products/Release/Mugi.app"
    )
    import glob

    matches = sorted(glob.glob(derived_glob), key=os.path.getmtime, reverse=True)
    return matches[0] if matches else None


def _check_config() -> int:
    if not os.path.isfile(PROJECT_YML):
        return _fail("config", f"missing {PROJECT_YML}")
    if not os.path.isfile(INFO_PLIST):
        return _fail("config", f"missing {INFO_PLIST}")

    yml_feed, yml_key = _read_yaml_sparkle_keys()
    plist_feed, plist_key = _read_plist_keys(INFO_PLIST)

    for label, feed, key in (
        ("project.yml", yml_feed, yml_key),
        ("Mugi/Info.plist", plist_feed, plist_key),
    ):
        if not feed or not key:
            return _fail("config", f"{label} missing SUFeedURL or SUPublicEDKey")
        if feed != DEFAULT_FEED:
            return _fail("config", f"{label} SUFeedURL mismatch: {feed!r}")
        if key != DEFAULT_PUBLIC_KEY:
            return _fail("config", f"{label} SUPublicEDKey mismatch")
        err = _validate_public_key(key)
        if err:
            return _fail("config", f"{label}: {err}")

    if yml_feed != plist_feed or yml_key != plist_key:
        return _fail("config", "project.yml and Mugi/Info.plist Sparkle keys differ")

    print("OK: config — SUFeedURL + SUPublicEDKey present in project.yml and Info.plist")
    return 0


def _check_bundle(app_path: str | None) -> int:
    if not app_path:
        print(
            "SKIP: bundle — no Release Mugi.app found "
            "(run `make build-swift-release` for full check)",
            file=sys.stderr,
        )
        return 0

    plist_path = os.path.join(app_path, "Contents", "Info.plist")
    if not os.path.isfile(plist_path):
        return _fail("bundle", f"missing {plist_path}")

    feed, key = _read_plist_keys(plist_path)
    if feed != DEFAULT_FEED or key != DEFAULT_PUBLIC_KEY:
        return _fail(
            "bundle",
            f"built app missing or mismatched Sparkle keys "
            f"(feed={feed!r}, key={'set' if key else 'missing'})",
        )

    print(f"OK: bundle — {app_path} has Sparkle keys")
    return 0


def _check_feed(require: bool) -> int:
    if not require:
        try:
            with urllib.request.urlopen(DEFAULT_FEED, timeout=15) as resp:
                body = resp.read()
        except urllib.error.HTTPError as exc:
            print(
                f"WARN: feed — {DEFAULT_FEED} returned HTTP {exc.code} "
                "(expected until first release + Pages deploy; use --require-feed to gate)",
                file=sys.stderr,
            )
            return 0
        except urllib.error.URLError as exc:
            print(f"WARN: feed — could not reach {DEFAULT_FEED}: {exc.reason}", file=sys.stderr)
            return 0
    else:
        try:
            with urllib.request.urlopen(DEFAULT_FEED, timeout=15) as resp:
                body = resp.read()
        except urllib.error.HTTPError as exc:
            return _fail("feed", f"{DEFAULT_FEED} returned HTTP {exc.code}")
        except urllib.error.URLError as exc:
            return _fail("feed", f"could not reach {DEFAULT_FEED}: {exc.reason}")

    if b"<rss" not in body and b"<channel" not in body:
        return _fail("feed", "response does not look like RSS/appcast XML")

    try:
        root = ET.fromstring(body)
    except ET.ParseError as exc:
        return _fail("feed", f"invalid XML: {exc}")

    ns = {"sparkle": "http://www.andymatuschak.org/xml-namespaces/sparkle"}
    signatures = root.findall(".//enclosure[@sparkle:edSignature]", ns)
    if not signatures:
        # Sparkle 2 may use sparkle:edSignature on item level in some generators
        if not re.search(rb"edSignature", body):
            return _fail("feed", "no sparkle:edSignature found on enclosures")
    print(f"OK: feed — {DEFAULT_FEED} reachable and signed")
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(description="Sparkle auto-update sanity check")
    parser.add_argument("--app", help="Path to Mugi.app (default: latest Release build)")
    parser.add_argument(
        "--require-feed",
        action="store_true",
        help="Fail if appcast feed is not live and signed (post-release gate)",
    )
    args = parser.parse_args()

    rc = _check_config()
    if rc:
        return rc

    app_path = _find_built_app(args.app)
    rc = _check_bundle(app_path)
    if rc:
        return rc

    rc = _check_feed(args.require_feed)
    if rc:
        return rc

    print("SPARKLE_SANITY_OK")
    return 0


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