"""Onboarding v2 — WKWebView-based first-run flow.

Replaces the AppKit-only `onboarding.py` (Whisper model download only)
with the designer-driven multi-page experience that covers full setup:

  1. Welcome              — click to start
  2. Model download       — Whisper weights from huggingface_hub
  3. Permissions          — Mic / Accessibility / Input Monitoring / Screen Recording
  4. Account              — optional magic-link activation (skip = Free)
  5. Quick tour           — 3 cards
  6. Done                 — close + open bank window

Lives inside the main NSApp / rumps run loop. The onboarding window is a
borderless NSWindow that hosts a WKWebView; bridge handlers in this
module advance pages, poll permissions, drive the model download, and
call into licensing.py for activation.

Re-entry surfaces (called from the menubar / account pane later):
  • `show_permissions_only(app)` — open just the permissions page when
    macOS has revoked something between launches.
  • `show_account_only(app)` — open just the account page as a sign-in
    sheet from inside the bank window.

State persistence:
  `~/Library/Application Support/Lumo/onboarding_complete` — touched when
  the user reaches the Done page. Presence + cached model + valid
  session.json = no full flow on next launch.
"""

from __future__ import annotations

import json
import threading
from pathlib import Path
from typing import Callable, Optional

import objc  # type: ignore
from AppKit import (  # type: ignore
    NSApp,
    NSApplication,
    NSBackingStoreBuffered,
    NSColor,
    NSMakeRect,
    NSURL,
    NSWindow,
    NSWindowStyleMaskClosable,
    NSWindowStyleMaskFullSizeContentView,
    NSWindowStyleMaskTitled,
)
from PyObjCTools import AppHelper  # type: ignore

from .web_shell import WebShell


# Window size mirrors the bank window's default so the design lands at
# the resolution the designer mocked it at.
ONB_W, ONB_H = 1180, 760


def _data_dir() -> Path:
    return Path.home() / "Library" / "Application Support" / "Lumo"


def _onboarding_complete_marker() -> Path:
    return _data_dir() / "onboarding_complete"


def has_completed_onboarding() -> bool:
    return _onboarding_complete_marker().exists()


def _dir_size(root: Path) -> int:
    """Total bytes in `root` recursively. Used to estimate download progress
    against `_MODEL_APPROX_BYTES`. Returns 0 if the dir doesn't exist yet —
    huggingface_hub creates it lazily inside snapshot_download."""
    if not root.exists():
        return 0
    total = 0
    for p in root.rglob("*"):
        try:
            if p.is_file():
                total += p.stat().st_size
        except OSError:
            pass
    return total


def mark_onboarding_complete() -> None:
    p = _onboarding_complete_marker()
    try:
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text("ok\n", encoding="utf-8")
    except Exception as e:
        print(f"[onboarding] couldn't write marker: {e}", flush=True)


def _perms_seen_marker() -> Path:
    """Touched the first time `all_required_granted()` returns True after
    a fresh install (or after the user actually clicked through the
    permissions page). Existence of this file means "macOS TCC says yes
    for this Mac" — we then never re-show the permissions onboarding,
    even if a boot-time probe transiently flickers denied.

    Why fire-and-forget instead of a periodic re-probe: real revocations
    surface immediately at the point of use (mic capture raises, hotkey
    install logs `CGEventTapCreate failed`) — that's where the user
    needs the error, not on every cold boot."""
    return _data_dir() / "perms_seen_granted_v1"


def _record_perms_seen() -> None:
    p = _perms_seen_marker()
    try:
        p.parent.mkdir(parents=True, exist_ok=True)
        from datetime import datetime
        p.write_text(datetime.utcnow().isoformat() + "Z\n", encoding="utf-8")
    except Exception as e:
        print(f"[onboarding] couldn't write perms-seen marker: {e}",
              flush=True)


# ── Decide whether onboarding should fire ──────────────────────────────


def should_run_full_onboarding(cfg: dict) -> bool:
    """First-launch detection. CR (2026-06-23) — simplified: full
    onboarding fires ONLY when the completion marker is missing.

    Previously we also forced the full flow when the local Whisper
    model wasn't cached. That made sense when Local was the only
    backend, but with Cloud Mode as the post-v8 default, a Mac
    without the local model can transcribe perfectly via /transcribe;
    forcing the welcome → model-download → permissions → account
    rerun on every cold boot of such a Mac was the actual cause of
    "у меня снова экран permissions при каждом запуске". Local-mode
    users who lack the model are now handled by the explicit
    "Downloading Whisper" overlay we surface from Settings when they
    flip Voice processing → Local."""
    return not has_completed_onboarding()


def should_show_permissions_only() -> bool:
    """CR (2026-06-30) — final form: if onboarding_complete marker
    exists, NEVER re-show the permissions page on launch.

    Earlier versions tried to be clever — live-probe mic/AX/IM and
    re-show onboarding if anything looked revoked. Three back-to-back
    bug reports showed this is a losing game: AVCaptureDevice mic
    status stays NotDetermined until Lirelle actively calls
    requestAccess (which only happens on first recording), so the
    probe lies for a fresh install and triggers onboarding even when
    the user already granted everything through System Settings.

    Real revocations surface at the actual use site (mic capture,
    hotkey install) with actionable error messages. The startup
    re-prompt was always more noise than signal."""
    return False


# ── Window / WebShell host ─────────────────────────────────────────────


PAGE_ORDER = ["permissions", "mode", "account", "done"]

PAGE_HTML = {
    "welcome":     "onboarding-welcome.html",
    "model":       "onboarding-model.html",         # legacy — kept for compat
    "mode":        "onboarding-mode.html",          # v0.1.21 — cloud/local chooser
    "permissions": "onboarding-permissions.html",
    "account":     "onboarding-account.html",
    "tour":        "onboarding-tour.html",
    "done":        "onboarding-done.html",
}


class OnboardingWindow:
    """Owns the NSWindow + WebShell. Bridge handlers route the page's
    data-action clicks back here for advance / permission probe / magic-
    link / model-download control."""

    def __init__(self, app, on_complete: Callable[[], None],
                 only_page: Optional[str] = None) -> None:
        self.app = app                         # LirelleApp, may be None for re-entry
        self.on_complete = on_complete
        self.only_page = only_page
        self.window: Optional[NSWindow] = None
        self.shell: Optional[WebShell] = None
        self.current_page: Optional[str] = None
        self._pending_email: str = ""
        # Resolved when reaching `done` or when the user closes the window.
        self._closed = False

    # ── public ─────────────────────────────────────────────────────────

    def start(self) -> None:
        if NSApp() is None:
            NSApplication.sharedApplication()
        self._build_window()
        self._build_shell()
        # CR (2026-06-23) — write the completion marker as soon as the
        # onboarding window is shown, not when the user reaches the
        # Done page. The marker is "has seen onboarding once", not
        # "has completed every step". If the user Cmd+Q's mid-flow or
        # clicks "Skip — I'll grant later", the next cold boot should
        # NOT replay the whole welcome→model→permissions→account
        # sequence. User can re-trigger from the menu if they want
        # to revisit anything.
        if self.only_page is None:
            mark_onboarding_complete()
        # Decide starting page.
        if self.only_page is not None:
            self._goto(self.only_page)
        else:
            # CR (2026-07-01) — start directly at permissions per the
            # simplified 4-step flow (permissions → mode → account →
            # done). Welcome/model/tour dropped as separate pages;
            # copy that lived there now lives in permissions/mode.
            self._goto("permissions")
        self.window.makeKeyAndOrderFront_(None)
        NSApp().activateIgnoringOtherApps_(True)

    # ── window build ───────────────────────────────────────────────────

    def _build_window(self) -> None:
        style = (NSWindowStyleMaskTitled
                 | NSWindowStyleMaskClosable
                 | NSWindowStyleMaskFullSizeContentView)
        rect = NSMakeRect(0, 0, ONB_W, ONB_H)
        win = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
            rect, style, NSBackingStoreBuffered, False)
        win.setTitle_("Lirelle")
        win.setTitlebarAppearsTransparent_(True)
        win.setBackgroundColor_(
            NSColor.colorWithCalibratedRed_green_blue_alpha_(
                0.039, 0.039, 0.039, 1.0))
        # FullSizeContentView puts the WebView under the title bar, so
        # clicks/drags in the would-be title strip go to the page instead
        # of moving the window. setMovableByWindowBackground tells AppKit
        # to drag-on-background, which restores the expected "grab the
        # title strip" behaviour without needing CSS -webkit-app-region.
        win.setMovableByWindowBackground_(True)
        win.center()
        # Track window-close so we don't leak when user hits the X.
        delegate = _OnboardingWindowDelegate.alloc().initWithOnboarding_(self)
        win.setDelegate_(delegate)
        # Keep delegate alive by stashing on window.
        objc.setAssociatedObject(win, b"_onbDelegate", delegate, 1)
        self.window = win

    def _build_shell(self) -> None:
        shell = WebShell()
        shell.build(NSMakeRect(0, 0, ONB_W, ONB_H))
        self.shell = shell
        self.window.setContentView_(shell.view())
        self._wire_bridge(shell)

    # ── bridge ─────────────────────────────────────────────────────────

    def _wire_bridge(self, shell: WebShell) -> None:
        b = shell.bridge.set_handler
        b("onbAdvance", lambda args: self._on_advance(args))
        b("onbOpenSettings", lambda args: self._on_open_settings(args))
        b("onbPermissionsState", lambda args: self._on_perm_state(args))
        b("onbStartModel", lambda args: self._on_start_model(args))
        b("onbSendCode", lambda args: self._on_send_code(args))
        b("onbActivate", lambda args: self._on_activate(args))
        b("onbFinish", lambda args: self._on_finish(args))
        # CR (2026-07-01) — mode chooser page (Cloud vs Local + download).
        b("onbSetMode", lambda args: self._on_set_mode(args))
        b("onbModelStatus", lambda args: self._on_model_status(args))

    # ── page navigation ────────────────────────────────────────────────

    def _goto(self, page: str) -> None:
        self.current_page = page
        html_name = PAGE_HTML.get(page)
        if html_name is None:
            print(f"[onboarding] unknown page: {page!r}", flush=True)
            return
        print(f"[onboarding] _goto -> {page!r} (html={html_name})", flush=True)
        self._load_html_with_binder(html_name)
        # NOTE — earlier this branch auto-skipped to "permissions" when
        # _model_cached() returned True, on the theory that we shouldn't make
        # a returning user watch a frozen 0% bar. That was a footgun: any false
        # positive in _model_cached (the same fail-open bug we just closed)
        # made the model page silently disappear, the user landed in
        # permissions, granted them, hit dictation, and the first transcribe
        # call crashed because the model wasn't actually there. The JS-side
        # onbStartModel handler already does the right thing when the model
        # IS cached (push 100% + advance), so the auto-skip here was redundant
        # AND a single point of silent failure. Keep the page visible always.
        if page == "permissions":
            # macOS only surfaces the Mic / Screen-Recording dialogs (and
            # adds the app to the corresponding Settings list) AFTER we
            # actually request access via the AVFoundation / Quartz APIs.
            # Without these calls the user can't grant the permissions even
            # by hand because the app never appears in the Settings list.
            # Fire both — they're idempotent and only show the dialog when
            # status is NotDetermined.
            try:
                from .permissions_wizard import (
                    request_microphone_access,
                    request_screen_recording_access,
                )
                request_microphone_access()
                request_screen_recording_access()
            except Exception as e:
                print(f"[onboarding] permission triggers failed: {e}",
                      flush=True)

    def _load_html_with_binder(self, html_name: str) -> None:
        from .web_shell import ui_dir
        html_path = ui_dir() / html_name
        if not html_path.exists():
            print(f"[onboarding] missing HTML: {html_path}", flush=True)
            return
        try:
            html = html_path.read_text(encoding="utf-8")
        except OSError as e:
            print(f"[onboarding] couldn't read {html_path}: {e}", flush=True)
            return
        binder = ui_dir() / "onboarding-bind.js"
        if binder.exists():
            try:
                tag = f"\n<script>\n{binder.read_text(encoding='utf-8')}\n</script>\n"
                if "</body>" in html:
                    html = html.replace("</body>", tag + "</body>")
                else:
                    html = html + tag
            except OSError as e:
                print(f"[onboarding] couldn't load binder: {e}", flush=True)
        base_url = NSURL.fileURLWithPath_(str(ui_dir()))

        def _do():
            if self.shell is not None and self.shell._webview is not None:
                self.shell._webview.loadHTMLString_baseURL_(html, base_url)
        AppHelper.callAfter(_do)

    # ── bridge handlers ────────────────────────────────────────────────

    def _on_advance(self, args: dict) -> dict:
        # CR (2026-06-23) — in "only_page" reentry mode (permissions revoked
        # mid-session), once the user finishes the one page we showed, close
        # the window instead of marching them through account/tour/done.
        # Without this, every cold-boot TCC race would re-run the full
        # post-permissions flow including "Sign in" — which is what the
        # user reported as "сессию не запоминает".
        if self.only_page is not None:
            AppHelper.callAfter(self._close)
            return {"ok": True, "closed": True}
        target = (args or {}).get("to") or ""
        if target in PAGE_HTML:
            AppHelper.callAfter(self._goto, target)
            return {"ok": True}
        # No target → use page order.
        try:
            i = PAGE_ORDER.index(self.current_page or "welcome")
            nxt = PAGE_ORDER[i + 1] if i + 1 < len(PAGE_ORDER) else "done"
        except ValueError:
            nxt = "done"
        AppHelper.callAfter(self._goto, nxt)
        return {"ok": True, "to": nxt}

    def _on_open_settings(self, args: dict) -> dict:
        slug = (args or {}).get("slug") or ""
        try:
            from .permissions_wizard import _open_settings
            _open_settings(slug)
            return {"ok": True}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def _on_perm_state(self, _args: dict) -> dict:
        try:
            from .permissions_wizard import probe_all
            return {"perms": probe_all()}
        except Exception as e:
            return {"perms": [], "error": str(e)}

    def _on_start_model(self, _args: dict) -> dict:
        if self._model_cached():
            self._push("model-progress", {"percent": 100, "status": "Model ready"})
            return {"ok": True, "already": True}
        # Spawn the actual download on a bg thread; push progress via the
        # bridge as huggingface_hub streams.
        threading.Thread(target=self._download_model, daemon=True).start()
        return {"ok": True}

    # ── CR (2026-07-01) — mode chooser ─────────────────────────────────
    #
    # onboarding-mode.html asks: Cloud (fast, needs internet) or
    # Local (offline, needs ~1.5 GB Whisper download). The user can
    # also Skip → treated as Cloud (default). If they pick Local the
    # page reuses the existing download flow (_download_model +
    # `model-progress` push events) so the same UI can drive both
    # onboarding and Settings-triggered downloads.

    def _on_set_mode(self, args: dict) -> dict:
        mode = ((args or {}).get("mode") or "cloud").strip().lower()
        cloud_mode = mode != "local"
        try:
            from . import web_handlers as _wh
            res = _wh.update_setting({"key": "dictation.cloud_mode",
                                      "value": cloud_mode})
            if not res.get("ok"):
                return {"ok": False, "error": res.get("error") or "save failed"}
        except Exception as e:  # noqa: BLE001
            return {"ok": False, "error": f"{type(e).__name__}: {e}"}
        # Local mode → kick the download unless already cached; the JS
        # side listens to the same "model-progress" push events used by
        # the legacy model page.
        if not cloud_mode and not self._model_cached():
            threading.Thread(target=self._download_model, daemon=True).start()
            return {"ok": True, "downloading": True}
        return {"ok": True, "downloading": False,
                "cached": self._model_cached()}

    def _on_model_status(self, _args: dict) -> dict:
        return {"cached": self._model_cached()}

    def _on_send_code(self, args: dict) -> dict:
        email = ((args or {}).get("email") or "").strip()
        if not email:
            return {"ok": False, "error": "email required"}
        try:
            from .licensing import request_magic_link
            ok, msg = request_magic_link(email)
            if ok:
                self._pending_email = email
            return {"ok": ok, "message": msg}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def _on_activate(self, args: dict) -> dict:
        email = ((args or {}).get("email") or self._pending_email or "").strip()
        code = ((args or {}).get("code") or "").strip()
        if not email or not code:
            return {"ok": False, "error": "email and code required"}
        try:
            from .licensing import activate_with_code
            ok, msg = activate_with_code(email, code)
            return {"ok": ok, "message": msg}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def _on_finish(self, _args: dict) -> dict:
        mark_onboarding_complete()
        AppHelper.callAfter(self._close)
        return {"ok": True}

    # ── helpers ────────────────────────────────────────────────────────

    def _model_cached(self) -> bool:
        """True iff the Whisper repo is fully present in the HF cache.

        Fallback is False (fail-closed). Returning True on probe failure
        was a hard bug: on a fresh Mac the huggingface_hub imports can raise
        before the model is downloaded, and the old fail-open path made the
        onboarding skip the download page entirely. The user landed in the
        app, hit dictation, and the first transcribe call crashed instead
        of triggering a managed download. Better to show one extra download
        screen on a flake than ship an app that pretends it's ready when
        it isn't."""
        try:
            from .transcriber import make_transcriber
            cfg_whisper = {}
            if self.app is not None:
                cfg_whisper = self.app.cfg.get("whisper", {})
            else:
                from .app import load_config
                cfg_whisper = load_config().get("whisper", {})
            probe = make_transcriber(cfg_whisper)
            return probe.is_model_ready()
        except Exception as e:
            print(f"[onboarding] model check failed: {e}", flush=True)
            return False

    # Whisper-medium-mlx weighs ~1.5 GB on disk after extraction. Used to
    # turn the bytes-on-disk reading into a 0-100% progress estimate while
    # snapshot_download is blocking. Slightly conservative so the bar never
    # creeps past 99% before the call returns.
    _MODEL_APPROX_BYTES = 1_530_000_000

    def _download_model(self) -> None:
        """Run snapshot_download in a worker thread; while it blocks, poll
        the HF cache size on disk every 600ms and push progress events to
        the JS layer so the user can see something is happening.

        Before this rewrite we pushed 0% → ~15-minute silent block → 100%.
        Anyone on a fresh install thought the app had hung."""
        import threading
        import time
        try:
            from huggingface_hub import snapshot_download  # type: ignore
            from huggingface_hub.file_download import repo_folder_name  # type: ignore
            from huggingface_hub.constants import HUGGINGFACE_HUB_CACHE  # type: ignore
            cfg_whisper = (self.app.cfg.get("whisper") if self.app
                           else {}).get("model") if self.app else None
            if cfg_whisper is None:
                from .app import load_config
                cfg_whisper = (load_config().get("whisper") or {}).get(
                    "model") or "mlx-community/whisper-medium-mlx"

            cache_root = Path(HUGGINGFACE_HUB_CACHE) / repo_folder_name(
                repo_id=cfg_whisper, repo_type="model",
            )

            self._push("model-progress",
                       {"percent": 1,
                        "status": "Connecting to huggingface.co…"})

            # Run the blocking download in a worker; main thread polls disk.
            result_box: dict = {"error": None, "done": False}

            def _worker():
                try:
                    snapshot_download(repo_id=cfg_whisper)
                except Exception as e:  # noqa: BLE001
                    result_box["error"] = e
                finally:
                    result_box["done"] = True

            t = threading.Thread(target=_worker, daemon=True)
            t.start()

            last_bytes = 0
            last_change_at = time.monotonic()
            while not result_box["done"]:
                time.sleep(0.6)
                try:
                    total = _dir_size(cache_root)
                except Exception:
                    total = last_bytes
                if total > last_bytes:
                    last_change_at = time.monotonic()
                    last_bytes = total
                pct_disk = (total / self._MODEL_APPROX_BYTES) * 100.0
                # Cap at 99% — the final 100% is only pushed after the
                # snapshot_download call actually returns (i.e. the
                # `.huggingface/` lockfiles are released, files renamed
                # from `.incomplete` to final).
                pct = min(99.0, max(1.0, pct_disk))
                status = (
                    f"Downloading… {total // (1024*1024)} MB of "
                    f"~{self._MODEL_APPROX_BYTES // (1024*1024)} MB"
                )
                # If progress has stalled >30s, surface that — the user
                # might be on a captive-portal WiFi.
                if time.monotonic() - last_change_at > 30 and last_bytes > 0:
                    status += "  (slow — check your network)"
                self._push("model-progress",
                           {"percent": pct, "status": status})

            if result_box["error"] is not None:
                raise result_box["error"]

            self._push("model-progress",
                       {"percent": 100, "status": "Model ready"})
            AppHelper.callAfter(self._goto, "permissions")
        except Exception as e:
            print(f"[onboarding] model download failed: {e}", flush=True)
            self._push("model-progress",
                       {"percent": 0, "status": f"Download failed: {e}",
                        "error": True})

    def _push(self, event: str, value) -> None:
        if self.shell is not None:
            try:
                self.shell.bridge.push(event, value)
            except Exception as e:
                print(f"[onboarding] push failed: {e}", flush=True)

    def _close(self) -> None:
        if self._closed:
            return
        self._closed = True
        try:
            if self.window is not None:
                self.window.close()
        except Exception:
            pass
        try:
            self.on_complete()
        except Exception as e:
            print(f"[onboarding] on_complete callback raised: {e}", flush=True)


# ── Window delegate ────────────────────────────────────────────────────


class _OnboardingWindowDelegate(objc.lookUpClass("NSObject")):
    def initWithOnboarding_(self, onboarding):  # type: ignore
        self = objc.super(_OnboardingWindowDelegate, self).init()
        if self is None:
            return None
        self._onb = onboarding
        return self

    def windowWillClose_(self, _notification):  # noqa: N802
        # User dismissed the panel (X). Treat as a finish so app proceeds
        # to bank window — they can re-trigger onboarding from the menu.
        try:
            self._onb._close()
        except Exception:
            pass


# ── Public boot helpers ────────────────────────────────────────────────


def show_full_onboarding(app, on_complete: Callable[[], None]) -> "OnboardingWindow":
    """Open the full 6-step flow. Used on first-launch detection from
    LirelleApp.start()."""
    window = OnboardingWindow(app, on_complete)
    window.start()
    return window


def show_permissions_only(app,
                          on_complete: Callable[[], None]) -> "OnboardingWindow":
    """Open just the permissions page. Use when the marker exists but a
    macOS-side permission revoke happened between launches."""
    window = OnboardingWindow(app, on_complete, only_page="permissions")
    window.start()
    return window


def show_account_only(app,
                      on_complete: Callable[[], None]) -> "OnboardingWindow":
    """Open just the account page as a sign-in flow from inside the bank
    window. The 'Sign in' button in the Account pane wires here."""
    window = OnboardingWindow(app, on_complete, only_page="account")
    window.start()
    return window
