import asyncio
from pathlib import Path
from typing import Any

import orjson
from loguru import logger
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

from paths import data_dir
from .base_service import BaseService

SETTINGS_FILE = data_dir() / "settings.json"


def normalize_remote_url(v: str) -> str:
    """Normalize a VoxCast Server URL: default http scheme, only http/https,
    no embedded credentials (a userinfo prefix could mask the real host),
    strip path/query/fragment and a trailing slash. Invalid input collapses
    to '' (remote off). Shared by the settings validator and the client's
    Test-connection button so both accept the exact same inputs."""
    from urllib.parse import urlparse
    v = (v or "").strip()
    if not v:
        return ""
    if "://" not in v:
        v = "http://" + v
    parsed = urlparse(v)
    if parsed.scheme not in ("http", "https"):
        return ""
    if parsed.username is not None or parsed.password is not None:
        return ""
    if not parsed.hostname:
        return ""
    if parsed.port is not None and not (1 <= parsed.port <= 65535):
        return ""
    netloc = f"{parsed.hostname}:{parsed.port}" if parsed.port else parsed.hostname
    return f"{parsed.scheme}://{netloc}"

# Rules shared by every built-in mode. Self-corrections: dictated speech often
# contains "on Tuesday, no wait, Wednesday" — only the corrected version may
# survive. The language itself is pinned by an automatically appended directive
# (see llm_service.language_directive), not here.
_MODE_COMMON = (
    "The input is dictated speech. Resolve self-corrections (keep only what "
    "the speaker corrected themselves to). Never invent facts, names or "
    "details that were not dictated. Output ONLY the resulting text: no "
    "preamble, no explanations, no quotes and no markdown code fences."
)

# Built-in AI post-processing modes (id "normal" = no LLM, raw transcript).
# Users can edit these and add custom modes in Settings. Bump LLM_BUILTIN_REV
# whenever modes are added or their default prompts change — _load() then
# migrates existing settings files once (new modes appended, unedited prompts
# upgraded, user-deleted modes stay deleted).
LLM_BUILTIN_REV = 2

DEFAULT_LLM_MODES: list[dict] = [
    {
        "id": "cleanup",
        "name": "Clean up",
        "prompt": (
            "You clean up dictated speech. Fix grammar, punctuation and "
            "capitalization, remove filler words and false starts, and keep "
            "the speaker's original meaning, language, wording and tone. Do "
            "not shorten or rephrase beyond what the fixes require. "
            + _MODE_COMMON
        ),
    },
    {
        "id": "email",
        "name": "E-Mail",
        "prompt": (
            "You turn dictated notes into a polished, well-structured email. "
            "Keep it concise. Match the level of formality the speaker used "
            "(formal vs. informal address). Add a fitting greeting and a "
            "closing phrase; if no recipient is named use a generic greeting, "
            "and never sign with a name unless the speaker dictated one. "
            + _MODE_COMMON
        ),
    },
    {
        "id": "reply",
        "name": "Reply",
        "prompt": (
            "You turn dictated notes into a short, natural chat or message "
            "reply. Keep the speaker's tone (friendly unless dictated "
            "otherwise) and keep it brief. " + _MODE_COMMON
        ),
    },
    {
        "id": "todo",
        "name": "To-do list",
        "prompt": (
            "You turn dictated speech into a Markdown task list. One task per "
            "line as '- [ ] task', phrased short and imperative. Keep every "
            "task that was mentioned, merge duplicates, and keep the "
            "speaker's order unless grouping related tasks is clearly "
            "better. " + _MODE_COMMON
        ),
    },
    {
        "id": "notes",
        "name": "Notes",
        "prompt": (
            "You condense dictated speech into structured notes. Use short "
            "Markdown bullet points, group related points, and keep ALL "
            "facts, numbers, names and dates exactly as dictated. Cut only "
            "filler and repetition, never content. " + _MODE_COMMON
        ),
    },
    {
        "id": "formal",
        "name": "Formal",
        "prompt": (
            "You rewrite dictated speech as polished, professional prose in a "
            "formal register, suitable for documents or official replies. "
            "Keep the full meaning; do not add an email greeting or "
            "sign-off. " + _MODE_COMMON
        ),
    },
]

# Previous default prompts by mode id. If a user's stored prompt still matches
# one of these, they never edited it — the migration may upgrade it to the
# current default. Edited prompts are never touched.
_LEGACY_BUILTIN_PROMPTS: dict[str, tuple[str, ...]] = {
    "cleanup": (
        "You clean up dictated speech. Fix grammar, punctuation and "
        "capitalization, remove filler words and false starts, and keep the "
        "speaker's original meaning, language and tone. Output ONLY the "
        "cleaned text, with no preamble or quotes.",
    ),
    "email": (
        "You turn dictated notes into a polished, well-structured email in "
        "the SAME language as the input. Add a fitting greeting and sign-off, "
        "keep it concise and professional. Output ONLY the email text.",
    ),
    "reply": (
        "You turn dictated notes into a short, natural chat/message reply in "
        "the SAME language and a friendly tone. Output ONLY the reply text.",
    ),
}


class AppSettings(BaseSettings):
    version: int = 1
    whisper_model: str = "medium"
    hotkey: str = "<ctrl>+<shift>+<space>"
    hotkey_mode: str = "toggle"
    audio_device_index: int | None = None
    output_mode: str = "both"  # "clipboard" | "type" | "both"
    insert_method: str = "paste"  # "paste" (Ctrl+V) | "typing" (keyboard simulation)
    compute_device: str = "auto"  # "auto" | "cuda" | "cpu"
    live_mode: bool = False  # stream text to the cursor while speaking (no AI enhancement)
    autostart: bool = False
    play_startup_sound: bool = True
    play_recording_sound: bool = True
    recording_sound_volume: float = 0.8  # 0.0 - 1.0
    play_shutdown_sound: bool = True
    window_x: int = 100
    window_y: int = 100
    always_on_top: bool = True
    small_panel: bool = False
    dock_edge: str = "none"  # "none" | "left" | "right" | "top"
    language: str | None = None
    ui_language: str = ""  # interface language: "" = auto (OS locale) | i18n.SUPPORTED code

    # ── File-to-Markdown conversion ──────────────────────────────────────
    convert_output_mode: str = "ask"          # "ask" | "beside" | "fixed_folder"
    convert_output_dir: str | None = None      # used when mode == "fixed_folder"
    convert_language: str | None = None        # None = follow `language`
    convert_timestamps: bool = False           # include per-segment timestamps
    convert_open_after: bool = False           # open the .md after conversion
    context_menu_enabled: bool = True          # right-click shell integration

    # ── Meeting recording (system audio + mic → transcript) ──────────────
    meeting_record_mic: bool = True            # capture own voice alongside
    meeting_keep_audio: bool = True            # keep the .wav next to the .md
    meeting_auto_summary: bool = False         # prepend an AI summary (needs LLM)
    meeting_output_dir: str = ""               # "" = Documents\VoxCast\Meetings
    meeting_language: str = ""                  # "" = auto-detect per segment (default)
    meeting_consent_ack: bool = False          # one-time legal notice accepted

    # ── Remote transcription (offload batch work to a VoxCast Server) ────
    # API key lives in Credential Manager (name "remote_server"), never here.
    remote_enabled: bool = False
    remote_url: str = ""                        # http(s)://host[:port], normalized
    # TLS trust for an https:// server: "verify" (real CA), "pinned"
    # (trust-on-first-use self-signed, cert stored below) or "insecure".
    remote_tls_mode: str = "verify"
    remote_cert_pem: str = ""                    # pinned self-signed cert (PEM)
    # Enhancement source when connected to a server that offers it:
    # "own" (client's own LLM key) or "server" (the server's central key).
    enhance_source: str = "own"

    # ── AI post-processing (LLM) — API key lives in Credential Manager ───
    llm_enabled: bool = False
    llm_provider: str = "anthropic"            # "anthropic" | "openai"
    llm_model: str | None = None               # selected model id
    llm_active_mode: str = "normal"            # "normal" | mode id from llm_modes
    llm_modes: list[dict] = Field(default_factory=lambda: list(DEFAULT_LLM_MODES))
    # Highest built-in mode revision already merged into llm_modes. 0 forces
    # the one-time migration in _load() (also correct for fresh installs:
    # the migration is idempotent against the current defaults).
    llm_builtin_rev: int = 0

    # ── License (token lives in Credential Manager, not here) ────────────
    license_key_masked: str | None = None      # for display only, e.g. "••••-••••-••••-ABCD"

    model_config = SettingsConfigDict(
        env_file=".env",
        env_prefix="VOXCAST_",
        extra="ignore",
    )

    @field_validator("whisper_model")
    @classmethod
    def validate_whisper_model(cls, v: str) -> str:
        allowed = {"base", "small", "medium", "large-v3-turbo"}
        if v not in allowed:
            return "medium"
        return v

    @field_validator("meeting_output_dir", mode="before")
    @classmethod
    def validate_meeting_output_dir(cls, v) -> str:
        return str(v).strip() if v else ""

    @field_validator("ui_language")
    @classmethod
    def validate_ui_language(cls, v: str) -> str:
        from .i18n import SUPPORTED
        return v if v == "" or v in SUPPORTED else ""

    @field_validator("hotkey_mode")
    @classmethod
    def validate_hotkey_mode(cls, v: str) -> str:
        allowed = {"hold", "toggle"}
        if v not in allowed:
            return "toggle"
        return v

    @field_validator("compute_device")
    @classmethod
    def validate_compute_device(cls, v: str) -> str:
        allowed = {"auto", "cuda", "cpu"}
        if v not in allowed:
            return "auto"
        return v

    @field_validator("output_mode")
    @classmethod
    def validate_output_mode(cls, v: str) -> str:
        allowed = {"clipboard", "type", "both"}
        if v not in allowed:
            return "both"
        return v

    @field_validator("language")
    @classmethod
    def validate_language(cls, v: str | None) -> str | None:
        if v is None:
            return None
        allowed = {
            "de", "en", "fr", "es", "it", "pl", "pt", "nl", "ru", "ja", "zh",
            "ko", "ar", "tr", "sv", "da", "no", "fi", "cs", "hu", "ro", "uk", "hi",
        }
        return v if v in allowed else None

    @field_validator("meeting_language")
    @classmethod
    def validate_meeting_language(cls, v: str) -> str:
        if not v:
            return ""  # auto-detect
        allowed = {
            "de", "en", "fr", "es", "it", "pl", "pt", "nl", "ru", "ja", "zh",
            "ko", "ar", "tr", "sv", "da", "no", "fi", "cs", "hu", "ro", "uk", "hi",
        }
        return v if v in allowed else ""

    @field_validator("insert_method")
    @classmethod
    def validate_insert_method(cls, v: str) -> str:
        allowed = {"paste", "typing"}
        if v not in allowed:
            return "paste"
        return v

    @field_validator("dock_edge")
    @classmethod
    def validate_dock_edge(cls, v: str) -> str:
        return v if v in {"none", "left", "right", "top"} else "none"

    @field_validator("convert_output_mode")
    @classmethod
    def validate_convert_output_mode(cls, v: str) -> str:
        allowed = {"ask", "beside", "fixed_folder"}
        return v if v in allowed else "ask"

    @field_validator("remote_url")
    @classmethod
    def validate_remote_url(cls, v: str) -> str:
        return normalize_remote_url(v)

    @field_validator("remote_tls_mode")
    @classmethod
    def validate_remote_tls_mode(cls, v: str) -> str:
        return v if v in {"verify", "pinned", "insecure"} else "verify"

    @field_validator("enhance_source")
    @classmethod
    def validate_enhance_source(cls, v: str) -> str:
        return v if v in {"own", "server"} else "own"

    @field_validator("convert_language")
    @classmethod
    def validate_convert_language(cls, v: str | None) -> str | None:
        if v is None or v == "":
            return None
        allowed = {
            "de", "en", "fr", "es", "it", "pl", "pt", "nl", "ru", "ja", "zh",
            "ko", "ar", "tr", "sv", "da", "no", "fi", "cs", "hu", "ro", "uk", "hi",
        }
        return v if v in allowed else None

    @field_validator("llm_provider")
    @classmethod
    def validate_llm_provider(cls, v: str) -> str:
        return v if v in {"anthropic", "openai"} else "anthropic"

    @field_validator("llm_modes")
    @classmethod
    def validate_llm_modes(cls, v: list) -> list:
        if not isinstance(v, list):
            return list(DEFAULT_LLM_MODES)
        clean = [m for m in v if isinstance(m, dict) and m.get("id") and m.get("prompt")]
        return clean or list(DEFAULT_LLM_MODES)

    # window_x/y are validated dynamically against connected screens in UiBridge


class SettingsService(BaseService):
    def __init__(self, settings_path: Path = SETTINGS_FILE) -> None:
        self._path = settings_path
        self._settings: AppSettings | None = None
        self._ready = False
        self._created_fresh = False
        self._locked_keys: set[str] = set()

    async def start(self) -> None:
        await self._load()
        self._ready = True
        logger.info("SettingsService started, path={}", self._path)

    async def stop(self) -> None:
        if self._settings is not None:
            await self.save()
        self._ready = False

    async def _load(self) -> None:
        created = False
        if self._path.exists():
            try:
                raw = orjson.loads(self._path.read_bytes())
                # Version migration: fill missing keys with defaults
                defaults = AppSettings().model_dump()
                merged = {**defaults, **raw}
                self._settings = AppSettings(**merged)
                logger.debug("Settings loaded from {}", self._path)
            except Exception as e:
                logger.warning("Failed to load settings ({}), using defaults", e)
                self._settings = AppSettings()
        else:
            self._settings = AppSettings()
            created = True
            self._created_fresh = True
            logger.info("Created default settings at {}", self._path)
        # Enterprise managed policy overlays user settings and locks fields.
        self._apply_managed()
        if self._migrate_builtin_modes() or created:
            await self.save()

    def _apply_managed(self) -> None:
        """Overlay machine-wide managed values (enterprise provisioning) on top
        of the loaded settings and record which keys are locked. Managed values
        win on EVERY load, so a locked field can never drift."""
        from . import managed_config
        policy = managed_config.load_managed()
        self._locked_keys = set(policy["locked"])
        managed_config.apply_secrets(policy["secrets"])
        values = policy["values"]
        if values and self._settings is not None:
            current = self._settings.model_dump()
            current.update(values)
            self._settings = AppSettings(**current)

    def locked_keys(self) -> set[str]:
        """Keys the managed policy forbids the user from changing."""
        return getattr(self, "_locked_keys", set())

    def is_locked(self, key: str) -> bool:
        return key in self.locked_keys()

    def _migrate_builtin_modes(self) -> bool:
        """One-time merge of new/updated built-in AI modes into stored settings.

        llm_modes is persisted as a whole list, so users who saved settings on
        an older version would otherwise never receive modes added later. Adds
        missing built-in ids and upgrades prompts the user never edited
        (matched against the legacy default texts); user edits and deletions
        made after this revision stay untouched."""
        s = self._settings
        if s is None or s.llm_builtin_rev >= LLM_BUILTIN_REV:
            return False
        modes = [dict(m) for m in s.llm_modes]
        by_id = {m.get("id"): m for m in modes}
        for default in DEFAULT_LLM_MODES:
            mode = by_id.get(default["id"])
            if mode is None:
                modes.append(dict(default))
            elif mode.get("prompt") in _LEGACY_BUILTIN_PROMPTS.get(default["id"], ()):
                mode["prompt"] = default["prompt"]
        self._settings = AppSettings(**{
            **s.model_dump(),
            "llm_modes": modes,
            "llm_builtin_rev": LLM_BUILTIN_REV,
        })
        logger.info("Built-in AI modes migrated to rev {}", LLM_BUILTIN_REV)
        return True

    async def save(self) -> None:
        if self._settings is None:
            return
        try:
            data = self._settings.model_dump()
            self._path.write_bytes(orjson.dumps(data, option=orjson.OPT_INDENT_2))
            logger.debug("Settings saved to {}", self._path)
        except Exception as e:
            logger.warning("Failed to save settings to {}: {}", self._path, e)

    def get(self) -> AppSettings:
        if self._settings is None:
            raise RuntimeError("SettingsService not started")
        return self._settings

    def update(self, **kwargs: Any) -> None:
        if self._settings is None:
            raise RuntimeError("SettingsService not started")
        # Managed policy wins: never let a save overwrite a locked field
        # (the UI also disables these, this is belt-and-suspenders).
        if self._locked_keys:
            dropped = [k for k in kwargs if k in self._locked_keys]
            for k in dropped:
                kwargs.pop(k)
            if dropped:
                logger.info("Ignoring change to managed-locked settings: {}",
                            ", ".join(dropped))
        current = self._settings.model_dump()
        current.update(kwargs)
        self._settings = AppSettings(**current)
        # Fire-and-forget save on the running event loop
        try:
            loop = asyncio.get_running_loop()
            loop.create_task(self.save())
        except RuntimeError:
            logger.warning("No running event loop, settings will be saved on next opportunity")

    @property
    def is_ready(self) -> bool:
        return self._ready

    @property
    def was_created(self) -> bool:
        """True when no settings file existed at startup (fresh install)."""
        return self._created_fresh
