"""Configuration management for MuraveyChina.

Loads/saves config from platform-specific user config directory.
API keys are NEVER stored here — use security.privacy_manager instead.
"""

from __future__ import annotations

import copy
import logging
import os
import sys
from pathlib import Path
from typing import Any

import yaml

log = logging.getLogger(__name__)

_DEFAULT_CONFIG: dict[str, Any] = {
    "provider": "proxy",
    "model": "deepseek-chat",
    "proxy_base_url": "https://license.muravey.app/v1",
    "hotkey": "ctrl+shift+space",
    # auto_paste is permanently True (silent mode) — not configurable
    "first_activation_shown": False,
    "overlay_opacity": 0.95,
    "overlay_position": {"x": None, "y": None},
    "language": "zh",
    # UI locale: "en" | "ru" | "zh_CN"
    "locale": "en",
    # UI colour theme: "light" | "dark" | "system" (follow the OS)
    "theme": "system",
    "update": {
        "github_repo": "",
        # Disabled by default: GitHub is blocked in China (GFW).
        # Users with VPN can enable manually.
        "check_on_start": False,
        "last_check": None,
    },
    "license": {
        # URL of your license verification endpoint (POST /verify).
        # Leave empty for stub mode (any 8+ char key is accepted).
        "server_url": "",
        # Cache TTL in seconds. Default: 86400 (24 hours).
        "cache_ttl": 86400,
        # If True, AI requests are blocked until a valid license is present.
        "require_license": False,
        # Grace period (seconds) for offline tolerance after session expires.
        # 24h to handle GFW blocking of license server in China.
        "grace_period": 86400,
        # How often (seconds) to validate session with the license server.
        "validation_interval": 3600,
    },
    "ai": {
        # Show AI response tokens as they arrive (true) or wait for the full
        # response before displaying (false).
        "stream_enabled": False,
        # Base URL for the local Ollama server (OpenAI-compatible endpoint).
        # Only used when provider = "ollama".
        "ollama_base_url": "http://localhost:11434/v1",
        # System prompt sent to AI with every request.
        # Customize to improve answer quality for your use case.
        "system_prompt": "You are an exam assistant. Answer questions accurately and concisely. Always respond in the same language as the question. For multiple choice questions, output only the correct option letter (A, B, C or D). For open-ended questions, give a clear and precise answer without unnecessary explanations.",
    },
    "stats": {
        # Set to false to disable statistics collection entirely.
        "enabled": True,
        # Path to the SQLite database. ~ is expanded at runtime.
        "db_path": "~/.muravey-china/stats.db",
        # tiktoken encoding for token counting.
        # cl100k_base is compatible with GPT-4, Deepseek, and Aliyun.
        "token_encoding": "cl100k_base",
    },
    "cache": {
        # Set to false to disable AI response caching entirely.
        "enabled": True,
        # Path to the SQLite cache database. ~ is expanded at runtime.
        "db_path": "~/.muravey-china/cache.db",
        # How long (seconds) to keep cached responses. Default: 86400 (24 h).
        "ttl_seconds": 86400,
        # Providers whose responses should never be cached (e.g. ["88api"]).
        "ignore_providers": [],
        # Models whose responses should never be cached.
        "ignore_models": [],
    },
    "prompts": {
        # Set to false to disable the prompts feature entirely.
        "enabled": True,
        # Path to the JSON prompts database. ~ is expanded at runtime.
        "db_path": "~/.muravey-china/prompts.json",
        # Global hotkey to open the quick-select prompts popup.
        "hotkey": "ctrl+shift+f1",
        # Enable slash commands in QuickSelectDialog (type /shortcut to trigger).
        "enable_slash_commands": True,
    },
    "stealth": {
        # Enable stealth-compatible mode for restricted exam environments.
        "enabled": False,
        # Extra exam software process names (beyond built-in SEB/CSCA list)
        # that trigger automatic stealth-mode activation.
        # Example: ["MyExam.exe", "LockScreen.exe"]
        "auto_detect_processes": [],
        "provider": "ollama",
        "model": "qwen2.5:1.5b",
        # Biometric typing: realistic human-like delays with typos.
        "biometric_typing": False,
        "typing_speed_ms": 50,
        "typo_rate": 0.01,
        # Standard deviation (ms) for gaussian typing delay distribution.
        "typing_variation_ms": 20,
        # Extra pause (ms) added after sentence-ending punctuation.
        "punctuation_pause_ms": 150,
        # How often (seconds) to re-check for exam processes (cache TTL).
        "detection_ttl_seconds": 5,
        # PEB masquerade: hide process from Task Manager and ProcessChecker.
        "process_masquerade": True,
        "masquerade_name": "RuntimeBroker.exe",
    },
    "conversation": {
        "enabled": True,
        "max_context_tokens": 80000,
        "keep_recent": 6,
        "max_messages": 100,
    },
    "knowledge": {
        "enabled": True,
        "db_path": "~/.muravey-china/knowledge.db",
        "similarity_threshold": 0.85,
        "auto_learn": False,
    },
    "cost": {
        "enabled": True,
    },
    "snippets": {
        "enabled": True,
    },
    "history": {
        "enabled": True,
        "max_entries": 1000,
        "autosave_interval": 10,
    },
    "clipboard_history": {
        "enabled": True,
        "max_entries": 10,
    },
    "panic_button": {
        "enabled": True,
        "hotkey": "ctrl+shift+f12",
    },
    "proxy": {
        # Manual proxy URL. Leave empty for auto-detection.
        # Supports http://, socks5://, socks4:// schemes.
        # Examples: "socks5://127.0.0.1:1080", "http://proxy.local:8080"
        "url": "",
        # Auto-detect local proxy (Clash, Shadowsocks, V2Ray, etc.)
        # when behind the GFW. Scans well-known local ports.
        "auto_detect": True,
        # SSH tunnel settings (optional). Start a local port-forward
        # to bypass the GFW when no other proxy is available.
        "ssh_tunnel": {
            "enabled": False,
            # SSH target: "user@host" or just "host" (uses ~/.ssh/config)
            "target": "",
            "local_port": 11080,
            "remote_host": "127.0.0.1",
            "remote_port": 443,
            "ssh_port": 22,
            # Path to SSH private key (empty = use default ~/.ssh/id_rsa)
            "ssh_key": "",
        },
    },
    "fallback_chain": ["deepseek", "aliyun", "88api", "ollama"],
    "auto_answer": {
        # Auto-answer: screenshot → AI → press key or type answer.
        # Single press: answer current question.
        # Double press (within 0.7s): full-auto — answer all questions.
        # Press again during full-auto: cancel.
        "enabled": True,
        # Hotkey to trigger auto-answer (ctrl+fn is not supported on Windows).
        "hotkey": "ctrl+shift+f6",
        # Key to advance to the next question after answering (enter or tab).
        "advance_key": "enter",
        # Seconds to wait between questions in full-auto mode.
        "advance_delay": 2.0,
    },
    "page_solver": {
        # Full-page solver: scrolls to top, scans entire page, answers ALL questions.
        # Handles multiple choice (key press), short text, and essays (text AI).
        # Press once to start, press again to cancel.
        "enabled": True,
        "hotkey": "ctrl+f10",
        # Key to advance between questions (tab recommended for most exam software).
        "advance_key": "tab",
        # Seconds to wait after each answer before moving to the next.
        "advance_delay": 1.0,
        # Max image size — screenshots > 1920x1080 are resized; smaller sent as-is.
        "max_image_size": 1920,
    },
    "answer_display": {
        # Answer Display: screenshot → AI → stealth overlay with answer.
        # Shows correct answer in bottom-right corner, invisible to screen sharing.
        "enabled": True,
        "hotkey": "ctrl+f9",
        # Seconds to show the answer overlay before auto-hiding (0 = manual hide).
        "timeout_sec": 5,
        # Max image size — screenshots > 1920x1080 are resized; smaller sent as-is.
        "max_image_size": 1920,
        # AI request timeout in seconds.
        "ai_timeout": 90.0,
    },
    "answer_window": {
        # Answer Window: screenshot → AI → small popup window with answer.
        # Same as answer_display but shows in a visible mini-window above CSCA.
        "enabled": True,
        "hotkey": "ctrl+f1",
        # Seconds to show the window before auto-hiding (0 = manual hide).
        "timeout_sec": 8,
        # Max image size — screenshots > 1920x1080 are resized; smaller sent as-is.
        "max_image_size": 1920,
        # AI request timeout in seconds.
        "ai_timeout": 90.0,
        # Text model for Stage 2 (reasoning). deepseek-chat = fast online.
        "text_model": "deepseek-chat",
    },
    "vision": {
        # Set to false to disable Vision (screenshot analysis) entirely.
        "enabled": True,
        # Provider for vision requests. aliyun = Qwen VL online (fast + accurate).
        "provider": "aliyun",
        # Vision model to use. qwen-vl-max is the most accurate for Chinese text + math.
        "model": "qwen-vl-max",
        # Vision hotkey disabled — use page_solver (shift+tab) instead.
        "hotkey": "ctrl+f7",
        # Maximum pixels for the longest side of the screenshot before sending to AI.
        "max_image_size": 1920,
        # Default prompt sent alongside the screenshot.
        "default_prompt": "Describe what you see in this screenshot in detail.",
    },
    "ocr_typist": {
        # OCR Typist: DISABLED — ctrl+shift+f8 reserved for debug mode.
        "enabled": False,
        "hotkey": "ctrl+shift+f8",
        "test_hotkey": "ctrl+shift+f9",
    },
    "math_solver": {
        # Math Solver (AK): screenshot → Ollama qwen2.5VL → SymPy → human answer.
        # Takes full screenshot, sends to vision model to extract math problem,
        # converts to SymPy code, solves it, and displays the human-readable answer.
        "enabled": True,
        "hotkey": "ctrl+`",
        # Ollama model for vision (must be vision-capable).
        "vision_model": "qwen2.5vl:3b",
        # Ollama base URL (uses the global ai.ollama_base_url by default).
        "ollama_base_url": "",
        # AI request timeout in seconds (vision models can be slow).
        "ai_timeout": 120.0,
        # Max image size — 1280 optimal for qwen2.5vl (quality + speed).
        "max_image_size": 1280,
    },
}


def _get_config_dir() -> Path:
    """Return platform-specific config directory."""
    if sys.platform == "win32":
        base = os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming"))
        return Path(base) / "MuraveyChina"
    elif sys.platform == "darwin":
        return Path.home() / "Library" / "Application Support" / "MuraveyChina"
    else:
        xdg = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
        return Path(xdg) / "MuraveyChina"


def _get_log_dir() -> Path:
    """Return platform-specific log directory."""
    if sys.platform == "win32":
        base = os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local"))
        return Path(base) / "MuraveyChina" / "Logs"
    elif sys.platform == "darwin":
        return Path.home() / "Library" / "Logs" / "MuraveyChina"
    else:
        return _get_config_dir() / "logs"


class Config:
    """Application configuration — reads/writes YAML, no API keys."""

    def __init__(self, data: dict[str, Any], path: Path) -> None:
        self._data = data
        self._path = path

    @classmethod
    def load(cls) -> Config:
        """Load config from disk, falling back to defaults."""
        config_dir = _get_config_dir()
        config_dir.mkdir(parents=True, exist_ok=True)
        config_path = config_dir / "config.yaml"

        data = copy.deepcopy(_DEFAULT_CONFIG)

        if config_path.exists():
            try:
                with open(config_path, encoding="utf-8") as f:
                    loaded = yaml.safe_load(f) or {}
                _deep_merge(data, loaded)
                log.debug("Config loaded from %s", config_path)
            except Exception as exc:
                log.warning("Failed to load config: %s — using defaults", exc)
        else:
            # Write defaults on first run
            cls._write(data, config_path)
            log.info("Created default config at %s", config_path)

        return cls(data, config_path)

    def save(self) -> None:
        """Persist config to disk."""
        self._path.parent.mkdir(parents=True, exist_ok=True)
        self._write(self._data, self._path)

    @staticmethod
    def _write(data: dict[str, Any], path: Path) -> None:
        with open(path, "w", encoding="utf-8") as f:
            yaml.dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)

    # ------------------------------------------------------------------ #
    # Typed accessors
    # ------------------------------------------------------------------ #

    @property
    def provider(self) -> str:
        return str(self._data.get("provider", "proxy"))

    @provider.setter
    def provider(self, value: str) -> None:
        self._data["provider"] = value

    @property
    def model(self) -> str:
        return str(self._data.get("model", "deepseek-chat"))

    @model.setter
    def model(self, value: str) -> None:
        self._data["model"] = value

    @property
    def hotkey(self) -> str:
        return str(self._data.get("hotkey", "ctrl+shift+space"))

    @hotkey.setter
    def hotkey(self, value: str) -> None:
        self._data["hotkey"] = value

    @property
    def auto_paste(self) -> bool:
        """Always True — silent mode is permanently enabled and cannot be changed."""
        return True

    @property
    def first_activation_shown(self) -> bool:
        """True after the welcome tutorial has been shown once."""
        return bool(self._data.get("first_activation_shown", False))

    @first_activation_shown.setter
    def first_activation_shown(self, value: bool) -> None:
        self._data["first_activation_shown"] = value
        self.save()

    @property
    def overlay_opacity(self) -> float:
        raw = float(self._data.get("overlay_opacity", 0.95))
        return max(0.1, min(1.0, raw))

    @property
    def overlay_position(self) -> dict[str, Any]:
        pos = self._data.get("overlay_position", {})
        return pos if isinstance(pos, dict) else {"x": None, "y": None}

    def set_overlay_position(self, x: int, y: int) -> None:
        self._data["overlay_position"] = {"x": x, "y": y}

    @property
    def github_repo(self) -> str:
        update = self._data.get("update", {})
        return str(update.get("github_repo", ""))

    @github_repo.setter
    def github_repo(self, value: str) -> None:
        self._data.setdefault("update", {})["github_repo"] = value

    @property
    def check_on_start(self) -> bool:
        update = self._data.get("update", {})
        # Default False: GitHub is blocked in China (GFW).
        return bool(update.get("check_on_start", False))

    def get(self, key: str, default: Any = None) -> Any:
        """Generic getter for nested dot-notation keys."""
        parts = key.split(".")
        node: Any = self._data
        for part in parts:
            if not isinstance(node, dict):
                return default
            node = node.get(part, default)
        return node

    def set(self, key: str, value: Any) -> None:
        """Generic setter for nested dot-notation keys."""
        parts = key.split(".")
        node = self._data
        for part in parts[:-1]:
            existing = node.get(part)
            if not isinstance(existing, dict):
                node[part] = {}
            node = node[part]
        node[parts[-1]] = value

    # ------------------------------------------------------------------ #
    # License accessors
    # ------------------------------------------------------------------ #

    @property
    def license_server_url(self) -> str:
        lic = self._data.get("license", {})
        return str(lic.get("server_url", ""))

    @license_server_url.setter
    def license_server_url(self, value: str) -> None:
        self._data.setdefault("license", {})["server_url"] = value

    @property
    def license_cache_ttl(self) -> int:
        lic = self._data.get("license", {})
        return int(lic.get("cache_ttl", 86400))

    @property
    def license_required(self) -> bool:
        # Hardcoded — cannot be overridden via config file
        return True

    @property
    def license_grace_period(self) -> int:
        lic = self._data.get("license", {})
        return int(lic.get("grace_period", 1800))

    @property
    def license_validation_interval(self) -> int:
        lic = self._data.get("license", {})
        return int(lic.get("validation_interval", 3600))

    # ------------------------------------------------------------------ #
    # AI accessors
    # ------------------------------------------------------------------ #

    @property
    def stream_enabled(self) -> bool:
        """Return True if token-by-token streaming is enabled."""
        ai_cfg = self._data.get("ai", {})
        return bool(ai_cfg.get("stream_enabled", True))

    @stream_enabled.setter
    def stream_enabled(self, value: bool) -> None:
        self._data.setdefault("ai", {})["stream_enabled"] = value

    @property
    def ollama_base_url(self) -> str:
        """Base URL for the local Ollama OpenAI-compatible endpoint."""
        return str(self._data.get("ai", {}).get("ollama_base_url", "http://localhost:11434/v1"))

    @ollama_base_url.setter
    def ollama_base_url(self, value: str) -> None:
        self._data.setdefault("ai", {})["ollama_base_url"] = value

    @property
    def proxy_base_url(self) -> str:
        """Base URL for the MuraveyChina license proxy server."""
        return str(self._data.get("proxy_base_url", "https://license.muravey.app/v1"))

    @proxy_base_url.setter
    def proxy_base_url(self, value: str) -> None:
        self._data["proxy_base_url"] = value

    @property
    def system_prompt(self) -> str:
        """System prompt sent to AI with every request."""
        return str(
            self._data.get("ai", {}).get(
                "system_prompt",
                "You are a helpful assistant.",
            )
        )

    @system_prompt.setter
    def system_prompt(self, value: str) -> None:
        self._data.setdefault("ai", {})["system_prompt"] = value

    # ------------------------------------------------------------------ #
    # Stats accessors
    # ------------------------------------------------------------------ #

    @property
    def stats_enabled(self) -> bool:
        return bool(self._data.get("stats", {}).get("enabled", True))

    @property
    def stats_db_path(self) -> str:
        return str(self._data.get("stats", {}).get("db_path", "~/.muravey-china/stats.db"))

    @property
    def stats_token_encoding(self) -> str:
        return str(self._data.get("stats", {}).get("token_encoding", "cl100k_base"))

    # ------------------------------------------------------------------ #
    # Cache accessors
    # ------------------------------------------------------------------ #

    @property
    def cache_enabled(self) -> bool:
        return bool(self._data.get("cache", {}).get("enabled", True))

    @cache_enabled.setter
    def cache_enabled(self, value: bool) -> None:
        self._data.setdefault("cache", {})["enabled"] = value

    @property
    def cache_db_path(self) -> str:
        return str(self._data.get("cache", {}).get("db_path", "~/.muravey-china/cache.db"))

    @property
    def cache_ttl_seconds(self) -> int:
        return int(self._data.get("cache", {}).get("ttl_seconds", 86400))

    @cache_ttl_seconds.setter
    def cache_ttl_seconds(self, value: int) -> None:
        self._data.setdefault("cache", {})["ttl_seconds"] = value

    @property
    def cache_ignore_providers(self) -> list[str]:
        raw = self._data.get("cache", {}).get("ignore_providers", [])
        return list(raw) if isinstance(raw, list) else []

    @property
    def cache_ignore_models(self) -> list[str]:
        raw = self._data.get("cache", {}).get("ignore_models", [])
        return list(raw) if isinstance(raw, list) else []

    # ------------------------------------------------------------------ #
    # Prompts accessors
    # ------------------------------------------------------------------ #

    @property
    def prompts_enabled(self) -> bool:
        return bool(self._data.get("prompts", {}).get("enabled", True))

    @property
    def prompts_db_path(self) -> Path:
        raw = self._data.get("prompts", {}).get("db_path", "~/.muravey-china/prompts.json")
        return Path(str(raw)).expanduser()

    @property
    def prompts_hotkey(self) -> str:
        return str(self._data.get("prompts", {}).get("hotkey", "ctrl+shift+f1"))

    @prompts_hotkey.setter
    def prompts_hotkey(self, value: str) -> None:
        self._data.setdefault("prompts", {})["hotkey"] = value

    @property
    def prompts_slash_commands_enabled(self) -> bool:
        return bool(self._data.get("prompts", {}).get("enable_slash_commands", True))

    @prompts_slash_commands_enabled.setter
    def prompts_slash_commands_enabled(self, value: bool) -> None:
        self._data.setdefault("prompts", {})["enable_slash_commands"] = value

    # ------------------------------------------------------------------ #
    # Auto-answer accessors
    # ------------------------------------------------------------------ #

    @property
    def auto_answer_enabled(self) -> bool:
        return bool(self._data.get("auto_answer", {}).get("enabled", True))

    @property
    def auto_answer_hotkey(self) -> str:
        return str(self._data.get("auto_answer", {}).get("hotkey", "ctrl+shift+f6"))

    @auto_answer_hotkey.setter
    def auto_answer_hotkey(self, value: str) -> None:
        self._data.setdefault("auto_answer", {})["hotkey"] = value

    # ------------------------------------------------------------------ #
    # Page-solver accessors
    # ------------------------------------------------------------------ #

    @property
    def page_solver_enabled(self) -> bool:
        return bool(self._data.get("page_solver", {}).get("enabled", True))

    @property
    def page_solver_hotkey(self) -> str:
        return str(self._data.get("page_solver", {}).get("hotkey", "ctrl+f10"))

    @page_solver_hotkey.setter
    def page_solver_hotkey(self, value: str) -> None:
        self._data.setdefault("page_solver", {})["hotkey"] = value

    # ------------------------------------------------------------------ #
    # Clipboard silent mode accessors
    # ------------------------------------------------------------------ #

    @property
    def clipboard_mode_enabled(self) -> bool:
        return bool(self._data.get("clipboard_mode", {}).get("enabled", True))

    @property
    def clipboard_mode_hotkey(self) -> str:
        return str(self._data.get("clipboard_mode", {}).get("hotkey", "ctrl+f12"))

    @clipboard_mode_hotkey.setter
    def clipboard_mode_hotkey(self, value: str) -> None:
        self._data.setdefault("clipboard_mode", {})["hotkey"] = value

    # ------------------------------------------------------------------ #
    # Vision accessors
    # ------------------------------------------------------------------ #

    @property
    def vision_enabled(self) -> bool:
        return bool(self._data.get("vision", {}).get("enabled", True))

    @vision_enabled.setter
    def vision_enabled(self, value: bool) -> None:
        self._data.setdefault("vision", {})["enabled"] = value

    @property
    def vision_provider(self) -> str:
        return str(self._data.get("vision", {}).get("provider", "aliyun"))

    @vision_provider.setter
    def vision_provider(self, value: str) -> None:
        self._data.setdefault("vision", {})["provider"] = value

    @property
    def vision_model(self) -> str:
        return str(self._data.get("vision", {}).get("model", "qwen-vl-max"))

    @vision_model.setter
    def vision_model(self, value: str) -> None:
        self._data.setdefault("vision", {})["model"] = value

    @property
    def vision_hotkey(self) -> str:
        # ctrl+f5 is reserved by CSCA — default moved to ctrl+f7
        return str(self._data.get("vision", {}).get("hotkey", "ctrl+f7"))

    @vision_hotkey.setter
    def vision_hotkey(self, value: str) -> None:
        self._data.setdefault("vision", {})["hotkey"] = value

    @property
    def vision_max_image_size(self) -> int:
        return int(self._data.get("vision", {}).get("max_image_size", 1920))

    @property
    def vision_default_prompt(self) -> str:
        return str(
            self._data.get("vision", {}).get(
                "default_prompt",
                "Describe what you see in this screenshot in detail.",
            )
        )

    # ------------------------------------------------------------------ #
    # Mouse-clicker accessors
    # ------------------------------------------------------------------ #

    @property
    def mouse_clicker_enabled(self) -> bool:
        return bool(self._data.get("mouse_clicker", {}).get("enabled", True))

    @property
    def mouse_clicker_hotkey(self) -> str:
        return str(self._data.get("mouse_clicker", {}).get("hotkey", "ctrl+shift+m"))

    @mouse_clicker_hotkey.setter
    def mouse_clicker_hotkey(self, value: str) -> None:
        self._data.setdefault("mouse_clicker", {})["hotkey"] = value

    # ------------------------------------------------------------------ #
    # Answer-display accessors
    # ------------------------------------------------------------------ #

    @property
    def answer_display_enabled(self) -> bool:
        return bool(self._data.get("answer_display", {}).get("enabled", True))

    @property
    def answer_display_hotkey(self) -> str:
        return str(
            self._data.get("answer_display", {}).get("hotkey", "ctrl+f9")
        )

    @answer_display_hotkey.setter
    def answer_display_hotkey(self, value: str) -> None:
        self._data.setdefault("answer_display", {})["hotkey"] = value

    # ------------------------------------------------------------------ #
    # Answer-window accessors
    # ------------------------------------------------------------------ #

    @property
    def answer_window_enabled(self) -> bool:
        return bool(self._data.get("answer_window", {}).get("enabled", True))

    @property
    def answer_window_hotkey(self) -> str:
        return str(
            self._data.get("answer_window", {}).get("hotkey", "ctrl+f1")
        )

    @answer_window_hotkey.setter
    def answer_window_hotkey(self, value: str) -> None:
        self._data.setdefault("answer_window", {})["hotkey"] = value

    # ------------------------------------------------------------------ #
    # Screenshot-viewer accessors
    # ------------------------------------------------------------------ #

    @property
    def screenshot_viewer_enabled(self) -> bool:
        return bool(self._data.get("screenshot_viewer", {}).get("enabled", True))

    @property
    def screenshot_viewer_hotkey(self) -> str:
        return str(self._data.get("screenshot_viewer", {}).get("hotkey", "ctrl+f11"))

    @screenshot_viewer_hotkey.setter
    def screenshot_viewer_hotkey(self, value: str) -> None:
        self._data.setdefault("screenshot_viewer", {})["hotkey"] = value

    # ------------------------------------------------------------------ #
    # Program-launcher accessors
    # ------------------------------------------------------------------ #

    @property
    def program_launcher_enabled(self) -> bool:
        return bool(self._data.get("program_launcher", {}).get("enabled", True))

    @property
    def program_launcher_hotkey(self) -> str:
        return str(self._data.get("program_launcher", {}).get("hotkey", "ctrl+f6"))

    @program_launcher_hotkey.setter
    def program_launcher_hotkey(self, value: str) -> None:
        self._data.setdefault("program_launcher", {})["hotkey"] = value

    @property
    def program_launcher_path(self) -> str:
        return str(self._data.get("program_launcher", {}).get("path", ""))

    @program_launcher_path.setter
    def program_launcher_path(self, value: str) -> None:
        self._data.setdefault("program_launcher", {})["path"] = value

    # ------------------------------------------------------------------ #
    # OCR Typist accessors (Ctrl+F8: screenshot → Vision AI → type answer)
    # ------------------------------------------------------------------ #

    @property
    def ocr_typist_enabled(self) -> bool:
        return bool(self._data.get("ocr_typist", {}).get("enabled", True))

    @property
    def ocr_typist_hotkey(self) -> str:
        # Ctrl+Shift+F8 — not used by CSCA (F2, F3, F4, Ctrl+F5 are forbidden)
        return str(self._data.get("ocr_typist", {}).get("hotkey", "ctrl+shift+f8"))

    @ocr_typist_hotkey.setter
    def ocr_typist_hotkey(self, value: str) -> None:
        self._data.setdefault("ocr_typist", {})["hotkey"] = value

    @property
    def ocr_test_hotkey(self) -> str:
        return str(self._data.get("ocr_typist", {}).get("test_hotkey", "ctrl+shift+f9"))

    # ------------------------------------------------------------------ #
    # Stealth mode accessors
    # ------------------------------------------------------------------ #

    @property
    def stealth_enabled(self) -> bool:
        """True when stealth-compatible mode is active."""
        return bool(self._data.get("stealth", {}).get("enabled", False))

    @stealth_enabled.setter
    def stealth_enabled(self, value: bool) -> None:
        self._data.setdefault("stealth", {})["enabled"] = value

    @property
    def stealth_auto_detect_processes(self) -> list[str]:
        """Extra process names that trigger stealth auto-detection."""
        raw = self._data.get("stealth", {}).get("auto_detect_processes", [])
        return [str(p) for p in raw] if isinstance(raw, list) else []

    @property
    def stealth_provider(self) -> str:
        return str(self._data.get("stealth", {}).get("provider", "ollama"))

    @property
    def stealth_model(self) -> str:
        return str(self._data.get("stealth", {}).get("model", "qwen2.5:7b"))

    @property
    def stealth_biometric_typing(self) -> bool:
        return bool(self._data.get("stealth", {}).get("biometric_typing", False))

    @property
    def stealth_typing_speed_ms(self) -> float:
        return float(self._data.get("stealth", {}).get("typing_speed_ms", 50))

    @property
    def stealth_typo_rate(self) -> float:
        return float(self._data.get("stealth", {}).get("typo_rate", 0.01))

    @property
    def stealth_typing_variation_ms(self) -> float:
        """Standard deviation (ms) for gaussian typing delay distribution."""
        return float(self._data.get("stealth", {}).get("typing_variation_ms", 20.0))

    @property
    def stealth_punctuation_pause_ms(self) -> float:
        """Extra pause (ms) added after sentence-ending punctuation."""
        return float(self._data.get("stealth", {}).get("punctuation_pause_ms", 150.0))

    @property
    def stealth_detection_ttl_seconds(self) -> float:
        """How often (seconds) to re-check for exam processes (cache TTL)."""
        return float(self._data.get("stealth", {}).get("detection_ttl_seconds", 5.0))

    # ------------------------------------------------------------------ #
    # Conversation accessors
    # ------------------------------------------------------------------ #

    @property
    def conversation_enabled(self) -> bool:
        return bool(self._data.get("conversation", {}).get("enabled", True))

    @property
    def conversation_max_context_tokens(self) -> int:
        return int(self._data.get("conversation", {}).get("max_context_tokens", 80000))

    @property
    def conversation_keep_recent(self) -> int:
        return int(self._data.get("conversation", {}).get("keep_recent", 6))

    @property
    def conversation_max_messages(self) -> int:
        return int(self._data.get("conversation", {}).get("max_messages", 100))

    # ------------------------------------------------------------------ #
    # Knowledge base accessors
    # ------------------------------------------------------------------ #

    @property
    def knowledge_enabled(self) -> bool:
        return bool(self._data.get("knowledge", {}).get("enabled", True))

    @property
    def knowledge_db_path(self) -> str:
        return str(self._data.get("knowledge", {}).get("db_path", "~/.muravey-china/knowledge.db"))

    @property
    def knowledge_similarity_threshold(self) -> float:
        return float(self._data.get("knowledge", {}).get("similarity_threshold", 0.85))

    @property
    def knowledge_auto_learn(self) -> bool:
        return bool(self._data.get("knowledge", {}).get("auto_learn", False))

    # ------------------------------------------------------------------ #
    # Cost tracking accessors
    # ------------------------------------------------------------------ #

    @property
    def cost_enabled(self) -> bool:
        return bool(self._data.get("cost", {}).get("enabled", True))

    # ------------------------------------------------------------------ #
    # Snippets accessors
    # ------------------------------------------------------------------ #

    @property
    def snippets_enabled(self) -> bool:
        return bool(self._data.get("snippets", {}).get("enabled", True))

    # ------------------------------------------------------------------ #
    # Fallback chain accessor
    # ------------------------------------------------------------------ #

    @property
    def fallback_chain(self) -> list[str]:
        raw = self._data.get("fallback_chain", ["deepseek", "aliyun", "88api", "ollama"])
        return list(raw) if isinstance(raw, list) else ["deepseek", "aliyun", "88api", "ollama"]

    # ------------------------------------------------------------------ #
    # Proxy / tunnel accessors
    # ------------------------------------------------------------------ #

    @property
    def proxy_url(self) -> str:
        """Manual proxy URL (empty = auto-detect)."""
        return str(self._data.get("proxy", {}).get("url", ""))

    @proxy_url.setter
    def proxy_url(self, value: str) -> None:
        self._data.setdefault("proxy", {})["url"] = value

    @property
    def proxy_auto_detect(self) -> bool:
        """Auto-detect local proxy when behind GFW."""
        return bool(self._data.get("proxy", {}).get("auto_detect", True))

    @proxy_auto_detect.setter
    def proxy_auto_detect(self, value: bool) -> None:
        self._data.setdefault("proxy", {})["auto_detect"] = value

    @property
    def ssh_tunnel_enabled(self) -> bool:
        return bool(self._data.get("proxy", {}).get("ssh_tunnel", {}).get("enabled", False))

    @ssh_tunnel_enabled.setter
    def ssh_tunnel_enabled(self, value: bool) -> None:
        self._data.setdefault("proxy", {}).setdefault("ssh_tunnel", {})["enabled"] = value

    @property
    def ssh_tunnel_target(self) -> str:
        return str(self._data.get("proxy", {}).get("ssh_tunnel", {}).get("target", ""))

    @property
    def ssh_tunnel_local_port(self) -> int:
        return int(self._data.get("proxy", {}).get("ssh_tunnel", {}).get("local_port", 11080))

    @property
    def ssh_tunnel_remote_host(self) -> str:
        return str(self._data.get("proxy", {}).get("ssh_tunnel", {}).get("remote_host", "127.0.0.1"))

    @property
    def ssh_tunnel_remote_port(self) -> int:
        return int(self._data.get("proxy", {}).get("ssh_tunnel", {}).get("remote_port", 443))

    @property
    def ssh_tunnel_ssh_port(self) -> int:
        return int(self._data.get("proxy", {}).get("ssh_tunnel", {}).get("ssh_port", 22))

    @property
    def ssh_tunnel_ssh_key(self) -> str:
        return str(self._data.get("proxy", {}).get("ssh_tunnel", {}).get("ssh_key", ""))

    # ------------------------------------------------------------------ #
    # History accessors
    # ------------------------------------------------------------------ #

    @property
    def history_enabled(self) -> bool:
        return bool(self._data.get("history", {}).get("enabled", True))

    @property
    def history_max_entries(self) -> int:
        return int(self._data.get("history", {}).get("max_entries", 1000))

    @property
    def history_autosave_interval(self) -> int:
        return int(self._data.get("history", {}).get("autosave_interval", 10))

    # ------------------------------------------------------------------ #
    # Clipboard history accessors
    # ------------------------------------------------------------------ #

    @property
    def clipboard_history_enabled(self) -> bool:
        return bool(self._data.get("clipboard_history", {}).get("enabled", True))

    @property
    def clipboard_history_max_entries(self) -> int:
        return int(self._data.get("clipboard_history", {}).get("max_entries", 10))

    # ------------------------------------------------------------------ #
    # Panic button accessors
    # ------------------------------------------------------------------ #

    @property
    def panic_button_enabled(self) -> bool:
        return bool(self._data.get("panic_button", {}).get("enabled", True))

    @property
    def panic_button_hotkey(self) -> str:
        return str(self._data.get("panic_button", {}).get("hotkey", "ctrl+shift+f12"))

    # ------------------------------------------------------------------ #
    # Math Solver (AK) accessors
    # ------------------------------------------------------------------ #

    @property
    def math_solver_enabled(self) -> bool:
        return bool(self._data.get("math_solver", {}).get("enabled", True))

    @property
    def math_solver_hotkey(self) -> str:
        return str(self._data.get("math_solver", {}).get("hotkey", "ctrl+`"))

    @math_solver_hotkey.setter
    def math_solver_hotkey(self, value: str) -> None:
        self._data.setdefault("math_solver", {})["hotkey"] = value

    @property
    def math_solver_vision_model(self) -> str:
        return str(self._data.get("math_solver", {}).get("vision_model", "qwen2.5vl:3b"))

    @property
    def math_solver_ollama_base_url(self) -> str:
        """Ollama URL for math solver — falls back to global ai.ollama_base_url."""
        custom = str(self._data.get("math_solver", {}).get("ollama_base_url", ""))
        return custom if custom else self.ollama_base_url

    @property
    def math_solver_ai_timeout(self) -> float:
        return float(self._data.get("math_solver", {}).get("ai_timeout", 120.0))

    @property
    def math_solver_max_image_size(self) -> int:
        return int(self._data.get("math_solver", {}).get("max_image_size", 1920))

    # ------------------------------------------------------------------ #
    # Config hot-reload
    # ------------------------------------------------------------------ #

    def check_and_reload(self) -> bool:
        """Reload config from disk if file has been modified. Returns True if reloaded."""
        try:
            mtime = self._path.stat().st_mtime
        except OSError:
            return False

        if not hasattr(self, "_last_mtime"):
            self._last_mtime = mtime
            return False

        if mtime <= self._last_mtime:
            return False

        try:
            with open(self._path, encoding="utf-8") as f:
                loaded = yaml.safe_load(f) or {}
            data = copy.deepcopy(_DEFAULT_CONFIG)
            _deep_merge(data, loaded)
            self._data = data
            self._last_mtime = mtime
            log.info("Config hot-reloaded from %s", self._path)
            return True
        except Exception as exc:
            log.warning("Config hot-reload failed: %s", exc)
            return False

    # ------------------------------------------------------------------ #
    # Locale accessor
    # ------------------------------------------------------------------ #

    @property
    def locale(self) -> str:
        """UI locale code — one of 'en', 'ru', 'zh_CN'."""
        return str(self._data.get("locale", "en"))

    @locale.setter
    def locale(self, value: str) -> None:
        self._data["locale"] = value

    @property
    def theme(self) -> str:
        """UI colour theme — one of 'light', 'dark', 'system'."""
        return str(self._data.get("theme", "system"))

    @theme.setter
    def theme(self, value: str) -> None:
        self._data["theme"] = value

    @property
    def config_dir(self) -> Path:
        return self._path.parent

    @property
    def log_dir(self) -> Path:
        return _get_log_dir()


def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> None:
    """Recursively merge override into base (in-place)."""
    for key, value in override.items():
        if key in base and isinstance(base[key], dict) and isinstance(value, dict):
            _deep_merge(base[key], value)
        else:
            base[key] = value
