"""
VoxCastApp - Service registry, Qt application setup, tray icon, async entry point.
"""
from __future__ import annotations

import asyncio
import sys
from pathlib import Path

from loguru import logger
from PySide6.QtCore import QUrl
from PySide6.QtGui import QIcon, QFontDatabase
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon

from paths import app_dir, data_dir
from bridge.signals import AppSignals, get_signals
from bridge.ui_bridge import UiBridge
from services.audio_service import AudioService
from services.conversion_service import ConversionService
from services.hotkey_service import HotkeyService
from services.license_service import LicenseService
from services.llm_service import LLMService
from services.model_manager import ModelManager
from services.i18n import t
from services.settings_service import SettingsService
from services.startup_service import StartupService
from services.transcription_service import TranscriptionService
from services.types import AppState
from services.update_service import UpdateService


_APP_DIR = app_dir()


def get_asset_path(filename: str) -> Path:
    """Resolve asset path for dev, PyInstaller, and Nuitka builds."""
    return _APP_DIR / "assets" / filename


# Queued recording start (hotkey before the audio backend is ready):
# bounded wait and its poll granularity.
QUEUED_START_MAX_WAIT_S = 8.0
QUEUED_START_POLL_S = 0.05


def get_ui_dir() -> Path:
    """Resolve QML UI directory for dev, PyInstaller, and Nuitka builds."""
    # In frozen/bundled builds, ui/ is at base level.
    # In dev mode, ui/ is inside src/.
    ui = _APP_DIR / "ui"
    if ui.is_dir():
        return ui
    return Path(__file__).parent / "ui"


class VoxCastApp:
    def __init__(self) -> None:
        self.qt_app = QApplication.instance()
        if self.qt_app is None:
            raise RuntimeError("QApplication must be created before VoxCastApp")

        self.signals: AppSignals = get_signals()
        self._loop: asyncio.AbstractEventLoop | None = None
        self._idle_timer: asyncio.TimerHandle | None = None
        self._error_timer: asyncio.TimerHandle | None = None
        self._shutting_down = False

        # Global hotkey: message-only HWND + Qt native event filter.
        # Must be installed before the hotkey service registers.
        from services.hotkey_native_filter import HotkeyNativeEventFilter
        self._hotkey_filter = HotkeyNativeEventFilter()
        self._hotkey_filter.initialize()
        self.qt_app.installNativeEventFilter(self._hotkey_filter)

        # Services
        self.settings_service = SettingsService()
        self.model_manager = ModelManager(self.signals)
        self.audio_service = AudioService(self.signals)
        # Local engine + router facade. Batch work (dictation, file conversion,
        # meetings) goes through the router so it can be offloaded to a remote
        # VoxCast Server; live windowing stays local (latency).
        self.transcription_service = TranscriptionService(self.signals)
        from services.transcription_router import TranscriptionRouter
        self.transcription_router = TranscriptionRouter(
            self.transcription_service, self.signals)
        self.conversion_service = ConversionService(self.signals, self.transcription_router)
        self.llm_service = LLMService(self.signals)
        self.license_service = LicenseService(self.settings_service)
        self.update_service = UpdateService()
        self.hotkey_service = HotkeyService(self.signals)
        self.hotkey_service.attach_filter(self._hotkey_filter)
        self.startup_service = StartupService()
        from services.meeting_recorder_service import MeetingRecorderService
        self.meeting_recorder = MeetingRecorderService(
            self.signals,
            self.settings_service,
            self.transcription_router,
            llm_service=self.llm_service,
        )
        from services.live_transcription_service import LiveTranscriptionService
        self.live_transcription = LiveTranscriptionService(
            self.signals, self.audio_service, self.transcription_service,
        )
        # Remote live streaming session (created lazily when remote+live).
        self._remote_live = None

        # QML engine
        self.engine = QQmlApplicationEngine()
        self.bridge = UiBridge(
            self.signals,
            self.settings_service,
            self.audio_service,
            self.transcription_router,
            self.hotkey_service,
            self.model_manager,
            self.startup_service,
            self.conversion_service,
            self.llm_service,
            self.license_service,
            self.update_service,
            meeting_recorder_service=self.meeting_recorder,
        )

        # Tray
        self._tray: QSystemTrayIcon | None = None
        self._window = None

        # IPC receiver for "--convert" forwarding from context-menu/CLI
        self._ipc_receiver = None
        self._pending_convert: str | None = None

    def _load_fonts(self) -> None:
        font_path = get_asset_path("MaterialIcons.ttf")
        if font_path.exists():
            fid = QFontDatabase.addApplicationFont(str(font_path))
            if fid < 0:
                logger.warning("Failed to load MaterialIcons font")
            else:
                logger.debug("MaterialIcons font loaded (id={})", fid)
        else:
            logger.warning("MaterialIcons.ttf not found at {}", font_path)

    def _setup_tray(self) -> None:
        icon_path = get_asset_path("logo.svg")
        icon = QIcon(str(icon_path)) if icon_path.exists() else QIcon()

        self._tray = QSystemTrayIcon(icon, self.qt_app)
        self._tray.setToolTip("VoxCast")

        self._rebuild_tray_menu()
        self._tray.activated.connect(self._on_tray_activated)
        # Clicking the "Meeting transcribed" toast opens the transcript.
        self._tray.messageClicked.connect(self._on_tray_message_clicked)
        # Native QMenu has no QML-style bindings — rebuild it when the
        # interface language changes so the tray switches live too.
        self.bridge.trChanged.connect(self._rebuild_tray_menu)
        # Same for the meeting entry (start ↔ stop swaps with the state).
        self.bridge.meetingStateChanged.connect(self._rebuild_tray_menu)
        # Tray must reflect a running/processing meeting even when the panel
        # is hidden — a § 201-sensitive recording may never be invisible.
        self.bridge.meetingStateChanged.connect(self._update_tray_for_meeting)
        # Consent overlay is a QML window — make sure it comes up in front
        # when the request originates from the tray.
        self.bridge.meetingConsentRequested.connect(self._bring_to_front)
        self._tray.show()

    def _rebuild_tray_menu(self) -> None:
        if self._tray is None:
            return
        menu = QMenu()
        menu.addAction(t("tray_show_hide"), self._toggle_window)
        menu.addAction(t("panel_acc_settings"), self.bridge.openSettings)
        menu.addSeparator()
        if self.meeting_recorder.state == "recording":
            menu.addAction(t("menu_meeting_stop"), self.bridge.stopMeetingRecording)
        elif self.bridge.meetingSupportsApps:
            # Submenu: whole system + the apps currently playing audio.
            # Populated lazily so the app list is fresh when it opens.
            sub = menu.addMenu(t("menu_meeting_start"))
            sub.setEnabled(self.meeting_recorder.state == "idle")
            sub.aboutToShow.connect(lambda s=sub: self._fill_meeting_submenu(s))
        else:
            act = menu.addAction(t("menu_meeting_start"), self._tray_start_meeting)
            # While a previous meeting is still transcribing, a new one can't start.
            act.setEnabled(self.meeting_recorder.state == "idle")
        menu.addSeparator()
        menu.addAction(t("act_quit"), self._quit)
        # Keep a reference: setContextMenu does not take ownership.
        self._tray_menu = menu
        self._tray.setContextMenu(menu)

    def _tray_start_meeting(self) -> None:
        """Whole-system start; consent gate lives in requestMeetingStart."""
        self.bridge.requestMeetingStart(0, "")

    def _fill_meeting_submenu(self, sub: QMenu) -> None:
        sub.clear()
        sub.addAction(t("meeting_source_system"),
                      lambda: self.bridge.requestMeetingStart(0, ""))
        apps = self.bridge.listAudioApps()
        if apps:
            sub.addSeparator()
        for a in apps[:10]:
            pid, name = int(a["pid"]), str(a["name"])
            sub.addAction(name,
                          lambda p=pid, n=name: self.bridge.requestMeetingStart(p, n))

    def _update_tray_for_meeting(self, state: str) -> None:
        if self._tray is None:
            return
        if state == "recording":
            icon_path = get_asset_path("icon_recording.ico")
            self._tray.setToolTip("VoxCast - " + t("status_meeting_recording"))
        elif state == "processing":
            icon_path = get_asset_path("logo.svg")
            self._tray.setToolTip(
                "VoxCast - " + t("status_meeting_transcribing", pct="…"))
        else:
            icon_path = get_asset_path("logo.svg")
            device = self.transcription_service.device
            self._tray.setToolTip(f"VoxCast - {t('panel_ready')} ({device.upper()})")
        if icon_path.exists():
            self._tray.setIcon(QIcon(str(icon_path)))

    def _on_meeting_done(self, md_path: str) -> None:
        """Remember the finished transcript so the toast click can open it."""
        self._last_meeting_transcript = md_path

    def _on_tray_message_clicked(self) -> None:
        import os
        path = getattr(self, "_last_meeting_transcript", None)
        if path and Path(path).exists():
            try:
                os.startfile(path)  # noqa: S606 — user-initiated, own file
            except OSError:
                logger.exception("opening transcript failed")

    def _on_tray_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
        if reason in (
            QSystemTrayIcon.ActivationReason.Trigger,       # single click
            QSystemTrayIcon.ActivationReason.DoubleClick,
        ):
            self._bring_to_front()

    def _show_tray_message(self, title: str, message: str,
                           duration_ms: int = 5000) -> None:
        """Show a Windows tray notification with VoxCast icon."""
        if self._tray is not None and self._tray.supportsMessages():
            self._tray.showMessage(title, message, self._tray.icon(), duration_ms)

    def set_pending_convert(self, path: str) -> None:
        """Queue a '--convert' file to process once services are ready
        (used when no instance was running to forward to)."""
        self._pending_convert = path

    def _setup_ipc(self) -> None:
        """Start the message-only receiver for cross-process convert requests."""
        from utils.ipc import IpcReceiver

        def _on_convert(path: str) -> None:
            # wndproc runs on the GUI thread; hop onto the loop to be safe.
            if self._loop is not None:
                self._loop.call_soon_threadsafe(self.bridge.convert_external, path)

        self._ipc_receiver = IpcReceiver(_on_convert)
        self._ipc_receiver.start()

    def _bring_to_front(self) -> None:
        """Show and raise the main window (and settings if open)."""
        if self._window is None:
            return
        self._window.show()
        self._window.raise_()
        self._window.requestActivate()
        # Also raise settings window if it's open
        if hasattr(self, 'engine'):
            roots = self.engine.rootObjects()
            if roots:
                # The Loader's item (settings window) needs raising too
                from PySide6.QtCore import QMetaObject, Qt
                QMetaObject.invokeMethod(roots[0], "raise", Qt.ConnectionType.QueuedConnection)

    def _toggle_window(self) -> None:
        """Toggle toolbar visibility (used from tray context menu)."""
        if self._window is None:
            return
        if self._window.isVisible():
            self._window.hide()
        else:
            self._bring_to_front()

    def _quit(self) -> None:
        if self._shutting_down or self._loop is None:
            return
        self._shutting_down = True
        self._shutdown_task = self._loop.create_task(self._shutdown())

    async def _shutdown(self) -> None:
        logger.info("Shutting down VoxCast...")
        try:
            # Play shutdown sound before stopping services (blocking so it finishes)
            settings = self.settings_service.get()
            if settings.play_shutdown_sound:
                sound_path = get_asset_path("shutdown.wav")
                if sound_path.exists():
                    await self._loop.run_in_executor(
                        None, self._play_sound_blocking, str(sound_path),
                    )

            # Stop the live stream FIRST (before the audio buffer is cleared by
            # audio_service.stop) so its final pass still sees the recording and
            # the last words are typed; then drain the typing worker.
            if getattr(self, "_remote_live_active", False):
                self._remote_live_active = False
                try:
                    await self._stop_remote_live()
                    await self._stop_live_typing()
                except Exception as e:
                    logger.warning("Remote live stop on shutdown failed: {}", e)
            if getattr(self, "_live_active", False):
                self._live_active = False
                try:
                    await self.live_transcription.stop_stream()
                    await self._stop_live_typing()
                except Exception as e:
                    logger.warning("Live stop on shutdown failed: {}", e)

            await asyncio.gather(
                self.hotkey_service.stop(),
                self.audio_service.stop(),
                self.meeting_recorder.stop(),
                self.live_transcription.stop(),
                self.transcription_service.stop(),
                self.conversion_service.stop(),
                self.llm_service.stop(),
                self.license_service.stop(),
                self.update_service.stop(),
                self.settings_service.stop(),
                return_exceptions=True,
            )
            try:
                self._hotkey_filter.shutdown()
            except Exception as e:
                logger.warning("Filter shutdown error: {}", e)
            try:
                if self._ipc_receiver is not None:
                    self._ipc_receiver.stop()
            except Exception as e:
                logger.warning("IPC receiver shutdown error: {}", e)
        except Exception as e:
            logger.error("Error during shutdown: {}", e)
        finally:
            # Signal the run() coroutine to exit cleanly
            if hasattr(self, "_quit_event"):
                self._quit_event.set()
            self.qt_app.quit()

    def _setup_qml(self) -> None:
        import os
        os.environ.setdefault("QT_LOGGING_RULES", "qml.warning=true")
        os.environ.setdefault("QML_DISABLE_DISK_CACHE", "1")
        ui_dir = get_ui_dir()
        self.engine.addImportPath(str(ui_dir))
        self.engine.rootContext().setContextProperty("AppBridge", self.bridge)

        # Expose assets directory as a file:// URL so QML can load images portably
        asset_dir_url = QUrl.fromLocalFile(str(get_asset_path("logo.svg").parent)).toString()
        self.engine.rootContext().setContextProperty("AssetDir", asset_dir_url + "/")

        qml_file = ui_dir / "main.qml"
        self.engine.load(QUrl.fromLocalFile(str(qml_file)))

        roots = self.engine.rootObjects()
        if roots:
            self._window = roots[0]
            logger.info("QML main window loaded OK ({})", type(self._window).__name__)
            # Force window into visible screen area (handles monitor changes)
            self._snap_window_to_screen()
        else:
            logger.error("Failed to load main.qml from {} — check QML errors above", qml_file)

    def _snap_window_to_screen(self) -> None:
        """After QML loaded: ensure window is fully visible on a connected screen."""
        if self._window is None:
            return
        cur_x = int(self._window.property("x"))
        cur_y = int(self._window.property("y"))
        w = int(self._window.property("width"))
        h = int(self._window.property("height"))
        new_x, new_y = self.bridge._ensure_on_screen(cur_x, cur_y, w, h)
        if new_x != cur_x or new_y != cur_y:
            self._window.setProperty("x", new_x)
            self._window.setProperty("y", new_y)
            logger.info("Window snapped from ({},{}) to ({},{})", cur_x, cur_y, new_x, new_y)

    def _connect_pipeline(self) -> None:
        """Wire up the audio -> transcription -> correction -> clipboard pipeline."""
        self._pending_tasks: set[asyncio.Task] = set()
        self._recording_starting = False  # debounce guard
        self._pending_record_start: asyncio.Task | None = None
        self._pending_record_abort = False

        def _spawn(coro):
            task = self._loop.create_task(coro)
            self._pending_tasks.add(task)
            def _on_done(t):
                self._pending_tasks.discard(t)
                if not t.cancelled() and t.exception():
                    logger.error("Pipeline task failed: {}", t.exception())
            task.add_done_callback(_on_done)
            return task

        def _start_recording_debounced():
            if self._recording_starting or self.audio_service.is_recording:
                return
            if self._pending_record_start is not None \
                    and not self._pending_record_start.done():
                # Start pressed again while the queued start is aborting
                # (start-stop-start within the wait window): revive the
                # queued intent instead of dropping this press.
                self._pending_record_abort = False
                return
            # Gate dictation behind activation.
            if not self.license_service.is_activated():
                self.bridge.show_activation(True)
                self.signals.tray_message.emit("VoxCast", t("act_first"))
                return
            # Don't start transcription recording while settings window is open
            if self.bridge._settings_window_visible:
                return
            # Hotkey during the startup tail: the audio backend may still be
            # starting. Queue the start instead of silently swallowing it
            # (users pressed the hotkey, spoke, and nothing was recorded).
            if not self.audio_service.is_ready:
                logger.info("Recording start queued: audio backend still starting")
                self._pending_record_abort = False
                # Through _spawn so a failure inside the queued start is
                # logged like every other pipeline task.
                self._pending_record_start = _spawn(self._start_when_audio_ready())
                return
            _spawn(self._on_recording_start())

        self.signals.recording_should_start.connect(_start_recording_debounced)
        self.signals.recording_should_stop.connect(
            lambda: _spawn(self._on_recording_stop())
        )
        self.signals.transcription_ready.connect(self._on_correction_ready)
        self.signals.state_changed.connect(self._on_state_changed)
        self.signals.tray_message.connect(self._show_tray_message)
        # Errors must be visible even when the panel is hidden/small — the
        # status line alone can't carry long-running flows like meetings.
        self.signals.error_occurred.connect(
            lambda msg: self._show_tray_message("VoxCast", msg))
        self.signals.meeting_done.connect(self._on_meeting_done)
        self.signals.live_text_committed.connect(self._on_live_text)

    async def _on_recording_start(self) -> None:
        if self._idle_timer is not None:
            self._idle_timer.cancel()
            self._idle_timer = None
        # Save the foreground window so we can restore focus when pasting
        try:
            from utils.win32_input import get_foreground_window
            self._last_foreground_hwnd = get_foreground_window()
        except Exception:
            self._last_foreground_hwnd = None
        self._recording_starting = True
        try:
            settings = self.settings_service.get()
            if settings.play_recording_sound:
                sound_path = get_asset_path("ping.wav")
                if sound_path.exists():
                    self.audio_service.play_sound(
                        str(sound_path), volume=settings.recording_sound_volume,
                    )
            await self.audio_service.async_start_recording()
            # Live mode: stream text to the cursor while speaking (no AI
            # enhancement). Remote mode streams to the server over a WebSocket;
            # otherwise the local model windows the audio. Only start when the
            # relevant engine is ready (readiness can flip during a reload).
            self._live_active = False
            self._remote_live_active = False
            if bool(settings.live_mode):
                lang = settings.language if settings.language else None
                if self.transcription_router.remote_active:
                    self._start_remote_live(lang)
                elif self.transcription_service.is_ready:
                    await self.live_transcription.start_stream(lang)
                    if self.live_transcription.running:
                        self._live_active = True
                        if self.transcription_service.device != "cuda":
                            # Allowed on CPU per user choice, but warn: it can lag.
                            self._show_tray_message("VoxCast", t("live_cpu_warning"))
                        self._start_live_typing()
        finally:
            self._recording_starting = False

    async def _start_when_audio_ready(self) -> None:
        """A hotkey press arrived before the audio backend finished starting.
        Begin recording the moment it is ready (bounded wait) instead of
        dropping the user's intent on the floor.

        Abort is flag-based (no task cancellation): a threaded stream start
        cannot be cancelled mid-flight, so injecting CancelledError could
        leave a half-open recording behind."""
        ticks = int(QUEUED_START_MAX_WAIT_S / QUEUED_START_POLL_S)
        for _ in range(ticks):
            if self._pending_record_abort:
                logger.info("Queued recording start aborted by second hotkey press")
                self.hotkey_service.notify_recording_stopped()
                return
            if self.audio_service.is_ready:
                # Re-check the gates the press was originally queued behind —
                # they may have closed while we waited (settings opened,
                # activation revoked).
                if self.bridge._settings_window_visible \
                        or not self.license_service.is_activated():
                    logger.info("Queued recording start dropped: gate closed "
                                "while waiting")
                    self.hotkey_service.notify_recording_stopped()
                    return
                await self._on_recording_start()
                # A stop request that arrived while the stream was opening:
                # run the full stop pipeline now that there is a recording.
                if self._pending_record_abort:
                    self.signals.recording_should_stop.emit()
                return
            await asyncio.sleep(QUEUED_START_POLL_S)
        logger.warning("Queued recording start dropped: audio backend "
                       "did not become ready in time")
        # The user pressed the hotkey and spoke — a silent drop here would be
        # the very bug this queue fixes. error_occurred surfaces as a toast.
        self.signals.error_occurred.emit(t("err_mic"))
        self.hotkey_service.notify_recording_stopped()

    async def _on_recording_stop(self) -> None:
        # Second hotkey press while the queued start is still waiting for the
        # audio backend: flag it — the waiter aborts cleanly (or, if the
        # stream is already opening, re-emits the stop once it is up).
        pending = self._pending_record_start
        if pending is not None and not pending.done():
            self._pending_record_abort = True
            return
        # Don't run transcription pipeline during mic test
        if self.audio_service.is_test_mode:
            return
        # Keep hotkey toggle state in sync with actual recording state
        self.hotkey_service.notify_recording_stopped()

        # Remote live: finalize the WS session (server commits the tail), then
        # stop recording. The session emits deltas via live_text_committed.
        if getattr(self, "_remote_live_active", False):
            self._remote_live_active = False
            self.signals.state_changed.emit(AppState.TRANSCRIBING)
            try:
                await self._stop_remote_live()
            finally:
                await self._stop_live_typing()
                await self.audio_service.async_stop_recording()
                self.signals.state_changed.emit(AppState.IDLE)
            return

        # Live mode: finalize the stream (commits the last words), then stop the
        # recording. No batch transcription, no AI enhancement.
        if getattr(self, "_live_active", False):
            self._live_active = False
            self.signals.state_changed.emit(AppState.TRANSCRIBING)
            try:
                # Final pass commits the last words (deltas are queued to the
                # typing worker) BEFORE the audio buffer is cleared.
                await self.live_transcription.stop_stream()
            finally:
                await self._stop_live_typing()   # drains queued deltas, joins
                await self.audio_service.async_stop_recording()
                self.signals.state_changed.emit(AppState.IDLE)
            return

        audio = await self.audio_service.async_stop_recording()
        if audio is not None and len(audio) > 0:
            self.signals.state_changed.emit(AppState.TRANSCRIBING)
            text, detected_lang = \
                await self.transcription_router.transcribe_with_language(audio)
            if text:
                text = await self._maybe_enhance(text, detected_lang)
                self.signals.transcription_ready.emit(text)
            else:
                self.signals.state_changed.emit(AppState.IDLE)
        else:
            self.signals.state_changed.emit(AppState.IDLE)

    # ─── Live-mode incremental typing (ordered, single worker) ─────────────

    def _ensure_typing_worker(self) -> None:
        """One persistent, ordered typing worker for the whole app session.

        A single worker + single queue means keystrokes are always typed in
        order and two utterances can never interleave (no per-utterance threads,
        no blocking joins on the event loop). Queue items:
          ("focus", hwnd) reset focus for a new session,
          ("text", delta) type it,
          ("flush", event) signal the event once reached (shutdown drain),
          None              stop the worker.
        """
        import queue
        import threading

        if getattr(self, "_live_type_q", None) is not None:
            return
        self._live_type_q: queue.Queue = queue.Queue()

        def _worker(q: "queue.Queue") -> None:
            import time
            from utils.win32_input import (
                force_foreground_window, type_text, release_modifiers,
            )
            focus_done = True
            target_hwnd = None
            while True:
                item = q.get()
                if item is None:
                    return
                kind, payload = item
                if kind == "focus":
                    target_hwnd = payload
                    focus_done = False
                    continue
                if kind == "flush":
                    payload.set()
                    continue
                try:  # kind == "text"
                    if not focus_done:
                        focus_done = True
                        release_modifiers()
                        time.sleep(0.03)
                        if target_hwnd:
                            force_foreground_window(target_hwnd)
                            time.sleep(0.08)
                    type_text(payload)
                except Exception as e:
                    logger.warning("Live typing failed: {}", e)

        self._live_type_thread = threading.Thread(
            target=_worker, args=(self._live_type_q,), name="live-typing", daemon=True)
        self._live_type_thread.start()

    def _start_live_typing(self) -> None:
        # Non-blocking: reset the persistent worker's focus for this session.
        self._ensure_typing_worker()
        self._live_type_q.put(("focus", getattr(self, "_last_foreground_hwnd", None)))

    async def _stop_live_typing(self) -> None:
        """Wait until all queued deltas of this session have been typed (used on
        shutdown so the last words are not lost when the process exits)."""
        import threading

        q = getattr(self, "_live_type_q", None)
        if q is None:
            return
        ev = threading.Event()
        q.put(("flush", ev))
        loop = asyncio.get_running_loop()
        await loop.run_in_executor(None, ev.wait, 3.0)

    def _on_live_text(self, delta: str) -> None:
        q = getattr(self, "_live_type_q", None)
        if q is not None and delta:
            q.put(("text", delta))

    # ─── Remote live streaming (WebSocket to the VoxCast Server) ───────────

    def _start_remote_live(self, language: str | None) -> None:
        """Open a streaming session to the server; committed deltas flow through
        the same typing worker as local live via live_text_committed."""
        from services.remote_transcription import RemoteLiveSession

        session = RemoteLiveSession(
            self.transcription_router.remote_backend,
            self.audio_service, self._loop, None, None,
        )

        # Identity guard: a straggling thread from a PREVIOUS session (e.g. a
        # slow handshake that finished after stop() returned) must never type
        # into the NEXT dictation. Callbacks are no-ops unless this is still the
        # current session — makes correctness independent of join timing.
        def _on_delta(delta: str) -> None:
            if self._remote_live is session:
                self.signals.live_text_committed.emit(delta)

        def _on_error(key: str) -> None:
            if self._remote_live is session:
                self.signals.error_occurred.emit(t(key))

        session._on_delta = _on_delta
        session._on_error = _on_error
        self._remote_live = session
        session.start(language)
        self._remote_live_active = True
        self._start_live_typing()

    async def _stop_remote_live(self) -> None:
        session, self._remote_live = self._remote_live, None
        if session is not None:
            # session.stop() joins the WS thread (blocking) — off the loop.
            await asyncio.get_running_loop().run_in_executor(None, session.stop)

    async def _remote_status_watch(self) -> None:
        """Refresh remote server status periodically while remote is active, so
        a transient not-ready state self-heals within one interval."""
        while True:
            if self.transcription_router.remote_active:
                try:
                    await self.transcription_router.refresh_remote()
                    # Refresh server-managed prompts + enhance availability so
                    # the AI mode picker and enhancement routing stay current.
                    backend = self.transcription_router.remote_backend
                    self.bridge.set_server_enhance_available(backend.enhance_available())
                    prompts = await backend.get_prompts()
                    self.bridge.set_server_prompts(prompts)
                except Exception as e:  # noqa: BLE001
                    logger.debug("Remote status refresh failed: {}", e)
            else:
                self.bridge.set_server_prompts([])
                self.bridge.set_server_enhance_available(False)
            await asyncio.sleep(15.0)

    def _active_mode_prompt(self, settings) -> str | None:
        """Return the system prompt of the active AI mode, or None for raw
        output. Local modes win; server-managed modes are the fallback so a
        company prompt can be selected even though it isn't in llm_modes."""
        if settings.llm_active_mode == "normal":
            return None
        for m in settings.llm_modes:
            if m.get("id") == settings.llm_active_mode:
                return m.get("prompt")
        for m in self.bridge.server_prompts():
            if m.get("id") == settings.llm_active_mode:
                return m.get("prompt")
        return None

    def _use_server_enhance(self, settings) -> bool:
        """Route enhancement to the server when remote is active and the user
        chose the server source. Optimistic (enhance_maybe): a stale status
        cache must not silently fall back to a local key the user may not have;
        the server enhance call surfaces an honest error if truly unavailable."""
        return (settings.enhance_source == "server"
                and self.transcription_router.remote_active
                and self.transcription_router.remote_backend.enhance_maybe())

    async def _maybe_enhance(
        self, text: str, detected_lang: tuple[str, float] | None = None,
    ) -> str:
        """Post-process the transcript with the active AI mode (if enabled).
        detected_lang is this dictation's (language, confidence) from Whisper.
        Falls back to the raw transcript on any failure."""
        self.bridge.set_last_raw_transcript(None)
        settings = self.settings_service.get()
        # Live mode never enhances — enforce the invariant even if the batch
        # path is reached because the model wasn't ready when live was requested.
        if settings.live_mode:
            return text
        if not settings.llm_enabled:
            return text
        prompt = self._active_mode_prompt(settings)
        if not prompt:
            return text

        use_server = self._use_server_enhance(settings)
        from services import credentials
        key = "" if use_server else credentials.get_secret(f"llm_{settings.llm_provider}")
        if not use_server and not key:
            logger.warning("AI mode active but no API key stored for {}", settings.llm_provider)
            return text

        # Pin the output language: forced dictation language wins, otherwise
        # the language Whisper just detected (if confident). The directive is
        # appended to EVERY mode prompt (built-in and custom) so short or
        # mixed-language dictations never come back translated.
        from services.llm_service import LANG_CONFIDENCE_MIN, language_directive
        lang = settings.language
        if not lang and detected_lang and detected_lang[1] >= LANG_CONFIDENCE_MIN:
            lang = detected_lang[0]
        prompt = f"{prompt.rstrip()}\n\n{language_directive(lang)}"

        self.signals.state_changed.emit(AppState.CORRECTING)
        try:
            if use_server:
                # Central enhancement on the server (its LLM key).
                enhanced = await self.transcription_router.remote_backend.enhance(
                    text, prompt)
            else:
                enhanced = await self.llm_service.enhance_auto(
                    text=text,
                    system_prompt=prompt,
                    provider=settings.llm_provider,
                    preferred_model=settings.llm_model or "",
                    api_key=key,
                )
            if enhanced and enhanced != text:
                # keep the raw transcript so the user can recover it if the
                # AI mangled the dictation (ModeMenu -> "Copy original")
                self.bridge.set_last_raw_transcript(text)
            return enhanced or text
        except Exception as e:
            logger.warning("AI enhancement failed: {}", e)
            self.signals.tray_message.emit("VoxCast", t("llm_enhance_failed", error=e))
            return text

    def _on_correction_ready(self, text: str) -> None:
        settings = self.settings_service.get()
        mode = settings.output_mode
        logger.debug("_on_correction_ready: mode={}, insert_method={}, hwnd={}, text='{}'",
                      mode, settings.insert_method,
                      getattr(self, "_last_foreground_hwnd", None),
                      text[:60] if text else "")

        if mode in ("clipboard", "both"):
            from PySide6.QtWidgets import QApplication
            QApplication.clipboard().setText(text)

        if mode in ("type", "both"):
            insert_method = settings.insert_method
            if insert_method == "typing":
                self._simulate_typing_at_cursor(text)
            else:
                # Ctrl+V paste — always set clipboard via Win32 API for
                # reliability.  Qt clipboard may not sync to the Windows
                # clipboard fast enough for longer texts in Nuitka builds.
                self._type_text_at_cursor(text, clipboard_already_set=False)

        self.bridge.transcriptionText = text
        self.signals.state_changed.emit(AppState.READY)
        # Auto-reset to idle after 2s, but only if still in "ready" state
        if self._idle_timer is not None:
            self._idle_timer.cancel()
        self._idle_timer = self._loop.call_later(
            2.0, self._auto_reset_to_idle
        )

    def _type_text_at_cursor(self, text: str, clipboard_already_set: bool = False) -> None:
        """Insert text at the current cursor position via clipboard paste (Ctrl+V)."""
        import threading
        hwnd = getattr(self, "_last_foreground_hwnd", None)

        def _do_paste():
            try:
                import time
                from utils.win32_input import (
                    force_foreground_window, paste_text, release_modifiers,
                )

                # Release any lingering modifier keys from the hotkey
                release_modifiers()
                time.sleep(0.05)

                # Restore focus to the window that was active before recording
                if hwnd:
                    focused = force_foreground_window(hwnd)
                    logger.debug("_do_paste: force_foreground hwnd={} → focused={}", hwnd, focused)
                    if not focused:
                        logger.warning("Could not restore focus to hwnd={}", hwnd)
                    time.sleep(0.12)  # settle time after focus switch
                else:
                    logger.warning("_do_paste: no hwnd saved, pasting to current foreground")

                paste_text(text, clipboard_already_set=clipboard_already_set)
                logger.debug("_do_paste: paste_text completed")
            except Exception as e:
                logger.warning("Failed to insert text at cursor: {}", e)

        threading.Thread(target=_do_paste, daemon=True).start()

    def _simulate_typing_at_cursor(self, text: str) -> None:
        """Insert text at cursor via keyboard simulation (KEYEVENTF_UNICODE)."""
        import threading
        hwnd = getattr(self, "_last_foreground_hwnd", None)

        def _do_type():
            try:
                import time
                from utils.win32_input import (
                    force_foreground_window, type_text, release_modifiers,
                )

                # Release any lingering modifier keys from the hotkey
                release_modifiers()
                time.sleep(0.05)

                if hwnd:
                    focused = force_foreground_window(hwnd)
                    logger.debug("_do_type: force_foreground hwnd={} → focused={}", hwnd, focused)
                    if not focused:
                        logger.warning("Could not restore focus to hwnd={}", hwnd)
                    time.sleep(0.12)

                type_text(text)
            except Exception as e:
                logger.warning("Failed to simulate typing at cursor: {}", e)

        threading.Thread(target=_do_type, daemon=True).start()

    def _auto_reset_to_idle(self) -> None:
        self._idle_timer = None
        if self.bridge.appState == AppState.READY:
            self.signals.state_changed.emit(AppState.IDLE)

    def _on_state_changed(self, state: str) -> None:
        # Cancel pending error recovery when leaving error state
        if state != AppState.ERROR and self._error_timer is not None:
            self._error_timer.cancel()
            self._error_timer = None
        # Auto-recover from error state after 3s
        if state == AppState.ERROR:
            if self._error_timer is not None:
                self._error_timer.cancel()
            def _maybe_recover():
                self._error_timer = None
                if self.bridge.appState == AppState.ERROR:
                    self.signals.state_changed.emit(AppState.IDLE)
            self._error_timer = self._loop.call_later(3.0, _maybe_recover)
        # Update tray icon and tooltip based on state
        if self._tray is None:
            return
        if state == AppState.LISTENING:
            icon_path = get_asset_path("icon_recording.ico")
            self._tray.setToolTip("VoxCast - " + t("tt_recording"))
        elif state == AppState.TRANSCRIBING:
            icon_path = get_asset_path("logo.svg")
            self._tray.setToolTip("VoxCast - " + t("panel_transcribing"))
        elif state == AppState.CONVERTING:
            icon_path = get_asset_path("logo.svg")
            self._tray.setToolTip("VoxCast - " + t("panel_converting"))
        else:
            # A running meeting owns the tray while dictation is quiet.
            if self.meeting_recorder.state != "idle":
                self._update_tray_for_meeting(self.meeting_recorder.state)
                return
            icon_path = get_asset_path("logo.svg")
            device = self.transcription_service.device
            self._tray.setToolTip(f"VoxCast - {t('panel_ready')} ({device.upper()})")
        if icon_path.exists():
            self._tray.setIcon(QIcon(str(icon_path)))

    @staticmethod
    def _play_sound_blocking(path: str) -> None:
        """Play a WAV file and wait for it to finish."""
        AudioService.play_sound_blocking(path)

    def is_first_run(self) -> bool:
        settings = self.settings_service.get()
        return not self.model_manager.whisper_model_exists(settings.whisper_model)

    async def run(self) -> None:
        self._loop = asyncio.get_running_loop()
        self._quit_event = asyncio.Event()

        # Start settings first (needed by everything else)
        await self.settings_service.start()
        settings = self.settings_service.get()

        # Resolve the interface language BEFORE anything user-visible happens
        # ("" = auto → Windows display language). QML strings bind to the
        # bridge's tr property; Python messages call i18n.t() at emit time.
        from services import i18n
        i18n.set_language(i18n.resolve_language(settings.ui_language))
        logger.info("UI language: {} (pref={!r})", i18n.current_language(), settings.ui_language)

        # Setup QML + tray (synchronous Qt setup)
        self._load_fonts()
        self._setup_tray()
        self._setup_qml()
        self._connect_pipeline()
        self._setup_ipc()

        # Store loop reference for thread-safe signal bridging
        self.hotkey_service.set_loop(self._loop)
        self.bridge.set_loop(self._loop)

        # ConversionService + LLMService are lightweight (no local model)
        await self.conversion_service.start()
        await self.llm_service.start()
        await self.license_service.start()
        await self.update_service.start()
        await self.meeting_recorder.start()
        await self.live_transcription.start()

        # Apply compute device preference to model services
        self.transcription_service.set_compute_device(settings.compute_device)

        # Apply remote transcription settings (offload batch work to a server).
        if settings.remote_enabled and settings.remote_url:
            from services import credentials
            api_key = credentials.get_secret("remote_server") or ""
            self.transcription_router.set_remote(
                True, settings.remote_url, api_key,
                settings.remote_tls_mode, settings.remote_cert_pem)
        # Periodic status refresh (self-gates on remote_active, so it also covers
        # remote being enabled later at runtime): a transient "not ready" must
        # self-heal instead of pinning readiness False for the whole session.
        self._track_task(self._remote_status_watch(), "Remote status watch")

        # Prime the cached VRAM probe (nvidia-smi, ~100-300ms) off the UI
        # thread on EVERY launch — the Settings model picker calls it
        # synchronously and must never block the UI on a subprocess.
        from services.transcription_service import gpu_vram_mb, is_cuda_available
        self._loop.run_in_executor(None, gpu_vram_mb)
        # Prewarm CUDA detection too: the first call initialises the driver
        # (~1s, GIL held) — pay it now instead of mid-dictation.
        self._loop.run_in_executor(None, is_cuda_available)
        # Pre-import comtypes on an MTA worker: its import-time COM init would
        # fail on the Qt STA main thread (first "record this app" click).
        from services.process_loopback import _import_comtypes
        self._loop.run_in_executor(None, _import_comtypes)
        # Pre-import httpx/ssl on a worker too: the first network call (update
        # check, license validation) otherwise pays a GIL-heavy import while
        # the user may already be dictating (frozen waveform).
        self._loop.run_in_executor(None, lambda: __import__("httpx"))

        # Check first run
        first_run = self.is_first_run()

        # Set "loading" state early so QML shows progress from the start
        self.bridge.set_app_state(AppState.LOADING)
        self.signals.download_status.emit(t("status_init_services"))

        # Start non-blocking services in parallel
        service_names = ["StartupService", "ModelManager", "AudioService", "HotkeyService"]
        results = await asyncio.gather(
            self.startup_service.start(),
            self.model_manager.start(),
            self._start_audio_with_status(),
            self.hotkey_service.start(),
            return_exceptions=True,
        )
        for name, result in zip(service_names, results):
            if isinstance(result, Exception):
                logger.error("{} failed to start: {}", name, result)

        # Configure hotkey from settings
        self.hotkey_service.set_hotkey(settings.hotkey)
        self.hotkey_service.set_mode(settings.hotkey_mode)

        # Notify bridge of initial device list
        devices = self.audio_service.list_devices()
        self.bridge.set_available_devices(devices)

        # Play startup sound early (before model loading)
        if settings.play_startup_sound:
            sound_path = get_asset_path("startup.wav")
            if sound_path.exists():
                self.audio_service.play_sound(str(sound_path))

        # Reflect stored activation state before the update check so the
        # changelog dialog knows whether to auto-show (activated users) or defer
        # to the activation overlay's update button (not-yet-activated users).
        if self.license_service.is_activated():
            self.bridge.set_license_activated(True)

        # Update check BEFORE the license gate: if activation is ever broken in
        # a shipped build, users can always self-heal by installing the newer
        # version straight from the activation overlay.
        startup_check_ok = await self.bridge.check_updates_startup()

        # Keep checking while the app runs — the tray app lives for weeks
        # without a restart, and an autostart that raced the network must
        # still learn about updates (quick retries, then every few hours).
        self._track_task(self.bridge.run_update_watch(startup_check_ok),
                         "Update watch")

        # Server-maintained LLM model recommendation (fail-silent, background).
        self._track_task(self.llm_service.refresh_remote_defaults(), "LLM defaults refresh")

        # License gate: require one-time online activation before any model
        # download or use. After activation the app runs offline.
        await self._ensure_activated()

        remote_configured = bool(settings.remote_enabled and settings.remote_url)
        if remote_configured and first_run:
            # Remote-only install: transcription happens on the server, so no
            # local model download is forced. Live captions will be disabled
            # (no local model) until the user downloads one in Settings.
            logger.info("Remote transcription enabled and no local model — "
                        "skipping local model setup")
            setup_ok = True
        elif first_run:
            logger.info("First run detected - starting model download flow")
            self.bridge.set_first_run_active(True)
            self.bridge.set_is_downloading(True)
            setup_ok = await self._run_first_run_setup()
            self.bridge.set_is_downloading(False)
            # Later downloads (model switch in Settings) reuse the same overlay
            # but must not ask for the dictation language again.
            self.bridge.set_first_run_active(False)
        else:
            # A local model exists: load it (also enables local live even when
            # batch work is offloaded to a remote server).
            await self._load_models()
            setup_ok = True

        self.signals.download_status.emit("")

        # A failed first-run download leaves the app without a usable engine:
        # every dictation would silently produce nothing. Say so instead of
        # claiming to be ready (and don't report a compute device we never got).
        # The app stays alive (tray + settings work, a restart retries setup).
        engine_ready = setup_ok and self.transcription_router.is_ready
        if not engine_ready:
            logger.error("Startup incomplete: transcription engine not ready")
            self.signals.error_occurred.emit(t("err_model_download"))
            self.bridge.set_app_state(AppState.ERROR)
        else:
            if not self.transcription_router.remote_active:
                self._notify_compute_status()

            # Transition loading → idle triggers the ready-reveal animation in
            # QML. The hotkey is live during the startup tail; if the user
            # already started dictating, don't stomp LISTENING back to idle
            # (that hid the waveform for the whole first recording).
            if self.bridge.appState == AppState.LOADING:
                self.bridge.set_app_state(AppState.IDLE)
            logger.info("VoxCast ready")

            # Finish transcripts for meetings interrupted by quit/crash (wav on
            # disk but no .md next to it). Needs the loaded model — do it now.
            self._track_task(self.meeting_recorder.resume_unprocessed(),
                             "Meeting resume")

        # Process a queued '--convert' request (started without a running instance)
        if self._pending_convert:
            path, self._pending_convert = self._pending_convert, None
            logger.info("Processing pending convert request: {}", path)
            self.bridge.convert_external(path)

        # Block until quit() is called -- keeps the Qt+asyncio event loop alive
        await self._quit_event.wait()

    def _track_task(self, coro, label: str) -> "asyncio.Task":
        """create_task + strong reference (the loop only keeps weak refs) +
        error logging, so background tasks can't vanish or fail silently."""
        task = self._loop.create_task(coro)
        self._pending_tasks.add(task)

        def _done(t: "asyncio.Task") -> None:
            self._pending_tasks.discard(t)
            if not t.cancelled() and t.exception():
                logger.error("{} failed: {}", label, t.exception())

        task.add_done_callback(_done)
        return task

    async def _ensure_activated(self) -> None:
        """Block startup until the app is activated (one-time, online).

        If already activated, kick off a best-effort online re-validation and
        continue immediately (offline grace keeps it activated on network error).
        """
        if self.license_service.is_activated():
            self.bridge.set_license_activated(True)
            task = self._track_task(self.license_service.validate(), "License re-validate")
            task.add_done_callback(self._on_revalidate_done)
            return

        logger.info("Not activated — showing activation gate")
        self.bridge.activation_event = asyncio.Event()
        self.bridge.set_license_activated(False)
        # Drop out of the "loading" state so the activation overlay isn't shown
        # behind a busy panel; the gate fully blocks startup here.
        self.bridge.set_app_state(AppState.IDLE)
        self.signals.download_status.emit("")
        self.bridge.show_activation(True)
        await self.bridge.activation_event.wait()
        self.bridge.show_activation(False)
        self.bridge.set_app_state(AppState.LOADING)
        logger.info("Activation complete — continuing startup")

    def _on_revalidate_done(self, task: "asyncio.Task") -> None:
        if task.cancelled():
            return
        try:
            ok = task.result()
        except Exception as e:
            logger.info("Background re-validation error (ignored): {}", e)
            return
        if not ok:
            # Server explicitly revoked the license; reflect it in the UI.
            logger.warning("License re-validation failed — marking deactivated")
            self.bridge.set_license_activated(False)

    async def _run_first_run_setup(self) -> bool:
        """Download models on first run and load them. False = setup failed."""
        # Create required directories in user data folder
        for d in ["models/whisper", "cache", "logs"]:
            (data_dir() / d).mkdir(parents=True, exist_ok=True)

        settings = self.settings_service.get()

        # Fresh install (no settings file existed): default to the best model
        # tier this machine's hardware can comfortably run. Never overrides an
        # existing settings file — a user's explicit model choice stays. The
        # check is surfaced in the overlay so the user understands what happens
        # (the model stays changeable in Settings).
        if self.settings_service.was_created:
            from services.model_manager import best_default_model
            from services.transcription_service import gpu_vram_mb

            self.signals.download_status.emit(t("status_hw_check"))
            # nvidia-smi subprocess (up to 5 s) — keep it off the event loop.
            vram = await self._loop.run_in_executor(None, gpu_vram_mb)
            best = best_default_model(vram)
            tier = t({"small": "model_swift", "medium": "model_precise",
                      "large-v3-turbo": "model_ultra"}.get(best, "model_precise"))
            if vram > 0:
                self.bridge.set_hardware_summary(
                    t("fr_hw_gpu", vram=max(1, round(vram / 1024)), model=tier))
            else:
                self.bridge.set_hardware_summary(t("fr_hw_cpu", model=tier))
            if best != settings.whisper_model:
                logger.info(
                    "First run: hardware fits '{}' (VRAM {} MB) - using it as default",
                    best, vram,
                )
                self.settings_service.update(whisper_model=best)
                settings = self.settings_service.get()

        model_name = settings.whisper_model

        # Download whisper model
        if not self.model_manager.whisper_model_exists(model_name):
            self.signals.download_status.emit(t("status_downloading_engine"))
            try:
                await self.model_manager.download_whisper(model_name)
            except Exception as e:
                logger.error("Whisper download failed: {}", e)
                self.signals.error_occurred.emit(t("err_model_download"))
                return False

        # Load models after download
        self.signals.download_status.emit(t("status_init_models"))
        self.signals.download_progress.emit(0.99)
        await self._load_models()
        self.signals.download_progress.emit(1.0)
        return True

    async def _start_audio_with_status(self) -> None:
        """Start AudioService with status feedback for slow Silero VAD init."""
        self.signals.download_status.emit(t("status_loading_vad"))
        await self.audio_service.start()

    async def _load_models(self) -> None:
        """Load transcription and correction models with status feedback."""
        # DLL search paths for CUDA are set up early in paths.py (at import time,
        # before ctranslate2 is loaded). No additional setup needed here.

        logger.info("Starting model loading...")
        self.signals.download_status.emit(t("status_loading_model"))
        # Apply the model the user actually selected — without this the service
        # silently loads its constructor default after every app restart.
        settings = self.settings_service.get()
        self.transcription_service.set_model(settings.whisper_model)
        try:
            await self.transcription_service.start()
        except Exception as e:
            logger.error("TranscriptionService load error: {}", e)

    def _notify_compute_status(self) -> None:
        """Show tray notification about GPU/CPU status after model loading."""
        device = self.transcription_service.device
        fell_back = self.transcription_service.fell_back_to_cpu

        if fell_back:
            self._show_tray_message(
                "VoxCast",
                t("tray_cpu_fallback"),
                8000,
            )
            logger.warning("Running on CPU (GPU fallback)")
        elif device == "cpu":
            settings = self.settings_service.get()
            if settings.compute_device == "cpu":
                self._show_tray_message(
                    "VoxCast",
                    t("tray_cpu_configured"),
                    4000,
                )
            else:
                self._show_tray_message(
                    "VoxCast",
                    t("tray_cpu_detected"),
                    8000,
                )
        else:
            logger.info("Running on GPU (CUDA)")
            # Only show GPU notification on first run
            if self.transcription_service.is_ready and self._tray is not None:
                self._tray.setToolTip(f"VoxCast - GPU accelerated ({device})")
