"""Хранилище настроек инстанса (`instance_settings`, key-value JSONB) и их бутстрап.

Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
`hand_queue`, `ai_level`, `summary_recipients`, `display_timezone`,
`registration_team_choice`, `registration_email_domain`, `contact_email`,
`media_limits`) —
новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
(например, при рестарте backend) не перетирает уже сделанные администратором
правки. Воркеры настройки только читают (`load_effective_config`); если
строк ещё нет (воркер стартовал раньше backend) — fallback на `plugins.yaml`
(т.к. воркеры в БД не пишут).
"""

import re
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from pydantic import BaseModel, EmailStr, TypeAdapter
from pydantic import ValidationError as PydanticValidationError
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession

from core.config import Settings
from core.plugins.config import (
    AiLevel,
    ChatConfig,
    HandQueueConfig,
    InstanceConfig,
    MediaLimitsConfig,
    PluginsConfig,
    PublishQualityCap,
    StageMaxTiles,
    SummarizerConfig,
    SummaryRecipientsMode,
    TranscriberConfig,
    load_plugins_config,
)
from models.instance_setting import InstanceSetting
from services.ai_levels import detect_ai_levels
from services.ai_tiers import TIERS
from services.consent_policy_text import DEFAULT_CONSENT_POLICY_TEXT

_KEY_TRANSCRIBER = "transcriber"
_KEY_SUMMARIZER = "summarizer"
_KEY_CHAT = "chat"
_KEY_HAND_QUEUE = "hand_queue"
_KEY_AI_LEVEL = "ai_level"
_KEY_SUMMARY_RECIPIENTS = "summary_recipients"
_KEY_DISPLAY_TIMEZONE = "display_timezone"
_KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
_KEY_CONTACT_EMAIL = "contact_email"
_KEY_MEDIA_LIMITS = "media_limits"
_KEY_CONSENT_POLICY = "consent_policy"
_KEY_DEVICE_CHECK = "device_check"
_KEY_VIRTUAL_BACKGROUND = "virtual_background"

BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
    _KEY_CHAT,
    _KEY_TRANSCRIBER,
    _KEY_SUMMARIZER,
    _KEY_AI_LEVEL,
)
"""Ключи, которыми управляет матрица «пресет → настройки» инсталлятора —
переиспользуется
`scripts/apply_preset_settings.py`, чтобы не дублировать список строковых
имён ключей `instance_settings`."""

MANAGED_KEYS: tuple[str, ...] = (
    _KEY_TRANSCRIBER,
    _KEY_SUMMARIZER,
    _KEY_CHAT,
    _KEY_HAND_QUEUE,
    _KEY_AI_LEVEL,
    _KEY_SUMMARY_RECIPIENTS,
    _KEY_DISPLAY_TIMEZONE,
    _KEY_REGISTRATION_TEAM_CHOICE,
    _KEY_REGISTRATION_EMAIL_DOMAIN,
    _KEY_CONTACT_EMAIL,
    _KEY_MEDIA_LIMITS,
    _KEY_CONSENT_POLICY,
    _KEY_DEVICE_CHECK,
    _KEY_VIRTUAL_BACKGROUND,
)
"""Все ключи, которыми управляет `InstanceSettingsService` — единый источник истины
для тестовой изоляции от состояния `instance_settings` в общей dev-БД
(`tests/conftest.py::clean_instance_settings`)."""

_DEFAULT_AI_LEVEL_VALUE = {"level": "min"}
_DEFAULT_SUMMARY_RECIPIENTS_VALUE = {"mode": "all"}
_DEFAULT_DISPLAY_TIMEZONE_VALUE = {"tz": "Europe/Moscow"}
_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE = {"enabled": False}
_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "domains": []}
"""Формат значения ключа `registration_email_domain` в БД. До версии с
несколькими доменами хранилась форма `{"enabled": bool, "domain": str|None}`
(один домен) — читающий код (`_extract_email_domains`) понимает обе формы
для обратной совместимости с уже развёрнутыми инстансами; при первом же
`update()` значение переписывается в новую форму (см. `update`)."""
_DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None}
_DEFAULT_MEDIA_LIMITS_VALUE: dict[str, Any] = {"publish_quality_cap": "off", "stage_max_tiles": 25}
_DEFAULT_DEVICE_CHECK_VALUE = {"enabled": False}
_DEFAULT_VIRTUAL_BACKGROUND_VALUE = {"enabled": False}
"""Замена фона видео (сессия 35). Дефолт — выключено: фича постоянно считает
нейросеть сегментации на клиенте, и включать её самим фактом обновления у тех,
кто ничего не просил, нельзя (то же правило, что и у остальных модулей)."""

_DEFAULT_CONSENT_POLICY_VALUE: dict[str, Any] = {
    "enabled": False,
    "text": DEFAULT_CONSENT_POLICY_TEXT,
    "version": 1,
}

# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
# без пробелов — валидация после нормализации (strip, «@», lower).
_EMAIL_DOMAIN_PATTERN = re.compile(
    r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$"
)

# Валидация формата контактного email — переиспользует тот же валидатор,
# что и `EmailStr` в pydantic-схемах (`schemas/admin.py` и др.), без
# отдельного регэкспа под адрес целиком.
_CONTACT_EMAIL_ADAPTER: TypeAdapter[str] = TypeAdapter(EmailStr)


class SettingsUpdateIn(BaseModel):
    """Частичное обновление настроек инстанса — все поля опциональны (PUT-патч).

    Используется и сервисным слоем (`InstanceSettingsService.update`), и
    (реэкспортом) админ-API Блока C (`api/admin.py`) как тело запроса
    `PUT /api/v1/admin/settings` — отдельная API-обёртка не нужна, схема
    один в один совпадает с контрактом.
    """

    chat_enabled: bool | None = None
    hand_queue_enabled: bool | None = None
    transcription_enabled: bool | None = None
    ai_level: AiLevel | None = None
    summary_recipients: SummaryRecipientsMode | None = None
    display_timezone: str | None = None
    registration_team_choice: bool | None = None
    registration_email_domain_enabled: bool | None = None
    registration_email_domains: list[str] | None = None
    contact_email_enabled: bool | None = None
    contact_email: str | None = None
    publish_quality_cap: PublishQualityCap | None = None
    stage_max_tiles: StageMaxTiles | None = None
    consent_required: bool | None = None
    consent_policy_text: str | None = None
    device_check_enabled: bool | None = None
    virtual_background_enabled: bool | None = None


class BootstrapOverrides(BaseModel):
    """Переопределения дефолтов бутстрапа по пресету инсталлятора (`BOOTSTRAP_*` в `.env`).

    Без них бутстрап `instance_settings` импортировал бы `plugins.yaml`,
    где всё `enabled: true`, — независимо от выбранного пресета
    поставки. Собирается `bootstrap_overrides_from_settings` и применяется
    ПОВЕРХ дефолтов `plugins.yaml` перед `INSERT ... ON CONFLICT DO NOTHING`
    (`ensure_bootstrapped`) — влияет только на чистую БД (первый запуск);
    принудительное обновление уже существующих строк на живой инсталляции —
    `scripts/apply_preset_settings.py`.
    """

    chat_enabled: bool | None = None
    # Единый переключатель «транскрибация+суммаризация» — как `transcription_enabled`
    # в `SettingsUpdateIn`, управляет `transcriber.enabled` и `summarizer.enabled` вместе.
    ai_enabled: bool | None = None
    ai_level: AiLevel | None = None


def bootstrap_overrides_from_settings(settings: Settings) -> BootstrapOverrides:
    """Собрать `BootstrapOverrides` из `BOOTSTRAP_*` полей `core.config.Settings`."""
    return BootstrapOverrides(
        chat_enabled=settings.bootstrap_chat_enabled,
        ai_enabled=settings.bootstrap_transcription_enabled,
        ai_level=settings.bootstrap_ai_level,
    )


def build_bootstrap_defaults(
    plugins: PluginsConfig, overrides: BootstrapOverrides | None = None
) -> dict[str, dict[str, Any]]:
    """Собрать словарь дефолтов всех ключей `instance_settings` из `plugins.yaml`,
    применив `overrides` пресета инсталлятора поверх (`chat`/`transcriber`+`summarizer`/`ai_level`).

    Переиспользуется `ensure_bootstrapped` (чистая БД) и
    `scripts/apply_preset_settings.py` (принудительное обновление живой БД).
    """
    defaults: dict[str, dict[str, Any]] = {
        _KEY_TRANSCRIBER: plugins.transcriber.model_dump(mode="json"),
        _KEY_SUMMARIZER: plugins.summarizer.model_dump(mode="json"),
        _KEY_CHAT: plugins.chat.model_dump(mode="json"),
        _KEY_HAND_QUEUE: plugins.hand_queue.model_dump(mode="json"),
        _KEY_AI_LEVEL: dict(_DEFAULT_AI_LEVEL_VALUE),
        _KEY_SUMMARY_RECIPIENTS: dict(_DEFAULT_SUMMARY_RECIPIENTS_VALUE),
        _KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE),
        _KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE),
        _KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
        _KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE),
        _KEY_MEDIA_LIMITS: dict(_DEFAULT_MEDIA_LIMITS_VALUE),
        _KEY_CONSENT_POLICY: dict(_DEFAULT_CONSENT_POLICY_VALUE),
        _KEY_DEVICE_CHECK: dict(_DEFAULT_DEVICE_CHECK_VALUE),
        _KEY_VIRTUAL_BACKGROUND: dict(_DEFAULT_VIRTUAL_BACKGROUND_VALUE),
    }
    if overrides is None:
        return defaults
    if overrides.chat_enabled is not None:
        defaults[_KEY_CHAT] = {**defaults[_KEY_CHAT], "enabled": overrides.chat_enabled}
    if overrides.ai_enabled is not None:
        defaults[_KEY_TRANSCRIBER] = {
            **defaults[_KEY_TRANSCRIBER],
            "enabled": overrides.ai_enabled,
        }
        defaults[_KEY_SUMMARIZER] = {
            **defaults[_KEY_SUMMARIZER],
            "enabled": overrides.ai_enabled,
        }
    if overrides.ai_level is not None:
        defaults[_KEY_AI_LEVEL] = {"level": overrides.ai_level}
    return defaults


class InvalidAiLevelError(ValueError):
    """Запрошенный уровень AI недоступен (см. `services.ai_levels.detect_ai_levels`)."""


class InvalidTimezoneError(ValueError):
    """`display_timezone` не является валидным именем IANA-таймзоны."""


class InvalidEmailDomainError(ValueError):
    """Некорректная настройка верификации домена email при регистрации.

    Поднимается при попытке включить верификацию без домена (`enabled=true`
    и пустой/отсутствующий домен) либо при домене, не проходящем валидацию
    простым паттерном доменного имени.
    """


class InvalidConsentPolicyError(ValueError):
    """Попытка включить обязательное согласие при пустом тексте регламента
    (`consent_required=True` без непустого `consent_policy_text`)."""


class InvalidContactEmailError(ValueError):
    """Некорректная настройка контактного адреса инстанса.

    Поднимается при попытке включить контактный адрес без email
    (`enabled=true` и пустой/отсутствующий email) либо при email, не
    проходящем валидацию формата (`EmailStr`) — см. `_normalize_contact_email`.
    """


class InstanceSettingsService:
    """CRUD-доступ к настройкам инстанса поверх таблицы `instance_settings`."""

    def __init__(self, session: AsyncSession) -> None:
        self._session = session

    async def ensure_bootstrapped(
        self, yaml_path: str | Path, overrides: BootstrapOverrides | None = None
    ) -> None:
        """Импортировать дефолты `plugins.yaml` в `instance_settings` (однократно, идемпотентно).

        `overrides` (матрица «пресет → настройки» инсталлятора, см.
        `bootstrap_overrides_from_settings`) подменяет `chat.enabled`,
        `transcriber.enabled`+`summarizer.enabled` и `ai_level` в дефолтах ДО
        `INSERT ... ON CONFLICT DO NOTHING` — влияет только на строки, которых
        ещё нет (чистая БД/первый запуск инсталлятора); уже существующие
        строки (живая инсталляция, возможно с ручными правками администратора)
        не трогает — `ON CONFLICT DO NOTHING` сохраняется как есть.
        """
        plugins = load_plugins_config(yaml_path)
        defaults = build_bootstrap_defaults(plugins, overrides)
        for key, value in defaults.items():
            stmt = (
                pg_insert(InstanceSetting)
                .values(key=key, value=value)
                .on_conflict_do_nothing(index_elements=["key"])
            )
            await self._session.execute(stmt)
        await self._session.commit()

    async def get(self) -> InstanceConfig:
        """Собрать эффективную конфигурацию из текущих строк `instance_settings`."""
        rows = await self._load_rows()
        return _build_config(rows)

    async def update(self, patch: SettingsUpdateIn) -> InstanceConfig:
        """Частично обновить настройки и вернуть новую эффективную конфигурацию.

        `transcription_enabled` пишет `enabled` сразу в обе секции
        (`transcriber`, `summarizer`) — это единый переключатель
        «транскрибация+суммаризация».
        """
        rows = await self._load_rows()
        cfg = _build_config(rows)

        if patch.ai_level is not None and patch.ai_level != cfg.ai_level:
            # Валидация только при фактической смене уровня (сравнение с уже
            # сохранённым cfg.ai_level) — иначе фронт, отправляющий текущий
            # ai_level вместе с любой другой правкой (см. `AdminSettingsTab`),
            # блокировал бы сохранение несвязанных настроек на слабом железе,
            # где текущий (давно и легитимно сохранённый) уровень недоступен
            # по факту заново переоценённых требований (RAM/модели).
            #
            # Не ослабляем проверку и при отключённых transcriber.enabled/
            # summarizer.enabled (когда уровень AI сейчас ни на что не
            # влияет): если проверять по факту переключения — это осознанное
            # намерение администратора сменить уровень, и молчаливое
            # сохранение недоступного значения подставит администратора при
            # последующем включении AI неработающей конфигурацией.
            statuses = {status.level: status for status in detect_ai_levels(cfg)}
            if not statuses[patch.ai_level].available:
                raise InvalidAiLevelError(
                    f"уровень AI {patch.ai_level!r} недоступен: {statuses[patch.ai_level].reason}"
                )
            cfg.ai_level = patch.ai_level
            await self._set(_KEY_AI_LEVEL, {"level": patch.ai_level})

        if patch.display_timezone is not None:
            _validate_timezone(patch.display_timezone)
            cfg.display_timezone = patch.display_timezone
            await self._set(_KEY_DISPLAY_TIMEZONE, {"tz": patch.display_timezone})

        if patch.summary_recipients is not None:
            cfg.summary_recipients = patch.summary_recipients
            await self._set(_KEY_SUMMARY_RECIPIENTS, {"mode": patch.summary_recipients})

        if patch.chat_enabled is not None:
            cfg.chat = ChatConfig(enabled=patch.chat_enabled)
            await self._set(_KEY_CHAT, cfg.chat.model_dump(mode="json"))

        if patch.hand_queue_enabled is not None:
            cfg.hand_queue = HandQueueConfig(enabled=patch.hand_queue_enabled)
            await self._set(_KEY_HAND_QUEUE, cfg.hand_queue.model_dump(mode="json"))

        if patch.device_check_enabled is not None:
            cfg.device_check_enabled = patch.device_check_enabled
            await self._set(_KEY_DEVICE_CHECK, {"enabled": patch.device_check_enabled})

        if patch.virtual_background_enabled is not None:
            cfg.virtual_background_enabled = patch.virtual_background_enabled
            await self._set(_KEY_VIRTUAL_BACKGROUND, {"enabled": patch.virtual_background_enabled})

        if patch.registration_team_choice is not None:
            cfg.registration_team_choice = patch.registration_team_choice
            await self._set(
                _KEY_REGISTRATION_TEAM_CHOICE, {"enabled": patch.registration_team_choice}
            )

        if (
            patch.registration_email_domain_enabled is not None
            or patch.registration_email_domains is not None
        ):
            enabled = (
                patch.registration_email_domain_enabled
                if patch.registration_email_domain_enabled is not None
                else cfg.registration_email_domain_enabled
            )
            raw_domains = (
                patch.registration_email_domains
                if patch.registration_email_domains is not None
                else cfg.registration_email_domains
            )
            domains = _normalize_email_domains(raw_domains)
            if enabled and not domains:
                raise InvalidEmailDomainError(
                    "нельзя включить верификацию домена email без указания хотя бы одного домена"
                )
            cfg.registration_email_domain_enabled = enabled
            cfg.registration_email_domains = domains
            await self._set(
                _KEY_REGISTRATION_EMAIL_DOMAIN, {"enabled": enabled, "domains": domains}
            )

        if patch.contact_email_enabled is not None or patch.contact_email is not None:
            contact_enabled = (
                patch.contact_email_enabled
                if patch.contact_email_enabled is not None
                else cfg.contact_email_enabled
            )
            raw_contact_email = (
                patch.contact_email if patch.contact_email is not None else cfg.contact_email
            )
            contact_email = (
                _normalize_contact_email(raw_contact_email) if raw_contact_email else None
            )
            if contact_enabled and contact_email is None:
                raise InvalidContactEmailError(
                    "нельзя включить контактный адрес без указания email"
                )
            cfg.contact_email_enabled = contact_enabled
            cfg.contact_email = contact_email
            await self._set(
                _KEY_CONTACT_EMAIL, {"enabled": contact_enabled, "email": contact_email}
            )

        if patch.transcription_enabled is not None:
            cfg.transcriber = cfg.transcriber.model_copy(
                update={"enabled": patch.transcription_enabled}
            )
            cfg.summarizer = cfg.summarizer.model_copy(
                update={"enabled": patch.transcription_enabled}
            )
            await self._set(_KEY_TRANSCRIBER, cfg.transcriber.model_dump(mode="json"))
            await self._set(_KEY_SUMMARIZER, cfg.summarizer.model_dump(mode="json"))

        if patch.publish_quality_cap is not None or patch.stage_max_tiles is not None:
            cap = (
                patch.publish_quality_cap
                if patch.publish_quality_cap is not None
                else cfg.media_limits.publish_quality_cap
            )
            max_tiles = (
                patch.stage_max_tiles
                if patch.stage_max_tiles is not None
                else cfg.media_limits.stage_max_tiles
            )
            cfg.media_limits = MediaLimitsConfig(publish_quality_cap=cap, stage_max_tiles=max_tiles)
            await self._set(_KEY_MEDIA_LIMITS, cfg.media_limits.model_dump(mode="json"))

        if patch.consent_required is not None or patch.consent_policy_text is not None:
            consent_required = (
                patch.consent_required
                if patch.consent_required is not None
                else cfg.consent_required
            )
            consent_text = (
                patch.consent_policy_text.strip()
                if patch.consent_policy_text is not None
                else cfg.consent_policy_text
            )
            if consent_required and not consent_text:
                raise InvalidConsentPolicyError(
                    "нельзя включить обязательное согласие с пустым текстом регламента"
                )
            # Версия — счётчик редакций текста, а не хеш/дата: администратору
            # проще сослаться на «редакцию №3», чем на хеш, а инкремент (в
            # отличие от даты) однозначно фиксирует факт правки даже при
            # повторном сохранении одного и того же текста в одну секунду.
            consent_version = cfg.consent_policy_version
            if consent_text != cfg.consent_policy_text:
                consent_version += 1
            cfg.consent_required = consent_required
            cfg.consent_policy_text = consent_text
            cfg.consent_policy_version = consent_version
            await self._set(
                _KEY_CONSENT_POLICY,
                {"enabled": consent_required, "text": consent_text, "version": consent_version},
            )

        await self._session.commit()
        return cfg

    async def _load_rows(self) -> dict[str, Any]:
        result = await self._session.execute(select(InstanceSetting))
        return {row.key: row.value for row in result.scalars().all()}

    async def _set(self, key: str, value: dict[str, Any]) -> None:
        stmt = (
            pg_insert(InstanceSetting)
            .values(key=key, value=value)
            .on_conflict_do_update(index_elements=["key"], set_={"value": value})
        )
        await self._session.execute(stmt)


async def load_effective_config(session: AsyncSession) -> InstanceConfig:
    """Загрузить эффективную конфигурацию для воркеров.

    Если `instance_settings` ещё пуста (воркер стартовал раньше бутстрапа
    backend) — fallback на `config/plugins.yaml` напрямую.
    Воркеры в БД не пишут — конкурентной гонки с бутстрапом нет.

    Уровни `medium`/`max` (ADR-004) перекрывают `transcriber`/
    `summarizer` спекой `TIERS[ai_level]` — см. `_apply_tier_overrides`.
    """
    result = await session.execute(select(InstanceSetting))
    rows = {row.key: row.value for row in result.scalars().all()}
    if not rows:
        from core.config import get_settings

        plugins = load_plugins_config(get_settings().plugins_config_path)
        cfg = InstanceConfig(
            transcriber=plugins.transcriber,
            summarizer=plugins.summarizer,
            chat=plugins.chat,
            hand_queue=plugins.hand_queue,
        )
    else:
        cfg = _build_config(rows)
    return _apply_tier_overrides(cfg)


def _apply_tier_overrides(cfg: InstanceConfig) -> InstanceConfig:
    """Подменить `transcriber`/`summarizer` спекой `TIERS[ai_level]` для `medium`/`max`.

    `min` не переопределяется — использует дефолты `plugins.yaml`/правки
    администратора как есть (обратная совместимость, ADR-004: min
    остаётся конфигурируемым через существующий механизм). Флаг `enabled`
    (переключатель «транскрибация+суммаризация» в админке) сохраняется из
    текущей конфигурации — подмена per-tier затрагивает только
    provider/model/options, не должна повторно включать отключённый модуль.
    """
    if cfg.ai_level in ("medium", "max"):
        spec = TIERS[cfg.ai_level]
        cfg.transcriber = spec.transcriber.model_copy(update={"enabled": cfg.transcriber.enabled})
        cfg.summarizer = spec.summarizer.model_copy(update={"enabled": cfg.summarizer.enabled})
    return cfg


def _validate_timezone(tz: str) -> None:
    """Проверить, что `tz` — валидное имя IANA-таймзоны."""
    try:
        ZoneInfo(tz)
    except ZoneInfoNotFoundError as exc:
        raise InvalidTimezoneError(f"неизвестная таймзона: {tz!r}") from exc


def _normalize_email_domain(domain: str) -> str:
    """Нормализовать домен email (strip, убрать ведущую «@», lower) и провалидировать.

    Валидация — простым паттерном доменного имени (минимум одна точка,
    допустимые символы, без пробелов); иначе `InvalidEmailDomainError`.
    """
    normalized = domain.strip()
    if normalized.startswith("@"):
        normalized = normalized[1:]
    normalized = normalized.lower()
    if not _EMAIL_DOMAIN_PATTERN.match(normalized):
        raise InvalidEmailDomainError(f"некорректный домен email: {domain!r}")
    return normalized


def _normalize_email_domains(domains: list[str]) -> list[str]:
    """Нормализовать список доменов: strip/lower/убрать «@» на каждом (см.
    `_normalize_email_domain`), отбросить пустые строки, убрать дубликаты
    (с сохранением порядка первого вхождения)."""
    normalized: list[str] = []
    for raw in domains:
        if not raw.strip():
            continue
        domain = _normalize_email_domain(raw)
        if domain not in normalized:
            normalized.append(domain)
    return normalized


def _extract_email_domains(value: dict[str, Any]) -> list[str]:
    """Достать список доменов из значения ключа `registration_email_domain`,
    понимая и текущую форму (`domains: [...]`), и форму до многодоменной
    поддержки (`domain: str | None`, один домен) — на проде уже записано
    именно старое значение, миграция БД для этого не нужна: следующий же
    `update()` перепишет строку в новую форму (см. docstring `update`)."""
    if "domains" in value:
        return list(value["domains"])
    legacy_domain = value.get("domain")
    return [legacy_domain] if legacy_domain else []


def _normalize_contact_email(email: str) -> str:
    """Нормализовать контактный email (strip, lower) и провалидировать формат."""
    normalized = email.strip().lower()
    try:
        _CONTACT_EMAIL_ADAPTER.validate_python(normalized)
    except PydanticValidationError as exc:
        raise InvalidContactEmailError(f"некорректный email: {email!r}") from exc
    return normalized


def _build_config(rows: dict[str, Any]) -> InstanceConfig:
    """Собрать `InstanceConfig` из строк `instance_settings` с фолбэком на дефолты моделей.

    Отсутствие отдельного ключа (например, настройка добавлена уже после
    бутстрапа существующего инстанса) не должно ронять чтение конфигурации —
    используется дефолт соответствующей Pydantic-модели/константы.
    """
    return InstanceConfig(
        transcriber=TranscriberConfig.model_validate(rows.get(_KEY_TRANSCRIBER, {})),
        summarizer=SummarizerConfig.model_validate(rows.get(_KEY_SUMMARIZER, {})),
        chat=ChatConfig.model_validate(rows.get(_KEY_CHAT, {})),
        hand_queue=HandQueueConfig.model_validate(rows.get(_KEY_HAND_QUEUE, {})),
        ai_level=rows.get(_KEY_AI_LEVEL, _DEFAULT_AI_LEVEL_VALUE).get("level", "min"),
        summary_recipients=rows.get(_KEY_SUMMARY_RECIPIENTS, _DEFAULT_SUMMARY_RECIPIENTS_VALUE).get(
            "mode", "all"
        ),
        display_timezone=rows.get(_KEY_DISPLAY_TIMEZONE, _DEFAULT_DISPLAY_TIMEZONE_VALUE).get(
            "tz", "Europe/Moscow"
        ),
        registration_team_choice=rows.get(
            _KEY_REGISTRATION_TEAM_CHOICE, _DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE
        ).get("enabled", False),
        registration_email_domain_enabled=rows.get(
            _KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE
        ).get("enabled", False),
        registration_email_domains=_extract_email_domains(
            rows.get(_KEY_REGISTRATION_EMAIL_DOMAIN, _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE)
        ),
        contact_email_enabled=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get(
            "enabled", False
        ),
        contact_email=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get("email"),
        media_limits=MediaLimitsConfig.model_validate(
            rows.get(_KEY_MEDIA_LIMITS, _DEFAULT_MEDIA_LIMITS_VALUE)
        ),
        consent_required=rows.get(_KEY_CONSENT_POLICY, _DEFAULT_CONSENT_POLICY_VALUE).get(
            "enabled", False
        ),
        consent_policy_text=rows.get(_KEY_CONSENT_POLICY, _DEFAULT_CONSENT_POLICY_VALUE).get(
            "text", DEFAULT_CONSENT_POLICY_TEXT
        ),
        consent_policy_version=rows.get(_KEY_CONSENT_POLICY, _DEFAULT_CONSENT_POLICY_VALUE).get(
            "version", 1
        ),
        device_check_enabled=rows.get(_KEY_DEVICE_CHECK, _DEFAULT_DEVICE_CHECK_VALUE).get(
            "enabled", False
        ),
        virtual_background_enabled=rows.get(
            _KEY_VIRTUAL_BACKGROUND, _DEFAULT_VIRTUAL_BACKGROUND_VALUE
        ).get("enabled", False),
    )
