"""
AAhead Shell — GUI entry point.

A fixed 640×480 pygame stage (the discovery shell: Home / Library / Game card
/ Search / Discover). On the handheld it renders fullscreen via SDL's KMSDRM
driver; on a dev machine it opens a window. Headless `--screenshot` renders a
single frame to PNG for review without hardware.

This is the UI layer only — there is no game-launch backend yet (pressing Play
shows a toast). Settings live in the separate AAhead Control port (the gear).

Controls (device gamepad via gptokeyb → keyboard; dev keyboard):
  D-pad / arrows  → move focus        A / Return → select / play
  B / Esc         → back              X          → context / settings
  Y               → search            L / R      → cycle (PageDown/PageUp)
"""

from __future__ import annotations

import argparse
import os
import sys
import time
import traceback

import pygame

_PORT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, _PORT_ROOT)

from gui.design import tokens as t                 # noqa: E402
from gui.data.dataset import Catalog               # noqa: E402


class App:
    def __init__(self, screenshot: str | None = None):
        self.screenshot = screenshot
        self._exit = False
        self._toast_text = ""
        self._toast_until = 0.0
        self.stack = []
        self.surface = None
        self._rstick = 0          # right-stick Y debounce (A–Z fast-jump)
        self._rstickx = 0         # right-stick X debounce
        self.catalog = Catalog.load()
        from gui.library.service import LibraryService
        self.library = LibraryService(self.catalog)

    # ── stack ────────────────────────────────────────────────────────
    @property
    def screen(self):
        return self.stack[-1] if self.stack else None

    def push(self, screen):
        self.stack.append(screen)

    def pop(self):
        if len(self.stack) > 1:
            self.stack.pop()
            top = self.screen
            if top and hasattr(top, "on_resume"):
                top.on_resume()
        else:
            self.request_exit()

    def request_exit(self):
        self._exit = True

    def toast(self, text: str, ms: int = 2200):
        self._toast_text = text
        self._toast_until = time.monotonic() * 1000 + ms

    # ── design-review screenshot (hotkey: L2/R2 on device, S/F12 on dev) ──
    def save_screenshot(self):
        if self.surface is None:
            return
        d = os.path.join(_PORT_ROOT, "screenshots")
        os.makedirs(d, exist_ok=True)
        n = type(self.screen).__name__ if self.screen else "screen"
        path = os.path.join(d, f"{time.strftime('%Y%m%d-%H%M%S')}_{n}.png")
        try:
            pygame.image.save(self.surface, path)
            self.toast("Screenshot saved")
        except Exception as exc:
            print(f"AAHEAD screenshot failed: {exc!r}", flush=True)

    # ── open helpers (navigation graph) ──────────────────────────────
    def open_game(self, gid: str, *, installed: bool | None = None):
        # installed=None → ask the merged library (catalog.is_installed returns True
        # before the overlay is set, e.g. on a dev box, so the UI is unchanged there).
        if installed is None:
            installed = self.catalog.is_installed(gid)
        from gui.screens.game_card import GameCardScreen
        self.push(GameCardScreen(self, gid, installed=installed))

    def launch_game(self, game, *, restart: bool = False,
                    slot: object = None) -> bool:
        """Resolve a game → emulator command, drop a `.launch` flag, and exit.
        launch.sh runs the emulator (gptokeyb off) and loops straight back here —
        same seamless flag→exit→return pattern as the controller remap.

        `restart=True` (the "Restart" button) writes an additional `MODE=restart`
        line into the flag so launch.sh skips the emulator's auto-state load
        before exec'ing the command. Default Continue stays a one-line flag —
        byte-identical to the pre-Step-E shape — so existing on-device installs
        whose launch.sh predates the MODE hint keep working until upgraded.

        Display preset + cheats application (P1.B + CT.C): for RetroArch
        invocations we materialise a libretro `.cht` next to the ROM (if the
        player has enabled curated cheats for this game) and write an
        override config with the per-system Display preset (§12 matrix) plus
        `cheats_apply_at_start = true` when needed. Both ride into the
        emulator via a single `--appendconfig` injected after the retroarch
        token. Standalone emulators (PPSSPP / DuckStation) pass through
        untouched — they wouldn't parse the flag."""
        if not game:
            return False
        spec = self.library.launch_spec(game)
        if not spec:
            self.toast("Not on this device yet — add it first")
            return False

        # ── P1.B + CT.C: build the launch overlay ────────────────────
        cmd = spec.command
        from gui.library import launch_overlay, cheat_apply
        if launch_overlay.is_retroarch_command(cmd):
            ps = self.library.play_state()
            gid = game["id"]
            curated = self.catalog.cheats_for(gid)
            enabled = ps.enabled_cheats(gid)
            # Materialize the .cht — multi-path write so at least one of
            # RetroArch's candidate auto-load locations gets our file. We
            # always emit the FULL curated list (enable=true only for the
            # player's selections) so the in-game cheat menu still shows
            # every option for in-emulator flipping. Pass spec.command so
            # the core_basename gets extracted for the per-core path.
            cht_path: str | None = None
            if curated:
                cht_path = cheat_apply.materialize_for_game(
                    spec.rom, curated, enabled, retroarch_command=spec.command)
            # Compose the override cfg: display preset + multi-variant cheat
            # apply switches + cheat_settings_path → our materialized file.
            cfg_text = launch_overlay.build_override_cfg(
                system=game["system"],
                look=ps.display_look(),
                cheats_at_start=bool(curated and enabled),
                cheat_path=cht_path if (curated and enabled) else None,
                # The Auto slot needs savestate_auto_load forced on (dArkOS
                # default is false). Numbered slots use --entryslot below.
                auto_load_state=(slot == "auto"),
            )
            cfg_path = launch_overlay.write_overlay(
                self.library.state_dir, gid, cfg_text)
            if cfg_path:
                cmd = launch_overlay.inject_appendconfig(cmd, cfg_path)

            # ── S3: load a specific save-state slot on launch ──────────
            # `slot` is an int (numbered slot → --entryslot N) or the
            # string "auto" (the quick-mode auto-save → RetroArch's
            # savestate_auto_load already loads it on a default Continue,
            # so no flag needed). None = normal Play/Continue.
            if isinstance(slot, int):
                cmd = launch_overlay.inject_entryslot(cmd, slot)

            # ── S4 (E.2): Restart drops the auto-save so RetroArch's
            # auto-load can't override a from-scratch start. We delete the
            # `<base>.state.auto` here (Mac/dev: dir absent → no-op; device:
            # real path resolved via savestates candidates). launch.sh's
            # MODE=restart line stays as the belt-and-suspenders log.
            if restart:
                from gui.library import savestates
                ap = savestates.auto_state_path(spec.rom, game.get("system", ""))
                if ap:
                    try:
                        os.remove(ap)
                        print(f"restart: dropped auto-save {ap}", flush=True)
                    except OSError as exc:               # noqa: BLE001
                        print(f"restart: could not drop {ap} ({exc!r})", flush=True)

        try:
            os.makedirs(os.path.dirname(self.library.flag_launch), exist_ok=True)
            with open(self.library.flag_launch, "w", encoding="utf-8") as fh:
                fh.write(cmd + "\n")
                if restart:
                    fh.write("MODE=restart\n")
        except Exception as exc:                          # noqa: BLE001
            print(f"launch_game flag failed: {exc!r}", flush=True)
            self.toast("Couldn't start the game")
            return False
        # Record session start so consume_pending_session() can credit the
        # playtime when launch.sh's loop hands control back to the shell.
        self.library.play_state().start_session(game["id"])
        self.toast(f"Launching {game.get('title', 'game')}…")
        self.request_exit()
        return True

    def open_system(self, system: str):
        from gui.screens.library import LibrarySystem
        self.push(LibrarySystem(self, system))

    def open_image(self, paths, index=0, captions=None):
        from gui.screens.image_viewer import ImageViewer
        self.push(ImageViewer(self, paths, index, captions))

    def open_series_index(self):
        from gui.screens.series import SeriesIndex
        self.push(SeriesIndex(self))

    def open_series(self, name):
        from gui.screens.series import SeriesDetail
        self.push(SeriesDetail(self, name))

    def open_collection(self, cid):
        from gui.screens.series import SeriesDetail
        col = next((c for c in self.catalog.collections if c["id"] == cid), None)
        if col:
            self.push(SeriesDetail(self, col["title"],
                                   self.catalog.collection_games(cid)))

    def open_filter(self, label: str, games: list):
        """Open a generic list view for a chip-filter result. Reuses
        SeriesDetail (same poster-grid + A-Z layout the Series and
        Collection screens already use) so a Discover chip-tap lands on
        a familiar UI instead of a one-off screen. Caller passes the
        chip's user-visible label (e.g. "Cozy", "Quick break") as the
        title and the pre-filtered game list as the body."""
        from gui.screens.series import SeriesDetail
        self.push(SeriesDetail(self, label, list(games or [])))

    def open_addgames(self):
        from gui.screens.addgames import AddGamesScreen
        self.push(AddGamesScreen(self))

    def open_assist(self, game):
        from gui.screens.assist import AssistScreen
        self.push(AssistScreen(self, game))

    def open_control(self):
        """Open AAhead Control as the in-process Settings section — seamless,
        one app, no relaunch (per the manifesto). It runs as a hosted screen in
        this same loop; B from its root folds back to the game shell."""
        from gui.screens.control_host import ControlHost
        self.push(ControlHost(self))

    def open_quick_menu(self, gid):
        from gui.screens.quickmenu import QuickMenu
        g = self.catalog.by_id.get(gid)
        if g:
            self.push(QuickMenu(self, g))

    def open_notifications(self):
        from gui.screens.notifications import NotificationsScreen
        self.push(NotificationsScreen(self))

    def open_resume(self):
        from gui.screens.resume import ResumeScreen
        self.push(ResumeScreen(self))

    def go_tab(self, name: str):
        """Top-level tab switch — replaces the root (tabs are roots)."""
        if name == "settings":
            self.open_control()
            return
        if name == "notifications":
            self.open_notifications()      # a pushed drawer, not a root swap
            return
        from gui.screens.home import HomeScreen
        from gui.screens.library import LibraryAll
        from gui.screens.search import SearchScreen
        from gui.screens.discover import DiscoverScreen
        cls = {"home": HomeScreen, "library": LibraryAll,
               "search": SearchScreen, "discover": DiscoverScreen}.get(name)
        if cls:
            self.stack = [cls(self)]

    # ── display ──────────────────────────────────────────────────────
    def _init_display(self):
        pygame.display.init()
        pygame.font.init()
        pygame.joystick.init()
        for i in range(pygame.joystick.get_count()):
            try:
                pygame.joystick.Joystick(i).init()
            except Exception:
                pass
        flags = 0
        if self.screenshot is None and os.environ.get("R36S_FULLSCREEN", "0") == "1":
            flags = pygame.FULLSCREEN
        self.surface = pygame.display.set_mode(
            (t.VIEWPORT_WIDTH, t.VIEWPORT_HEIGHT), flags)
        pygame.display.set_caption("AAhead")
        pygame.mouse.set_visible(False)
        from gui.screens.home import HomeScreen
        self.stack = [HomeScreen(self)]

    # ── input ────────────────────────────────────────────────────────
    def _handle_event(self, ev) -> str | None:
        if ev.type == pygame.QUIT:
            return "quit"
        if ev.type == pygame.KEYDOWN:
            if ev.key in (pygame.K_s, pygame.K_F12):
                return "screenshot"
            return {
                pygame.K_LEFT: "left", pygame.K_RIGHT: "right",
                pygame.K_UP: "up", pygame.K_DOWN: "down",
                pygame.K_RETURN: "a", pygame.K_ESCAPE: "b",
                pygame.K_x: "x", pygame.K_y: "y",
                pygame.K_PAGEDOWN: "l", pygame.K_PAGEUP: "r",
                pygame.K_TAB: "start",
                pygame.K_HOME: "azprev", pygame.K_END: "aznext",  # right-stick A–Z
            }.get(ev.key)
        if ev.type == pygame.JOYHATMOTION:
            x, y = ev.value
            if x == -1: return "left"
            if x == 1: return "right"
            if y == 1: return "up"
            if y == -1: return "down"
        if ev.type == pygame.JOYBUTTONDOWN:
            if ev.button in (0, 1):
                return "a" if ev.button == 1 else "b"
            if ev.button == 2: return "x"
            if ev.button == 3: return "y"
            if ev.button in (4, 6): return "l"
            if ev.button in (5, 7): return "r"
            if ev.button in (8, 9): return "start"
        if ev.type == pygame.JOYAXISMOTION:
            if ev.axis == 0:
                if ev.value < -0.6: return "left"
                if ev.value > 0.6: return "right"
            if ev.axis == 1:
                if ev.value < -0.6: return "up"
                if ev.value > 0.6: return "down"
            # Right stick → fast A–Z/page scroll. gptokeyb does NOT grab the right
            # stick on this clone, so pygame reads it raw — and the axes report
            # INVERTED (physical up/left = positive). Flip the targets to match.
            if ev.axis == 3:                      # right stick Y (inverted)
                if abs(ev.value) < 0.4:
                    self._rstick = 0
                elif ev.value > 0.6 and self._rstick != 1:
                    self._rstick = 1; return "azprev"     # physical up → back
                elif ev.value < -0.6 and self._rstick != -1:
                    self._rstick = -1; return "aznext"    # physical down → forward
            if ev.axis == 2:                      # right stick X (inverted)
                if abs(ev.value) < 0.4:
                    self._rstickx = 0
                elif ev.value > 0.6 and self._rstickx != 1:
                    self._rstickx = 1; return "azprev"    # physical left → back
                elif ev.value < -0.6 and self._rstickx != -1:
                    self._rstickx = -1; return "aznext"   # physical right → forward
        return None

    # ── frame ────────────────────────────────────────────────────────
    def _draw_frame(self, dt_ms: float):
        self.surface.fill(t.Color.BG_DEEP)
        self.screen.update(dt_ms)
        self.screen.draw(self.surface)
        self._draw_toast()

    def _draw_toast(self):
        from gui.shared import fonts
        from gui.shared import render
        now = time.monotonic() * 1000
        if not self._toast_text or now > self._toast_until:
            return
        label = fonts.render(t.Type.BODY_STRONG, self._toast_text, t.Color.TEXT_PRIMARY)
        pad_x, pad_y = 16, 10
        w = label.get_width() + pad_x * 2 + 6
        h = label.get_height() + pad_y * 2
        x = (t.VIEWPORT_WIDTH - w) // 2
        y = t.VIEWPORT_HEIGHT - t.Space.FOOTER_HINT_HEIGHT - h - 8
        card = render.rounded_fill((w, h), t.Color.SURFACE_ELEVATED, t.Radius.BUTTON)
        pygame.draw.rect(card, t.Color.ACCENT_CYAN, (0, 0, 4, h),
                         border_top_left_radius=t.Radius.BUTTON,
                         border_bottom_left_radius=t.Radius.BUTTON)
        self.surface.blit(card, (x, y))
        self.surface.blit(label, (x + pad_x + 6, y + pad_y))

    # ── run modes ────────────────────────────────────────────────────
    def run_screenshot(self, screen_name: str | None = None):
        os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
        # Freeze chrome readings so screenshot regen is reproducible — otherwise
        # rendered clock / battery drift with wall-clock minute + real sysfs.
        os.environ.setdefault("AAHEAD_FAKE_CLOCK", "14:32")
        os.environ.setdefault("AAHEAD_FAKE_BATTERY", "82")
        os.environ.setdefault("AAHEAD_FAKE_WIFI", "1")
        os.environ.setdefault("AAHEAD_FAKE_BT", "1")
        self._init_display()
        if screen_name and screen_name != "home":
            self._push_named(screen_name)
        for _ in range(3):
            self._draw_frame(16.0)
        pygame.image.save(self.surface, self.screenshot)
        print(f"saved screenshot → {self.screenshot}")
        pygame.quit()

    def _push_named(self, name: str):
        if name in ("game", "card", "g1"):
            ce = self.catalog.continue_entries()
            gid = ce[0]["game"]["id"] if ce else self.catalog.games[0]["id"]
            self.open_game(gid)
        elif name in ("library", "l0"):
            from gui.screens.library import LibraryAll
            self.stack = [LibraryAll(self)]
        elif name in ("system", "l1"):
            self.go_tab("library")
            self.open_system("SNES")
        elif name == "search":
            self.go_tab("search")
        elif name in ("search-results", "search-noresults"):
            self.go_tab("search")
            s = self.stack[-1]
            s.query = "mario" if name == "search-results" else "elden ring"
            s._recompute()
            if name == "search-noresults":
                s.committed = True
            else:
                s.area = "results"
        elif name == "discover":
            self.go_tab("discover")
        elif name in ("series", "series_index"):
            self.open_series_index()
        elif name in ("addgames", "add"):
            self.open_addgames()
        elif name == "addgames-offline":
            from gui.screens.addgames import AddGamesScreen
            self.push(AddGamesScreen(self, online=False))
        elif name in ("g3", "not-installed"):
            self.open_game(self.catalog.games[0]["id"], installed=False)
        elif name in ("quickmenu", "q1"):
            ce = self.catalog.continue_entries()
            gid = ce[0]["game"]["id"] if ce else self.catalog.games[0]["id"]
            self.open_quick_menu(gid)
        elif name in ("notifications", "n1"):
            self.open_notifications()
        elif name in ("resume", "r1"):
            self.open_resume()
        elif name in ("h0", "coldstart"):
            from gui.screens.home import HomeScreen
            self.stack = [HomeScreen(self, cold=True)]
        elif name == "assist":
            self.open_assist(self.catalog.games[0])

    def run_remap(self):
        """Controller remap — the one flow that needs gptokeyb OFF. launch.sh
        runs this (after the remap flag) WITHOUT gptokeyb, so RawRemap reads the
        RAW joystick, applies the gptk compensation, then we return to the game."""
        self._init_display()
        from gui.control.screens.input import RawRemap
        self.stack = [RawRemap(self)]
        clock = pygame.time.Clock()
        while not self._exit:
            dt = clock.tick(60)
            for ev in pygame.event.get():
                if ev.type == pygame.QUIT:
                    self._exit = True
                elif ev.type == pygame.KEYDOWN and ev.key == pygame.K_ESCAPE:
                    self._exit = True
                else:
                    self.screen.handle_raw(ev)
            if getattr(self.screen, "should_exit", False):
                self._exit = True
            self._draw_frame(dt)
            pygame.display.flip()
        pygame.quit()

    def run(self):
        self._init_display()
        # AAHEAD_DEV=1 — we're on a dev box (Mac, CI). Skip device-only
        # bootstrap (sysfs scans, es_systems dumps, library overlay) so we don't
        # log noisy OSErrors against /roms and so the 182 demo catalog shows
        # in its "off-device" mode (every game treated as available for G1/G2,
        # not G3 not-installed). The dev wrapper sets this; the device unset.
        dev = bool(os.environ.get("AAHEAD_DEV"))
        if not dev:
            self.library.ensure_indexed()      # first boot: catalog the card (no moves)
            self.library.dump_diagnostics()    # snapshot es_systems + cores for launch tuning
            try:                               # never let bad library data brick the launcher
                if self.library.has_library():  # merge the card's games into the read API
                    self.catalog.set_library(self.library.manifest().get("items", []),
                                             enriched=self.library.enriched())
            except Exception as exc:           # noqa: BLE001 — degrade to catalog-only
                print(f"library merge failed, running catalog-only: {exc!r}", flush=True)
        # Wire the writable user-state file (favorites, continue, playtime)
        # in place of the shipped demo seed. Screens calling `catalog.is_favorite`
        # / `catalog.continue_entries` now see live mutations made via
        # `library.play_state().toggle_favorite(...)` and friends.
        self.catalog.play_state = self.library.play_state().data
        # If we just came back from an emulator session, credit the playtime
        # before the first frame draws — the Home Continue rail will show
        # this game on top with the freshly-accumulated time.
        self.library.play_state().consume_pending_session()
        clock = pygame.time.Clock()
        DIRS = ("up", "down", "left", "right")
        DELAY, RATE = 350, 90        # ms: hold-to-repeat for d-pad/stick scrolling
        held, held_next = None, 0
        while not self._exit:
            dt = clock.tick(60)
            now = pygame.time.get_ticks()
            for ev in pygame.event.get():
                action = self._handle_event(ev)
                if action in DIRS:                       # (re)arm auto-repeat
                    held, held_next = action, now + DELAY
                elif self._is_release(ev):
                    held = None
                if action == "quit":
                    self._exit = True
                elif action == "screenshot":
                    self.save_screenshot()
                elif action:
                    self.screen.on_action(action)
            # held direction repeats while the button stays down (self-adjusts:
            # if gptokeyb already repeats, its KEYDOWNs keep re-arming the delay)
            if held and now >= held_next:
                self.screen.on_action(held)
                held_next = now + RATE
            self._draw_frame(dt)
            pygame.display.flip()
        pygame.quit()

    @staticmethod
    def _is_release(ev) -> bool:
        """A d-pad/stick release that should stop auto-repeat."""
        if ev.type == pygame.KEYUP and ev.key in (pygame.K_LEFT, pygame.K_RIGHT,
                                                   pygame.K_UP, pygame.K_DOWN):
            return True
        if ev.type == pygame.JOYBUTTONUP:
            return True
        if ev.type == pygame.JOYHATMOTION and ev.value == (0, 0):
            return True
        if ev.type == pygame.JOYAXISMOTION and ev.axis in (0, 1) and abs(ev.value) < 0.4:
            return True
        return False


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--screenshot", metavar="PATH",
                    help="render one frame to PATH and exit (headless)")
    ap.add_argument("--screen", metavar="NAME", default=None,
                    help="screen to preview: home|game")
    ap.add_argument("--remap", action="store_true",
                    help="controller remap (run without gptokeyb)")
    args = ap.parse_args()
    app = App(screenshot=args.screenshot)
    try:
        if args.screenshot:
            app.run_screenshot(args.screen)
        elif args.remap:
            app.run_remap()
        else:
            app.run()
    except Exception as exc:
        print(f"AAHEAD_SHELL crash: {exc!r}", flush=True)
        traceback.print_exc()
        try:
            pygame.quit()
        except Exception:
            pass
        return 1
    return 0


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