"""
UiBridge - QObject exposing all app state and actions to QML via properties and slots.
"""
from __future__ import annotations

import asyncio
from typing import TYPE_CHECKING

from loguru import logger
from PySide6.QtCore import (
    Property,
    QObject,
    Signal,
    Slot,
)

from services import i18n
from services.i18n import t
from services.types import AppState

if TYPE_CHECKING:
    from bridge.signals import AppSignals
    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.settings_service import SettingsService
    from services.startup_service import StartupService
    from services.transcription_service import TranscriptionService
    from services.types import AudioDevice
    from services.update_service import UpdateService

    import numpy as np

# Upper bound for tearing the mic-test stream down. PortAudio close can hang on
# flaky drivers; without a bound the owning task would never finish and the
# task-done guards in start/playMicTest would lock the feature out for good.
_MIC_TEST_STOP_TIMEOUT = 10.0


class UiBridge(QObject):
    # QML-notifiable property change signals
    appStateChanged = Signal(str)
    downloadProgressChanged = Signal(float)
    downloadStatusChanged = Signal(str)
    firstRunActiveChanged = Signal(bool)
    hardwareSummaryChanged = Signal(str)
    isDownloadingChanged = Signal(bool)
    transcriptionTextChanged = Signal(str)
    availableDevicesChanged = Signal(list)
    currentHotkeyChanged = Signal(str)
    windowXChanged = Signal(int)
    windowYChanged = Signal(int)
    settingsWindowVisibleChanged = Signal(bool)
    smallPanelChanged = Signal(bool)

    # Audio level for waveform visualization
    audioLevelChanged = Signal(float)

    # Mic test state for Settings voice test
    micTestStateChanged = Signal(str)

    # Whether a test recording exists for playback
    micTestHasRecordingChanged = Signal(bool)

    # Forwarded app signals for QML
    errorOccurred = Signal(str)

    # AI post-processing (LLM) state for QML
    llmActiveModeChanged = Signal(str)
    llmAvailableModelsChanged = Signal(list)
    llmModelsLoadingChanged = Signal(bool)
    llmHasKeyChanged = Signal(bool)

    # Remote transcription server
    remoteKeyChanged = Signal(bool)
    remoteTestResult = Signal(bool, str)   # (ok, message)
    remoteDiscoveryBusyChanged = Signal(bool)
    remoteDiscoveryResult = Signal(list)   # [{name, ip, port, url, version}]
    serverPromptsChanged = Signal(list)    # [{id, name}] managed server modes
    enhanceSourceChanged = Signal(str)     # "own" | "server"
    serverCertResult = Signal(bool, str, str)  # (ok, fingerprint, pem) TOFU
    serverEnhanceChanged = Signal(bool)    # server offers central enhancement

    # License / activation state for QML
    licenseActivatedChanged = Signal(bool)
    activationVisibleChanged = Signal(bool)
    licenseBusyChanged = Signal(bool)
    licenseActivationResult = Signal(bool, str)  # (ok, message)

    llmEnabledChanged = Signal(bool)

    # Interface language: fires when the translation table changes so every
    # QML binding on AppBridge.tr.* re-evaluates live.
    trChanged = Signal()

    # Update state for QML
    updateAvailableChanged = Signal(bool)
    updateInfoChanged = Signal()                 # version / notes / mandatory
    updateCheckingChanged = Signal(bool)
    updateDownloadingChanged = Signal(bool)
    updateProgressChanged = Signal(float)
    updateStatusChanged = Signal(str)
    updatePromptRequested = Signal()             # show the changelog dialog
    liveModeOnGpuChanged = Signal()              # compute device changed (GPU⇄CPU)

    # Meeting recorder state for QML
    meetingStateChanged = Signal(str)
    meetingElapsedChanged = Signal()
    meetingProgressChanged = Signal()
    meetingConsentAckChanged = Signal(bool)
    meetingConsentRequested = Signal()   # tray asks QML to show the consent overlay

    # Raw transcript kept after AI enhancement (undo/recovery)
    rawTranscriptChanged = Signal()

    def __init__(
        self,
        signals: AppSignals,
        settings_service: SettingsService,
        audio_service: AudioService,
        transcription_service: TranscriptionService,
        hotkey_service: HotkeyService,
        model_manager: ModelManager,
        startup_service: StartupService,
        conversion_service: ConversionService,
        llm_service: LLMService,
        license_service: LicenseService,
        update_service: UpdateService,
        meeting_recorder_service=None,
    ) -> None:
        super().__init__()
        self._signals = signals
        self._settings_service = settings_service
        self._audio_service = audio_service
        self._transcription_service = transcription_service
        self._hotkey_service = hotkey_service
        self._model_manager = model_manager
        self._startup_service = startup_service
        self._conversion_service = conversion_service
        self._llm_service = llm_service
        self._license_service = license_service
        self._update_service = update_service
        self._meeting_recorder = meeting_recorder_service
        self._llm_available_models: list[str] = []
        self._llm_models_loading = False
        self._remote_discovering = False
        self._server_prompts: list[dict] = []
        self._server_enhance_available = False

        # License / activation state
        self._license_activated = False
        self._activation_visible = False
        self._license_busy = False
        # Set by the app: released when activation succeeds, unblocking startup.
        self.activation_event: asyncio.Event | None = None

        # Update state
        self._update_available = False
        self._update_prompt_shown_version = ""   # changelog dialog shown once per version
        self._update_prompt_pending = False      # prompt deferred while the user works
        self._update_version = ""
        self._update_notes = ""
        self._update_mandatory = False
        self._update_checking = False
        self._update_downloading = False
        self._update_progress = 0.0
        self._update_status = ""
        self._update_url = ""
        self._update_sha256 = ""
        self._update_is_delta = False

        # Internal state
        self._app_state = AppState.IDLE
        self._audio_level = 0.0
        self._mic_test_state = "idle"
        self._mic_test_has_recording = False
        self._mic_test_task: asyncio.Task | None = None
        self._mic_test_stopping = False
        self._download_progress = 0.0
        self._download_status = ""
        self._hardware_summary = ""
        self._is_downloading = False
        self._first_run_active = False
        self._transcription_text = ""
        self._available_devices: list[AudioDevice] = []
        self._settings_window_visible = False
        self._loop: asyncio.AbstractEventLoop | None = None
        self._last_level_time: float = 0.0

        # Meeting recorder state
        self._meeting_state = "idle"
        self._meeting_elapsed = 0
        self._meeting_progress = 0.0
        self._last_transcript_path = ""

        # Raw transcript of the last enhanced dictation ("" = none kept)
        self._last_raw_transcript = ""

        # Strong refs for fire-and-forget tasks (see _track)
        self._bg_tasks: set[asyncio.Task] = set()

        # Connect app signals to bridge
        self._signals.state_changed.connect(self._on_state_changed)
        self._signals.download_progress.connect(self._on_download_progress)
        self._signals.download_status.connect(self._on_download_status)
        self._signals.error_occurred.connect(self.errorOccurred)
        self._signals.level_updated.connect(self._on_audio_level)
        self._signals.device_list_updated.connect(self._on_devices_changed)
        self._signals.meeting_state.connect(self._on_meeting_state)
        self._signals.meeting_elapsed.connect(self._on_meeting_elapsed)
        self._signals.meeting_progress.connect(self._on_meeting_progress)
        self._signals.meeting_done.connect(self._on_meeting_done)

    def set_loop(self, loop: asyncio.AbstractEventLoop) -> None:
        self._loop = loop

    def _track(self, coro, label: str) -> "asyncio.Task":
        """create_task + STRONG reference + error logging.

        asyncio only keeps weak references to tasks — a bare create_task can
        be garbage-collected mid-flight, silently killing the job (a user
        conversion died this way: CONVERTING state, then nothing, no file,
        no error). Every fire-and-forget bridge task must go through here."""
        task = self._loop.create_task(coro)
        self._bg_tasks.add(task)

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

        task.add_done_callback(_done)
        return task

    # ─── Properties ────────────────────────────────────────────────────────

    @Property(str, notify=appStateChanged)
    def appState(self) -> str:
        return self._app_state

    def set_app_state(self, state: str) -> None:
        if self._app_state != state:
            self._app_state = state
            self.appStateChanged.emit(state)
            if self._update_prompt_pending:
                self._maybe_show_update_prompt()

    @Property(float, notify=audioLevelChanged)
    def audioLevel(self) -> float:
        return self._audio_level

    @Property(float, notify=downloadProgressChanged)
    def downloadProgress(self) -> float:
        return self._download_progress

    @Property(str, notify=downloadStatusChanged)
    def downloadStatus(self) -> str:
        return self._download_status

    @Property(str, notify=hardwareSummaryChanged)
    def hardwareSummary(self) -> str:
        """First-run hardware check result shown in the download overlay."""
        return self._hardware_summary

    def set_hardware_summary(self, text: str) -> None:
        if self._hardware_summary != text:
            self._hardware_summary = text
            self.hardwareSummaryChanged.emit(text)

    @Property(bool, notify=isDownloadingChanged)
    def isDownloading(self) -> bool:
        return self._is_downloading

    def set_is_downloading(self, value: bool) -> None:
        if self._is_downloading != value:
            self._is_downloading = value
            self.isDownloadingChanged.emit(value)

    @Property(bool, notify=firstRunActiveChanged)
    def firstRunActive(self) -> bool:
        """True only during the first-run setup flow. The download overlay
        asks for the dictation language then; a plain model switch must not."""
        return self._first_run_active

    def set_first_run_active(self, value: bool) -> None:
        if self._first_run_active != value:
            self._first_run_active = value
            self.firstRunActiveChanged.emit(value)

    @Slot(str, result=bool)
    def isModelInstalled(self, model_name: str) -> bool:
        """Whether the given engine tier is already on disk (no download)."""
        try:
            return self._model_manager.whisper_model_exists(model_name)
        except Exception as e:
            logger.error("isModelInstalled failed for {}: {}", model_name, e)
            return False

    @Slot(str, result=str)
    def modelDownloadSize(self, model_name: str) -> str:
        """Human-readable download size for the model-switch confirmation."""
        from services.model_manager import WHISPER_MODELS
        info = WHISPER_MODELS.get(model_name)
        if not info:
            return ""
        gb = info["size_bytes"] / 1_000_000_000
        if gb >= 1.0:
            return f"{gb:.1f} GB"
        return f"{info['size_bytes'] / 1_000_000:.0f} MB"

    @Property(str, notify=transcriptionTextChanged)
    def transcriptionText(self) -> str:
        return self._transcription_text

    @transcriptionText.setter  # type: ignore[attr-defined]
    def transcriptionText(self, value: str) -> None:
        if self._transcription_text != value:
            self._transcription_text = value
            self.transcriptionTextChanged.emit(value)

    @Property(list, notify=availableDevicesChanged)
    def availableDevices(self) -> list:
        return self._available_devices

    def set_available_devices(self, devices: list[AudioDevice]) -> None:
        self._available_devices = devices
        self.availableDevicesChanged.emit(devices)

    @Property(str, notify=currentHotkeyChanged)
    def currentHotkey(self) -> str:
        return self._hotkey_service.current_hotkey

    @Property(int, notify=windowXChanged)
    def windowX(self) -> int:
        if self._settings_service.is_ready:
            x = self._settings_service.get().window_x
            y = self._settings_service.get().window_y
            sx, sy = self._ensure_on_screen(x, y, 300, 44)
            return sx
        return 100

    @Property(int, notify=windowYChanged)
    def windowY(self) -> int:
        if self._settings_service.is_ready:
            x = self._settings_service.get().window_x
            y = self._settings_service.get().window_y
            _, sy = self._ensure_on_screen(x, y, 300, 44)
            return sy
        return 100

    def _ensure_on_screen(self, x: int, y: int, w: int = 300, h: int = 44) -> tuple[int, int]:
        """Ensure the window is usable on a connected screen.

        The window must have at least half its width visible on some screen
        so the user can always grab it to drag. If not, snap to the primary
        screen center.
        """
        from PySide6.QtWidgets import QApplication
        from PySide6.QtCore import QRect

        app = QApplication.instance()
        if app is None:
            return x, y

        screens = app.screens()
        if not screens:
            return x, y

        min_w = w // 2  # at least half the toolbar width must be visible
        window_rect = QRect(x, y, w, h)

        for screen in screens:
            geom = screen.availableGeometry()
            intersection = geom.intersected(window_rect)
            if intersection.width() >= min_w and intersection.height() >= h // 2:
                # Visible enough — but also clamp y so the bar isn't half-hidden vertically
                clamped_y = max(geom.y(), min(y, geom.y() + geom.height() - h))
                if clamped_y != y:
                    if self._settings_service.is_ready:
                        self._settings_service.update(window_y=clamped_y)
                return x, clamped_y

        # Not visible on any screen — snap to primary screen center-top
        primary = app.primaryScreen() or screens[0]
        geom = primary.availableGeometry()
        new_x = geom.x() + (geom.width() - w) // 2
        new_y = geom.y() + 40
        logger.info("Window ({},{}) off-screen, snapping to ({},{})", x, y, new_x, new_y)
        if self._settings_service.is_ready:
            self._settings_service.update(window_x=new_x, window_y=new_y)
        return new_x, new_y

    @Property(bool, notify=settingsWindowVisibleChanged)
    def settingsWindowVisible(self) -> bool:
        return self._settings_window_visible

    @Property(str, notify=micTestStateChanged)
    def micTestState(self) -> str:
        return self._mic_test_state

    @Property(bool, notify=micTestHasRecordingChanged)
    def micTestHasRecording(self) -> bool:
        return self._mic_test_has_recording

    # ─── Settings properties for QML binding ───────────────────────────────

    @Property(str)
    def whisperModel(self) -> str:
        if self._settings_service.is_ready:
            return self._settings_service.get().whisper_model
        return "medium"

    # ─── Model / hardware compatibility (Settings model picker) ───────────

    @Slot(str, str, result=str)
    def modelCompatLevel(self, model: str, device_pref: str) -> str:
        """'gpu_ok' | 'gpu_low' | 'cpu' | 'cpu_forced' for the given model."""
        from services.model_manager import compat_level
        from services.transcription_service import gpu_vram_mb
        return compat_level(model, gpu_vram_mb(), device_pref)

    @Slot(str, str, result=str)
    def modelCompatText(self, model: str, device_pref: str) -> str:
        """Localized one-liner telling the user how this model will run here."""
        from services.transcription_service import gpu_vram_mb
        level = self.modelCompatLevel(model, device_pref)
        key = {
            "gpu_ok": "compat_gpu_ok",
            "gpu_low": "compat_gpu_low",
            "cpu": "compat_cpu",
            "cpu_forced": "compat_cpu_forced",
        }[level]
        return t(key, vram=f"{gpu_vram_mb() / 1024:.0f}")

    @Property(bool)
    def liveMode(self) -> bool:
        if self._settings_service.is_ready:
            return self._settings_service.get().live_mode
        return False

    @Property(bool, notify=liveModeOnGpuChanged)
    def liveModeOnGpu(self) -> bool:
        """True when the running model uses the GPU (live mode is smooth).
        Not constant: the compute device changes when the user switches it in
        Settings (model reload) or on a GPU→CPU fallback."""
        try:
            return self._transcription_service.device == "cuda"
        except Exception:
            return False

    # ─── Meeting recorder ──────────────────────────────────────────────────

    def _on_meeting_state(self, state: str) -> None:
        self._meeting_state = state
        if state == "recording":
            self._meeting_elapsed = 0
            self.meetingElapsedChanged.emit()
        if state == "processing":
            self._meeting_progress = 0.0
            self.meetingProgressChanged.emit()
        self.meetingStateChanged.emit(state)
        if self._update_prompt_pending:
            self._maybe_show_update_prompt()

    def _on_meeting_elapsed(self, seconds: int) -> None:
        self._meeting_elapsed = seconds
        self.meetingElapsedChanged.emit()

    def _on_meeting_progress(self, fraction: float) -> None:
        self._meeting_progress = fraction
        self.meetingProgressChanged.emit()

    def _on_meeting_done(self, md_path: str) -> None:
        self._last_transcript_path = md_path

    @Slot()
    def openLastTranscript(self) -> None:
        import os

        if self._last_transcript_path and os.path.isfile(self._last_transcript_path):
            try:
                os.startfile(self._last_transcript_path)  # noqa: S606
            except OSError:
                logger.exception("opening transcript failed")

    # ─── Raw transcript recovery after AI enhancement ──────────────────────

    def set_last_raw_transcript(self, text: str | None) -> None:
        self._last_raw_transcript = text or ""
        self.rawTranscriptChanged.emit()

    @Property(bool, notify=rawTranscriptChanged)
    def hasRawTranscript(self) -> bool:
        return bool(self._last_raw_transcript)

    @Slot()
    def copyRawTranscript(self) -> None:
        """Put the pre-enhancement transcript on the clipboard."""
        if not self._last_raw_transcript:
            return
        from PySide6.QtWidgets import QApplication

        app = QApplication.instance()
        if app is not None:
            app.clipboard().setText(self._last_raw_transcript)
            self._signals.tray_message.emit("VoxCast", t("tray_raw_copied"))

    @Property(str, notify=meetingStateChanged)
    def meetingState(self) -> str:
        return self._meeting_state

    @Property(str, notify=meetingElapsedChanged)
    def meetingElapsedText(self) -> str:
        s = self._meeting_elapsed
        if s >= 3600:
            return f"{s // 3600}:{(s % 3600) // 60:02d}:{s % 60:02d}"
        return f"{s // 60:02d}:{s % 60:02d}"

    @Property(float, notify=meetingProgressChanged)
    def meetingProgress(self) -> float:
        return self._meeting_progress

    @Property(bool, notify=meetingConsentAckChanged)
    def meetingConsentAck(self) -> bool:
        if self._settings_service.is_ready:
            return self._settings_service.get().meeting_consent_ack
        return False

    @Property(bool, constant=True)
    def meetingSupportsApps(self) -> bool:
        """Per-app capture needs Windows 10 2004+."""
        try:
            from services.process_loopback import is_supported

            return is_supported()
        except Exception:
            return False

    @Slot(result="QVariantList")
    def listAudioApps(self) -> list:
        """Apps currently playing audio — for the 'what to record' picker."""
        try:
            from services.process_loopback import list_audio_apps

            return list_audio_apps()
        except Exception:
            logger.exception("listAudioApps failed")
            return []

    @Slot(int, str)
    def requestMeetingStart(self, pid: int, name: str) -> None:
        """Start a meeting recording for an app (pid>0) or the whole system
        (pid<=0), passing the one-time consent gate first."""
        self._pending_meeting_target = (pid, name) if pid > 0 else (0, "")
        if not self.meetingConsentAck:
            self.meetingConsentRequested.emit()
            return
        self._start_pending_meeting()

    def _start_pending_meeting(self) -> None:
        if self._meeting_recorder is None or self._loop is None:
            return
        pid, name = getattr(self, "_pending_meeting_target", (0, ""))
        self._pending_meeting_target = (0, "")
        self._track(self._meeting_recorder.start_recording(
            target_pid=pid or None, target_name=name), "Meeting start")

    @Slot()
    def acceptMeetingConsent(self) -> None:
        """One-time legal notice accepted — persist and start recording."""
        self._settings_service.update(meeting_consent_ack=True)
        self.meetingConsentAckChanged.emit(True)
        self._start_pending_meeting()

    @Slot()
    def startMeetingRecording(self) -> None:
        """Whole-system start (kept for callers without a picker)."""
        self.requestMeetingStart(0, "")

    @Slot()
    def stopMeetingRecording(self) -> None:
        if self._meeting_recorder is None or self._loop is None:
            return
        self._track(self._meeting_recorder.stop_recording(), "Meeting stop")

    @Slot()
    def openMeetingsFolder(self) -> None:
        import os

        if self._meeting_recorder is None:
            return
        try:
            folder = self._meeting_recorder._output_dir()
            folder.mkdir(parents=True, exist_ok=True)
            os.startfile(str(folder))  # noqa: S606 — local folder, user action
        except OSError:
            logger.exception("could not open meetings folder")

    @Slot(result=str)
    def meetingsFolderDisplay(self) -> str:
        """Path shown in Settings ('' setting → localized default hint)."""
        if self._settings_service.is_ready:
            configured = (self._settings_service.get().meeting_output_dir or "").strip()
            if configured:
                return configured
        return t("meeting_folder_default")

    @Property(bool)
    def autostart(self) -> bool:
        return self._startup_service.is_autostart_enabled()

    @Property(bool)
    def playStartupSound(self) -> bool:
        if self._settings_service.is_ready:
            return self._settings_service.get().play_startup_sound
        return True

    @Property(bool)
    def playRecordingSound(self) -> bool:
        if self._settings_service.is_ready:
            return self._settings_service.get().play_recording_sound
        return True

    @Property(float)
    def recordingSoundVolume(self) -> float:
        if self._settings_service.is_ready:
            return self._settings_service.get().recording_sound_volume
        return 0.8

    @Property(bool)
    def playShutdownSound(self) -> bool:
        if self._settings_service.is_ready:
            return self._settings_service.get().play_shutdown_sound
        return True

    @Property(bool)
    def alwaysOnTop(self) -> bool:
        if self._settings_service.is_ready:
            return self._settings_service.get().always_on_top
        return True

    @Property(bool, notify=smallPanelChanged)
    def smallPanel(self) -> bool:
        if self._settings_service.is_ready:
            return self._settings_service.get().small_panel
        return False

    @Property(str)
    def computeDevice(self) -> str:
        if self._settings_service.is_ready:
            return self._settings_service.get().compute_device
        return "auto"

    @Property(str)
    def dockEdge(self) -> str:
        if self._settings_service.is_ready:
            return self._settings_service.get().dock_edge
        return "none"

    @Property(str)
    def language(self) -> str:
        if self._settings_service.is_ready:
            return self._settings_service.get().language or ""
        return ""

    @Property(str)
    def outputMode(self) -> str:
        if self._settings_service.is_ready:
            return self._settings_service.get().output_mode
        return "both"

    @Property(str)
    def insertMethod(self) -> str:
        if self._settings_service.is_ready:
            return self._settings_service.get().insert_method
        return "paste"

    @Property(str)
    def hotkeyMode(self) -> str:
        if self._settings_service.is_ready:
            return self._settings_service.get().hotkey_mode
        return "toggle"

    @Property(str)
    def convertOutputMode(self) -> str:
        if self._settings_service.is_ready:
            return self._settings_service.get().convert_output_mode
        return "ask"

    @Property(str)
    def convertOutputDir(self) -> str:
        if self._settings_service.is_ready:
            return self._settings_service.get().convert_output_dir or ""
        return ""

    @Property(bool)
    def convertTimestamps(self) -> bool:
        if self._settings_service.is_ready:
            return self._settings_service.get().convert_timestamps
        return False

    @Property(str)
    def convertLanguage(self) -> str:
        """Language forced for file conversion; '' = auto-detect per segment."""
        if self._settings_service.is_ready:
            return self._settings_service.get().convert_language or ""
        return ""

    # ── Remote transcription server ──────────────────────────────────────
    def server_prompts(self) -> list[dict]:
        """Server-managed enhancement prompts (Python-side, for app.py)."""
        return self._server_prompts

    def set_server_prompts(self, prompts: list[dict]) -> None:
        if prompts != self._server_prompts:
            self._server_prompts = list(prompts)
            self.serverPromptsChanged.emit(self._qml_server_prompts())

    def set_server_enhance_available(self, available: bool) -> None:
        if bool(available) != self._server_enhance_available:
            self._server_enhance_available = bool(available)
            self.serverEnhanceChanged.emit(self._server_enhance_available)

    @Property(bool, notify=serverEnhanceChanged)
    def serverEnhanceAvailable(self) -> bool:
        return self._server_enhance_available

    def _qml_server_prompts(self) -> list:
        return [{"id": p["id"], "name": p["name"]} for p in self._server_prompts]

    @Property("QVariant", notify=serverPromptsChanged)
    def serverPrompts(self) -> list:
        """[{id, name}] for the AI mode picker (prompt text stays server-side)."""
        return self._qml_server_prompts()

    @Property(str, notify=enhanceSourceChanged)
    def enhanceSource(self) -> str:
        if self._settings_service.is_ready:
            return self._settings_service.get().enhance_source
        return "own"

    @Property(bool)
    def remoteEnabled(self) -> bool:
        if self._settings_service.is_ready:
            return self._settings_service.get().remote_enabled
        return False

    @Property(str)
    def remoteUrl(self) -> str:
        if self._settings_service.is_ready:
            return self._settings_service.get().remote_url
        return ""

    @Property(str)
    def remoteTlsMode(self) -> str:
        if self._settings_service.is_ready:
            return self._settings_service.get().remote_tls_mode
        return "verify"

    @Property(str)
    def remoteCertPem(self) -> str:
        if self._settings_service.is_ready:
            return self._settings_service.get().remote_cert_pem
        return ""

    @Property(bool, notify=remoteKeyChanged)
    def remoteKeySet(self) -> bool:
        from services import credentials
        return credentials.has_secret("remote_server")

    @Slot(str)
    def setRemoteApiKey(self, key: str) -> None:
        # A managed policy that provisions + locks the key owns it: never let
        # the user overwrite a locked server key for the session.
        if self._settings_service.is_locked("remote_server_api_key"):
            return
        from services import credentials
        if key.strip():
            credentials.set_secret("remote_server", key.strip())
            self.remoteKeyChanged.emit(True)

    @Slot()
    def clearRemoteApiKey(self) -> None:
        if self._settings_service.is_locked("remote_server_api_key"):
            return
        from services import credentials
        credentials.delete_secret("remote_server")
        self.remoteKeyChanged.emit(False)

    @Slot(str, str)
    @Slot(str, str, str, str)
    def testRemoteConnection(self, url: str, key: str,
                             tls_mode: str = "verify", cert_pem: str = "") -> None:
        """Test-connection button: probe /v1/status with the entered URL/key
        (before saving). Result comes back via remoteTestResult(ok, message)."""
        if self._loop is None:
            self.remoteTestResult.emit(False, t("err_remote_unreachable"))
            return
        self._track(self._run_remote_test(url, key, tls_mode, cert_pem),
                    "Remote test")

    @Slot(str, result=bool)
    def isLocked(self, key: str) -> bool:
        """True when a managed policy locks this setting (enterprise). QML
        disables the matching control."""
        return self._settings_service.is_locked(key)

    @Slot(result=bool)
    def hasManagedPolicy(self) -> bool:
        return bool(self._settings_service.locked_keys())

    @Slot()
    def discoverRemoteServers(self) -> None:
        """'Find server' button: broadcast a LAN probe and report every
        VoxCast Server that answers via remoteDiscoveryResult."""
        if self._remote_discovering or self._loop is None:
            return
        self._remote_discovering = True
        self.remoteDiscoveryBusyChanged.emit(True)
        self._track(self._run_remote_discovery(), "Remote discovery")

    async def _run_remote_discovery(self) -> None:
        from services.server_discovery import discover_servers
        results: list[dict] = []
        try:
            results = await discover_servers()
        except Exception as e:  # noqa: BLE001
            logger.warning("Server discovery failed: {}", e)
        finally:
            self._remote_discovering = False
            self.remoteDiscoveryBusyChanged.emit(False)
        self.remoteDiscoveryResult.emit(results)

    async def _run_remote_test(self, url: str, key: str,
                               tls_mode: str = "verify", cert_pem: str = "") -> None:
        from services.remote_transcription import RemoteError, RemoteTranscriptionBackend
        from services.settings_service import normalize_remote_url
        # Normalize exactly like saving does, so the Test button's result
        # predicts the saved config's behavior (e.g. "host:8590" → http://...).
        url = normalize_remote_url(url)
        if not url:
            self.remoteTestResult.emit(False, t("err_remote_unreachable"))
            return
        # If the key field was left blank, fall back to the stored key so a
        # saved server can be re-tested without re-typing the secret.
        if not key.strip():
            from services import credentials
            key = credentials.get_secret("remote_server") or ""
        # Fall back to the saved cert when the caller passed none (re-testing
        # a pinned server without re-fetching).
        if tls_mode == "pinned" and not cert_pem:
            cert_pem = self._settings_service.get().remote_cert_pem
        backend = RemoteTranscriptionBackend(self._signals)
        backend.configure(url, key, tls_mode, cert_pem)
        try:
            status = await backend.test_connection()
        except RemoteError as e:
            self.remoteTestResult.emit(False, t(e.i18n_key))
            return
        except Exception as e:  # noqa: BLE001
            logger.warning("Remote test failed: {}", e)
            self.remoteTestResult.emit(False, t("err_remote_unreachable"))
            return
        # Build a friendly "Connected: <Tier> on <GPU/CPU>" line (tier names,
        # never "Whisper"/"cuda").
        self.remoteTestResult.emit(True, self._format_remote_status(status))

    # ── TLS trust-on-first-use ────────────────────────────────────────────

    @Slot(str)
    def fetchServerCertificate(self, url: str) -> None:
        """Fetch an https server's certificate so the user can confirm its
        fingerprint (TOFU). Result via serverCertResult(ok, fingerprint, pem)."""
        if self._loop is None:
            return
        self._track(self._run_fetch_cert(url), "Fetch server cert")

    async def _run_fetch_cert(self, url: str) -> None:
        from urllib.parse import urlparse

        from services.http_exec import run_blocking
        from services.settings_service import normalize_remote_url
        from services.tls_trust import fetch_server_cert_sync, format_fingerprint
        url = normalize_remote_url(url)
        parsed = urlparse(url)
        if parsed.scheme != "https" or not parsed.hostname:
            self.serverCertResult.emit(False, "", "")
            return
        port = parsed.port or 443
        try:
            pem, fp = await run_blocking(
                fetch_server_cert_sync, parsed.hostname, port)
        except Exception as e:  # noqa: BLE001
            logger.info("Certificate fetch failed for {}: {}", url, e)
            self.serverCertResult.emit(False, "", "")
            return
        self.serverCertResult.emit(True, format_fingerprint(fp), pem)

    @staticmethod
    def _format_remote_status(status: dict) -> str:
        tier_map = {"small": t("model_swift"), "medium": t("model_precise"),
                    "large-v3-turbo": t("model_ultra"), "base": t("model_swift")}
        model = status.get("model", "")
        tier = tier_map.get(model, model)
        gpu = status.get("gpu", {})
        vram = gpu.get("vram_mb", 0)
        on_gpu = status.get("device") == "cuda"
        where = t("remote_on_gpu", vram=max(1, round(vram / 1024))) if on_gpu \
            else t("remote_on_cpu")
        msg = t("remote_connected", model=tier, where=where)
        # Advertise central enhancement + a pending server update, if any.
        enh = status.get("enhance") or {}
        if enh.get("available"):
            msg += "  " + t("remote_enhance_available")
        upd = status.get("update_available")
        if upd:
            msg += "  " + t("remote_update_available", version=upd)
        return msg

    @Property(bool)
    def meetingRecordMic(self) -> bool:
        if self._settings_service.is_ready:
            return self._settings_service.get().meeting_record_mic
        return True

    @Property(bool)
    def meetingKeepAudio(self) -> bool:
        if self._settings_service.is_ready:
            return self._settings_service.get().meeting_keep_audio
        return True

    @Property(bool)
    def meetingAutoSummary(self) -> bool:
        if self._settings_service.is_ready:
            return self._settings_service.get().meeting_auto_summary
        return False

    @Property(str)
    def meetingOutputDir(self) -> str:
        if self._settings_service.is_ready:
            return self._settings_service.get().meeting_output_dir or ""
        return ""

    @Property(str)
    def meetingLanguage(self) -> str:
        if self._settings_service.is_ready:
            return self._settings_service.get().meeting_language or ""
        return ""

    @Property(bool)
    def convertOpenAfter(self) -> bool:
        if self._settings_service.is_ready:
            return self._settings_service.get().convert_open_after
        return False

    @Property(bool)
    def contextMenuEnabled(self) -> bool:
        if self._settings_service.is_ready:
            return self._settings_service.get().context_menu_enabled
        return True

    # ─── Interface language / translations ─────────────────────────────────

    @Property("QVariant", notify=trChanged)
    def tr(self) -> dict:
        """Full translation table for QML (current language over EN fallback)."""
        return i18n.table_for_qml()

    @Property(str, notify=trChanged)
    def uiLanguage(self) -> str:
        """Stored preference: "" = auto (OS language), else an i18n.SUPPORTED code."""
        if self._settings_service.is_ready:
            return self._settings_service.get().ui_language
        return ""

    # ─── AI post-processing (LLM) properties ───────────────────────────────

    @Property(bool, notify=llmEnabledChanged)
    def llmEnabled(self) -> bool:
        if self._settings_service.is_ready:
            return self._settings_service.get().llm_enabled
        return False

    @Property(str)
    def llmProvider(self) -> str:
        if self._settings_service.is_ready:
            return self._settings_service.get().llm_provider
        return "anthropic"

    @Property(str)
    def llmModel(self) -> str:
        if self._settings_service.is_ready:
            return self._settings_service.get().llm_model or ""
        return ""

    @Property(str, notify=llmActiveModeChanged)
    def llmActiveMode(self) -> str:
        if self._settings_service.is_ready:
            return self._settings_service.get().llm_active_mode
        return "normal"

    @Property("QVariant", notify=trChanged)
    def llmModes(self) -> list:
        """Mode definitions [{id,name,prompt,display_name}] (without the
        implicit 'normal'). display_name localizes untouched built-in names;
        notify=trChanged re-localizes the menus on live language switch."""
        if not self._settings_service.is_ready:
            return []
        return [
            {**m, "display_name": i18n.builtin_mode_display_name(
                str(m.get("id", "")), str(m.get("name", "")))}
            for m in self._settings_service.get().llm_modes
        ]

    @Property("QVariant", notify=trChanged)
    def uiLanguageOptions(self) -> list:
        """Selectable interface languages [{code, name}] with native display
        names (single source: i18n.SUPPORTED)."""
        return [{"code": c, "name": i18n.NATIVE_NAMES.get(c, c)}
                for c in i18n.SUPPORTED]

    @Property(bool, notify=llmHasKeyChanged)
    def llmHasKey(self) -> bool:
        from services import credentials
        provider = (self._settings_service.get().llm_provider
                    if self._settings_service.is_ready else "anthropic")
        return credentials.has_secret(f"llm_{provider}")

    @Property("QVariant", notify=llmAvailableModelsChanged)
    def llmAvailableModels(self) -> list:
        return self._llm_available_models

    @Property(bool, notify=llmModelsLoadingChanged)
    def llmModelsLoading(self) -> bool:
        return self._llm_models_loading

    def _set_llm_models_loading(self, value: bool) -> None:
        if self._llm_models_loading != value:
            self._llm_models_loading = value
            self.llmModelsLoadingChanged.emit(value)

    # ─── License / activation properties ───────────────────────────────────

    @Property(bool, notify=licenseActivatedChanged)
    def licenseActivated(self) -> bool:
        return self._license_activated

    @Property(bool, notify=activationVisibleChanged)
    def activationVisible(self) -> bool:
        return self._activation_visible

    @Property(bool, notify=licenseBusyChanged)
    def licenseBusy(self) -> bool:
        return self._license_busy

    @Property(str, constant=True)
    def machineId(self) -> str:
        from services.license_service import machine_id
        return machine_id()[:16]  # short, for display only

    @Property(str, notify=licenseActivatedChanged)
    def licenseKeyMasked(self) -> str:
        if self._settings_service.is_ready:
            return self._settings_service.get().license_key_masked or ""
        return ""

    # ─── Update properties ─────────────────────────────────────────────────

    @Property(str, constant=True)
    def currentVersion(self) -> str:
        from version import __version__
        return __version__

    @Property(bool, notify=updateAvailableChanged)
    def updateAvailable(self) -> bool:
        return self._update_available

    @Property(str, notify=updateInfoChanged)
    def updateVersion(self) -> str:
        return self._update_version

    @Property(str, notify=updateInfoChanged)
    def updateNotes(self) -> str:
        return self._update_notes

    @Property(bool, notify=updateInfoChanged)
    def updateMandatory(self) -> bool:
        return self._update_mandatory

    @Property(bool, notify=updateCheckingChanged)
    def updateChecking(self) -> bool:
        return self._update_checking

    @Property(bool, notify=updateDownloadingChanged)
    def updateDownloading(self) -> bool:
        return self._update_downloading

    @Property(float, notify=updateProgressChanged)
    def updateProgress(self) -> float:
        return self._update_progress

    @Property(str, notify=updateStatusChanged)
    def updateStatus(self) -> str:
        return self._update_status

    # ─── Slots (callable from QML) ─────────────────────────────────────────

    @Slot()
    def startRecording(self) -> None:
        self._signals.recording_should_start.emit()

    @Slot()
    def stopRecording(self) -> None:
        self._signals.recording_should_stop.emit()

    @Slot()
    def openSettings(self) -> None:
        logger.debug("openSettings called, visible={} -> True", self._settings_window_visible)
        self._settings_window_visible = True
        self.settingsWindowVisibleChanged.emit(True)
        logger.debug("settingsWindowVisibleChanged emitted True")

    @Slot()
    def closeSettings(self) -> None:
        logger.info("closeSettings called")
        # Stop any running mic test when settings window closes
        if self._mic_test_state != "idle":
            self.stopMicTest()
        self._settings_window_visible = False
        self.settingsWindowVisibleChanged.emit(False)

    @Slot(str, int, int, result="QVariant")
    def calcDockPos(self, edge: str, toolbar_x: int, toolbar_y: int):
        """Calculate hidden/shown positions for dock edge. Panel slides off-screen."""
        logger.debug("calcDockPos called: edge={}, toolbar=({},{})", edge, toolbar_x, toolbar_y)
        try:
            from PySide6.QtWidgets import QApplication
            from PySide6.QtCore import QPoint

            app = QApplication.instance()
            if not app:
                logger.error("calcDockPos: no QApplication instance")
                return {"hx": toolbar_x, "hy": toolbar_y, "sx": toolbar_x, "sy": toolbar_y}

            # Use a point near panel top-left for screen detection (avoids
            # multi-monitor issues when center overshoots into adjacent screen)
            probe = QPoint(int(toolbar_x) + 10, int(toolbar_y) + 10)
            screen = app.screenAt(probe)
            if screen is None:
                screen = app.primaryScreen()
            if not screen:
                logger.error("calcDockPos: no screen found")
                return {"hx": toolbar_x, "hy": toolbar_y, "sx": toolbar_x, "sy": toolbar_y}

            geom = screen.availableGeometry()
            pw, ph = 300, 44  # panel width, height
            peek = 6  # pixels visible when hidden
            tx, ty = int(toolbar_x), int(toolbar_y)

            if edge == "left":
                hx = geom.x() - pw + peek
                hy = max(geom.y(), min(ty, geom.y() + geom.height() - ph))
                sx = geom.x()
                sy = hy
            elif edge == "right":
                hx = geom.x() + geom.width() - peek
                hy = max(geom.y(), min(ty, geom.y() + geom.height() - ph))
                sx = geom.x() + geom.width() - pw
                sy = hy
            elif edge == "top":
                hx = max(geom.x(), min(tx, geom.x() + geom.width() - pw))
                hy = geom.y() - ph + peek
                sx = hx
                sy = geom.y()
            else:
                return {"hx": tx, "hy": ty, "sx": tx, "sy": ty}

            self._settings_service.update(dock_edge=edge)
            logger.info("Dock {}: hidden=({},{}), shown=({},{}) screen={}",
                        edge, hx, hy, sx, sy, screen.name())
            return {"hx": int(hx), "hy": int(hy), "sx": int(sx), "sy": int(sy)}
        except Exception as e:
            logger.error("calcDockPos exception: {}", e)
            return {"hx": int(toolbar_x), "hy": int(toolbar_y),
                    "sx": int(toolbar_x), "sy": int(toolbar_y)}

    @Slot(int, int, int, int, result=str)
    def detectEdgeSnap(self, win_x: int, win_y: int, win_w: int, win_h: int) -> str:
        """Check if window position is near a screen edge. Returns edge name or 'none'.
        Used during drag for live glow preview only.
        """
        from PySide6.QtWidgets import QApplication
        from PySide6.QtCore import QPoint

        app = QApplication.instance()
        if not app:
            return "none"

        wx, wy, ww, wh = int(win_x), int(win_y), int(win_w), int(win_h)
        center = QPoint(wx + ww // 2, wy + wh // 2)
        screen = app.screenAt(center)
        if not screen:
            screen = app.primaryScreen()
        if not screen:
            return "none"

        return self._detect_edge(screen, wx, wy, ww, wh)

    def _detect_edge(self, screen, wx: int, wy: int, ww: int, wh: int) -> str:
        geom = screen.availableGeometry()
        margin = 20
        if wx <= geom.x() + margin:
            return "left"
        if wx + ww >= geom.x() + geom.width() - margin:
            return "right"
        if wy <= geom.y() + margin:
            return "top"
        return "none"

    @Slot(int, int, int, int, result="QVariant")
    def tryDock(self, win_x: int, win_y: int, win_w: int, win_h: int):
        """All-in-one dock handler called on mouse release.
        Detects edge, returns screen geometry so QML can compute positions.
        Returns {edge, screenLeft/Top/Right/Bottom, anchorPos} or {edge: 'none'}.
        """
        from PySide6.QtWidgets import QApplication
        from PySide6.QtCore import QPoint

        wx, wy, ww, wh = int(win_x), int(win_y), int(win_w), int(win_h)
        logger.debug("tryDock: win=({},{} {}x{})", wx, wy, ww, wh)

        app = QApplication.instance()
        if not app:
            return {"edge": "none"}

        center = QPoint(wx + ww // 2, wy + wh // 2)
        screen = app.screenAt(center)
        if not screen:
            screen = app.primaryScreen()
        if not screen:
            return {"edge": "none"}

        edge = self._detect_edge(screen, wx, wy, ww, wh)
        logger.info("tryDock: detected edge={}", edge)

        if edge == "none":
            return {"edge": "none"}

        geom = screen.availableGeometry()

        # anchorPos: y-position for left/right, center-x for top
        if edge in ("left", "right"):
            anchor = max(geom.y(), min(wy, geom.y() + geom.height() - wh))
        else:
            anchor = wx + ww // 2

        self._settings_service.update(dock_edge=edge)
        logger.info("tryDock: docked {} on screen {} anchor={}",
                     edge, screen.name(), anchor)
        return {
            "edge": edge,
            "screenLeft": int(geom.x()),
            "screenTop": int(geom.y()),
            "screenRight": int(geom.x() + geom.width()),
            "screenBottom": int(geom.y() + geom.height()),
            "anchorPos": int(anchor),
        }

    @Slot()
    def undock(self) -> None:
        self._settings_service.update(dock_edge="none")
        logger.info("Undocked")

    @Slot(result="QVariant")
    def getScreenCenter(self):
        """Return center-top position on primary screen for undocking."""
        from PySide6.QtWidgets import QApplication
        app = QApplication.instance()
        screen = app.primaryScreen() if app else None
        if screen:
            g = screen.availableGeometry()
            return {"x": g.x() + (g.width() - 300) // 2, "y": g.y() + 40}
        return {"x": 100, "y": 40}

    @Slot(int, int, int, int, result="QVariant")
    def calcSettingsPos(self, toolbar_x: int, toolbar_y: int, win_w: int, win_h: int):
        """Calculate settings window position centered on the screen where the toolbar is."""
        from PySide6.QtWidgets import QApplication
        from PySide6.QtCore import QPoint

        app = QApplication.instance()
        # Find the screen that contains the toolbar center
        center = QPoint(toolbar_x + 160, toolbar_y + 28)
        screen = app.screenAt(center) if app else None
        if screen is None and app:
            screen = app.primaryScreen()

        if screen:
            geom = screen.availableGeometry()
            x = geom.x() + (geom.width() - win_w) // 2
            y = geom.y() + (geom.height() - win_h) // 2
        else:
            x = toolbar_x
            y = toolbar_y + 60

        logger.debug("Settings position: ({}, {}) on screen {}", x, y,
                      screen.name() if screen else "?")
        return {"x": x, "y": y}

    @Slot(int, int)
    def saveWindowPos(self, x: int, y: int) -> None:
        if self._settings_service.is_ready:
            # Save as-is - position will be validated on next startup via _ensure_on_screen
            self._settings_service.update(window_x=x, window_y=y)

    @Slot()
    def quit(self) -> None:
        from PySide6.QtWidgets import QApplication
        QApplication.instance().quit()

    @Slot(str)
    def saveSettings(self, settings_json: str) -> None:
        if not self._settings_service.is_ready:
            return

        import orjson
        try:
            s = orjson.loads(settings_json)
        except Exception as e:
            logger.error("Failed to parse settings JSON: {}", e)
            return

        whisper_model = s.get("whisper_model", "medium")
        hotkey = s.get("hotkey", "<ctrl>+<shift>+<space>")
        hotkey_mode = s.get("hotkey_mode", "toggle")
        autostart = s.get("autostart", False)
        play_startup_sound = s.get("play_startup_sound", True)
        recording_sound_volume = s.get("recording_sound_volume", 0.8)
        play_shutdown_sound = s.get("play_shutdown_sound", True)
        always_on_top = s.get("always_on_top", True)
        small_panel = s.get("small_panel", False)
        language = s.get("language", "")
        output_mode = s.get("output_mode", "both")
        insert_method = s.get("insert_method", "paste")
        compute_device = s.get("compute_device", "auto")
        live_mode = s.get("live_mode", False)
        audio_device_index = s.get("audio_device_index", -1)
        convert_output_mode = s.get("convert_output_mode", "ask")
        convert_output_dir = s.get("convert_output_dir", "")
        convert_language = s.get("convert_language", "")
        convert_timestamps = s.get("convert_timestamps", False)
        convert_open_after = s.get("convert_open_after", False)
        context_menu_enabled = s.get("context_menu_enabled", True)
        ui_language = s.get("ui_language", "")
        meeting_record_mic = s.get("meeting_record_mic", True)
        meeting_keep_audio = s.get("meeting_keep_audio", True)
        meeting_auto_summary = s.get("meeting_auto_summary", False)
        meeting_output_dir = s.get("meeting_output_dir", "")
        meeting_language = s.get("meeting_language", "")
        remote_enabled = s.get("remote_enabled", False)
        remote_url = s.get("remote_url", "")
        remote_tls_mode = s.get("remote_tls_mode", "verify")
        remote_cert_pem = s.get("remote_cert_pem", "")
        enhance_source = s.get("enhance_source", "own")

        # Capture old values before applying changes
        old_model = self._settings_service.get().whisper_model
        old_device = self._settings_service.get().compute_device
        old_context_menu = self._settings_service.get().context_menu_enabled
        old_remote_enabled = self._settings_service.get().remote_enabled

        self._settings_service.update(
            whisper_model=whisper_model,
            hotkey=hotkey,
            hotkey_mode=hotkey_mode,
            autostart=autostart,
            play_startup_sound=play_startup_sound,
            play_recording_sound=recording_sound_volume > 0,
            recording_sound_volume=recording_sound_volume,
            play_shutdown_sound=play_shutdown_sound,
            always_on_top=always_on_top,
            small_panel=small_panel,
            language=language if language else None,
            output_mode=output_mode,
            insert_method=insert_method,
            compute_device=compute_device,
            live_mode=live_mode,
            audio_device_index=audio_device_index if audio_device_index >= 0 else None,
            convert_output_mode=convert_output_mode,
            convert_output_dir=convert_output_dir if convert_output_dir else None,
            convert_language=convert_language if convert_language else None,
            convert_timestamps=convert_timestamps,
            convert_open_after=convert_open_after,
            context_menu_enabled=context_menu_enabled,
            ui_language=ui_language,
            meeting_record_mic=meeting_record_mic,
            meeting_keep_audio=meeting_keep_audio,
            meeting_auto_summary=meeting_auto_summary,
            meeting_output_dir=meeting_output_dir,
            meeting_language=meeting_language,
            remote_enabled=remote_enabled,
            remote_url=remote_url,
            remote_tls_mode=remote_tls_mode,
            remote_cert_pem=remote_cert_pem,
            enhance_source=enhance_source,
        )

        # Apply interface language immediately — every QML string bound to
        # AppBridge.tr re-evaluates on trChanged (no restart needed).
        new_lang = i18n.resolve_language(ui_language)
        if new_lang != i18n.current_language():
            i18n.set_language(new_lang)
            logger.info("UI language switched to {}", new_lang)
            self.trChanged.emit()

        # Apply context-menu (shell integration) change immediately
        if context_menu_enabled != old_context_menu:
            self._apply_context_menu(context_menu_enabled)

        # Apply settings immediately
        self._hotkey_service.set_hotkey(hotkey)
        self._hotkey_service.set_mode(hotkey_mode)
        self._transcription_service.set_language(language if language else None)

        if autostart:
            self._startup_service.enable_autostart()
        else:
            self._startup_service.disable_autostart()

        if audio_device_index >= 0:
            self._audio_service.set_device(audio_device_index)
        else:
            self._audio_service.set_device(None)

        self.currentHotkeyChanged.emit(hotkey)
        self.smallPanelChanged.emit(small_panel)
        self.closeSettings()

        # Apply remote transcription: route batch work to the server (or back
        # to local). The API key is stored separately (setRemoteApiKey).
        if hasattr(self._transcription_service, "set_remote"):
            from services import credentials
            remote_key = credentials.get_secret("remote_server") or ""
            saved = self._settings_service.get()
            self._transcription_service.set_remote(
                remote_enabled, saved.remote_url, remote_key,
                saved.remote_tls_mode, saved.remote_cert_pem)
            self.enhanceSourceChanged.emit(saved.enhance_source)
            if remote_enabled and self._loop is not None:
                self._track(self._transcription_service.refresh_remote(),
                            "Remote status refresh")

        # Hot-reload models if whisper model or compute device changed
        model_changed = old_model != whisper_model
        device_changed = old_device != compute_device
        # Turning remote OFF with no local engine loaded needs a local load.
        remote_turned_off = old_remote_enabled and not remote_enabled
        needs_local_load = (
            remote_turned_off
            and hasattr(self._transcription_service, "live_capable")
            and not self._transcription_service.live_capable
        )
        if model_changed or device_changed or needs_local_load:
            logger.info(
                "Model/device/remote change — reloading local engine "
                "(model: {} -> {}, device: {} -> {}, remote_off: {})",
                old_model, whisper_model, old_device, compute_device, remote_turned_off,
            )
            if self._loop is not None:
                self._track(
                    self._reload_models(whisper_model, compute_device,
                                        model_changed or needs_local_load),
                    "Model reload")
        else:
            self._transcription_service.set_compute_device(compute_device)
            logger.info("Settings saved and applied")

    async def _reload_models(
        self, model_name: str, compute_device: str, model_changed: bool
    ) -> None:
        """Hot-reload transcription model after settings change."""
        try:
            self.set_app_state(AppState.LOADING)
            self._signals.download_status.emit(t("status_switching_model"))

            # Download whisper model if it changed and isn't available locally
            if model_changed:
                from pathlib import Path
                from paths import data_dir

                model_path = data_dir() / "models" / "whisper" / model_name
                if not model_path.exists():
                    logger.info("Model {} not found locally, downloading...", model_name)
                    self._signals.download_status.emit(t("status_downloading_engine"))
                    self.set_is_downloading(True)
                    try:
                        await self._model_manager.download_whisper(model_name)
                    finally:
                        self.set_is_downloading(False)

            # Stop transcription service
            await self._transcription_service.stop()

            # Apply new model and device settings
            self._transcription_service.set_model(model_name)
            self._transcription_service.set_compute_device(compute_device)

            # Restart transcription service
            self._signals.download_status.emit(t("status_loading"))
            await self._transcription_service.start()

            self._signals.download_status.emit("")
            self.set_app_state(AppState.IDLE)
            # The effective compute device may have changed (GPU⇄CPU) — refresh
            # the live-mode hint binding in Settings.
            self.liveModeOnGpuChanged.emit()
            logger.info("Model hot-reload complete")

        except Exception as e:
            logger.error("Model hot-reload failed: {}", e)
            self._signals.download_status.emit("")
            self.set_is_downloading(False)
            self._signals.error_occurred.emit(t("err_model_switch"))
            self.set_app_state(AppState.ERROR)

    @Slot()
    def resetSettings(self) -> None:
        from services.settings_service import AppSettings
        defaults = AppSettings()
        # Apply all default values to the current settings
        self._settings_service.update(**defaults.model_dump())
        # Live QML bindings (panel AI button, mode menu) only re-evaluate on
        # their notify signals — emit for the notify-backed values we changed.
        self.llmEnabledChanged.emit(defaults.llm_enabled)
        self.llmActiveModeChanged.emit(defaults.llm_active_mode)
        # Reset also revokes the one-time meeting-recording consent — notify
        # QML so the consent gate state stays in sync.
        self.meetingConsentAckChanged.emit(defaults.meeting_consent_ack)
        # Interface language falls back to auto (OS language) — apply live so
        # both Python t() calls and QML tr bindings reset with everything else.
        new_lang = i18n.resolve_language(defaults.ui_language)
        if new_lang != i18n.current_language():
            i18n.set_language(new_lang)
        self.trChanged.emit()
        logger.info("Settings reset to defaults")

    @Slot(str)
    def setFirstRunLanguage(self, lang_code: str) -> None:
        """Set language during first-run setup (before model download)."""
        self._settings_service.update(language=lang_code if lang_code else None)
        self._transcription_service.set_language(lang_code if lang_code else None)
        logger.info("First-run language set to: {}", lang_code or "auto")

    @Slot(str)
    def startHotkeyRecording(self, current: str) -> None:
        """Signal QML that hotkey recording mode is active."""
        logger.debug("Hotkey recording started")

    @Slot(int)
    def setAudioDevice(self, index: int) -> None:
        self._audio_service.set_device(index if index >= 0 else None)

    # ─── File conversion slots ─────────────────────────────────────────────

    @Slot(list)
    def convertPaths(self, urls: list) -> None:
        """Convert files dropped onto the panel. Drag-drop always asks where
        to save (force_ask=True)."""
        from pathlib import Path
        from PySide6.QtCore import QUrl

        paths: list[Path] = []
        for u in urls:
            local = QUrl(u).toLocalFile() if not isinstance(u, str) else u
            if local.startswith("file:"):
                local = QUrl(local).toLocalFile()
            if local:
                paths.append(Path(local))
        self.start_conversion(paths, force_ask=True)

    def convert_external(self, path_str: str) -> None:
        """Entry point for IPC/CLI '--convert' requests. Respects the
        configured output mode."""
        from pathlib import Path
        try:
            p = Path(path_str).resolve()
        except (OSError, ValueError) as e:
            logger.warning("convert_external: invalid path {!r}: {}", path_str, e)
            return
        if not p.is_file():
            logger.warning("convert_external: not a file: {}", p)
            self._signals.tray_message.emit("VoxCast", t("tray_file_not_found"))
            return
        self.start_conversion([p], force_ask=False)

    def start_conversion(self, paths: "list[Path]", force_ask: bool) -> None:
        from pathlib import Path
        from services.conversion_service import ConversionService

        valid = [Path(p) for p in paths
                 if Path(p).is_file() and ConversionService.is_supported(Path(p))]
        if not valid:
            self._signals.tray_message.emit("VoxCast", t("tray_no_supported_files"))
            return

        outputs = self._resolve_outputs(valid, force_ask)
        if not outputs:
            return  # user cancelled the dialog

        if self._loop is None:
            logger.warning("start_conversion called before event loop is ready")
            self._signals.tray_message.emit("VoxCast", t("tray_still_starting"))
            return
        self._track(self._run_conversions(outputs), "Conversion batch")

    def _resolve_outputs(
        self, paths: "list[Path]", force_ask: bool
    ) -> "list[tuple[Path, Path]] | None":
        """Return list of (input, output) Path pairs, or None if cancelled."""
        from pathlib import Path
        from PySide6.QtWidgets import QFileDialog

        mode = "ask"
        if self._settings_service.is_ready:
            mode = self._settings_service.get().convert_output_mode
        if force_ask:
            mode = "ask"

        if mode == "beside":
            return [(p, p.with_suffix(".md")) for p in paths]

        if mode == "fixed_folder":
            target = None
            if self._settings_service.is_ready:
                target = self._settings_service.get().convert_output_dir
            if target and Path(target).is_dir():
                return [(p, Path(target) / (p.stem + ".md")) for p in paths]
            # No valid folder configured — inform the user, then ask manually.
            self._signals.tray_message.emit(
                "VoxCast", t("tray_no_output_folder"),
            )

        # mode == "ask" (or fallback)
        if len(paths) == 1:
            src = paths[0]
            start = str(src.with_suffix(".md"))
            out, _ = QFileDialog.getSaveFileName(
                None, "Save Markdown as", start, "Markdown (*.md)",
            )
            if not out:
                return None
            return [(src, Path(out))]

        folder = QFileDialog.getExistingDirectory(None, "Choose output folder")
        if not folder:
            return None
        return [(p, Path(folder) / (p.stem + ".md")) for p in paths]

    async def _run_conversions(self, outputs: "list[tuple[Path, Path]]") -> None:
        from services.types import AppState

        settings = self._settings_service.get() if self._settings_service.is_ready else None
        language = None
        timestamps = False
        open_after = False
        if settings is not None:
            # Only an explicit convert language forces one. The dictation
            # language deliberately does NOT apply here: converted files
            # (podcasts, meeting recordings, memos) are routinely in another
            # language, so the default is per-segment auto-detection — the
            # same approach meetings use.
            language = settings.convert_language or None
            timestamps = settings.convert_timestamps
            open_after = settings.convert_open_after

        from services.conversion_service import EngineNotReadyError

        self.set_app_state(AppState.CONVERTING)
        ok = 0
        last_out = None
        for input_path, output_path in outputs:
            try:
                result = await self._conversion_service.convert_file(
                    input_path, output_path,
                    language=language, with_timestamps=timestamps,
                )
                ok += 1
                last_out = result
            except EngineNotReadyError as e:
                # The engine did not come up within the wait budget — every
                # remaining file would wait the full budget again. Abort the
                # whole batch with one message instead.
                logger.error("Conversion batch aborted, engine not ready: {}", e)
                self._signals.tray_message.emit("VoxCast", str(e))
                break
            except Exception as e:
                logger.error("Conversion failed for {}: {}", input_path, e)
                self._signals.tray_message.emit(
                    "VoxCast", t("tray_convert_failed", name=input_path.name, error=e),
                )

        # A model reload may have taken over the app state (LOADING/ERROR)
        # while we were converting — only reclaim it if it is still ours.
        if self._app_state == AppState.CONVERTING:
            self.set_app_state(AppState.IDLE)

        if ok == 1 and last_out is not None:
            self._signals.tray_message.emit("VoxCast", t("tray_saved", name=last_out.name))
            if open_after:
                self._open_path(last_out)
        elif ok > 1:
            self._signals.tray_message.emit("VoxCast", t("tray_converted", n=ok))

    @staticmethod
    def _open_path(path) -> None:
        import os
        try:
            os.startfile(str(path))  # noqa: S606 (intended: open result in default app)
        except Exception as e:
            logger.warning("Could not open {}: {}", path, e)

    @Slot(result=str)
    def browseConvertDir(self) -> str:
        from PySide6.QtWidgets import QFileDialog
        current = ""
        if self._settings_service.is_ready:
            current = self._settings_service.get().convert_output_dir or ""
        folder = QFileDialog.getExistingDirectory(None, "Choose output folder", current)
        return folder or current

    @Slot(result=str)
    def browseMeetingDir(self) -> str:
        from PySide6.QtWidgets import QFileDialog
        current = ""
        if self._settings_service.is_ready:
            current = self._settings_service.get().meeting_output_dir or ""
        folder = QFileDialog.getExistingDirectory(None, "Choose meetings folder", current)
        return folder or current

    def _apply_context_menu(self, enabled: bool) -> None:
        try:
            from utils import shell_integration
            if enabled:
                shell_integration.register()
            else:
                shell_integration.unregister()
        except Exception as e:
            logger.error("Failed to update context menu integration: {}", e)
            self._signals.tray_message.emit(
                "VoxCast", t("tray_context_menu_failed"),
            )

    # ─── AI post-processing (LLM) slots ────────────────────────────────────

    @Slot(str)
    def setLlmActiveMode(self, mode_id: str) -> None:
        """Switch the active AI mode (from the panel menu / AI button)."""
        if not self._settings_service.is_ready:
            return
        s = self._settings_service.get()
        valid = {"normal", *(m.get("id") for m in s.llm_modes),
                 *(p.get("id") for p in self._server_prompts)}
        if mode_id not in valid:
            return
        update = {"llm_active_mode": mode_id}
        # Quick-switch UX: picking a real mode from the panel implies "use AI"
        # — enable enhancement automatically when an API key is configured.
        if mode_id != "normal" and not s.llm_enabled and self.llmHasKeyFor(s.llm_provider):
            update["llm_enabled"] = True
        self._settings_service.update(**update)
        self.llmActiveModeChanged.emit(mode_id)
        if update.get("llm_enabled"):
            self.llmEnabledChanged.emit(True)
        elif mode_id != "normal" and not self.llmHasKeyFor(s.llm_provider):
            # Without a key the mode would silently do nothing — tell the user
            # instead of letting them believe the dictation was enhanced.
            self._signals.tray_message.emit("VoxCast", t("llm_mode_needs_key"))

    @Slot(str, str)
    def setLlmApiKey(self, provider: str, key: str) -> None:
        from services import credentials
        provider = provider if provider in ("anthropic", "openai") else "anthropic"
        if key.strip():
            credentials.set_secret(f"llm_{provider}", key.strip())
        self.llmHasKeyChanged.emit(True)

    @Slot(str, result=bool)
    def llmHasKeyFor(self, provider: str) -> bool:
        """Read-only: whether an API key is stored for the given provider.
        Lets Settings show the correct key status while the user switches the
        provider radio before saving (llmHasKey reflects the *saved* provider)."""
        from services import credentials
        provider = provider if provider in ("anthropic", "openai") else "anthropic"
        return credentials.has_secret(f"llm_{provider}")

    @Slot(str)
    def clearLlmApiKey(self, provider: str) -> None:
        from services import credentials
        credentials.delete_secret(f"llm_{provider}")
        self.llmHasKeyChanged.emit(False)
        self._llm_available_models = []
        self.llmAvailableModelsChanged.emit([])

    @Slot(str)
    def loadLlmModels(self, provider: str) -> None:
        """Fetch the provider's model list using the stored key (async)."""
        if self._loop is None:
            return
        self._track(self._load_llm_models(provider), "LLM model list")

    async def _load_llm_models(self, provider: str) -> None:
        from services import credentials
        from services.llm_service import LLMError

        provider = provider if provider in ("anthropic", "openai") else "anthropic"
        key = credentials.get_secret(f"llm_{provider}")
        if not key:
            self._signals.tray_message.emit("VoxCast", t("tray_need_api_key"))
            return
        self._set_llm_models_loading(True)
        try:
            models = await self._llm_service.list_models(provider, key)
            self._llm_available_models = models
            self.llmAvailableModelsChanged.emit(models)
        except LLMError as e:
            self._signals.tray_message.emit("VoxCast", f"Could not load models: {e}")
        except Exception as e:
            logger.error("loadLlmModels failed: {}", e)
            self._signals.tray_message.emit("VoxCast", t("tray_models_load_failed"))
        finally:
            self._set_llm_models_loading(False)

    @Slot(str)
    def saveLlmSettings(self, settings_json: str) -> None:
        if not self._settings_service.is_ready:
            return
        import orjson
        try:
            s = orjson.loads(settings_json)
        except Exception as e:
            logger.error("Failed to parse LLM settings JSON: {}", e)
            return
        modes = s.get("llm_modes")
        update: dict = {
            "llm_enabled": bool(s.get("llm_enabled", False)),
            "llm_provider": s.get("llm_provider", "anthropic"),
            "llm_model": s.get("llm_model") or None,
            "llm_active_mode": s.get("llm_active_mode", "normal"),
        }
        if isinstance(modes, list):
            update["llm_modes"] = modes
        self._settings_service.update(**update)
        self.llmActiveModeChanged.emit(update["llm_active_mode"])
        self.llmHasKeyChanged.emit(self.llmHasKey)
        self.llmEnabledChanged.emit(update["llm_enabled"])
        # llmModes binds notify=trChanged (shared with the i18n table) — emit
        # so ModeMenu/Settings combos pick up added/renamed/deleted modes
        # without a language switch or restart.
        self.trChanged.emit()
        logger.info("LLM settings saved (enabled={}, provider={}, mode={})",
                    update["llm_enabled"], update["llm_provider"], update["llm_active_mode"])

    # ─── License / activation slots ────────────────────────────────────────

    def set_license_activated(self, value: bool) -> None:
        if self._license_activated != value:
            self._license_activated = value
            self.licenseActivatedChanged.emit(value)
            if self._update_prompt_pending:
                self._maybe_show_update_prompt()

    def show_activation(self, value: bool) -> None:
        if self._activation_visible != value:
            self._activation_visible = value
            self.activationVisibleChanged.emit(value)
            if self._update_prompt_pending:
                self._maybe_show_update_prompt()
        elif value:
            # Already visible but requested again (e.g. dictation attempt while
            # unactivated): re-emit so QML re-raises the window. It is no longer
            # always-on-top and may sit behind other windows.
            self.activationVisibleChanged.emit(value)

    @Slot(str)
    def activateLicense(self, license_key: str) -> None:
        """Activate online with a license key (from the activation overlay)."""
        if self._loop is None or self._license_busy:
            return
        # Set the guard synchronously — a double-click must not schedule twice.
        self._set_license_busy(True)
        self._track(self._activate(license_key), "Activation")

    async def _activate(self, license_key: str) -> None:
        try:
            ok, msg = await self._license_service.activate(license_key)
        except Exception as e:  # defensive: never let the gate hang
            logger.error("activateLicense failed: {}", e)
            ok, msg = False, t("lic_unexpected")
        finally:
            self._set_license_busy(False)
        self.licenseActivationResult.emit(ok, msg)
        if ok:
            # set_license_activated emits licenseActivatedChanged, which also
            # refreshes the licenseKeyMasked binding (shared notify signal).
            self.set_license_activated(True)
            self.show_activation(False)
            if self.activation_event is not None:
                self.activation_event.set()

    def _set_license_busy(self, value: bool) -> None:
        if self._license_busy != value:
            self._license_busy = value
            self.licenseBusyChanged.emit(value)

    @Slot()
    def openActivation(self) -> None:
        """Show the activation overlay (from Settings, when not activated)."""
        self.show_activation(True)

    @Slot()
    def quitApp(self) -> None:
        """Quit from the activation gate (user declines to activate)."""
        from PySide6.QtWidgets import QApplication
        app = QApplication.instance()
        if app is not None:
            app.quit()

    # ─── Update slots ──────────────────────────────────────────────────────

    async def check_updates_startup(self) -> bool:
        """Awaited pre-gate update check. Never raises — a failed check must
        not block startup (offline users, server down). Returns whether the
        check reached the server, so the update watcher can pace its retries."""
        try:
            return await self._check_updates(manual=False)
        except Exception as e:
            logger.info("Startup update check failed: {}", e)
            return False

    async def run_update_watch(self, startup_ok: bool) -> None:
        """Lifetime update watcher for the long-running tray app: re-checks a
        few times per day, and retries quickly with backoff after a failed
        check (an autostarted app often races the network coming up). Never
        raises — one bad iteration must not end update checking for good."""
        from services.update_service import next_check_delay
        failures = 0 if startup_ok else 1
        while True:
            await asyncio.sleep(next_check_delay(failures == 0, failures))
            try:
                ok = await self._check_updates(manual=False)
            except Exception as e:
                logger.error("Update watch iteration failed: {}", e)
                ok = False
            failures = 0 if ok else failures + 1

    @Slot()
    def checkForUpdates(self) -> None:
        if self._loop is not None:
            self._track(self._check_updates(manual=True), "Manual update check")

    async def _check_updates(self, manual: bool) -> bool:
        """Run one update check. Returns True when the server answered (with
        or without an update), False when the check itself failed."""
        from services.update_service import UpdateError
        self._set_update_checking(True)
        if manual:
            self._set_update_status(t("upd_checking"))
        try:
            info = await self._update_service.check()
        except UpdateError as e:
            if manual:
                self._set_update_status(str(e))
            else:
                logger.info("Silent update check failed: {}", e)
            return False
        finally:
            self._set_update_checking(False)

        if info is None:
            if manual:
                self._set_update_status(t("upd_latest"))
            self._set_update_available(False)
            return True
        self._update_version = info["version"]
        self._update_notes = info["notes"]
        self._update_mandatory = info["mandatory"]
        self._update_url = info["url"]
        self._update_sha256 = info["sha256"]
        self._update_is_delta = bool(info.get("delta"))
        self.updateInfoChanged.emit()
        self._set_update_available(True)
        self._set_update_status(t("upd_available", version=info["version"]))
        # Surface the changelog dialog. A manual Settings check always shows
        # it (the user explicitly asked); automatic checks (startup + watcher)
        # prompt once per discovered version for activated users. Not-yet-
        # activated users get the update via the activation overlay instead,
        # so don't double up.
        if manual:
            self._update_prompt_pending = False
            self._update_prompt_shown_version = self._update_version
            self.updatePromptRequested.emit()
        else:
            self._maybe_show_update_prompt()
        return True

    def _maybe_show_update_prompt(self) -> None:
        """Open the modal changelog dialog for an available update, but never
        interrupt active work: while dictating, transcribing, converting,
        meeting-recording or still inside the activation gate the prompt is
        deferred and re-tried on every relevant state change."""
        from services.update_service import prompt_decision
        decision = prompt_decision(
            update_available=self._update_available,
            license_activated=self._license_activated,
            activation_visible=self._activation_visible,
            app_state=self._app_state,
            meeting_state=self._meeting_state,
            version=self._update_version,
            shown_version=self._update_prompt_shown_version,
        )
        if decision != "show":
            self._update_prompt_pending = decision == "defer"
            return
        self._update_prompt_pending = False
        self._update_prompt_shown_version = self._update_version
        self.updatePromptRequested.emit()

    @Slot()
    def downloadAndInstallUpdate(self) -> None:
        if self._loop is None or self._update_downloading:
            return
        if not getattr(self, "_update_url", ""):
            return
        # Set the guard synchronously — a double-click must not schedule twice.
        self._set_update_downloading(True)
        self._track(self._download_install(), "Update download")

    async def _download_install(self) -> None:
        from services.update_service import UpdateError
        self._set_update_progress(0.0)
        self._set_update_status(t("upd_downloading", version=self._update_version))
        try:
            path = await self._update_service.download(
                self._update_url, self._update_sha256, self._on_download_progress_pct,
            )
        except UpdateError as e:
            self._set_update_status(str(e))
            self._set_update_downloading(False)
            return
        except Exception as e:
            logger.error("Update download failed: {}", e)
            self._set_update_status(t("upd_failed"))
            self._set_update_downloading(False)
            return
        self._set_update_status(t("upd_starting_installer"))
        if self._update_is_delta:
            # The app-only package can be refused by the installer's runtime
            # gate. We are about to quit, so leave a breadcrumb: if the next
            # launch still runs this version, the next check takes the full
            # installer instead of looping on a delta that cannot apply.
            from services.update_service import mark_delta_attempt
            mark_delta_attempt(self._update_version)
        try:
            self._update_service.launch_installer(path)
        except Exception as e:
            logger.error("Failed to launch installer: {}", e)
            self._set_update_status(t("upd_installer_failed"))
            self._set_update_downloading(False)
            from services.update_service import clear_delta_marker
            clear_delta_marker()  # installer never ran; keep the delta option
            return
        # Quit so the installer can replace the running files.
        from PySide6.QtWidgets import QApplication
        app = QApplication.instance()
        if app is not None:
            app.quit()
        else:
            self._set_update_downloading(False)

    def _on_download_progress_pct(self, fraction: float) -> None:
        self._set_update_progress(fraction)

    def _set_update_available(self, value: bool) -> None:
        if self._update_available != value:
            self._update_available = value
            self.updateAvailableChanged.emit(value)

    def _set_update_checking(self, value: bool) -> None:
        if self._update_checking != value:
            self._update_checking = value
            self.updateCheckingChanged.emit(value)

    def _set_update_downloading(self, value: bool) -> None:
        if self._update_downloading != value:
            self._update_downloading = value
            self.updateDownloadingChanged.emit(value)

    def _set_update_progress(self, value: float) -> None:
        self._update_progress = value
        self.updateProgressChanged.emit(value)

    def _set_update_status(self, value: str) -> None:
        if self._update_status != value:
            self._update_status = value
            self.updateStatusChanged.emit(value)

    # ─── Mic test slots ───────────────────────────────────────────────────

    def _set_mic_test_state(self, state: str) -> None:
        if self._mic_test_state != state:
            self._mic_test_state = state
            self.micTestStateChanged.emit(state)
            self._signals.mic_test_state_changed.emit(state)

    @Slot(int)
    def startMicTest(self, device_index: int) -> None:
        """Start a mic test recording with the given device."""
        # If already running, stop first
        if self._mic_test_state != "idle":
            self.stopMicTest()
            return
        # The state flips to idle immediately on stop, but the cancelled task
        # may still be tearing its stream down in an executor — never start a
        # second recording while that teardown is in flight.
        if self._mic_test_task is not None and not self._mic_test_task.done():
            return
        # A dictation may still be recording (toggle mode keeps running while
        # Settings is open). Taking the device from under it would stop the
        # stream and swallow the user's speech, so refuse instead.
        if self._audio_service.is_recording:
            logger.info("Mic test refused: a recording is already in progress")
            self._signals.error_occurred.emit(t("err_mic_test_busy"))
            return
        if self._loop is None:
            return
        self._set_mic_test_task(self._loop.create_task(self._run_mic_test(device_index)))

    def _set_mic_test_task(self, task: asyncio.Task) -> None:
        """Own the mic-test task and always disarm the stop guard when it ends.

        Tying the reset to task completion (instead of one coroutine's finally)
        keeps the guard correct for BOTH the recording and the playback task —
        otherwise a stop during playback would leave it armed and silently
        swallow every later stop.
        """
        self._mic_test_task = task
        task.add_done_callback(self._on_mic_test_task_done)

    def _on_mic_test_task_done(self, _task: asyncio.Task) -> None:
        self._mic_test_stopping = False

    @Slot()
    def stopMicTest(self) -> None:
        """Stop recording or playback (idempotent)."""
        task = self._mic_test_task
        if task is not None and not task.done():
            if self._mic_test_stopping:
                # Cleanup is already running: a second cancel() would abort the
                # teardown mid-flight and throw the partial recording away.
                return
            self._mic_test_stopping = True
            # The task's CancelledError handler tears the stream down and
            # saves the partial recording so it can be replayed.
            task.cancel()
        elif self._audio_service.is_recording and self._audio_service.is_test_mode:
            # Safety net if no task owns the stream anymore.
            self._audio_service.stop_recording()
        # Stop any sounddevice playback that may be running in an executor thread
        try:
            import sounddevice as sd
            sd.stop()
        except Exception as e:
            logger.debug("sounddevice stop failed: {}", e)
        self._set_mic_test_state("idle")

    async def _stop_test_recording(self) -> "np.ndarray | None":
        """Stop the test stream without ever hanging the mic test.

        PortAudio teardown holds a lock and has been seen to block on flaky
        devices. `shield` keeps a stray cancel from skipping the WAV save,
        `wait_for` guarantees the owning task always completes, so the
        task-done guards in start/playMicTest can never wedge permanently.
        """
        fut = asyncio.ensure_future(self._audio_service.async_stop_recording())
        try:
            return await asyncio.wait_for(asyncio.shield(fut), timeout=_MIC_TEST_STOP_TIMEOUT)
        except (asyncio.TimeoutError, asyncio.CancelledError):
            logger.warning("Mic test: audio teardown did not finish in time")
            return None

    @Slot()
    def playMicTest(self) -> None:
        """Replay the last test recording."""
        import tempfile
        import os
        # Never overwrite a live recording/teardown task — losing its reference
        # would orphan the open input stream (Stop would cancel the wrong task).
        if self._mic_test_state != "idle":
            return
        if self._mic_test_task is not None and not self._mic_test_task.done():
            return
        wav_path = os.path.join(tempfile.gettempdir(), "voxcast_mic_test.wav")
        if not os.path.exists(wav_path):
            return
        if self._loop is None:
            return
        self._set_mic_test_state("playing")
        self._set_mic_test_task(self._loop.create_task(self._play_test_wav(wav_path)))

    async def _run_mic_test(self, device_index: int) -> None:
        """Record up to 5s from the selected device, save WAV, auto-play."""
        import contextlib
        import tempfile
        import os

        # Save and set the test device
        original_device = self._audio_service.current_device_index
        self._audio_service.set_device(device_index if device_index >= 0 else None)

        loop = asyncio.get_running_loop()
        start_fut: asyncio.Future | None = None
        try:
            self._set_mic_test_state("recording")
            # Opening the input stream can block for seconds on the first use
            # of a device (driver/endpoint init) — keep it off the GUI event
            # loop. shield() so a Stop click cannot orphan the open: the
            # CancelledError path below waits for it before tearing down.
            start_fut = loop.run_in_executor(
                None, lambda: self._audio_service.start_recording(test_mode=True))
            await asyncio.shield(start_fut)

            if not self._audio_service.is_recording:
                self._set_mic_test_state("idle")
                return

            # Wait up to 5 seconds
            await asyncio.sleep(5.0)

            # Stop and get audio (stream teardown can block briefly too)
            audio = await self._stop_test_recording()

            if audio is not None and len(audio) > 0:
                wav_path = os.path.join(tempfile.gettempdir(), "voxcast_mic_test.wav")
                from services.audio_service import AudioService
                AudioService.save_test_wav(audio, wav_path)
                self._mic_test_has_recording = True
                self.micTestHasRecordingChanged.emit(True)

                # Auto-play
                self._set_mic_test_state("playing")
                await self._play_test_wav_inner(wav_path)
                self._set_mic_test_state("idle")
            else:
                self._set_mic_test_state("idle")

        except asyncio.CancelledError:
            # User clicked stop during recording — wait out an in-flight
            # stream open, then save the partial audio for playback.
            if start_fut is not None and not start_fut.done():
                with contextlib.suppress(Exception):
                    await asyncio.shield(start_fut)
            if self._audio_service.is_recording:
                # Stream teardown holds the PortAudio lock and can block for
                # seconds on slow devices — keep it off the GUI event loop
                # here too (awaiting during cancellation cleanup is fine, the
                # CancelledError has already been delivered), and shield the
                # save from a stray second cancel.
                audio = await self._stop_test_recording()
                if audio is not None and len(audio) > 0:
                    wav_path = os.path.join(tempfile.gettempdir(), "voxcast_mic_test.wav")
                    from services.audio_service import AudioService
                    AudioService.save_test_wav(audio, wav_path)
                    self._mic_test_has_recording = True
                    self.micTestHasRecordingChanged.emit(True)
            self._set_mic_test_state("idle")
        except Exception as e:
            logger.error("Mic test error: {}", e)
            self._set_mic_test_state("idle")
        finally:
            # Restore original device. The stop guard is disarmed by the
            # task's done-callback (_set_mic_test_task), which also covers
            # the playback task.
            self._audio_service.set_device(original_device)

    async def _play_test_wav(self, wav_path: str) -> None:
        """Play a test WAV and return to idle when done."""
        try:
            await self._play_test_wav_inner(wav_path)
        except asyncio.CancelledError:
            pass
        except Exception as e:
            logger.error("Mic test playback error: {}", e)
        finally:
            self._set_mic_test_state("idle")

    async def _play_test_wav_inner(self, wav_path: str) -> None:
        """Play a WAV file and wait for it to finish (runs in executor)."""
        loop = asyncio.get_running_loop()
        await loop.run_in_executor(None, self._play_wav_blocking, wav_path)

    @staticmethod
    def _play_wav_blocking(path: str) -> None:
        """Blocking WAV playback using sounddevice."""
        from services.audio_service import AudioService
        AudioService.play_sound_blocking(path)

    # ─── Internal signal handlers ──────────────────────────────────────────

    def _on_state_changed(self, state: str) -> None:
        self.set_app_state(state)

    def _on_download_progress(self, progress: float) -> None:
        if self._download_progress != progress:
            self._download_progress = progress
            self.downloadProgressChanged.emit(progress)

    def _on_download_status(self, status: str) -> None:
        if self._download_status != status:
            self._download_status = status
            self.downloadStatusChanged.emit(status)

    def _on_audio_level(self, level: float) -> None:
        import math
        import time
        now = time.monotonic()
        if now - self._last_level_time < 0.016:
            return  # ~60 fps max
        self._last_level_time = now
        normalized = min(math.sqrt(max(level, 0.0) * 40.0), 1.0)
        self._audio_level = normalized
        self.audioLevelChanged.emit(normalized)

    def _on_devices_changed(self, devices: list) -> None:
        self.set_available_devices(devices)
