"""Persistent app-mode configuration and runtime state."""

from __future__ import annotations

import json
import logging
import os
import threading
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from sqlalchemy.orm import Session, sessionmaker

from app.core.config import settings
from app.services import vault

STATUS_CONFIGURED = "configured"
STATUS_SKIPPED = "skipped"
STATUS_MISSING = "missing"

PROJECT_ROOT = Path(__file__).resolve().parents[2]
LOGGER = logging.getLogger("sentineledge.runtime_config")
_LOCK = threading.Lock()
_ACTIVE_CONFIG: "AppRuntimeConfig | None" = None
_LOADED = False
_SKIPPED_FOR_RUN = False
UI_MODE_ASSISTANT = "assistant"
UI_MODE_CONFIG = "config"
_APP_UI_MODE = UI_MODE_ASSISTANT


@dataclass(slots=True)
class AppRuntimeConfig:
    """Serializable runtime configuration values."""

    todoist_api_token: str | None
    vault_paths: list[str]
    language: str
    language_configured: bool
    ai_provider: str
    gemini_api_key: str | None
    gemini_model: str
    ollama_model: str
    allow_fallback: bool
    providers: dict[str, dict[str, Any]]
    default_task_provider: str
    default_notes_provider: str
    updated_at: str

    @property
    def assistant_default_language(self) -> str:
        """Backward-compatible alias for previous field name."""
        return self.language


@dataclass(slots=True)
class ConfigState:
    """Public status payload for UI and routes."""

    status: str
    configured: bool
    skipped: bool
    has_todoist_token: bool
    todoist_token_masked: str | None
    vault_paths_count: int
    language: str
    assistant_default_language: str
    language_configured: bool
    ai_provider: str
    has_gemini_api_key: bool
    gemini_model: str
    ollama_model: str
    allow_fallback: bool
    default_task_provider: str
    default_notes_provider: str

    def as_dict(self) -> dict[str, Any]:
        """Return JSON-ready status payload."""
        return {
            "status": self.status,
            "configured": self.configured,
            "skipped": self.skipped,
            "has_todoist_token": self.has_todoist_token,
            "todoist_token_masked": self.todoist_token_masked,
            "vault_paths_count": self.vault_paths_count,
            "language": self.language,
            "assistant_default_language": self.assistant_default_language,
            "language_configured": self.language_configured,
            "ai_provider": self.ai_provider,
            "has_gemini_api_key": self.has_gemini_api_key,
            "gemini_model": self.gemini_model,
            "ollama_model": self.ollama_model,
            "allow_fallback": self.allow_fallback,
            "default_task_provider": self.default_task_provider,
            "default_notes_provider": self.default_notes_provider,
        }


@dataclass(slots=True)
class SaveConfigResult:
    """Save result including non-blocking validation warnings."""

    config: AppRuntimeConfig
    warnings: list[str]
    missing_paths: list[str]


TASK_PROVIDER_IDS = (
    "todoist",
    "google_tasks",
    "microsoft_todo",
    "trello",
    "github_issues",
)
NOTES_PROVIDER_IDS = (
    "vault_local",
    "notion",
    "github_issues",
)
ALL_PROVIDER_IDS = tuple(dict.fromkeys([*TASK_PROVIDER_IDS, *NOTES_PROVIDER_IDS]))
AI_PROVIDER_IDS = ("gemini", "ollama")


def _config_path() -> Path:
    """Resolve config JSON location."""
    raw = (os.getenv("ORION_CONFIG_PATH") or os.getenv("SENTINELEDGE_CONFIG_PATH") or "").strip()
    if raw:
        return Path(raw).expanduser().resolve()
    return Path.home() / ".orion-ai" / "config.json"


def _legacy_config_path() -> Path:
    """Return previous in-repo config path for backward-compatible reads."""
    return PROJECT_ROOT / ".sentineledge" / "config.json"


def _allow_legacy_config_fallback() -> bool:
    """Return True only when legacy config fallback is explicitly enabled."""
    raw = str(os.getenv("ORION_ALLOW_LEGACY_CONFIG_FALLBACK") or "").strip().lower()
    return raw in {"1", "true", "yes", "on"}


def config_file_path() -> Path:
    """Return current runtime config file path."""
    return _config_path()


def _utc_timestamp() -> str:
    """Return current UTC timestamp in ISO format."""
    return datetime.now(timezone.utc).isoformat()


def _normalize_token(raw_value: object | None) -> str | None:
    """Normalize token field."""
    if raw_value is None:
        return None
    token = str(raw_value).strip()
    return token or None


def _normalize_path(raw_path: object) -> str | None:
    """Normalize one configured path to absolute canonical value."""
    text = str(raw_path or "").strip()
    if not text:
        return None

    try:
        resolved = vault.normalize_vault_path(text, require_existing_dir=False)
    except Exception:
        candidate = Path(text).expanduser()
        if not candidate.is_absolute():
            candidate = (PROJECT_ROOT / candidate).resolve()
        else:
            candidate = candidate.resolve()
        return str(candidate)
    return str(resolved)


def _normalize_paths(raw_paths: object | None) -> list[str]:
    """Normalize path list and remove duplicates preserving order."""
    if not isinstance(raw_paths, list):
        return []

    normalized: list[str] = []
    seen: set[str] = set()
    for raw in raw_paths:
        path = _normalize_path(raw)
        if not path or path in seen:
            continue
        seen.add(path)
        normalized.append(path)
    return normalized


def _normalize_language(raw_value: object | None) -> str:
    """Normalize assistant default language into supported values."""
    value = str(raw_value or "").strip().lower()
    if value in {"en", "english", "inglés", "ingles"}:
        return "en"
    return "es"


def _normalize_ai_provider(raw_value: object | None) -> str:
    """Normalize selected AI provider id."""
    value = str(raw_value or "").strip().lower()
    if value in AI_PROVIDER_IDS:
        return value
    return "gemini"


def _normalize_model_name(raw_value: object | None, fallback: str) -> str:
    """Normalize model name with non-empty fallback."""
    value = str(raw_value or "").strip()
    return value or fallback


def _default_ai_provider() -> str:
    """Resolve default provider from environment settings."""
    return _normalize_ai_provider(getattr(settings, "ai_provider", "gemini"))


def _default_ollama_model() -> str:
    """Resolve default Ollama model from environment settings."""
    return _normalize_model_name(getattr(settings, "ollama_model", None), fallback="llama3.2:3b")


def _default_gemini_model() -> str:
    """Resolve default Gemini model from environment settings."""
    return _normalize_model_name(getattr(settings, "gemini_model", None), fallback="gemini-2.5-flash")


def _default_allow_fallback() -> bool:
    """Resolve default provider fallback policy from environment."""
    return bool(getattr(settings, "ai_allow_fallback", False))


def _mask_token(token: str | None) -> str | None:
    """Return masked token representation."""
    if not token:
        return None
    if len(token) <= 4:
        return "*" * len(token)
    stars = "*" * max(4, len(token) - 4)
    return f"{token[:2]}{stars}{token[-2:]}"


def _normalize_provider_id(provider_id: object | None) -> str:
    """Normalize provider id fallback to empty string when unknown."""
    value = str(provider_id or "").strip().lower()
    if value in ALL_PROVIDER_IDS:
        return value
    return ""


def _default_provider_settings(provider_id: str) -> dict[str, Any]:
    """Return default normalized provider payload."""
    auth_mode = "token"
    if provider_id in {"google_tasks", "microsoft_todo"}:
        auth_mode = "oauth"
    if provider_id == "vault_local":
        auth_mode = "local"
    return {
        "enabled": False,
        "auth_mode": auth_mode,
        "credentials": {},
        "settings": {},
        "last_error": None,
    }


def _normalize_provider_payload(provider_id: str, payload: object | None) -> dict[str, Any]:
    """Normalize persisted provider payload."""
    normalized = _default_provider_settings(provider_id)
    if not isinstance(payload, dict):
        return normalized

    normalized["enabled"] = bool(payload.get("enabled"))
    auth_mode_raw = str(payload.get("auth_mode") or "").strip().lower()
    if auth_mode_raw:
        normalized["auth_mode"] = auth_mode_raw

    credentials_raw = payload.get("credentials")
    if isinstance(credentials_raw, dict):
        credentials: dict[str, str] = {}
        for key, value in credentials_raw.items():
            key_text = str(key or "").strip()
            if not key_text:
                continue
            value_text = str(value).strip() if value is not None else ""
            if not value_text:
                continue
            credentials[key_text] = value_text
        normalized["credentials"] = credentials

    settings_raw = payload.get("settings")
    if isinstance(settings_raw, dict):
        settings: dict[str, Any] = {}
        for key, value in settings_raw.items():
            key_text = str(key or "").strip()
            if not key_text:
                continue
            settings[key_text] = value
        normalized["settings"] = settings

    last_error = payload.get("last_error")
    if isinstance(last_error, str) and last_error.strip():
        normalized["last_error"] = last_error.strip()
    return normalized


def _build_default_providers(todoist_token: str | None, vault_paths: list[str]) -> dict[str, dict[str, Any]]:
    """Build initial provider map from legacy fields."""
    providers = {provider_id: _default_provider_settings(provider_id) for provider_id in ALL_PROVIDER_IDS}
    if todoist_token:
        providers["todoist"]["enabled"] = True
        providers["todoist"]["credentials"] = {"token": todoist_token}
    if vault_paths:
        providers["vault_local"]["enabled"] = True
        providers["vault_local"]["settings"] = {"paths": list(vault_paths)}
    return providers


def _normalize_providers(
    raw_providers: object | None,
    *,
    todoist_token: str | None,
    vault_paths: list[str],
) -> dict[str, dict[str, Any]]:
    """Normalize provider map and merge with defaults."""
    merged = _build_default_providers(todoist_token=todoist_token, vault_paths=vault_paths)
    if isinstance(raw_providers, dict):
        for raw_provider_id, payload in raw_providers.items():
            provider_id = _normalize_provider_id(raw_provider_id)
            if not provider_id:
                continue
            merged[provider_id] = _normalize_provider_payload(provider_id, payload)

    todoist_credentials = merged["todoist"].get("credentials")
    if not isinstance(todoist_credentials, dict):
        todoist_credentials = {}
    if todoist_token:
        todoist_credentials["token"] = todoist_token
    else:
        todoist_credentials.pop("token", None)
    merged["todoist"]["credentials"] = todoist_credentials

    vault_settings = merged["vault_local"].get("settings")
    if not isinstance(vault_settings, dict):
        vault_settings = {}
    vault_settings["paths"] = list(vault_paths)
    merged["vault_local"]["settings"] = vault_settings

    if not todoist_credentials.get("token"):
        merged["todoist"]["enabled"] = False
    if not vault_paths:
        merged["vault_local"]["enabled"] = False
    return merged


def _read_disk_config() -> AppRuntimeConfig | None:
    """Read config file from disk if available."""
    path = _config_path()
    if not path.exists():
        legacy = _legacy_config_path()
        if _allow_legacy_config_fallback() and legacy.exists():
            LOGGER.warning(
                "Runtime config load using legacy fallback path=%s legacy_path=%s",
                path,
                legacy,
            )
            path = legacy
        else:
            LOGGER.info(
                "Runtime config load: no config file found path=%s legacy_path=%s legacy_fallback_enabled=%s",
                path,
                legacy,
                _allow_legacy_config_fallback(),
            )
            return None

    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        LOGGER.warning("Runtime config load failed path=%s error=%s", path, exc)
        return None

    if not isinstance(payload, dict):
        LOGGER.warning("Runtime config load failed path=%s reason=invalid_payload_type", path)
        return None

    token = _normalize_token(payload.get("todoist_api_token"))
    vault_paths = _normalize_paths(payload.get("vault_paths"))
    language_raw = payload.get("language")
    if language_raw is None:
        language_raw = payload.get("assistant_default_language")
    default_language = _normalize_language(language_raw)
    language_configured = bool(payload.get("language_configured"))
    ai_provider = _normalize_ai_provider(payload.get("ai_provider") or _default_ai_provider())
    gemini_api_key = _normalize_token(payload.get("gemini_api_key"))
    gemini_model = _normalize_model_name(payload.get("gemini_model"), fallback=_default_gemini_model())
    ollama_model = _normalize_model_name(payload.get("ollama_model"), fallback=_default_ollama_model())
    allow_fallback = bool(payload.get("allow_fallback")) if "allow_fallback" in payload else _default_allow_fallback()
    providers = _normalize_providers(payload.get("providers"), todoist_token=token, vault_paths=vault_paths)
    default_task_provider = _normalize_provider_id(payload.get("default_task_provider")) or "todoist"
    if default_task_provider not in TASK_PROVIDER_IDS:
        default_task_provider = "todoist"
    default_notes_provider = _normalize_provider_id(payload.get("default_notes_provider")) or "vault_local"
    if default_notes_provider not in NOTES_PROVIDER_IDS:
        default_notes_provider = "vault_local"
    updated_at_raw = payload.get("updated_at")
    updated_at = str(updated_at_raw).strip() if isinstance(updated_at_raw, str) else ""
    if not updated_at:
        updated_at = _utc_timestamp()

    config = AppRuntimeConfig(
        todoist_api_token=token,
        vault_paths=vault_paths,
        language=default_language,
        language_configured=language_configured,
        ai_provider=ai_provider,
        gemini_api_key=gemini_api_key,
        gemini_model=gemini_model,
        ollama_model=ollama_model,
        allow_fallback=allow_fallback,
        providers=providers,
        default_task_provider=default_task_provider,
        default_notes_provider=default_notes_provider,
        updated_at=updated_at,
    )
    LOGGER.info(
        "Runtime config loaded path=%s ai_provider=%s gemini_model=%s has_gemini_key=%s updated_at=%s",
        path,
        config.ai_provider,
        config.gemini_model,
        bool(config.gemini_api_key),
        config.updated_at,
    )
    return config


def _write_disk_config(config: AppRuntimeConfig) -> None:
    """Persist config atomically to disk."""
    path = _config_path()
    path.parent.mkdir(parents=True, exist_ok=True)

    payload = {
        "version": 1,
        "todoist_api_token": config.todoist_api_token,
        "vault_paths": config.vault_paths,
        "language": config.language,
        "language_configured": bool(config.language_configured),
        "ai_provider": config.ai_provider,
        "gemini_api_key": config.gemini_api_key,
        "gemini_model": config.gemini_model,
        "ollama_model": config.ollama_model,
        "allow_fallback": bool(config.allow_fallback),
        "providers": config.providers,
        "default_task_provider": config.default_task_provider,
        "default_notes_provider": config.default_notes_provider,
        "updated_at": config.updated_at,
    }

    tmp_path = path.with_suffix(".tmp")
    tmp_path.write_text(
        f"{json.dumps(payload, indent=2)}\n",
        encoding="utf-8",
    )
    tmp_path.replace(path)
    LOGGER.info(
        "Runtime config saved path=%s ai_provider=%s gemini_model=%s has_gemini_key=%s updated_at=%s",
        path,
        config.ai_provider,
        config.gemini_model,
        bool(config.gemini_api_key),
        config.updated_at,
    )


def _apply_environment(config: AppRuntimeConfig | None) -> None:
    """Apply TODOIST token to process environment."""
    if config is None:
        return
    if config.todoist_api_token:
        os.environ["TODOIST_API_TOKEN"] = config.todoist_api_token
        return
    os.environ.pop("TODOIST_API_TOKEN", None)


def _ensure_loaded_locked() -> None:
    """Load disk config into memory once."""
    global _ACTIVE_CONFIG, _LOADED
    if _LOADED:
        return
    _ACTIVE_CONFIG = _read_disk_config()
    _LOADED = True
    _apply_environment(_ACTIVE_CONFIG)


def _sync_vault_paths(db: Session, configured_paths: list[str]) -> list[str]:
    """Sync enabled vault paths with current configured list."""
    warnings: list[str] = []
    configured_set = set(configured_paths)

    for path in configured_paths:
        candidate = Path(path)
        if not candidate.exists() or not candidate.is_dir():
            warnings.append(f"Vault path not found: {path}")
            continue

        try:
            vault.register_vault_path(db=db, raw_path=path)
        except Exception as exc:  # pragma: no cover - runtime defensive path
            warnings.append(f"Could not register vault path '{path}': {exc}")

    try:
        current_rows = vault.list_vault_paths(db=db)
    except Exception as exc:  # pragma: no cover - runtime defensive path
        warnings.append(f"Could not list vault paths: {exc}")
        return warnings

    for row in current_rows:
        row_path = str(row.get("path", ""))
        should_enable = row_path in configured_set
        is_enabled = bool(row.get("enabled"))
        if is_enabled == should_enable:
            continue
        try:
            vault.set_vault_path_enabled(
                db=db,
                path_id=int(row["id"]),
                enabled=should_enable,
            )
        except Exception as exc:  # pragma: no cover - runtime defensive path
            warnings.append(f"Could not update vault path '{row_path}': {exc}")

    return warnings


def initialize_runtime_config(
    session_factory: sessionmaker[Session] | None = None,
) -> list[str]:
    """Load persisted runtime config and optionally sync vault paths."""
    global _ACTIVE_CONFIG, _LOADED, _SKIPPED_FOR_RUN, _APP_UI_MODE
    with _LOCK:
        _ACTIVE_CONFIG = _read_disk_config()
        _LOADED = True
        _SKIPPED_FOR_RUN = False
        _APP_UI_MODE = UI_MODE_ASSISTANT
        config = _ACTIVE_CONFIG
        _apply_environment(config)
        LOGGER.info(
            "Runtime config initialized loaded=%s ui_mode=%s path=%s",
            bool(config is not None),
            _APP_UI_MODE,
            _config_path(),
        )

    if config is None or session_factory is None:
        return []

    with session_factory() as db:
        warnings = _sync_vault_paths(db=db, configured_paths=config.vault_paths)
        db.commit()
        return warnings


def reset_runtime_state() -> None:
    """Reset in-memory cache (useful for tests)."""
    global _ACTIVE_CONFIG, _LOADED, _SKIPPED_FOR_RUN, _APP_UI_MODE
    with _LOCK:
        _ACTIVE_CONFIG = None
        _LOADED = False
        _SKIPPED_FOR_RUN = False
        _APP_UI_MODE = UI_MODE_ASSISTANT


def has_saved_config() -> bool:
    """Return True when persisted config is available."""
    with _LOCK:
        _ensure_loaded_locked()
        return _ACTIVE_CONFIG is not None


def app_mode_initial_path() -> str:
    """Return initial route for app mode startup."""
    return "/assistant/ui" if has_saved_config() else "/config"


def set_app_ui_mode(mode: str) -> str:
    """Set active App Mode UI screen (assistant/config)."""
    normalized = str(mode or "").strip().lower()
    target = UI_MODE_CONFIG if normalized == UI_MODE_CONFIG else UI_MODE_ASSISTANT
    global _APP_UI_MODE
    with _LOCK:
        _APP_UI_MODE = target
    return target


def get_app_ui_mode() -> str:
    """Return active App Mode UI screen (assistant/config)."""
    with _LOCK:
        return _APP_UI_MODE


def is_voice_capture_paused() -> bool:
    """Return True when voice capture should be paused by UI mode."""
    return get_app_ui_mode() == UI_MODE_CONFIG


def get_runtime_config() -> AppRuntimeConfig | None:
    """Return active config snapshot."""
    with _LOCK:
        _ensure_loaded_locked()
        if _ACTIVE_CONFIG is None:
            return None
        return AppRuntimeConfig(
            todoist_api_token=_ACTIVE_CONFIG.todoist_api_token,
            vault_paths=list(_ACTIVE_CONFIG.vault_paths),
            language=_ACTIVE_CONFIG.language,
            language_configured=bool(_ACTIVE_CONFIG.language_configured),
            ai_provider=_ACTIVE_CONFIG.ai_provider,
            gemini_api_key=_ACTIVE_CONFIG.gemini_api_key,
            gemini_model=_ACTIVE_CONFIG.gemini_model,
            ollama_model=_ACTIVE_CONFIG.ollama_model,
            allow_fallback=bool(_ACTIVE_CONFIG.allow_fallback),
            providers=json.loads(json.dumps(_ACTIVE_CONFIG.providers)),
            default_task_provider=_ACTIVE_CONFIG.default_task_provider,
            default_notes_provider=_ACTIVE_CONFIG.default_notes_provider,
            updated_at=_ACTIVE_CONFIG.updated_at,
        )


def get_effective_todoist_token(fallback: str | None = None) -> str | None:
    """Resolve Todoist token from runtime config with fallback."""
    with _LOCK:
        _ensure_loaded_locked()
        if _ACTIVE_CONFIG is None:
            return fallback
        return _ACTIVE_CONFIG.todoist_api_token


def get_default_language(fallback: str = "es") -> str:
    """Return configured default assistant language."""
    normalized_fallback = _normalize_language(fallback)
    with _LOCK:
        _ensure_loaded_locked()
        if _ACTIVE_CONFIG is None:
            return normalized_fallback
        return _normalize_language(_ACTIVE_CONFIG.language)


def get_ai_provider(fallback: str | None = None) -> str:
    """Return configured AI provider id."""
    normalized_fallback = _normalize_ai_provider(fallback or _default_ai_provider())
    with _LOCK:
        _ensure_loaded_locked()
        if _ACTIVE_CONFIG is None:
            return normalized_fallback
        return _normalize_ai_provider(_ACTIVE_CONFIG.ai_provider)


def get_gemini_api_key(fallback: str | None = None) -> str | None:
    """Return configured Gemini API key (if any)."""
    default_value = settings.gemini_api_key if fallback is None else fallback
    with _LOCK:
        _ensure_loaded_locked()
        if _ACTIVE_CONFIG is None:
            return default_value
        return _ACTIVE_CONFIG.gemini_api_key or default_value


def get_gemini_model(fallback: str | None = None) -> str:
    """Return configured Gemini model name."""
    default_value = _normalize_model_name(fallback, fallback=_default_gemini_model())
    with _LOCK:
        _ensure_loaded_locked()
        if _ACTIVE_CONFIG is None:
            return default_value
        return _normalize_model_name(_ACTIVE_CONFIG.gemini_model, fallback=default_value)


def get_ollama_model(fallback: str | None = None) -> str:
    """Return configured Ollama model name."""
    default_value = _normalize_model_name(fallback, fallback=_default_ollama_model())
    with _LOCK:
        _ensure_loaded_locked()
        if _ACTIVE_CONFIG is None:
            return default_value
        return _normalize_model_name(_ACTIVE_CONFIG.ollama_model, fallback=default_value)


def get_allow_fallback(fallback: bool | None = None) -> bool:
    """Return configured AI fallback policy."""
    default_value = _default_allow_fallback() if fallback is None else bool(fallback)
    with _LOCK:
        _ensure_loaded_locked()
        if _ACTIVE_CONFIG is None:
            return default_value
        return bool(_ACTIVE_CONFIG.allow_fallback)


def get_default_task_provider(fallback: str = "todoist") -> str:
    """Return current default task provider id."""
    normalized_fallback = fallback if fallback in TASK_PROVIDER_IDS else "todoist"
    with _LOCK:
        _ensure_loaded_locked()
        if _ACTIVE_CONFIG is None:
            return normalized_fallback
        current = _normalize_provider_id(_ACTIVE_CONFIG.default_task_provider)
        if current in TASK_PROVIDER_IDS:
            return current
    return normalized_fallback


def get_default_notes_provider(fallback: str = "vault_local") -> str:
    """Return current default notes provider id."""
    normalized_fallback = fallback if fallback in NOTES_PROVIDER_IDS else "vault_local"
    with _LOCK:
        _ensure_loaded_locked()
        if _ACTIVE_CONFIG is None:
            return normalized_fallback
        current = _normalize_provider_id(_ACTIVE_CONFIG.default_notes_provider)
        if current in NOTES_PROVIDER_IDS:
            return current
    return normalized_fallback


def get_providers_snapshot() -> dict[str, dict[str, Any]]:
    """Return normalized provider map snapshot."""
    with _LOCK:
        _ensure_loaded_locked()
        if _ACTIVE_CONFIG is None:
            return _build_default_providers(todoist_token=None, vault_paths=[])
        return json.loads(json.dumps(_ACTIVE_CONFIG.providers))


def get_config_state() -> ConfigState:
    """Return current configuration status."""
    with _LOCK:
        _ensure_loaded_locked()
        config = _ACTIVE_CONFIG
        skipped = _SKIPPED_FOR_RUN

    configured = config is not None
    if configured:
        status = STATUS_CONFIGURED
    elif skipped:
        status = STATUS_SKIPPED
    else:
        status = STATUS_MISSING

    token = config.todoist_api_token if config else None
    vault_paths_count = len(config.vault_paths) if config else 0
    default_language = _normalize_language(config.language if config else "es")
    language_configured = bool(config.language_configured) if config else False
    ai_provider = _normalize_ai_provider(config.ai_provider if config else _default_ai_provider())
    gemini_key = config.gemini_api_key if config and config.gemini_api_key else settings.gemini_api_key
    gemini_model = _normalize_model_name(config.gemini_model if config else None, fallback=_default_gemini_model())
    ollama_model = _normalize_model_name(config.ollama_model if config else None, fallback=_default_ollama_model())
    allow_fallback = bool(config.allow_fallback) if config else _default_allow_fallback()
    default_task_provider = config.default_task_provider if config else "todoist"
    if default_task_provider not in TASK_PROVIDER_IDS:
        default_task_provider = "todoist"
    default_notes_provider = config.default_notes_provider if config else "vault_local"
    if default_notes_provider not in NOTES_PROVIDER_IDS:
        default_notes_provider = "vault_local"
    return ConfigState(
        status=status,
        configured=configured,
        skipped=bool(skipped and not configured),
        has_todoist_token=bool(token),
        todoist_token_masked=_mask_token(token),
        vault_paths_count=vault_paths_count,
        language=default_language,
        assistant_default_language=default_language,
        language_configured=language_configured,
        ai_provider=ai_provider,
        has_gemini_api_key=bool(gemini_key),
        gemini_model=gemini_model,
        ollama_model=ollama_model,
        allow_fallback=allow_fallback,
        default_task_provider=default_task_provider,
        default_notes_provider=default_notes_provider,
    )


def mark_skipped_for_run() -> ConfigState:
    """Mark missing configuration as skipped for this process run."""
    global _SKIPPED_FOR_RUN
    with _LOCK:
        _ensure_loaded_locked()
        if _ACTIVE_CONFIG is None:
            _SKIPPED_FOR_RUN = True
    return get_config_state()


def save_config(
    *,
    todoist_api_token: str | None,
    vault_paths: list[str],
    assistant_default_language: str,
    language_configured: bool,
    ai_provider: str | None = None,
    gemini_api_key: str | None = None,
    gemini_model: str | None = None,
    ollama_model: str | None = None,
    allow_fallback: bool | None = None,
    providers: dict[str, dict[str, Any]] | None = None,
    default_task_provider: str | None = None,
    default_notes_provider: str | None = None,
    db: Session | None = None,
) -> SaveConfigResult:
    """Persist runtime configuration and return warnings."""
    token = _normalize_token(todoist_api_token)
    normalized_paths = _normalize_paths(vault_paths)
    normalized_language = _normalize_language(assistant_default_language)
    existing = get_runtime_config()
    normalized_ai_provider = _normalize_ai_provider(
        ai_provider if ai_provider is not None else (existing.ai_provider if existing else _default_ai_provider()),
    )
    normalized_gemini_api_key = (
        _normalize_token(gemini_api_key)
        if gemini_api_key is not None
        else (existing.gemini_api_key if existing else None)
    )
    normalized_gemini_model = _normalize_model_name(
        gemini_model if gemini_model is not None else (existing.gemini_model if existing else _default_gemini_model()),
        fallback=_default_gemini_model(),
    )
    normalized_ollama_model = _normalize_model_name(
        ollama_model if ollama_model is not None else (existing.ollama_model if existing else _default_ollama_model()),
        fallback=_default_ollama_model(),
    )
    normalized_allow_fallback = (
        bool(allow_fallback) if allow_fallback is not None else (bool(existing.allow_fallback) if existing else _default_allow_fallback())
    )
    merged_providers = _normalize_providers(
        providers if providers is not None else (existing.providers if existing else None),
        todoist_token=token,
        vault_paths=normalized_paths,
    )
    normalized_default_task_provider = _normalize_provider_id(default_task_provider)
    if normalized_default_task_provider not in TASK_PROVIDER_IDS:
        normalized_default_task_provider = existing.default_task_provider if existing else "todoist"
    if normalized_default_task_provider not in TASK_PROVIDER_IDS:
        normalized_default_task_provider = "todoist"

    normalized_default_notes_provider = _normalize_provider_id(default_notes_provider)
    if normalized_default_notes_provider not in NOTES_PROVIDER_IDS:
        normalized_default_notes_provider = existing.default_notes_provider if existing else "vault_local"
    if normalized_default_notes_provider not in NOTES_PROVIDER_IDS:
        normalized_default_notes_provider = "vault_local"
    warnings: list[str] = []
    missing_paths: list[str] = []

    if token and len(token) < 20:
        warnings.append("Todoist token seems short. It was saved, but verify it if requests fail.")
    if token and any(char.isspace() for char in token):
        warnings.append("Todoist token includes whitespace. It was saved exactly as provided.")
    if normalized_ai_provider == "gemini" and not (normalized_gemini_api_key or settings.gemini_api_key):
        warnings.append("Gemini provider selected without API key. AI responses may be unavailable.")

    for path in normalized_paths:
        candidate = Path(path)
        if candidate.exists() and candidate.is_dir():
            continue
        missing_paths.append(path)
        warnings.append(f"Vault path not found: {path}")

    config = AppRuntimeConfig(
        todoist_api_token=token,
        vault_paths=normalized_paths,
        language=normalized_language,
        language_configured=bool(language_configured),
        ai_provider=normalized_ai_provider,
        gemini_api_key=normalized_gemini_api_key,
        gemini_model=normalized_gemini_model,
        ollama_model=normalized_ollama_model,
        allow_fallback=normalized_allow_fallback,
        providers=merged_providers,
        default_task_provider=normalized_default_task_provider,
        default_notes_provider=normalized_default_notes_provider,
        updated_at=_utc_timestamp(),
    )

    global _ACTIVE_CONFIG, _LOADED, _SKIPPED_FOR_RUN
    with _LOCK:
        _write_disk_config(config)
        _ACTIVE_CONFIG = config
        _LOADED = True
        _SKIPPED_FOR_RUN = False
        _apply_environment(config)

    # Keep AI provider client cache synchronized with the newest persisted config.
    try:
        from app.services.ai import provider_registry as ai_provider_registry
    except Exception:  # pragma: no cover - defensive import guard
        ai_provider_registry = None
    if ai_provider_registry is not None:
        ai_provider_registry.invalidate_runtime_cache(reason="runtime_config.save_config")

    if db is not None:
        warnings.extend(_sync_vault_paths(db=db, configured_paths=normalized_paths))

    unique_warnings: list[str] = []
    seen_warning: set[str] = set()
    for warning in warnings:
        if warning in seen_warning:
            continue
        seen_warning.add(warning)
        unique_warnings.append(warning)

    return SaveConfigResult(
        config=config,
        warnings=unique_warnings,
        missing_paths=missing_paths,
    )
