#!/usr/bin/env python3
"""
Verify Flatpak desktop filenames used for pinning against Flathub AppStream.

quick-setup pins apps before install using canonical desktop IDs. This script
checks every Flatpak ref in the catalog against:

  https://flathub.org/api/v2/appstream/{flatpak_id}

and compares the published launchable desktop-id to what app_pins.vala assumes.

Usage:
  ./scripts/verify_flatpak_desktops.py
  ./scripts/verify_flatpak_desktops.py --json report.json

Exit code 0 when all refs match (or only have documented overrides). Non-zero on
mismatches, missing Flathub apps, or API failures (with --strict).
"""

from __future__ import annotations

import argparse
import json
import re
import sys
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path

SCRIPT_DIR = Path(__file__).parent
PROJECT_ROOT = SCRIPT_DIR.parent

FLATHUB_APPSTREAM_API = "https://flathub.org/api/v2/appstream/{flatpak_id}"

# Keep in sync with register_flatpak() in src/app_pins.vala (flatpak_pin_rules map).
# Full desktop map used for verification; unlisted ids default to {flatpak_id}.desktop.
FLATPAK_DESKTOP_MAP: dict[str, str] = {
    "org.mozilla.firefox": "org.mozilla.firefox.desktop",
    "org.chromium.Chromium": "org.chromium.Chromium.desktop",
    "app.zen_browser.zen": "app.zen_browser.zen.desktop",
    "io.gitlab.librewolf-community": "io.gitlab.librewolf-community.desktop",
    "com.brave.Browser": "com.brave.Browser.desktop",
    "com.vivaldi.Vivaldi": "com.vivaldi.Vivaldi.desktop",
    "com.google.Chrome": "com.google.Chrome.desktop",
    "com.microsoft.Edge": "com.microsoft.Edge.desktop",
    "com.opera.Opera": "com.opera.Opera.desktop",
    "org.onlyoffice.desktopeditors": "org.onlyoffice.desktopeditors.desktop",
    "org.mozilla.Thunderbird": "org.mozilla.Thunderbird.desktop",
    "org.gnome.Geary": "org.gnome.Geary.desktop",
    "us.zoom.Zoom": "us.zoom.Zoom.desktop",
    "com.slack.Slack": "com.slack.Slack.desktop",
    "org.jitsi.jitsi-meet": "org.jitsi.jitsi-meet.desktop",
    "com.mattermost.Desktop": "com.mattermost.Desktop.desktop",
    "md.obsidian.Obsidian": "md.obsidian.Obsidian.desktop",
    "com.logseq.Logseq": "com.logseq.Logseq.desktop",
    "com.todoist.Todoist": "com.todoist.Todoist.desktop",
    "com.spotify.Client": "com.spotify.Client.desktop",
    "com.valvesoftware.Steam": "com.valvesoftware.Steam.desktop",
    "net.lutris.Lutris": "net.lutris.Lutris.desktop",
    "com.heroicgameslauncher.hgl": "com.heroicgameslauncher.hgl.desktop",
    "com.discordapp.Discord": "com.discordapp.Discord.desktop",
    "com.obsproject.Studio": "com.obsproject.Studio.desktop",
    "org.gimp.GIMP": "org.gimp.GIMP.desktop",
    "org.kde.krita": "org.kde.krita.desktop",
    "org.blender.Blender": "org.blender.Blender.desktop",
    "org.gnome.Showtime": "org.gnome.Showtime.desktop",
    "de.haeckerfelix.Shortwave": "de.haeckerfelix.Shortwave.desktop",
    "ca.edestcroix.Recordbox": "ca.edestcroix.Recordbox.desktop",
    "org.gnome.Podcasts": "org.gnome.Podcasts.desktop",
    "io.github.amit9838.mousam": "io.github.amit9838.mousam.desktop",
    "org.gnome.SoundRecorder": "org.gnome.SoundRecorder.desktop",
    "com.github.maoschanz.drawing": "com.github.maoschanz.drawing.desktop",
    "org.gnome.World.Secrets": "org.gnome.World.Secrets.desktop",
    "io.gitlab.news_flash.NewsFlash": "io.gitlab.news_flash.NewsFlash.desktop",
    "com.rafaelmardojai.Blanket": "com.rafaelmardojai.Blanket.desktop",
    "app.drey.Warp": "app.drey.Warp.desktop",
    "org.gnome.DejaDup": "org.gnome.DejaDup.desktop",
    "org.gnome.Music": "org.gnome.Music.desktop",
    "org.gnome.Lollypop": "org.gnome.Lollypop.desktop",
    "org.gnome.Rhythmbox3": "org.gnome.Rhythmbox3.desktop",
    "org.musicbrainz.Picard": "org.musicbrainz.Picard.desktop",
    "org.gnome.SoundJuicer": "org.gnome.SoundJuicer.desktop",
    "org.soundconverter.SoundConverter": "org.soundconverter.SoundConverter.desktop",
    "io.github.sharkwouter.Minigalaxy": "io.github.sharkwouter.Minigalaxy.desktop",
    "com.usebottles.bottles": "com.usebottles.bottles.desktop",
    "com.vysp3r.ProtonPlus": "com.vysp3r.ProtonPlus.desktop",
    "org.libretro.RetroArch": "org.libretro.RetroArch.desktop",
    "com.dosbox_x.DOSBox-X": "com.dosbox_x.DOSBox-X.desktop",
    "org.prismlauncher.PrismLauncher": "org.prismlauncher.PrismLauncher.desktop",
    "io.github.antimicrox.antimicrox": "io.github.antimicrox.antimicrox.desktop",
    "org.kde.kdenlive": "org.kde.kdenlive.desktop",
    "org.pitivi.Pitivi": "org.pitivi.Pitivi.desktop",
    "org.audacityteam.Audacity": "org.audacityteam.Audacity.desktop",
    "fm.reaper.Reaper": "fm.reaper.Reaper.desktop",
    "fr.handbrake.ghb": "fr.handbrake.ghb.desktop",
    "org.inkscape.Inkscape": "org.inkscape.Inkscape.desktop",
    "org.darktable.Darktable": "org.darktable.Darktable.desktop",
    "com.icons8.Lunacy": "com.icons8.Lunacy.desktop",
    "com.authormore.penpotdesktop": "com.authormore.penpotdesktop.desktop",
    "io.github.alainm23.planify": "io.github.alainm23.planify.desktop",
    "org.gnome.Weather": "org.gnome.Weather.desktop",
    "org.gnome.World.PikaBackup": "org.gnome.World.PikaBackup.desktop",
    "org.vinegarhq.Sober": "org.vinegarhq.Sober.desktop",
    "org.libreoffice.LibreOffice": "org.libreoffice.LibreOffice.desktop",
}

REF_PATTERN = re.compile(r'"app_id":\s*"(app/[^"]+)"')
CURATED_PATTERN = re.compile(
    r'add_curated_app\("([^"]+)",\s*"([^"]+)"'
)


@dataclass
class CatalogApp:
    name: str
    app_ref: str
    sources: list[str] = field(default_factory=list)

    @property
    def flatpak_id(self) -> str:
        return self.app_ref.split("/")[1]


@dataclass
class VerifyResult:
    flatpak_id: str
    name: str
    sources: list[str]
    assumed_desktop: str
    pinned_desktop: str
    flathub_desktop: str | None
    status: str
    detail: str | None = None


def assumed_desktop(flatpak_id: str) -> str:
    return f"{flatpak_id}.desktop"


def pinned_desktop(flatpak_id: str) -> str:
    return FLATPAK_DESKTOP_MAP.get(flatpak_id, assumed_desktop(flatpak_id))


def load_catalog() -> dict[str, CatalogApp]:
    apps: dict[str, CatalogApp] = {}

    def add(name: str, app_ref: str, source: str) -> None:
        if not app_ref.startswith("app/"):
            return
        flatpak_id = app_ref.split("/")[1]
        key = app_ref
        if key not in apps:
            apps[key] = CatalogApp(name=name, app_ref=app_ref)
        entry = apps[key]
        if name and entry.name in ("", "Unknown"):
            entry.name = name
        if source not in entry.sources:
            entry.sources.append(source)

    def scan_text(text: str, source: str, name_key: str | None = None) -> None:
        if name_key:
            # Not used for vala/json simple scan
            pass
        for match in REF_PATTERN.finditer(text):
            add("Unknown", match.group(1), source)

    scan_text(
        (PROJECT_ROOT / "src" / "defaults.vala").read_text(encoding="utf-8"),
        "defaults.vala",
    )
    scan_text(
        (PROJECT_ROOT / "data" / "example_apps_config.json").read_text(
            encoding="utf-8"
        ),
        "example_apps_config.json",
    )

    apps_vala = (PROJECT_ROOT / "src" / "pages" / "apps.vala").read_text(
        encoding="utf-8"
    )
    for flatpak_id, name in CURATED_PATTERN.findall(apps_vala):
        add(name, f"app/{flatpak_id}/x86_64/stable", "apps.vala (curated)")

    # Enrich names from JSON where possible
    try:
        config = json.loads(
            (PROJECT_ROOT / "data" / "example_apps_config.json").read_text(
                encoding="utf-8"
            )
        )
        for category in config.get("apps", {}).get("categories", []):
            for app in category.get("apps", []):
                app_id = app.get("app_id", "")
                if app_id.startswith("app/"):
                    add(app.get("name", "Unknown"), app_id, "example_apps_config.json")
    except json.JSONDecodeError:
        pass

    browser_json_match = re.search(
        r'DEFAULT_WEB_BROWSER_JSON = """(.*?)"""',
        (PROJECT_ROOT / "src" / "defaults.vala").read_text(encoding="utf-8"),
        re.DOTALL,
    )
    if browser_json_match:
        try:
            browser = json.loads(browser_json_match.group(1))
            for app in browser.get("apps", []):
                app_id = app.get("app_id", "")
                if app_id.startswith("app/"):
                    add(app.get("name", "Unknown"), app_id, "defaults.vala (browsers)")
        except json.JSONDecodeError:
            pass

    return apps


def fetch_flathub_desktop(flatpak_id: str, timeout: float = 20.0) -> tuple[str | None, str | None]:
    url = FLATHUB_APPSTREAM_API.format(flatpak_id=flatpak_id)
    try:
        with urllib.request.urlopen(url, timeout=timeout) as response:
            data = json.load(response)
    except urllib.error.HTTPError as exc:
        if exc.code == 404:
            return None, "not on Flathub (HTTP 404)"
        return None, f"HTTP {exc.code}"
    except urllib.error.URLError as exc:
        return None, str(exc.reason)
    except json.JSONDecodeError as exc:
        return None, f"invalid JSON: {exc}"

    launchable = data.get("launchable")
    if isinstance(launchable, dict) and launchable.get("type") == "desktop-id":
        return launchable.get("value"), None
    if isinstance(launchable, list):
        for item in launchable:
            if item.get("type") == "desktop-id":
                return item.get("value"), None
    return None, f"no desktop-id launchable in API response"


def verify_app(app: CatalogApp, delay: float) -> VerifyResult:
    flatpak_id = app.flatpak_id
    assumed = assumed_desktop(flatpak_id)
    pinned = pinned_desktop(flatpak_id)
    flathub, error = fetch_flathub_desktop(flatpak_id)
    if delay > 0:
        time.sleep(delay)

    if error:
        return VerifyResult(
            flatpak_id=flatpak_id,
            name=app.name,
            sources=app.sources,
            assumed_desktop=assumed,
            pinned_desktop=pinned,
            flathub_desktop=None,
            status="not_on_flathub" if "404" in error else "api_error",
            detail=error,
        )

    if flathub == pinned:
        status = "ok"
        detail = None
    elif pinned != assumed:
        status = "ok"
        detail = f"explicit desktop map (Flathub publishes {flathub!r})"
    elif flathub == assumed:
        status = "ok"
        detail = None
    else:
        status = "mismatch"
        detail = (
            f"Flathub publishes {flathub!r}; app_pins.vala pins {pinned!r} "
            f"(default rule gives {assumed!r})"
        )

    return VerifyResult(
        flatpak_id=flatpak_id,
        name=app.name,
        sources=app.sources,
        assumed_desktop=assumed,
        pinned_desktop=pinned,
        flathub_desktop=flathub,
        status=status,
        detail=detail,
    )


def print_report(results: list[VerifyResult]) -> None:
    by_status: dict[str, list[VerifyResult]] = {}
    for result in results:
        by_status.setdefault(result.status, []).append(result)

    ok = by_status.get("ok", [])
    mismatches = by_status.get("mismatch", [])
    missing = by_status.get("not_on_flathub", [])
    api_errors = by_status.get("api_error", [])

    print(f"Checked {len(results)} Flatpak ref(s) from quick-setup catalog\n")

    if ok:
        print(f"OK ({len(ok)}):")
        for r in sorted(ok, key=lambda x: x.flatpak_id):
            suffix = f" — {r.detail}" if r.detail else ""
            print(f"  {r.flatpak_id} -> {r.flathub_desktop}{suffix}")
        print()

    if mismatches:
        print(f"MISMATCH — update APP_PINS_DESKTOP_OVERRIDES / app_pins.vala ({len(mismatches)}):")
        for r in sorted(mismatches, key=lambda x: x.flatpak_id):
            print(f"  {r.name} ({r.flatpak_id})")
            print(f"    sources: {', '.join(r.sources)}")
            print(f"    {r.detail}")
            print(f"    add override: {r.flatpak_id!r}: {r.flathub_desktop!r}")
        print()

    if missing:
        print(f"NOT ON FLATHUB ({len(missing)}):")
        for r in sorted(missing, key=lambda x: x.flatpak_id):
            print(f"  {r.name} ({r.flatpak_id}) — {r.detail}")
            print(f"    sources: {', '.join(r.sources)}")
        print()

    if api_errors:
        print(f"API ERRORS ({len(api_errors)}):")
        for r in sorted(api_errors, key=lambda x: x.flatpak_id):
            print(f"  {r.flatpak_id}: {r.detail}")
        print()

    print(
        f"Summary: {len(ok)} ok, {len(mismatches)} mismatch, "
        f"{len(missing)} not on Flathub, {len(api_errors)} api error(s)"
    )


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Verify Flatpak desktop IDs for quick-setup pinning."
    )
    parser.add_argument(
        "--delay",
        type=float,
        default=0.12,
        help="Seconds between Flathub API requests (default: 0.12)",
    )
    parser.add_argument(
        "--json",
        type=Path,
        help="Write full results JSON to this path",
    )
    parser.add_argument(
        "--strict",
        action="store_true",
        help="Treat API errors as failures (default: only mismatches and missing refs)",
    )
    args = parser.parse_args()

    catalog = load_catalog()
    results = [
        verify_app(app, args.delay if i else 0.0)
        for i, app in enumerate(sorted(catalog.values(), key=lambda a: a.app_ref))
    ]

    print_report(results)

    if args.json:
        payload = [
            {
                "flatpak_id": r.flatpak_id,
                "name": r.name,
                "sources": r.sources,
                "assumed_desktop": r.assumed_desktop,
                "pinned_desktop": r.pinned_desktop,
                "flathub_desktop": r.flathub_desktop,
                "status": r.status,
                "detail": r.detail,
            }
            for r in results
        ]
        args.json.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
        print(f"Wrote {args.json}")

    failures = [r for r in results if r.status == "mismatch" or r.status == "not_on_flathub"]
    if args.strict:
        failures.extend(r for r in results if r.status == "api_error")

    return 1 if failures else 0


if __name__ == "__main__":
    sys.exit(main())
