"""MuraveyChina — main entry point.

Architecture:
  - TrayIcon runs blocking on the main thread
  - Hotkey callback fires in a background thread
  - AI streaming runs in a background thread, updates overlay via signals
  - TypingEngine types/pastes response after streaming completes
"""

from __future__ import annotations

import logging
import os
import sys
import threading
import time
from collections.abc import Callable

from .config import Config
from .i18n import _, setup_i18n

log = logging.getLogger(__name__)


def _setup_logging(config: Config) -> None:
    """Configure file + console logging."""
    log_dir = config.log_dir
    log_dir.mkdir(parents=True, exist_ok=True)
    log_file = log_dir / "muravey_china.log"

    handlers: list[logging.Handler] = [
        logging.StreamHandler(sys.stdout),
        logging.FileHandler(log_file, encoding="utf-8"),
    ]

    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
        handlers=handlers,
    )
    log.debug("Logging to %s", log_file)


def _get_overlay(config: Config, on_insert, on_cancel=None):
    """Return platform-specific overlay."""
    if sys.platform == "win32":
        # PyQt6 overlay — needs QApplication
        from .ui.overlay_win import OverlayWindow

        return OverlayWindow(on_insert=on_insert, on_cancel=on_cancel)
    elif sys.platform == "darwin":
        from .ui.overlay_mac import OverlayWindow

        return OverlayWindow(on_insert=on_insert, on_cancel=on_cancel)
    else:
        raise RuntimeError(f"Unsupported platform: {sys.platform}")


def _get_settings_dialog(
    config: Config,
    on_hotkey_change,
    on_check_update,
    on_clear_cache=None,
    on_language_change=None,
    on_theme_change=None,
    on_prompts_hotkey_change=None,
    on_vision_hotkey_change=None,
):
    """Return platform-specific settings dialog/window."""
    if sys.platform == "win32":
        from .ui.settings_win import SettingsDialog

        return SettingsDialog(
            config,
            on_hotkey_change=on_hotkey_change,
            on_check_update=on_check_update,
            on_clear_cache=on_clear_cache,
            on_language_change=on_language_change,
            on_theme_change=on_theme_change,
            on_prompts_hotkey_change=on_prompts_hotkey_change,
            on_vision_hotkey_change=on_vision_hotkey_change,
        )
    elif sys.platform == "darwin":
        from .ui.settings_mac import SettingsWindow

        return SettingsWindow(
            config,
            on_hotkey_change=on_hotkey_change,
            on_check_update=on_check_update,
            on_language_change=on_language_change,
            on_theme_change=on_theme_change,
        )
    else:
        raise RuntimeError(f"Unsupported platform: {sys.platform}")


class AppContext:
    """Central application context — owns all components."""

    def __init__(self) -> None:
        self.config = Config.load()
        _setup_logging(self.config)

        # Windows: warn if not running as Administrator
        if sys.platform == "win32":
            try:
                import ctypes
                if not ctypes.windll.shell32.IsUserAnAdmin():
                    log.warning(
                        "\u26a0 Not running as Administrator — CSCA network bypass requires admin rights. "
                        "Right-click cmd \u2192 'Run as administrator'"
                    )
            except Exception:
                pass

        # Initialise UI locale before any UI components are created
        setup_i18n(self.config.locale)

        # Initialise theme manager (must run before any UI is created)
        from .ui.theme_manager import ThemeManager

        self.theme_mgr = ThemeManager.setup(self.config)
        self.theme_mgr.add_callback(self._on_theme_change)

        log.info(
            "MuraveyChina starting (platform=%s, locale=%s, theme=%s → %s)",
            sys.platform,
            self.config.locale,
            self.config.theme,
            self.theme_mgr.get_current_theme(),
        )

        # GFW detection — DISABLED (CPU-only mode, no network)
        # try:
        #     from .utils.gfw_detect import probe_gfw_async
        #     probe_gfw_async(...)
        # except Exception:
        #     pass

        from .hotkey.manager import get_hotkey_manager
        from .typing.engine import TypingEngine

        # License V2 — DISABLED (CPU-only, no network validation)
        self._license_v2_available = False

        # Stats manager (optional — disabled if stats.enabled=false)
        self.stats_mgr = None
        if self.config.stats_enabled:
            from .stats.stats_manager import StatsManager

            self.stats_mgr = StatsManager(self.config.stats_db_path)
            log.debug("Stats enabled: %s", self.config.stats_db_path)

        # Cache manager (optional — disabled if cache.enabled=false)
        self.cache_mgr = None
        if self.config.cache_enabled:
            from .cache.cache_manager import AICache

            self.cache_mgr = AICache(
                self.config.cache_db_path,
                ttl_seconds=self.config.cache_ttl_seconds,
            )
            log.info(
                "Cache enabled: %s (TTL=%ds)",
                self.config.cache_db_path,
                self.config.cache_ttl_seconds,
            )

        # Exam software auto-detection: enable stealth mode if SEB/CSCA/etc. is running
        try:
            from .seb.detection import is_exam_running

            extra = self.config.stealth_auto_detect_processes
            if not self.config.stealth_enabled and is_exam_running(
                extra or None,
                ttl=self.config.stealth_detection_ttl_seconds,
            ):
                log.info("Exam software detected — auto-enabling stealth mode")
                self.config.stealth_enabled = True
        except Exception as exc:
            log.debug("Exam software detection skipped: %s", exc)

        # Auto-select providers: keep online providers active (with proxy/tunnel)
        # even when CSCA is running — proxy bypasses network restrictions.
        _stealth = self._stealth = self.config.stealth_enabled
        self._csca_protections_active = False

        if _stealth:
            # CSCA running — keep online providers, proxy/tunnel handles network
            log.info("Stealth mode: keeping online providers (proxy/tunnel active)")
        else:
            pass  # Config already has online providers from defaults

        _vision_provider = self.config.vision_provider
        _vision_model = self.config.vision_model
        _text_model = self.config.get("answer_window.text_model", "deepseek-chat")

        # ── CPU-only mode: no online AI, no proxy, no network ──
        log.debug("CPU-only mode: no deepseek, no aliyun, no proxy, no localhost.")
        log.debug("Only LlamaCpp (CPU) + SymPy.")

        if _stealth:
            log.info("Stealth mode enabled")

        # ChinaAdapter — DISABLED (no network)
        self.adapter = None

        # Proxy/tunnel/network — ALL DISABLED

        self.hotkey_mgr = get_hotkey_manager(stealth=_stealth)
        # Connect CSCA open/close callbacks
        if hasattr(self.hotkey_mgr, '_on_csca_open'):
            self.hotkey_mgr._on_csca_open = self._on_csca_open
            self.hotkey_mgr._on_csca_close = self._on_csca_close
        self.typing_engine = TypingEngine(
            auto_paste=self.config.auto_paste,
            delay_ms=int(self.config.stealth_typing_speed_ms) if _stealth else 30,
            stealth=_stealth,
            biometric=True if _stealth else False,
            typo_rate=self.config.stealth_typo_rate if _stealth else 0.0,
            variation_ms=int(self.config.stealth_typing_variation_ms),
        )

        if _stealth:
            # PEB masquerade: hide from ProcessChecker / Task Manager
            if sys.platform == "win32" and self.config.get(
                "stealth.process_masquerade", True
            ):
                try:
                    from .seb.process_stealth import (
                        apply_process_stealth,
                        start_peb_watchdog,
                    )

                    name = self.config.get(
                        "stealth.masquerade_name", "RuntimeBroker.exe"
                    )
                    if apply_process_stealth(name):
                        log.info("Process masquerade active: %s", name)
                        start_peb_watchdog(masquerade_name=name, interval=3.0)
                    else:
                        log.warning("Process masquerade failed")
                except Exception as exc:
                    log.debug("Process masquerade skipped: %s", exc)

            # Watchdog: auto-restart if CSCA kills the process
            if sys.platform == "win32":
                try:
                    from .seb.watchdog import start_watchdog

                    start_watchdog()
                except Exception as exc:
                    log.debug("Watchdog start skipped: %s", exc)

            # Anti-debug watchdog: detect and counter debugger attachment
            try:
                from .seb.anti_debug import start_anti_debug_watchdog

                start_anti_debug_watchdog(interval=15.0)
            except Exception as exc:
                log.debug("Anti-debug watchdog skipped: %s", exc)

            # Runtime integrity watchdog: detect binary tampering
            try:
                from .seb.integrity import start_integrity_watchdog

                start_integrity_watchdog(interval=300.0)
            except Exception as exc:
                log.debug("Integrity watchdog skipped: %s", exc)

            # CSCA-specific protections: terminate guard, process hiding, CBT hook
            self._csca_protections_active = False
            if sys.platform == "win32":
                try:
                    from .seb.stealth_init import init_csca_protections

                    init_csca_protections()
                    self._csca_protections_active = True
                except Exception as exc:
                    log.debug("CSCA protections skipped: %s", exc)

            try:
                from .seb.stealth_check import log_stealth_status

                log_stealth_status(self.config, self.hotkey_mgr, self.typing_engine)
            except Exception as exc:
                log.debug("Stealth status check skipped: %s", exc)

        # Conversation memory (optional)
        self.memory = None
        if self.config.conversation_enabled:
            from .memory import ConversationMemory

            self.memory = ConversationMemory(
                max_context_tokens=self.config.conversation_max_context_tokens,
                keep_recent=self.config.conversation_keep_recent,
                max_messages=self.config.conversation_max_messages,
            )
            log.debug("Conversation memory enabled")

        # Knowledge base (optional)
        self.knowledge_mgr = None
        if self.config.knowledge_enabled:
            from .knowledge import KnowledgeManager

            db_path = os.path.expanduser(self.config.knowledge_db_path)
            self.knowledge_mgr = KnowledgeManager(
                db_path=db_path,
                similarity_threshold=self.config.knowledge_similarity_threshold,
            )
            log.debug("Knowledge base enabled: %s", db_path)

        # Cost tracker (optional)
        self.cost_tracker = None
        if self.config.cost_enabled:
            from .cost_tracker import CostTracker

            self.cost_tracker = CostTracker()
            log.debug("Cost tracking enabled")

        # Snippet manager (optional)
        self.snippet_mgr = None
        if self.config.snippets_enabled:
            from .snippets import SnippetManager

            self.snippet_mgr = SnippetManager()
            log.debug("Snippet library enabled")

        # History manager (optional)
        self.history_mgr = None
        if self.config.history_enabled:
            from .history import HistoryManager

            self.history_mgr = HistoryManager(
                data_dir=os.path.expanduser("~/.muravey-china"),
                max_entries=self.config.history_max_entries,
                autosave_interval=self.config.history_autosave_interval,
            )
            log.debug("History manager enabled (max=%d)", self.config.history_max_entries)

        # Clipboard history (optional)
        self.clipboard_history = None
        if self.config.clipboard_history_enabled:
            from .clipboard_history import ClipboardHistory

            self.clipboard_history = ClipboardHistory(
                max_entries=self.config.clipboard_history_max_entries,
            )
            log.debug("Clipboard history enabled")

        # Resource manager
        from .resource_manager import ResourceManager

        self.resource_mgr = ResourceManager()

        # Register SSH tunnel cleanup on exit
        self.resource_mgr.register_resource(
            "ssh_tunnel", lambda: self.adapter.stop_ssh_tunnel()
        )

        # Register CSCA protections cleanup (must happen before exit to avoid BSOD)
        if getattr(self, "_csca_protections_active", False):
            from .seb.stealth_init import cleanup_csca_protections

            self.resource_mgr.register_resource(
                "csca_protections", cleanup_csca_protections
            )

        # Usage statistics
        from .usage_stats import UsageStatistics

        self.usage_stats = UsageStatistics()

        # Panic button (optional)
        self.panic_button = None
        if self.config.panic_button_enabled:
            from .panic_button import PanicButton

            self.panic_button = PanicButton()
            self.panic_button.set_callbacks(
                hide_all=self._panic_hide_all,
                show_all=self._panic_show_all,
            )
            log.debug("Panic button enabled: hotkey=%s", self.config.panic_button_hotkey)

        # Export manager — uses history entries for export
        from .export_manager import ExportManager

        self.export_mgr = ExportManager()

        # License — v2 system (server-synced) with v1 fallback
        self.license_mgr_v2 = None
        self._validation_thread = None
        self._license_status = "expired"

        # Try v2 license system first (server-synced)
        try:
            from .license.license_v2_client import LicenseV2Client
            from .license.machine_fingerprint import get_machine_id

            machine_id = get_machine_id()
            self._license_v2 = LicenseV2Client(data_dir=self.config.data_dir if hasattr(self.config, "data_dir") else None)
            self._license_v2.register_and_sync(machine_id)
            self._license_status, _ = self._license_v2.check_access()
            self.licensed = self._license_status in ("free", "paid")
            log.info("License v2: status=%s, cutoff=%s, paid_until=%s, referral=%s",
                     self._license_status,
                     self._license_v2.get_free_cutoff_date(),
                     self._license_v2.get_paid_until(),
                     self._license_v2.get_referral_code())
        except Exception as exc:
            log.warning("License v2 failed: %s — no fallback, license expired", exc)
            self._license_v2 = None
            self.licensed = False
            self._license_status = "expired"

        # Trial period ended April 21, 2026 — no more free access

        if self.licensed:
            log.info("License: active (%s)", self._license_status)
        else:
            log.info("License: inactive — expired")

        # Prompt manager (optional — disabled if prompts.enabled=false)
        self.prompt_mgr = None
        if self.config.prompts_enabled:
            from .prompts.prompt_manager import PromptManager

            self.prompt_mgr = PromptManager(self.config.prompts_db_path)
            log.debug("Prompt manager enabled: %s", self.config.prompts_db_path)

        # Cancel event for in-progress AI streaming — replaced on each request
        self._cancel_event: threading.Event | None = None

        # Active prompts hotkey (tracks registered OS hotkey)
        self._active_prompts_hotkey: str = self.config.prompts_hotkey

        # Active vision hotkey (tracks registered OS hotkey)
        self._active_vision_hotkey: str = self.config.vision_hotkey

        # Auto-answerer (optional — disabled if auto_answer.enabled=false)
        self.auto_answerer = None
        if self.config.auto_answer_enabled:
            from .auto_answer.auto_answerer import AutoAnswerer

            self.auto_answerer = AutoAnswerer(self.config, self.typing_engine)
            log.debug("Auto-answer enabled: hotkey=%s", self.config.auto_answer_hotkey)

        # Active auto-answer hotkey
        self._active_auto_answer_hotkey: str = self.config.auto_answer_hotkey

        # Page solver (optional — disabled if page_solver.enabled=false)
        self.page_solver = None
        if self.config.page_solver_enabled:
            from .auto_answer.page_solver import PageSolver

            self.page_solver = PageSolver(self.config, self.typing_engine)
            log.debug("Page solver enabled: hotkey=%s", self.config.page_solver_hotkey)

        # Mouse clicker (optional — disabled if mouse_clicker.enabled=false)
        self.mouse_clicker = None
        if self.config.mouse_clicker_enabled:
            from .auto_answer.mouse_clicker import MouseClicker

            self.mouse_clicker = MouseClicker(self.config)
            log.debug("Mouse clicker enabled: hotkey=%s", self.config.mouse_clicker_hotkey)

        # Answer display (optional — disabled if answer_display.enabled=false)
        self.answer_display = None
        if self.config.answer_display_enabled:
            from .auto_answer.answer_display import AnswerDisplay

            self.answer_display = AnswerDisplay(self.config)
            log.debug("Answer display enabled: hotkey=%s", self.config.answer_display_hotkey)

        # Answer window (optional — small popup, above CSCA)
        self.answer_window = None
        if self.config.answer_window_enabled:
            from .auto_answer.answer_window import AnswerWindow

            self.answer_window = AnswerWindow(self.config)
            log.debug("Answer window enabled: hotkey=%s", self.config.answer_window_hotkey)

        # Screenshot viewer (optional — real-time screenshot above CSCA)
        self.screenshot_viewer = None
        if self.config.screenshot_viewer_enabled:
            from .screenshot_viewer.screenshot_viewer import ScreenshotViewer

            self.screenshot_viewer = ScreenshotViewer(self.config)
            log.debug("Screenshot viewer enabled: hotkey=%s", self.config.screenshot_viewer_hotkey)

        # Program launcher (optional — launch any app via hotkey, stealthy under CSCA)
        self.program_launcher = None
        if self.config.program_launcher_enabled:
            from .program_launcher.program_launcher import ProgramLauncher

            self.program_launcher = ProgramLauncher(self.config)
            log.debug("Program launcher enabled: hotkey=%s path=%s",
                     self.config.program_launcher_hotkey,
                     self.config.program_launcher_path or "(not configured)")

        # OCR Typist — DISABLED (ctrl+shift+f8 reserved for debug mode)
        self.ocr_typist = None

        # Math Solver AK (Ctrl+Shift+F7: screenshot → Ollama qwen2.5VL → SymPy → answer)
        self.math_solver = None
        if self.config.math_solver_enabled:
            from .math_solver.math_solver import MathSolver

            self.math_solver = MathSolver(self.config)
            log.debug("Math Solver enabled: hotkey=%s model=%s",
                     self.config.math_solver_hotkey,
                     self.config.math_solver_vision_model)

        # Currently registered hotkey (tracks what's actually active in the OS,
        # may temporarily diverge from config.hotkey during re-registration)
        self._active_hotkey: str = self.config.hotkey

        # Overlay (lazy — created after Qt app starts)
        self.overlay = None

        # Update checker — uses license.muravey.app/version (works in China)
        from .updater.updater import UpdateChecker

        self.updater = None
        _update_enabled = getattr(self.config, "update_check_on_start", False)
        _update_repo = getattr(self.config, "update_github_repo", "")
        if _update_enabled and _update_repo:
            try:
                def _on_update(version, url):
                    log.info("Update available: %s — %s", version, url)
                    if self.overlay:
                        self.overlay.show_response(f"Update available: v{version}")

                self.updater = UpdateChecker(
                    github_repo=_update_repo,
                    cache_dir=getattr(self.config, "data_dir", None),
                    callback=_on_update,
                )
                self.updater.check_async()
            except Exception as exc:
                log.debug("Update checker failed: %s", exc)

        self._shutdown = threading.Event()

        # Config hot-reload — keep (reads local file, no network)
        self._config_reload_thread = threading.Thread(
            target=self._config_reload_loop, daemon=True, name="config-reload"
        )
        self._config_reload_thread.start()

        # License validation timer — DISABLED (no network)
        log.debug("Startup complete — CPU-only mode, no network activity.")

    def _config_reload_loop(self) -> None:
        """Periodically check and reload config from disk."""
        while not self._shutdown.is_set():
            self._shutdown.wait(30.0)
            if self._shutdown.is_set():
                break
            try:
                if self.config.check_and_reload():
                    log.info("Config hot-reloaded — settings updated")
            except Exception as exc:
                log.debug("Config reload check failed: %s", exc)

    def _show_activation_dialog(self) -> None:
        """Show platform-specific activation dialog for V2 licensing."""
        try:
            if sys.platform == "win32":
                from .ui.activation_dialog_win import ActivationDialog
            elif sys.platform == "darwin":
                from .ui.activation_dialog_mac import ActivationDialog
            else:
                log.warning("No activation dialog for platform %s", sys.platform)
                return

            dialog = ActivationDialog(self.license_mgr_v2)
            success = dialog.show_and_activate()
            if success:
                self.licensed = True
                log.info("License V2 activated successfully")
                self._start_license_validation_timer()
                # Show welcome tutorial only once — on first ever activation
                if not self.config.first_activation_shown:
                    self._show_welcome()
                    self.config.first_activation_shown = True
            else:
                log.warning("License activation cancelled or failed")
        except Exception:
            log.exception("Failed to show activation dialog")

    def _show_welcome(self) -> None:
        """Show the first-activation welcome/tutorial dialog (Windows only)."""
        if sys.platform != "win32":
            return
        try:
            from PyQt6.QtWidgets import QApplication

            from .ui.welcome_win import WelcomeWindow

            app = QApplication.instance()
            if app is None:
                log.debug("No Qt application — skipping welcome dialog")
                return
            win = WelcomeWindow(
                hotkey=self.config.hotkey,
                clipboard_hotkey=self.config.clipboard_mode_hotkey,
                page_solver_hotkey=self.config.page_solver_hotkey,
            )
            win.exec()
        except Exception:
            log.exception("Failed to show welcome dialog")

    def _start_license_validation_timer(self) -> None:
        """Start background thread for hourly license validation."""
        if self._validation_thread and self._validation_thread.is_alive():
            return
        self._validation_thread = threading.Thread(
            target=self._license_validation_loop, daemon=True, name="license-validation"
        )
        self._validation_thread.start()
        log.info(
            "License validation timer started (interval=%ds)",
            self.config.license_validation_interval,
        )

    def _license_validation_loop(self) -> None:
        """Periodically validate license session with server."""
        interval = self.config.license_validation_interval
        while not self._shutdown.is_set():
            self._shutdown.wait(float(interval))
            if self._shutdown.is_set():
                break
            if not self.license_mgr_v2:
                break
            try:
                result = self.license_mgr_v2.validate_online()
                if result.valid:
                    self.licensed = True
                    remaining = result.time_remaining
                    if 0 < remaining <= 3600:
                        log.info("License session expiring soon (%d min left)", remaining // 60)
                        self._handle_license_expiring(remaining // 60)
                else:
                    log.warning("License validation failed: %s", result.reason)
                    self._handle_license_expired()
            except Exception:
                log.exception("License validation error")

    def _handle_license_expiring(self, minutes_remaining: int) -> None:
        """Notify user that session is about to expire."""
        try:
            if sys.platform == "win32":
                from .ui.activation_dialog_win import ActivationDialog
            elif sys.platform == "darwin":
                from .ui.activation_dialog_mac import ActivationDialog
            else:
                return
            dialog = ActivationDialog(self.license_mgr_v2)
            dialog.show_expiry_warning(minutes_remaining)
        except Exception:
            log.debug("Could not show expiry warning")

    def _check_license_live(self) -> bool:
        """Re-verify license from session storage (not from cached self.licensed).

        Prevents bypass via memory patching of self.licensed flag.
        """
        if self.license_mgr_v2:
            # Reload from disk/keychain and re-validate RSA signature
            self.license_mgr_v2._load_cached_session()
            is_valid = self.license_mgr_v2.is_valid()
            self.licensed = is_valid
            return is_valid
        return False

    def _handle_license_expired(self) -> None:
        """Handle expired license session."""
        self.licensed = False
        log.warning("License session expired — restricting AI features")
        if self.overlay:
            self.overlay.show_response(_("app.license_required"))

    def start(self) -> None:
        """Start all components and block until quit."""
        # Windows requires a QApplication for the overlay
        if sys.platform == "win32":
            try:
                from PyQt6.QtWidgets import QApplication

                self._qt_app = QApplication.instance() or QApplication(sys.argv)
            except ImportError:
                log.warning("PyQt6 not available — overlay disabled")

        # Show splash/loading window immediately
        self._splash = None
        if sys.platform == "win32":
            try:
                from .ui.splash_win import SplashWindow
                self._splash = SplashWindow()
                # Process events so the splash actually renders
                if hasattr(self, "_qt_app") and self._qt_app:
                    self._qt_app.processEvents()
            except Exception as exc:
                log.debug("Splash window skipped: %s", exc)

        # Create overlay
        self.overlay = _get_overlay(
            self.config,
            on_insert=self._on_insert,
            on_cancel=self._on_cancel_stream,
        )

        # Create answer display overlay (must be in main/Qt thread)
        if self.answer_display:
            from .auto_answer.answer_display import AnswerOverlayWidget

            answer_overlay = AnswerOverlayWidget()
            self.answer_display.set_overlay(answer_overlay)
            log.debug("Answer display overlay created")

        # Create answer window widget (must be in main/Qt thread)
        if self.answer_window:
            from .auto_answer.answer_window import AnswerWindowWidget

            answer_win_widget = AnswerWindowWidget()
            self.answer_window.set_widget(answer_win_widget)
            log.debug("Answer window widget created")

        # OCR Typist — disabled (debug mode uses ctrl+shift+f8)

        # Connect Math Solver to answer window
        if self.math_solver and self.answer_window:
            self.math_solver.set_answer_window(self.answer_window)

        # Create screenshot viewer widget (must be in main/Qt thread)
        if self.screenshot_viewer:
            from .screenshot_viewer.screenshot_viewer import ScreenshotViewerWidget

            sv_widget = ScreenshotViewerWidget(self.config)
            self.screenshot_viewer.set_widget(sv_widget)
            log.debug("Screenshot viewer widget created")

        # --- Temporarily disabled hotkeys (keep only Page Solver + Mouse Clicker) ---
        hotkey = self.config.hotkey  # needed for tray label
        # # Register main hotkey
        # hotkey = self.config.hotkey
        # ok = self.hotkey_mgr.register(hotkey, self._on_hotkey)
        # if ok:
        #     log.info("Hotkey registered: %s", hotkey)
        # else:
        #     log.error("Failed to register hotkey: %s", hotkey)
        #     _hk_upper = hotkey.upper()
        #     def _warn_hotkey_failed() -> None:
        #         import time
        #         time.sleep(1.5)
        #         if self.overlay:
        #             self.overlay.show_response(
        #                 f"⚠️ Hotkey {_hk_upper} could not be registered "
        #                 f"(already used by another app). "
        #                 f"Please change it in Settings → Hotkeys."
        #             )
        #     threading.Thread(target=_warn_hotkey_failed, daemon=True).start()

        # # Register prompts hotkey
        # if self.prompt_mgr:
        #     prompts_hk = self.config.prompts_hotkey
        #     ok_p = self.hotkey_mgr.register(prompts_hk, self._on_prompts_hotkey)
        #     if ok_p:
        #         log.info("Prompts hotkey registered: %s", prompts_hk)
        #     else:
        #         log.warning("Failed to register prompts hotkey: %s", prompts_hk)

        # ── Only CPU-related hotkeys active ──

        # Answer window (Ctrl+F1) — popup with answer text
        if self.answer_window:
            aw_hk = self.config.answer_window_hotkey
            ok_aw = self.hotkey_mgr.register(aw_hk, self._on_answer_window_hotkey)
            if ok_aw:
                log.debug("Answer window hotkey registered: %s", aw_hk)

        # Panic button (Ctrl+Shift+F12) — instant hide/show
        if self.panic_button:
            pb_hk = self.config.panic_button_hotkey
            ok_pb = self.hotkey_mgr.register(pb_hk, self._on_panic_hotkey)
            if ok_pb:
                log.debug("Panic button hotkey registered: %s", pb_hk)

        # Math Solver (Ctrl+`) — CPU only, no network
        if self.math_solver:
            ms_hk = self.config.math_solver_hotkey
            ok_ms = self.hotkey_mgr.register(ms_hk, self._on_math_solver_hotkey)
            print(f"[STARTUP] Math solver hotkey {ms_hk} registered: {ok_ms}", flush=True)

        # Screenshot viewer (Ctrl+F11) — show last screenshot
        if self.screenshot_viewer:
            sv_hk = self.config.screenshot_viewer_hotkey
            ok_sv = self.hotkey_mgr.register(sv_hk, self._on_screenshot_viewer_hotkey)
            if ok_sv:
                log.debug("Screenshot viewer hotkey registered: %s", sv_hk)

        # Global solving lock — ignore all hotkeys (except panic) while solving
        self._solving = False

        # Debug mode (toggled from tray menu)
        self._debug_mode = False
        self._debug_original_hotkey_cb = None

        log.debug("Hotkeys active: math_solver=%s, answer_window=%s, panic=%s, screenshot_viewer=%s",
                 self.config.math_solver_hotkey, self.config.answer_window_hotkey,
                 self.config.panic_button_hotkey, self.config.screenshot_viewer_hotkey)

        # # Register clipboard silent mode hotkey
        # if self.config.clipboard_mode_enabled:
        #     cb_hk = self.config.clipboard_mode_hotkey
        #     ok_cb = self.hotkey_mgr.register(cb_hk, self._on_clipboard_hotkey)
        #     if ok_cb:
        #         log.info("Clipboard hotkey registered: %s", cb_hk)
        #     else:
        #         log.warning("Failed to register clipboard hotkey: %s", cb_hk)

        # Force-activate CSCA bypass if exam software is running
        # (LL hook + Raw Input — needed because CSCA blocks GetAsyncKeyState)
        if sys.platform == "win32":
            try:
                from .seb.detection import is_exam_running
                if is_exam_running():
                    log.info("CSCA detected at startup — activating keyboard bypass")
                    if hasattr(self.hotkey_mgr, "_activate_csca_bypass"):
                        self.hotkey_mgr._activate_csca_bypass()
            except Exception as exc:
                log.debug("CSCA startup check skipped: %s", exc)

        # Apply initial theme to Qt app (Windows)
        if sys.platform == "win32":
            self._apply_qt_theme(self.theme_mgr.get_current_theme())

        # Start update check
        if self.config.check_on_start:
            self.updater.check_async()

        # Debug screenshot window (created lazily on first use)
        self._debug_screenshot_win = None

        # Build tray icon
        from .ui.tray import TrayIcon

        hotkey_label = hotkey.upper().replace("+", "+")
        # Check if user has an active V2 session
        _has_session = self.license_mgr_v2 is not None and self.license_mgr_v2.is_valid()
        _is_trial = self._license_status == "free" and not _has_session
        self.tray = TrayIcon(
            on_process=self._on_hotkey,
            on_settings=self._show_settings,
            on_quit=self._quit,
            on_stats=self._show_stats if self.stats_mgr else None,
            on_prompts=self._show_prompts_window if self.prompt_mgr else None,
            on_debug_screenshot=self._show_debug_screenshot,
            on_mode_switch=self._on_mode_switch,
            on_debug_toggle=self._on_debug_toggle,
            on_activate=self._on_activate_license if _is_trial else None,
            hotkey_label=hotkey_label,
            stealth=self._stealth,
            is_trial=_is_trial,
        )

        # Splash → ready
        if self._splash:
            try:
                self._splash.set_ready()
            except Exception:
                pass

        # Run tray (blocking)
        try:
            self.tray.run()
        except KeyboardInterrupt:
            self._quit()

    # ------------------------------------------------------------------ #
    # Hotkey handler
    # ------------------------------------------------------------------ #

    def _on_hotkey(self) -> None:
        """Called when the global hotkey is pressed."""
        log.debug("Hotkey triggered")
        # Show thinking indicator only when overlay mode is active (auto_paste=False)
        if self.overlay and not self.config.auto_paste and hasattr(self.overlay, "show_thinking"):
            self.overlay.show_thinking()
        thread = threading.Thread(target=self._process_selection, daemon=True, name="ai-worker")
        thread.start()

    def _on_vision_hotkey(self) -> None:
        """Called when the vision hotkey is pressed — take screenshot and analyze."""
        if not self.config.vision_enabled:
            return
        log.debug("Vision hotkey triggered")
        thread = threading.Thread(
            target=self._process_screenshot, daemon=True, name="vision-worker"
        )
        thread.start()

    def _on_auto_hotkey(self) -> None:
        """Called when the auto-answer hotkey is pressed."""
        if not self.auto_answerer:
            return
        log.debug("Auto-answer hotkey triggered")
        # Dispatch to AutoAnswerer (handles single/double-press logic internally)
        self.auto_answerer.on_hotkey()

    def _on_page_solver_hotkey(self) -> None:
        """Called when the page-solver hotkey is pressed."""
        if not self.page_solver:
            return
        log.debug("Page solver hotkey triggered")
        threading.Thread(
            target=self.page_solver.on_hotkey, daemon=True, name="page-solver"
        ).start()

    def _on_mouse_clicker_hotkey(self) -> None:
        """Called when mouse-clicker hotkey is pressed."""
        if not self.mouse_clicker:
            return
        log.debug("Mouse clicker hotkey triggered")
        threading.Thread(
            target=self.mouse_clicker.on_hotkey, daemon=True, name="mouse-clicker"
        ).start()

    def _on_answer_display_hotkey(self) -> None:
        """Called when answer-display hotkey is pressed — redirected to answer window."""
        # All answers go only to the Ctrl+F1 answer window (no floating overlay)
        if not self.answer_window:
            return
        log.debug("Answer display hotkey → redirected to answer window")
        threading.Thread(
            target=self.answer_window.on_hotkey, daemon=True, name="answer-display"
        ).start()

    def _on_answer_window_hotkey(self) -> None:
        """Called when Ctrl+F1 is pressed — show last answer again (no new solve)."""
        if self._solving:
            return
        if self.math_solver:
            log.debug("Showing last answer (Ctrl+F1)")
            self.math_solver.show_last_answer()
        else:
            log.debug("No math_solver — cannot show last answer")

    def _on_ocr_reader_hotkey(self) -> None:
        """Called when OCR reader hotkey (Ctrl+F7) is pressed."""
        if self._solving:
            return
        if self.answer_window:
            self.answer_window.on_ocr_hotkey()

    def _on_screenshot_viewer_hotkey(self) -> None:
        """Called when screenshot-viewer hotkey is pressed (Ctrl+F11) — toggle show/hide."""
        if self._solving:
            return
        if not self.screenshot_viewer:
            return
        log.debug("Screenshot viewer hotkey triggered")
        threading.Thread(
            target=self.screenshot_viewer.on_hotkey, daemon=True, name="screenshot-viewer"
        ).start()

    def _on_program_launcher_hotkey(self) -> None:
        """Called when program-launcher hotkey is pressed (Ctrl+F6) — toggle launch/kill."""
        if not self.program_launcher:
            return
        log.debug("Program launcher hotkey triggered")
        threading.Thread(
            target=self.program_launcher.on_hotkey, daemon=True, name="launcher"
        ).start()

    def _on_ocr_typist_hotkey(self) -> None:
        """Called when OCR Typist hotkey is pressed (Ctrl+F8) — screenshot → Vision AI → type answer."""
        if not self.ocr_typist:
            return
        log.debug("OCR Typist hotkey triggered")
        threading.Thread(
            target=self.ocr_typist.on_hotkey, daemon=True, name="ocr-typist"
        ).start()

    def _on_ocr_test_hotkey(self) -> None:
        """Called when OCR Test hotkey is pressed (Ctrl+Shift+F9) — screenshot → read ALL text → type."""
        if not self.ocr_typist:
            return
        threading.Thread(
            target=self.ocr_typist.on_test_hotkey, daemon=True, name="ocr-test"
        ).start()

    def _warmup_vision_model(self, model: str) -> None:
        """Pre-load vision model into Ollama memory at startup.

        Sends a tiny 1x1 pixel dummy image so Ollama loads the model weights
        into RAM/VRAM before the user presses Ctrl+F1. This eliminates the
        20-30s cold-start delay on the first real request.
        """
        import base64
        import time as _time
        try:
            _time.sleep(3.0)  # wait for Ollama to be ready
            from .ai.vision_adapter import VisionAdapter
            adapter = VisionAdapter(self.config, timeout=60.0)
            # Tiny 1x1 white JPEG (minimal image to force model load)
            _TINY_JPEG_B64 = (
                "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8U"
                "HRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgN"
                "DRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIy"
                "MjL/wAARCAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAA"
                "AAAAAAAAAAAAAP/EABQBAQAAAAAAAAAAAAAAAAAAAAD/xAAUEQEAAAAAAAAAAAAAAAAAAAAA"
                "/9oADAMBAAIRAxEAPwCwABmX/9k="
            )
            cancel = threading.Event()
            raw = ""
            for chunk in adapter.analyze(
                _TINY_JPEG_B64, "A",
                cancel_event=cancel,
                stream=False,
                model=model,
                temperature=0.0,
                max_tokens=1,
            ):
                raw += chunk
            log.info("Vision model warmup complete (%s)", model)
        except Exception as exc:
            log.debug("Vision model warmup failed (non-critical): %s", exc)

    def _warmup_text_model(self, model: str) -> None:
        """Pre-load text model into Ollama memory at startup (improvement 7).

        Sends a simple text request so Ollama loads the model weights into RAM
        before the user triggers an answer request.
        """
        try:
            time.sleep(5.0)  # wait a bit after vision warmup starts
            from .ai.provider_ollama import OllamaProvider
            from .ai.base import AIRequest

            base_url = getattr(self.config, "ollama_base_url", "http://localhost:11434/v1")
            text_provider = OllamaProvider(base_url=base_url)
            request = AIRequest(
                prompt="A",
                system_prompt="Reply with one letter.",
                temperature=0.0,
                max_tokens=1,
                model=model,
            )
            response = text_provider.ask(request)
            log.info("Text model warmup complete (%s)", model)
        except Exception as exc:
            log.debug("Text model warmup failed (non-critical): %s", exc)

    def _auto_pull_missing_models(self, vision_model: str, text_model: str) -> None:
        """Auto-pull vision and text models if not installed in Ollama (improvement 4).

        Runs in background thread — does not block startup.
        Only pulls models that are not already installed.
        """
        import subprocess
        import urllib.request as _req

        ollama_base = (self.config.ollama_base_url or "http://localhost:11434").rstrip("/")
        ollama_root = ollama_base[:-3] if ollama_base.endswith("/v1") else ollama_base

        try:
            with _req.urlopen(f"{ollama_root}/api/tags", timeout=5) as resp:
                import json as _json
                data = _json.loads(resp.read())
            installed_names = {m.get("name", "") for m in data.get("models", [])}
        except Exception as exc:
            log.debug("[autopull] Could not list Ollama models: %s", exc)
            return

        for model in (vision_model, text_model):
            # Check if model or its base name is installed
            bare = model.split(":")[0]
            already_installed = model in installed_names or any(
                n == model or n.split(":")[0] == bare for n in installed_names
            )
            if already_installed:
                log.info("[autopull] Model already installed: %s", model)
                continue

            log.info("[autopull] Model not found, pulling: %s", model)
            try:
                proc = subprocess.Popen(
                    ["ollama", "pull", model],
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    text=True,
                )
                for line in proc.stdout:  # type: ignore[union-attr]
                    line = line.strip()
                    if line:
                        log.info("[autopull] %s: %s", model, line)
                proc.wait()
                if proc.returncode == 0:
                    log.info("[autopull] Successfully pulled: %s", model)
                else:
                    log.warning("[autopull] Pull failed for %s (exit=%d)", model, proc.returncode)
            except Exception as exc:
                log.warning("[autopull] Could not pull %s: %s", model, exc)

        # Also download GGUF files for llama-cpp-python fallback (if not present)
        try:
            from .ai.provider_llamacpp import (
                is_llamacpp_available,
                are_models_present,
                download_gguf_models,
            )
            if is_llamacpp_available() and not are_models_present():
                log.info("[autopull] Downloading GGUF models for llama-cpp-python fallback...")
                success = download_gguf_models(
                    progress_callback=lambda f, done, total: log.info(
                        "[autopull] GGUF %s: %.1f MB / %.1f MB",
                        f, done / 1e6, total / 1e6 if total else 0,
                    )
                )
                if success:
                    log.info("[autopull] GGUF models downloaded successfully")
                else:
                    log.warning("[autopull] Some GGUF model downloads failed")
        except ImportError:
            pass
        except Exception as exc:
            log.debug("[autopull] GGUF download skipped: %s", exc)

    def _init_proxy_tunnel(self) -> None:
        """Auto-start proxy detection, SSH tunnel, and network protection.

        Runs synchronously so the network is fully ready before hotkeys
        are registered — avoids connection failures when CSCA is blocking.
        The proxy stays active even when stealth/CSCA mode activates —
        online providers (used as fallback) still need network access.

        Network protection (pre-warm, keepalive, watchdog) is started to
        keep connections alive through CSCA's network blocking.
        """
        t0 = time.monotonic()
        try:
            # 1. Try SSH tunnel if configured
            if self.config.ssh_tunnel_enabled:
                ok = self.adapter.start_ssh_tunnel()
                if ok:
                    log.info("SSH tunnel started for GFW bypass")

                    # Start network protection with tunnel proxy
                    self._start_network_protection(self.adapter.get_proxy_url())
                    log.info("Network ready in %.2fs", time.monotonic() - t0)
                    return
                log.warning("SSH tunnel failed — falling back to proxy detection")

            # 2. Auto-detect proxy (env, system, local ports)
            proxy = self.adapter.get_proxy_url()
            if proxy:
                log.info("Proxy active for GFW bypass: %s", proxy)
            else:
                log.debug("No proxy detected — direct connections will be used")

            # 3. Start network protection (pre-warm connections, watchdog)
            self._start_network_protection(proxy)
        except Exception as exc:
            log.debug("Proxy/tunnel init failed: %s", exc)
        log.info("Network ready in %.2fs", time.monotonic() - t0)

    def _start_network_protection(self, proxy_url: str = "") -> None:
        """Start network protection to survive CSCA blocking.

        Pre-warms connections, enables TCP keepalive, and starts
        a watchdog that auto-reconnects if connections drop.
        """
        try:
            from .utils.network_protect import init_network_protection

            self._net_protector = init_network_protection(
                proxy_url=proxy_url,
                check_interval=30.0,
            )

            # Register cleanup
            if hasattr(self, "resource_mgr") and self.resource_mgr:
                self.resource_mgr.register(
                    "network_protector",
                    lambda: self._net_protector.stop() if self._net_protector else None,
                )
        except Exception as exc:
            log.debug("Network protection init failed: %s", exc)

    def _ensure_online_providers(self) -> None:
        """Check internet connectivity and log status.

        Online providers (DeepSeek, Aliyun) are always used — proxy/tunnel
        handles GFW bypass when direct connections are blocked.
        """
        from .utils.gfw_detect import _can_connect, _CHINA_HOSTS

        online = False
        for host, port in _CHINA_HOSTS:
            if _can_connect(host, port, timeout=3.0):
                online = True
                break

        if online:
            log.info("Internet available — using online LLMs (deepseek + aliyun)")
        else:
            log.warning("Internet not directly reachable — proxy/tunnel will handle connectivity")

    def _setup_llamacpp_fallback(self) -> None:
        """Initialize llama-cpp-python provider as PRIMARY (runs in background thread).

        If llama-cpp-python is installed AND GGUF model files exist in
        ~/.muravey-china/models/, pre-loads models into GPU/RAM so they are
        ready instantly. This is the default provider — no network connections.
        """
        try:
            from .ai.provider_llamacpp import (
                is_llamacpp_available,
                are_models_present,
                get_llamacpp_provider,
                MODELS_DIR,
            )
        except ImportError:
            log.debug("[llamacpp] provider_llamacpp module not available")
            return

        if not is_llamacpp_available():
            log.info("[llamacpp] llama-cpp-python not installed — fallback unavailable")
            return

        if not are_models_present(vision=True, text=False) and not are_models_present(vision=False, text=True):
            log.info("[llamacpp] No GGUF model files in %s — fallback unavailable", MODELS_DIR)
            return

        log.info("[llamacpp] Pre-loading GGUF models for offline fallback...")
        provider = get_llamacpp_provider()
        if provider is not None:
            log.info("[llamacpp] Fallback ready: %s", provider)
        else:
            log.warning("[llamacpp] Failed to initialize fallback provider")

    def _detect_vision_model(self) -> str:
        """Auto-detect best available vision model from installed Ollama models.

        Priority: minicpm-v > qwen2.5-vl:7b > qwen2-vl:7b > llava:7b > llava
        Falls back to minicpm-v if Ollama is unreachable.
        """
        _preferred = [
            "minicpm-v",
            "qwen2.5-vl:7b",
            "qwen2-vl:7b",
            "llava:7b",
            "llava",
        ]
        try:
            from .ai.provider_ollama import list_models
            installed = list_models(
                self.config.get("ai.ollama_base_url", "http://localhost:11434/v1"),
                timeout=3.0,
            )
            # Normalize names (strip :latest suffix for comparison)
            installed_norm = {m.split(":")[0] + (":" + m.split(":")[1] if ":" in m else "") for m in installed}
            installed_norm |= {m.split(":")[0] for m in installed}  # also bare names
            for candidate in _preferred:
                bare = candidate.split(":")[0]
                if candidate in installed or bare in installed_norm:
                    log.info("Vision model auto-detected: %s", candidate)
                    return candidate
            # Nothing matched — default to minicpm-v
            log.warning("No known vision model found in Ollama, defaulting to minicpm-v")
        except Exception as exc:
            log.debug("Vision model detection failed: %s", exc)
        return "minicpm-v"

    @staticmethod
    def _detect_optimal_image_size() -> int:
        """Return max image width cap.

        Kept for backward compatibility.  The new rule is simple:
        screenshots larger than 1920x1080 are resized to fit within that
        box (aspect ratio preserved); smaller ones are sent as-is.
        All resizing is handled by ``image_utils.resize_image()``.
        """
        return 1920

    def _on_csca_open(self) -> None:
        """Called when CSCA is detected — start session logging."""
        if self.math_solver:
            self.math_solver.start_session()
            log.info("CSCA opened — session started")

    def _on_csca_close(self) -> None:
        """Called when CSCA closes."""
        if self.math_solver:
            self.math_solver.end_session()

    def _on_mode_switch(self, mode: str) -> None:
        """Called from tray menu when user switches mode."""
        if self.math_solver:
            self.math_solver.set_mode(mode)
            log.info("Mode switched to: %s", mode)

    def _on_math_solver_hotkey(self) -> None:
        """Called when math solver hotkey is pressed — screenshot → text."""
        if not self.math_solver:
            return
        if self._solving:
            return
        log.debug("Math solver hotkey triggered")
        threading.Thread(target=self._run_solver, daemon=True, name="math-solver").start()

    def _run_solver(self) -> None:
        """Run math solver with solving lock."""
        self._solving = True
        try:
            self.math_solver.on_hotkey()
        finally:
            self._solving = False

    def _on_translate_hotkey(self) -> None:
        """Called when translate hotkey is pressed — screenshot → Chinese to Russian."""
        if self._solving:
            return
        if not self.math_solver:
            return
        log.debug("Translate hotkey triggered")
        threading.Thread(target=self.math_solver.on_translate_hotkey, daemon=True, name="math-translate").start()

    # ------------------------------------------------------------------ #
    # Debug mode (Ctrl+Shift+F8)
    # ------------------------------------------------------------------ #

    def _on_activate_license(self) -> None:
        """Show token activation dialog using VBS InputBox (works from any thread on Windows)."""
        try:
            import subprocess
            import tempfile

            from .license.machine_fingerprint import get_machine_id
            machine_id = get_machine_id()

            # Single InputBox for token
            vbs = tempfile.NamedTemporaryFile(suffix=".vbs", delete=False, mode="w")
            vbs.write(
                'token = InputBox("Enter your activation token:" & vbCrLf & vbCrLf & "Machine ID: ' + machine_id + '", "Muravey — Activate License", "")\n'
                'If token = "" Then\n'
                '  WScript.Echo "CANCEL"\n'
                'Else\n'
                '  WScript.Echo token\n'
                'End If\n'
            )
            vbs.close()
            try:
                result = subprocess.run(["cscript", "//Nologo", vbs.name], capture_output=True, text=True, timeout=120)
                token = result.stdout.strip()
            finally:
                import os
                os.unlink(vbs.name)
            if token == "CANCEL":
                return
            if not token or len(token) < 16:
                self._show_msgbox("Invalid token. Please check and try again.", "Muravey")
                return

            # Activate via LicenseManagerV2
            if not hasattr(self, "license_mgr_v2") or self.license_mgr_v2 is None:
                from .license.license_manager_v2 import LicenseManagerV2
                self.license_mgr_v2 = LicenseManagerV2()

            result = self.license_mgr_v2.activate(token)
            if result.success:
                self.licensed = True
                self._license_status = "paid"
                self._show_msgbox(
                    f"Activated! Valid for {result.time_remaining // 3600} hours.\n"
                    "Restart Muravey to apply.",
                    "Muravey",
                )
            else:
                self._show_msgbox(result.error or "Activation failed", "Muravey — Activation Error")

        except Exception as exc:
            log.error("Activate license failed: %s", exc)
            self._show_msgbox(f"Error: {exc}", "Muravey Error")

    def _show_msgbox(self, text, title="Muravey"):
        """Show Windows message box (works from any thread)."""
        try:
            import ctypes
            ctypes.windll.user32.MessageBoxW(0, str(text), str(title), 0x40)
        except Exception:
            print(f"[{title}] {text}", flush=True)

    def _on_debug_toggle(self) -> None:
        """Toggle debug mode on/off."""
        if self._debug_mode:
            self._debug_deactivate()
        else:
            threading.Thread(target=self._debug_ask_password, daemon=True, name="debug-pw").start()

    def _debug_ask_password(self) -> None:
        """Show password input via Windows input box (works from any thread)."""
        try:
            import ctypes
            import ctypes.wintypes

            # Use a VBS InputBox via subprocess — reliable from any thread
            import subprocess, tempfile, os
            vbs = tempfile.NamedTemporaryFile(suffix=".vbs", delete=False, mode="w")
            vbs.write(
                'pw = InputBox("Enter debug password:", "Debug Mode", "")\n'
                'If pw = "" Then\n'
                '  WScript.Echo "CANCEL"\n'
                'Else\n'
                '  WScript.Echo pw\n'
                'End If\n'
            )
            vbs.close()
            try:
                result = subprocess.run(
                    ["cscript", "//Nologo", vbs.name],
                    capture_output=True, text=True, timeout=60,
                )
                pw = result.stdout.strip()
            finally:
                os.unlink(vbs.name)

            if pw == "CANCEL" or not pw:
                return
            if pw == "msbc062@$":
                self._debug_activate()
            else:
                self._show_debug_msg("Wrong password")
        except Exception as exc:
            log.debug("Debug password dialog failed: %s", exc)
            print(f"[DEBUG] Password dialog error: {exc}", flush=True)

    def _debug_activate(self) -> None:
        """Activate debug mode: replace Ctrl+` with debug OCR pipeline."""
        self._debug_mode = True
        ms_hk = self.config.math_solver_hotkey  # ctrl+`
        # Unregister normal Ctrl+` and register debug version
        self.hotkey_mgr.unregister(ms_hk)
        self.hotkey_mgr.register(ms_hk, self._on_debug_hotkey)
        log.info("DEBUG MODE ACTIVATED — Ctrl+` now runs debug OCR pipeline")
        self._show_debug_msg("DEBUG MODE ON")

    def _debug_deactivate(self) -> None:
        """Deactivate debug mode: restore Ctrl+` to normal math solver."""
        self._debug_mode = False
        ms_hk = self.config.math_solver_hotkey  # ctrl+`
        # Unregister debug and restore original
        self.hotkey_mgr.unregister(ms_hk)
        if self.math_solver:
            self.hotkey_mgr.register(ms_hk, self._on_math_solver_hotkey)
        log.info("DEBUG MODE DEACTIVATED — Ctrl+` restored to normal")
        self._show_debug_msg("DEBUG MODE OFF")

    def _show_debug_msg(self, text: str) -> None:
        """Show short message in answer window."""
        if self.answer_window:
            self.answer_window.show_text(text, timeout=3)
        print(f"[DEBUG] {text}", flush=True)

    def _on_debug_hotkey(self) -> None:
        """Debug Ctrl+`: screenshot → OCR all text → find numbers → sum via Qwen 8B."""
        if self._solving:
            return
        threading.Thread(target=self._run_debug_pipeline, daemon=True, name="debug-pipeline").start()

    def _run_debug_pipeline(self) -> None:
        """Run debug pipeline with solving lock."""
        self._solving = True
        try:
            self._debug_pipeline()
        finally:
            self._solving = False

    def _debug_pipeline(self) -> None:
        """Debug pipeline: OCR → show text → extract numbers → sum with Qwen 8B."""
        try:
            if not self.math_solver:
                self._show_debug_msg("No math solver")
                return

            self._show_debug_msg("Reading...")
            print("[DEBUG] Capturing screenshot...", flush=True)

            # Step 1: Screenshot
            img_b64 = self.math_solver._capture_and_crop()
            if not img_b64:
                self._show_debug_msg("Screenshot failed")
                return

            # Step 2: OCR — read ALL text from screenshot
            print("[DEBUG] Running Vision OCR...", flush=True)
            ocr_prompt = "Read ALL text from this image exactly as written. Include every word, number, symbol, and character you see. Do not skip anything. Do not solve or interpret — just read and output the raw text."
            ocr_text = self.math_solver._llamacpp_vision_with_prompt(img_b64, ocr_prompt)
            if not ocr_text:
                self._show_debug_msg("OCR failed")
                return

            print(f"\n[DEBUG] ===== OCR TEXT =====\n{ocr_text}\n[DEBUG] ===== END =====\n", flush=True)

            # Show OCR text in answer window
            # Truncate for display if too long
            display_text = ocr_text[:300] + ("..." if len(ocr_text) > 300 else "")
            self.answer_window.show_text(display_text, timeout=8) if self.answer_window else None

            # Step 3: Send to Qwen 8B to find all numbers and sum them
            import time
            time.sleep(3)  # let user read OCR text first

            print("[DEBUG] Sending to Qwen 8B for number extraction + sum...", flush=True)
            sum_prompt = (
                f"Here is text from a screenshot:\n\n"
                f'"""\n{ocr_text}\n"""\n\n'
                f"Task:\n"
                f"1. Find ALL numbers and digits in this text (integers, decimals, fractions, percentages — everything that is a number)\n"
                f"2. List every number you found\n"
                f"3. Add them all together step by step\n"
                f"4. Show the final sum\n\n"
                f"Format:\n"
                f"Numbers found: [list]\n"
                f"Sum: n1 + n2 + n3 + ... = TOTAL\n"
                f"ANSWER: [final total]"
            )
            result = self.math_solver._llamacpp_text(sum_prompt)
            if not result:
                self._show_debug_msg("Qwen 8B failed")
                return

            print(f"\n[DEBUG] ===== QWEN 8B RESULT =====\n{result}\n[DEBUG] ===== END =====\n", flush=True)

            # Show result in answer window
            self._show_debug_msg(result[:200])

        except Exception as exc:
            log.error("[DEBUG] Pipeline failed: %s", exc, exc_info=True)
            self._show_debug_msg(f"Error: {exc}")

    def _on_panic_hotkey(self) -> None:
        """Called when panic button hotkey is pressed — toggle hide/show all."""
        if not self.panic_button:
            return
        log.debug("Panic button hotkey triggered (currently hidden=%s)", self.panic_button.is_hidden)
        self.panic_button.toggle()

    def _panic_hide_all(self) -> None:
        """Hide ALL windows, stop AI requests, make program invisible.

        Called by PanicButton.toggle() on first press.
        Unregisters all hotkeys except the panic button itself.
        """
        # Cancel any in-progress AI request
        if self._cancel_event:
            self._cancel_event.set()

        # Hide overlay
        if self.overlay and hasattr(self.overlay, "hide_overlay"):
            try:
                self.overlay.hide_overlay()
            except Exception:
                log.debug("Failed to hide overlay", exc_info=True)

        # Hide answer display
        if self.answer_display and hasattr(self.answer_display, "hide"):
            try:
                self.answer_display.hide()
            except Exception:
                log.debug("Failed to hide answer display", exc_info=True)

        # Hide answer window
        if self.answer_window and hasattr(self.answer_window, "hide"):
            try:
                self.answer_window.hide()
            except Exception:
                log.debug("Failed to hide answer window", exc_info=True)

        # Hide screenshot viewer
        if self.screenshot_viewer and hasattr(self.screenshot_viewer, "hide"):
            try:
                self.screenshot_viewer.hide()
            except Exception:
                log.debug("Failed to hide screenshot viewer", exc_info=True)

        # Stop auto-answerer if running
        if self.auto_answerer and hasattr(self.auto_answerer, "stop"):
            try:
                self.auto_answerer.stop()
            except Exception:
                log.debug("Failed to stop auto-answerer", exc_info=True)

        # Stop page solver if running
        if self.page_solver and hasattr(self.page_solver, "stop"):
            try:
                self.page_solver.stop()
            except Exception:
                log.debug("Failed to stop page solver", exc_info=True)

        # Stop mouse clicker if running
        if self.mouse_clicker and hasattr(self.mouse_clicker, "stop"):
            try:
                self.mouse_clicker.stop()
            except Exception:
                log.debug("Failed to stop mouse clicker", exc_info=True)

        # Unregister all hotkeys except panic button
        pb_hk = self.config.panic_button_hotkey
        self._panic_unregistered_hotkeys: list[tuple[str, object]] = []
        for hk_name, hk_val, hk_cb in self._get_registered_hotkey_list():
            if hk_val and hk_val != pb_hk:
                try:
                    self.hotkey_mgr.unregister(hk_val)
                    self._panic_unregistered_hotkeys.append((hk_val, hk_cb))
                except Exception:
                    log.debug("Failed to unregister hotkey %s", hk_val, exc_info=True)

        log.warning("PANIC HIDE: all windows hidden, %d hotkeys disabled",
                     len(self._panic_unregistered_hotkeys))

    def _panic_show_all(self) -> None:
        """Restore all windows and re-register hotkeys.

        Called by PanicButton.toggle() on second press.
        """
        # Re-register all hotkeys that were unregistered during panic hide
        restored = 0
        for hk_val, hk_cb in getattr(self, "_panic_unregistered_hotkeys", []):
            try:
                self.hotkey_mgr.register(hk_val, hk_cb)
                restored += 1
            except Exception:
                log.debug("Failed to re-register hotkey %s", hk_val, exc_info=True)
        self._panic_unregistered_hotkeys = []

        log.info("PANIC RESTORE: %d hotkeys re-registered", restored)

    def _get_registered_hotkey_list(self) -> list[tuple[str, str | None, object]]:
        """Return list of (name, hotkey_string, callback) for all currently active hotkeys.

        Used by panic button to unregister/re-register hotkeys.
        """
        hotkeys: list[tuple[str, str | None, object]] = []

        # Vision hotkey
        if self.config.vision_enabled and self.config.vision_hotkey:
            hotkeys.append(("vision", self.config.vision_hotkey, self._on_vision_hotkey))

        # Page solver
        if self.page_solver:
            hotkeys.append(("page_solver", self.config.page_solver_hotkey, self._on_page_solver_hotkey))

        # Mouse clicker
        if self.mouse_clicker:
            hotkeys.append(("mouse_clicker", self.config.mouse_clicker_hotkey, self._on_mouse_clicker_hotkey))

        # Answer display
        if self.answer_display:
            hotkeys.append(("answer_display", self.config.answer_display_hotkey, self._on_answer_display_hotkey))

        # Answer window
        if self.answer_window:
            hotkeys.append(("answer_window", self.config.answer_window_hotkey, self._on_answer_window_hotkey))

        # Screenshot viewer
        if self.screenshot_viewer:
            hotkeys.append(("screenshot_viewer", self.config.screenshot_viewer_hotkey, self._on_screenshot_viewer_hotkey))

        # Program launcher
        if self.program_launcher:
            hotkeys.append(("program_launcher", self.config.program_launcher_hotkey, self._on_program_launcher_hotkey))

        # OCR Typist — DISABLED (ctrl+shift+f8 used by debug mode)

        # Debug mode
        hotkeys.append(("debug_toggle", "ctrl+shift+f8", self._on_debug_toggle))

        return hotkeys

    def _on_clipboard_hotkey(self) -> None:
        """Called when clipboard hotkey is pressed — silently process clipboard text."""
        if not self.config.clipboard_mode_enabled:
            return
        log.debug("Clipboard hotkey triggered")
        thread = threading.Thread(
            target=self._process_clipboard_silent, daemon=True, name="clipboard-worker"
        )
        thread.start()

    def _process_clipboard_silent(self) -> None:
        """Read clipboard, send to AI, auto-type response. No overlay shown."""
        import time as _time
        import sys as _sys

        # License check
        if not self._check_license_live():
            log.warning("Clipboard AI blocked: license required")
            return

        # Read clipboard
        try:
            import pyperclip

            text = pyperclip.paste()
        except Exception as exc:
            log.warning("Failed to read clipboard: %s", exc)
            return

        if not text or not text.strip():
            log.info("Clipboard is empty — ignoring")
            return

        log.info("Clipboard silent mode: processing %d chars", len(text))

        # Save target window for paste-back (Windows)
        _target_hwnd = None
        if _sys.platform == "win32":
            import ctypes as _ctypes

            _target_hwnd = _ctypes.windll.user32.GetForegroundWindow()

        # Small delay so hotkey keys are fully released
        _time.sleep(0.3)

        def _paste(response: str) -> None:
            if _target_hwnd and _sys.platform == "win32":
                import ctypes as _ctypes2

                _ctypes2.windll.user32.SetForegroundWindow(_target_hwnd)
                _time.sleep(0.15)
            self.typing_engine.type_text(response)
            # Clear clipboard so the question text is not left in plaintext
            try:
                import pyperclip as _pc

                _pc.copy("")
            except Exception:
                pass

        # Always silent — no overlay, always auto-paste
        self._run_ai_pipeline(text.strip(), silent=True, paste_fn=_paste, track_clipboard=True)

    def _on_prompts_hotkey(self) -> None:
        """Called when the prompts hotkey is pressed — open quick-select popup."""
        if not self.prompt_mgr:
            return
        log.debug("Prompts hotkey triggered")
        if sys.platform == "win32":
            try:
                from PyQt6.QtCore import QTimer

                QTimer.singleShot(0, self._show_quick_prompts)
            except ImportError:
                pass
        else:
            self._show_quick_prompts()

    def _show_quick_prompts(self) -> None:
        """Open the QuickSelectDialog (must be called from Qt main thread on Windows)."""
        if self.prompt_mgr is None:
            return
        try:
            from .ui.prompts_window import QuickSelectDialog

            dlg = QuickSelectDialog(
                self.prompt_mgr,
                on_use_prompt=self._on_use_prompt,
            )
            dlg.exec()
        except Exception as exc:
            log.error("Failed to show quick prompts: %s", exc)

    def _show_prompts_window(self) -> None:
        """Open the full PromptsWindow."""
        if not self.prompt_mgr:
            return
        try:
            from .ui.prompts_window import PromptsWindow

            dlg = PromptsWindow(
                self.prompt_mgr,
                on_use_prompt=self._on_use_prompt,
            )
            dlg.exec()
        except Exception as exc:
            log.error("Failed to show prompts window: %s", exc)

    def _on_use_prompt(self, prompt_id: str, text_override: str | None = None) -> None:
        """Called when user selects a prompt — render and send to AI.

        Args:
            prompt_id:     ID of the prompt to use.
            text_override: If provided, substitute this text for {text} instead of
                           capturing the current selection.  Used by slash commands.
        """
        if not self.prompt_mgr:
            return
        if text_override is not None:
            selected_text = text_override
        else:
            # Capture any selected text to fill {text} placeholder
            try:
                from .platforms import get_selected_text

                selected_text = get_selected_text(stealth=self.config.stealth_enabled)
            except Exception:
                selected_text = ""

        rendered = self.prompt_mgr.render_prompt(prompt_id, variables={"text": selected_text})
        if not rendered:
            log.warning("Prompt id=%s rendered to empty string — skipping", prompt_id)
            return

        self.prompt_mgr.increment_usage(prompt_id)
        log.info("Using prompt id=%s: %d chars", prompt_id, len(rendered))

        thread = threading.Thread(
            target=self._process_explicit_text,
            args=(rendered,),
            daemon=True,
            name="ai-prompt-worker",
        )
        thread.start()

    def _process_command(self, text: str) -> bool:
        """Parse a slash command and trigger the matching prompt.

        Format: ``/shortcut [rest_of_text]``
        ``rest_of_text`` is passed as the ``{text}`` variable to the prompt.

        Returns:
            True if the command was recognised and dispatched, False otherwise.
        """
        if not text.startswith("/") or not self.prompt_mgr:
            return False
        if not self.config.prompts_slash_commands_enabled:
            return False

        shortcut_part, _, rest = text.partition(" ")
        prompt = self.prompt_mgr.find_by_shortcut(shortcut_part)
        if not prompt:
            return False

        rendered = self.prompt_mgr.render_prompt(prompt["id"], variables={"text": rest.strip()})
        if not rendered:
            log.warning("Command '%s' rendered to empty string — skipping", shortcut_part)
            return False

        self.prompt_mgr.increment_usage(prompt["id"])
        log.info("Slash command '%s' → prompt '%s'", shortcut_part, prompt.get("name", ""))

        thread = threading.Thread(
            target=self._process_explicit_text,
            args=(rendered,),
            daemon=True,
            name="ai-command-worker",
        )
        thread.start()
        return True

    def _process_explicit_text(self, text: str) -> None:
        """Send an already-captured/rendered text to AI (skip text capture step)."""
        if not self._check_license_live():
            log.warning("AI request blocked: license required but not active")
            if self.overlay:
                self.overlay.show_response(_("app.license_required"))
            return

        log.info(
            "Processing explicit text: %d chars with provider '%s'", len(text), self.config.provider
        )

        def _paste(response: str) -> None:
            self.typing_engine.type_text(response)

        self._run_ai_pipeline(text, silent=False, paste_fn=_paste)

    def _process_selection(self) -> None:
        """Capture selected text, send to AI, show in overlay."""
        # 0. License check
        if not self._check_license_live():
            log.warning("AI request blocked: license required but not active")
            if self.overlay:
                self.overlay.show_response(_("app.license_required"))
            return

        # 1. Capture selected text
        # Brief delay ensures hotkey keys are released before Ctrl+C simulation
        import time as _time

        _time.sleep(0.5)

        # Save target window BEFORE overlay appears (so we can restore focus later)
        import sys as _sys

        _target_hwnd = None
        if _sys.platform == "win32" and self.config.auto_paste:
            import ctypes as _ctypes

            _target_hwnd = _ctypes.windll.user32.GetForegroundWindow()
            log.info("Saved target window hwnd=%s for auto-paste", _target_hwnd)

        from .platforms import get_selected_text

        text = get_selected_text(stealth=self.config.stealth_enabled)

        if not text.strip():
            log.info("No text selected — ignoring hotkey")
            if self.overlay and hasattr(self.overlay, "show_error"):
                self.overlay.show_response(
                    "\u26a0\ufe0f No text selected\n\nSelect text first, then press hotkey."
                )
            return

        log.info("Processing %d chars with provider '%s'", len(text), self.config.provider)

        # 1b. Check knowledge base for instant answer
        if self.knowledge_mgr:
            kb_result = self.knowledge_mgr.find_answer(text.strip())
            if kb_result:
                log.info("Knowledge base hit: %s", kb_result["question"][:50])
                answer = kb_result["answer"]
                if self.config.auto_paste:
                    # Silent mode: auto-paste directly without overlay
                    if _target_hwnd and _sys.platform == "win32":
                        import ctypes as _ctypes2

                        _ctypes2.windll.user32.SetForegroundWindow(_target_hwnd)
                        _time.sleep(0.15)
                    if answer:
                        self.typing_engine.type_text(answer)
                else:
                    if self.overlay:
                        self.overlay.show_response(answer)
                return

        # Silent mode: when auto_paste is on — skip overlay entirely
        _silent = self.config.auto_paste

        def _paste(response: str) -> None:
            if _target_hwnd and _sys.platform == "win32":
                import ctypes as _ctypes2

                _ctypes2.windll.user32.SetForegroundWindow(_target_hwnd)
                _time.sleep(0.15)
                log.info("Restored focus to hwnd=%s before paste", _target_hwnd)
            self.typing_engine.type_text(response)

        self._run_ai_pipeline(text, silent=_silent, paste_fn=_paste, track_clipboard=True)

    # ------------------------------------------------------------------ #
    # Shared AI processing pipeline
    # ------------------------------------------------------------------ #

    def _run_ai_pipeline(
        self,
        text: str,
        *,
        silent: bool = False,
        paste_fn: Callable[[str], None] | None = None,
        track_clipboard: bool = False,
    ) -> None:
        """Shared AI request pipeline used by both _process_selection and _process_explicit_text.

        Args:
            text:            Input text to send to AI.
            silent:          If True, skip overlay updates (stealth/auto-paste mode).
            paste_fn:        Optional callable(str) for auto-paste on success.
            track_clipboard: If True, add input text to clipboard history.
        """
        if self.overlay and not silent:
            if not hasattr(self.overlay, "show_thinking") or self.overlay._state != "thinking":
                self.overlay.show_response(_("app.processing"))

        if hasattr(self, "tray"):
            self.tray.set_busy(True)

        cancel_event = threading.Event()
        self._cancel_event = cancel_event

        full_response = ""
        cancelled = False
        success = False
        cached = False
        detected = "general"
        t_start = time.monotonic()

        from .utils.normalize import query_hash
        from .utils.token_counter import count_tokens

        input_tokens = count_tokens(text, self.config.stats_token_encoding)

        provider = self.config.provider
        model = self.config.model
        use_cache = (
            self.cache_mgr is not None
            and self.config.cache_enabled
            and provider not in self.config.cache_ignore_providers
            and model not in self.config.cache_ignore_models
        )
        cache_key = query_hash(text) if use_cache else ""

        try:
            # 1. Check cache before calling AI
            if use_cache and self.cache_mgr is not None:
                cached_text = self.cache_mgr.get(cache_key, provider, model)
                if cached_text:
                    full_response = cached_text
                    success = True
                    cached = True
                    log.info("Cache hit: %d chars (provider=%s)", len(full_response), provider)
                    if self.overlay and not silent:
                        self.overlay.show_response(full_response)
                    if self.config.auto_paste and full_response and paste_fn:
                        paste_fn(full_response)
                    return

            # 2. Auto-detect template type for optimal system prompt
            from .prompts.auto_detect import TEMPLATE_PROMPTS, detect_template

            detected = detect_template(text)
            system_prompt = TEMPLATE_PROMPTS.get(detected, self.config.system_prompt)

            # 3. Build AI request (with conversation memory if enabled)
            from .ai.base import AIRequest

            if self.memory:
                self.memory.set_system_prompt(system_prompt)
                self.memory.add_user_message(text)
                request = AIRequest(
                    prompt=text,
                    system_prompt=system_prompt,
                    messages=self.memory.get_messages_with_system(),
                )
            else:
                request = AIRequest(prompt=text, system_prompt=system_prompt)

            # 4. Send request (streaming or non-streaming)
            if self.config.stream_enabled:
                if self.overlay and not silent:
                    self.overlay.show_response("")
                    self.overlay.set_streaming_signal.emit(True)

                for chunk in self.adapter.stream(request, cancel_event=cancel_event):
                    full_response += chunk
                    if self.overlay and not silent:
                        self.overlay.append_chunk(chunk)

                cancelled = cancel_event.is_set()
                success = bool(full_response) and not cancelled
            else:
                from typing import cast as _cast

                from .ai.base import AIResponse as _AIResponse

                response = _cast(_AIResponse, self.adapter.ask(request))
                if response.ok:
                    full_response = response.text
                    success = True
                    if self.overlay and not silent:
                        self.overlay.show_response(full_response)
                else:
                    if self.overlay and not silent:
                        self.overlay.show_response(_("app.error_response", error=response.error))

            # 5. Save to cache
            if (
                use_cache
                and self.cache_mgr is not None
                and success
                and not cancelled
                and full_response
            ):
                try:
                    self.cache_mgr.set(cache_key, provider, model, full_response)
                except Exception as exc:
                    log.warning("Failed to save to cache: %s", exc)

            # 6. Save to conversation memory
            if success and not cancelled and full_response and self.memory:
                self.memory.add_assistant_message(full_response)
                self.memory.save()

            # 7. Auto-learn: save Q&A to knowledge base (with dedup)
            if (
                success
                and not cancelled
                and full_response
                and self.knowledge_mgr
                and self.config.knowledge_auto_learn
            ):
                try:
                    existing = self.knowledge_mgr.find_answer(text.strip())
                    if not existing:
                        self.knowledge_mgr.add_qa(
                            question=text.strip(),
                            answer=full_response.strip(),
                            source="ai",
                        )
                except Exception as exc:
                    log.warning("Failed to auto-learn to knowledge base: %s", exc)

            # 8. Auto-paste
            if not cancelled and self.config.auto_paste and full_response and paste_fn:
                paste_fn(full_response)

        except Exception as exc:
            log.error("Processing error: %s", exc)
            if self.overlay and not silent:
                if hasattr(self.overlay, "show_error"):
                    self.overlay.show_error(f"\n\n{exc}")
                else:
                    self.overlay.append_chunk(_("app.error_exception", error=exc))
        finally:
            duration_ms = int((time.monotonic() - t_start) * 1000)
            # Visual feedback: green bar when done, handled before set_streaming
            if self.overlay and not silent and success and not cancelled:
                if hasattr(self.overlay, "show_done"):
                    self.overlay.show_done()
            if self.overlay and not silent:
                self.overlay.set_streaming_signal.emit(False)
            if hasattr(self, "tray"):
                self.tray.set_busy(False)

            # Record stats
            if self.stats_mgr:
                output_tokens = count_tokens(full_response, self.config.stats_token_encoding)
                try:
                    self.stats_mgr.add_record(
                        provider=provider,
                        model=model,
                        input_tokens=input_tokens,
                        output_tokens=output_tokens,
                        duration_ms=duration_ms,
                        success=success,
                        cancelled=cancelled,
                        cached=cached,
                    )
                except Exception as exc:
                    log.warning("Failed to record stats: %s", exc)

            # Record cost
            if self.cost_tracker and success and full_response:
                try:
                    self.cost_tracker.record(model, text, full_response)
                except Exception as exc:
                    log.warning("Failed to record cost: %s", exc)

            # Record to history
            if self.history_mgr and success and full_response:
                try:
                    self.history_mgr.add(
                        text=text.strip(),
                        template=detected,
                        result=full_response.strip(),
                        provider=provider,
                        tokens=input_tokens,
                    )
                except Exception as exc:
                    log.warning("Failed to record history: %s", exc)

            # Auto-save snippet
            if self.snippet_mgr and self.config.snippets_enabled and success and full_response:
                try:
                    self.snippet_mgr.save_snippet(
                        text=full_response.strip(),
                        title=text.strip()[:50],
                        tags=[provider, detected],
                    )
                except Exception as exc:
                    log.warning("Failed to auto-save snippet: %s", exc)

            # Track clipboard for context
            if track_clipboard and self.clipboard_history and text.strip():
                try:
                    self.clipboard_history.add(text.strip())
                except Exception as exc:
                    log.warning("Failed to track clipboard: %s", exc)

    def _process_screenshot(self) -> None:
        """Capture the screen, send to Vision AI, stream result and auto-paste."""
        from .ai.vision_adapter import VISION_PROVIDERS, VisionAdapter, VisionError

        # License check
        if not self._check_license_live():
            log.warning("Vision request blocked: license required but not active")
            return

        provider_name = self.config.vision_provider
        if provider_name not in VISION_PROVIDERS:
            log.warning("[vision] Unsupported provider '%s'", provider_name)
            return

        # Save target window BEFORE screenshot (so we can restore focus for paste)
        _target_hwnd = None
        if sys.platform == "win32" and self.config.auto_paste:
            import ctypes as _ctypes

            _target_hwnd = _ctypes.windll.user32.GetForegroundWindow()
            log.info("[vision] Saved target hwnd=%s for auto-paste", _target_hwnd)

        # Silent mode: when auto_paste is on — skip overlay entirely
        _silent = self.config.auto_paste

        if self.overlay and not _silent:
            self.overlay.show_response(_("app.vision_processing"))
        if hasattr(self, "tray"):
            self.tray.set_busy(True)

        cancel_event = threading.Event()
        self._cancel_event = cancel_event

        full_response = ""
        cancelled = False

        try:
            # Capture + resize + encode
            from .screenshot import capture_screen
            from .utils.image_utils import image_to_base64, resize_image

            img = capture_screen()
            img = resize_image(img)
            image_b64 = image_to_base64(img)
            log.info(
                "[vision] Screenshot captured: %dx%d → %d base64 chars",
                img.size[0],
                img.size[1],
                len(image_b64),
            )

            prompt = self.config.vision_default_prompt
            adapter = VisionAdapter(self.config)

            if self.overlay and not _silent:
                self.overlay.show_response("")
                self.overlay.set_streaming_signal.emit(True)

            for chunk in adapter.analyze(image_b64, prompt, cancel_event=cancel_event):
                full_response += chunk
                if self.overlay and not _silent:
                    self.overlay.append_chunk(chunk)

            cancelled = cancel_event.is_set()
            log.info(
                "[vision] Stream finished: %d chars%s",
                len(full_response),
                " (cancelled)" if cancelled else "",
            )

            # Auto-paste
            if not cancelled and self.config.auto_paste and full_response:
                if _target_hwnd and sys.platform == "win32":
                    import ctypes as _ctypes2
                    import time as _time

                    _ctypes2.windll.user32.SetForegroundWindow(_target_hwnd)
                    _time.sleep(0.15)
                    log.info("[vision] Restored focus to hwnd=%s before paste", _target_hwnd)
                self.typing_engine.type_text(full_response)

        except VisionError as exc:
            log.error("[vision] Error: %s", exc)
            if self.overlay and not _silent:
                self.overlay.show_response(str(exc))
        except RuntimeError as exc:
            log.error("[vision] Screenshot capture failed: %s", exc)
            if self.overlay and not _silent:
                self.overlay.show_response(_("app.error_exception", error=exc))
        except Exception as exc:
            log.error("[vision] Processing error: %s", exc)
            if self.overlay and not _silent:
                self.overlay.append_chunk(_("app.error_exception", error=exc))
        finally:
            if self.overlay and not _silent:
                self.overlay.set_streaming_signal.emit(False)
            if hasattr(self, "tray"):
                self.tray.set_busy(False)

    # ------------------------------------------------------------------ #
    # Insert / settings / quit
    # ------------------------------------------------------------------ #

    def _clear_cache(self) -> int:
        """Clear all cached AI responses. Returns number of deleted entries."""
        if self.cache_mgr is None:
            return 0
        count = self.cache_mgr.clear()
        log.info("Cache cleared: %d entries removed", count)
        return count

    def _on_cancel_stream(self) -> None:
        """Called when user clicks 'Отмена' in the overlay."""
        if self._cancel_event is not None:
            self._cancel_event.set()
            log.info("User cancelled AI stream")

    def _show_stats(self) -> None:
        """Open the statistics window."""
        if not self.stats_mgr:
            return
        try:
            from .ui.stats_window import StatsWindow

            dlg = StatsWindow(self.stats_mgr)
            dlg.exec()
        except Exception as exc:
            log.error("Failed to show stats window: %s", exc)

    def _on_insert(self, text: str) -> None:
        """Called when user clicks 'Вставить' in the overlay."""
        threading.Thread(
            target=self.typing_engine.type_text,
            args=(text,),
            daemon=True,
        ).start()

    def _show_debug_screenshot(self) -> None:
        """Open the debug screenshot test window (shows screenshot on top of all windows)."""
        try:
            if sys.platform not in ("win32", "darwin"):
                log.warning("Debug screenshot not supported on %s", sys.platform)
                return
            from PyQt6.QtWidgets import QApplication
            from .ui.debug_screenshot_win import DebugScreenshotWindow

            app = QApplication.instance()
            if app is None:
                log.warning("No Qt application running — cannot show debug screenshot")
                return

            # Create once, reuse on subsequent calls
            if self._debug_screenshot_win is None:
                self._debug_screenshot_win = DebugScreenshotWindow()

            self._debug_screenshot_win.show_centered()
        except Exception:
            log.exception("Failed to show debug screenshot window")

    def _show_settings(self) -> None:
        """Show the settings dialog/window."""
        try:
            dlg = _get_settings_dialog(
                self.config,
                on_hotkey_change=self._on_hotkey_change,
                on_check_update=lambda: self.updater.check_async(force=True),
                on_clear_cache=self._clear_cache,
                on_language_change=self._on_language_change,
                on_theme_change=self._on_theme_change,
                on_prompts_hotkey_change=self._on_prompts_hotkey_change
                if self.prompt_mgr
                else None,
                on_vision_hotkey_change=self._on_vision_hotkey_change
                if self.config.vision_enabled
                else None,
            )
            if sys.platform == "win32":
                dlg.exec()
            else:
                dlg.show()
        except Exception as exc:
            log.error("Failed to show settings: %s", exc)

    def _on_language_change(self, lang_code: str) -> None:
        """Retranslate all open windows after a language switch."""
        log.info("Language changed to '%s' — retranslating UI", lang_code)
        if self.overlay and hasattr(self.overlay, "retranslate_ui"):
            try:
                self.overlay.retranslate_ui()
            except Exception as exc:
                log.warning("Failed to retranslate overlay: %s", exc)

    def _on_theme_change(self, theme: str) -> None:
        """Apply a new colour theme to all open UI components.

        Called from any thread (darkdetect listener or settings dialog).
        All Qt operations are deferred to the main thread via QTimer.singleShot.
        """
        log.info("Theme changed to '%s'", theme)

        # Apply Qt global stylesheet (Windows — deferred to Qt main thread)
        if sys.platform == "win32":
            try:
                from PyQt6.QtCore import QTimer

                QTimer.singleShot(0, lambda: self._apply_qt_theme(theme))
            except ImportError:
                pass

        # Update tray icon (pystray is thread-safe for icon updates)
        if hasattr(self, "tray"):
            try:
                self.tray.update_icon_theme(theme)
            except Exception as exc:
                log.warning("Failed to update tray icon: %s", exc)

        # Update overlay appearance
        if self.overlay and hasattr(self.overlay, "apply_theme"):
            try:
                self.overlay.apply_theme(theme)
            except Exception as exc:
                log.warning("Failed to update overlay theme: %s", exc)

    def _apply_qt_theme(self, theme: str) -> None:
        """Load and apply the QSS stylesheet for the given theme to QApplication."""
        try:
            from PyQt6.QtWidgets import QApplication

            app = QApplication.instance()
            if app is None:
                return
            qss = self.theme_mgr.load_qss(theme)
            app.setStyleSheet(qss)  # type: ignore[attr-defined]
            log.debug("Qt theme applied: %s (%d bytes QSS)", theme, len(qss))
        except ImportError:
            pass
        except Exception as exc:
            log.warning("Failed to apply Qt theme: %s", exc)

    def _on_hotkey_change(self, new_hotkey: str) -> bool:
        """Re-register hotkey after settings change. Returns True on success.

        Uses _active_hotkey (the actually-registered hotkey) as the old key to
        unregister, rather than config.hotkey, to avoid a race where config is
        updated before the OS registration is swapped.
        """
        old_hotkey = self._active_hotkey
        ok = self.hotkey_mgr.update_hotkey(old_hotkey, new_hotkey, self._on_hotkey)
        if ok:
            self._active_hotkey = new_hotkey
            log.info("Hotkey changed: %s → %s", old_hotkey, new_hotkey)
        else:
            log.error("Failed to register new hotkey: %s", new_hotkey)
        return ok

    def _on_prompts_hotkey_change(self, new_hotkey: str) -> bool:
        """Re-register prompts hotkey after settings change. Returns True on success."""
        old_hotkey = self._active_prompts_hotkey
        ok = self.hotkey_mgr.update_hotkey(old_hotkey, new_hotkey, self._on_prompts_hotkey)
        if ok:
            self._active_prompts_hotkey = new_hotkey
            log.info("Prompts hotkey changed: %s → %s", old_hotkey, new_hotkey)
        else:
            log.error("Failed to register new prompts hotkey: %s", new_hotkey)
        return ok

    def _on_vision_hotkey_change(self, new_hotkey: str) -> bool:
        """Re-register vision hotkey after settings change. Returns True on success."""
        old_hotkey = self._active_vision_hotkey
        ok = self.hotkey_mgr.update_hotkey(old_hotkey, new_hotkey, self._on_vision_hotkey)
        if ok:
            self._active_vision_hotkey = new_hotkey
            log.info("Vision hotkey changed: %s → %s", old_hotkey, new_hotkey)
        else:
            log.error("Failed to register new vision hotkey: %s", new_hotkey)
        return ok

    def _on_update_available(self, new_version: str, download_url: str) -> None:
        """Notify user about available update."""
        log.info("Update available: v%s — %s", new_version, download_url)
        if hasattr(self, "tray"):
            self.tray.show_notification(
                _("app.update_title"),
                _("app.update_msg", version=new_version),
            )

    def _quit(self) -> None:
        """Shutdown all components."""
        log.info("Shutting down MuraveyChina")
        self.hotkey_mgr.stop()
        # Flush history before exit
        if self.history_mgr:
            self.history_mgr.flush()
        # Cleanup registered resources
        self.resource_mgr.cleanup_all()
        self._shutdown.set()
        sys.exit(0)


def main() -> None:
    """Application entry point."""
    # If launched as watchdog subprocess — run monitor loop instead of GUI
    from .seb.watchdog import is_watchdog_mode, run_watchdog

    if is_watchdog_mode():
        run_watchdog()
        return

    try:
        app = AppContext()

        # If expired — show activation/buy dialog
        if not app.licensed and app._license_status == "expired":
            from .ui.expired_dialog_win import show_expired_dialog
            pricing = {}
            ref_code = ""
            cutoff = ""
            if hasattr(app, '_license_v2') and app._license_v2:
                pricing = app._license_v2.get_pricing()
                ref_code = app._license_v2.get_referral_code()
                cutoff = app._license_v2.get_free_cutoff_date()
            activated = show_expired_dialog(
                status="expired",
                free_cutoff=cutoff,
                referral_code=ref_code,
                pricing=pricing,
            )
            if activated:
                # Token activated — reload license state and continue
                app.licensed = True
                app._license_status = "paid"
            else:
                sys.exit(0)

        app.start()
    except Exception as exc:
        logging.basicConfig(level=logging.ERROR)
        log.exception("Fatal error: %s", exc)
        sys.exit(1)


if __name__ == "__main__":  # pragma: no cover
    main()
