"""Минимальный settings для standalone trade-in MVP."""

from typing import Literal

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")

    # required — задаётся через env DATABASE_URL. Нет дефолта: fail-fast при старте
    # если переменная не задана (C-3 security audit).
    database_url: str

    # In-app asyncio scheduler enable flag (#581).
    # Set SCHEDULER_ENABLE=false when using the systemd timer trigger instead.
    # Default true preserves existing behaviour.
    scheduler_enable: bool = Field(default=True, validation_alias="SCHEDULER_ENABLE")

    # ── strangler-миграция scheduler → scraper_kit (#2192, завершена #2397 Part C) ────
    # scheduler_main.py теперь безусловно идёт kit-путём (scraper_kit.orchestration.scheduler
    # + product_handlers) — legacy app.services.scheduler.scheduler_loop fallback удалён
    # (был мёртвым грузом: прод давно на USE_KIT_SCHEDULER=true). Поле оставлено (extra=
    # "ignore" в model_config защищает от startup-краха на leftover env var), но больше
    # ни на что не влияет.
    use_kit_scheduler: bool = Field(default=False, validation_alias="USE_KIT_SCHEDULER")
    # Proxy-pool флаги — объявлены заранее (потребители P3/P4 отдельно), дефолт False.
    use_proxy_pool_curl: bool = Field(default=False, validation_alias="USE_PROXY_POOL_CURL")
    use_proxy_pool_browser: bool = Field(default=False, validation_alias="USE_PROXY_POOL_BROWSER")

    cors_origins: list[str] = ["http://localhost", "http://localhost:3000", "http://localhost:8080"]
    environment: str = "dev"

    # ── #2213: defense-in-depth для trusted-header auth ───────────────────────
    # Backend доверяет заголовку X-Authenticated-User (его проставляет Caddy после
    # basic_auth). Но backend сидит на общей docker-сети gendesign_shared вместе с
    # обоими стеками — любой контейнер мог бы отправить поддельный
    # `X-Authenticated-User: admin` напрямую на tradein-backend:8000, минуя Caddy,
    # и получить админ-доступ. Общий секрет закрывает эту дыру: Caddy добавляет
    # X-Internal-Auth-Secret из env, backend требует точного совпадения на любом
    # запросе с X-Authenticated-User (constant-time compare).
    #
    # Дизайн — fail-open до провижининга: пусто (дефолт) → защита НЕ активна
    # (поведение как раньше + один WARNING на старте). Секрет задаётся руками в
    # .env.runtime ОБОИХ стедов (Caddy главного стека + tradein-backend) и НИКОГДА
    # не коммитится. ENV: TRADEIN_INTERNAL_AUTH_SECRET.
    tradein_internal_auth_secret: str = Field(
        default="", validation_alias="TRADEIN_INTERNAL_AUTH_SECRET"
    )

    # ── #2550: DB-auth foundation (bcrypt password hashing + session cookie) ────
    # Подготовительные поля для #2549 (эпик). Enforcement непустого session_secret
    # (fail-fast при пустом значении в prod) добавится в #2552 — здесь дефолт
    # намеренно пустой, чтобы прод-контейнер не падал на старте до того как
    # секрет проставлен в .env.runtime. ENV: SESSION_SECRET.
    session_secret: str = Field(default="", validation_alias="SESSION_SECRET")
    # Имя cookie для DB-based сессии (отдельно от Caddy basic_auth / trusted-header).
    session_cookie_name: str = Field(
        default="tradein_session", validation_alias="SESSION_COOKIE_NAME"
    )
    # TTL сессии в часах. Дефолт 720ч (30 дней).
    session_ttl_hours: int = Field(default=720, validation_alias="SESSION_TTL_HOURS")
    # "dual" — переходный режим (Caddy trusted-header ИЛИ DB-сессия оба валидны);
    # "db_only" — только DB-сессия (Caddy basic_auth убран). Переключение — #2552+.
    auth_mode: Literal["dual", "db_only"] = Field(default="dual", validation_alias="AUTH_MODE")
    # Rate-limit на /login: не более login_rate_limit попыток за
    # login_rate_limit_window_s секунд на ключ (обычно IP или username).
    login_rate_limit: int = Field(default=5, validation_alias="LOGIN_RATE_LIMIT")
    login_rate_limit_window_s: int = Field(
        default=300, validation_alias="LOGIN_RATE_LIMIT_WINDOW_S"
    )

    # Geocoder. Env var name `YANDEX_GEOCODER_API_KEY` — consistent с scripts/
    # backfill_house_coords.py + audit_address_mismatch.py + main backend
    # OpenRouteService_API_KEY pattern. Renamed from YANDEX_GEOCODER_KEY (PR F).
    yandex_geocoder_api_key: str | None = None  # 25K req/day free после регистрации
    yandex_suggest_key: str | None = None  # для frontend autocomplete (proxy через backend)
    # для User-Agent в Nominatim (Nominatim Usage Policy)
    contact_email: str = "erginrajpopxbe@outlook.com"

    # Public URL — для QR-кода в PDF, shareable links, etc.
    public_url: str = "http://127.0.0.1:8080"

    # GlitchTip DSN — мониторинг ошибок (Sentry-совместимый). #396.
    # Пусто = мониторинг выключен (dev). В prod — env GLITCHTIP_DSN из .env.runtime.
    glitchtip_dsn: str | None = None

    # Ключ шифрования для pgp_sym_encrypt (Cian session cookies).
    # Задаётся через env COOKIE_ENCRYPTION_KEY. Пусто = шифрование не работает.
    cookie_encryption_key: str = ""

    # Redis URL для hot-cache (Phase 3.2). Задаётся через env REDIS_URL.
    redis_url: str = "redis://localhost:6379/0"

    # Rate-limit публичного /api/* (per-user / per-IP sliding window). ENV:
    # RATE_LIMIT, RATE_LIMIT_WINDOW_S. Не более rate_limit запросов за
    # rate_limit_window_s секунд на КЛЮЧ (ключ = authenticated username, либо
    # client IP для анонимов). См. ratelimit.py (#655, #2213).
    rate_limit: int = 300
    rate_limit_window_s: float = 60.0
    # #2213: аутентифицированный трафик БОЛЬШЕ не освобождается целиком (это было
    # плацебо — заголовок X-Authenticated-User клиент-контролируем). Вместо этого
    # per-user лимит = rate_limit × этот множитель. Живой пилот (kopylov/admin) не
    # должен упираться — множитель щедрый (×5 = 1500/60с при дефолте), но подделка
    # заголовка уже не даёт безлимит. ENV: RATE_LIMIT_AUTHENTICATED_MULTIPLIER.
    rate_limit_authenticated_multiplier: int = Field(
        default=5, validation_alias="RATE_LIMIT_AUTHENTICATED_MULTIPLIER"
    )

    # Password for tradein_fdw_reader role — used by backend startup to create/refresh
    # USER MAPPING for postgres_fdw → gendesign DB (gendesign_remote server).
    # Пусто = USER MAPPING не создаётся, gendesign_cad_buildings не работает (dev).
    gendesign_fdw_password: str | None = None

    # DaData /clean/address — обогащение target-адреса канонической формой,
    # kadastr_num, ФИАС, координатами, метро. Используется в estimator для
    # on-demand enrichment (PR Q1). Demo tier: 100 req/день. Если хотя бы один
    # не задан — service возвращает None gracefully, estimator продолжает.
    # ENV: DADATA_API_TOKEN, DADATA_API_SECRET.
    dadata_api_token: str | None = None
    dadata_api_secret: str | None = None

    # ── #651: IMV / Yandex blend (killer accuracy fix) ──────────────────────
    # Радиусная медиана ₽/м² системно недооценивает премиум/видовые квартиры
    # (нет class/segment/IMV-коррекции → premium ~2x underestimate, case 50М vs
    # факт ~100М). Если внешний якорь (Avito IMV recommended_price из
    # house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
    # `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
    # расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
    # При отсутствии IMV/Yandex no-op (медиана не меняется).
    estimate_imv_blend_weight: float = 0.5  # вес якоря в blend: median*(1-w)+A*w
    estimate_imv_blend_threshold: float = 1.15  # якорь должен быть > медианы ×1.15

    # ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ──────────
    # Радиусная медиана размывает премию дома/ЖК → премиум ~2.5x недооценка,
    # комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
    # similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
    # дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95)
    # и tighter FSD-диапазон.
    # Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
    estimate_sb_min_comps: int = 4  # стоп на первом тире с ≥ N активных комплов
    estimate_sb_area_sigma: float = 0.18  # σ log-нормального area-веса (Gaussian)
    estimate_sb_rooms_match_boost: float = 1.6  # ×вес если rooms компла == target
    # #680-WB within-building heterogeneity refine: floor-similarity Gaussian по
    # ОТНОСИТЕЛЬНОЙ вертикальной позиции (floor/total_floors). Прижимает якорь к
    # комплам с похожим этажом — мелкокомнатный/нижний юнит во флагман-доме больше
    # не наследует цену видового топ-этажа. 0.0 → выключено (точно старое поведение).
    # Откалибровано на 55 golden (offline): σ_f=0.25 даёт лучший medAPE без потери
    # покрытия; Хохрякова 3к/153 overshoot 64%→1.5%, флагман 4к 17.5%→5.4%.
    estimate_sb_floor_sigma: float = 0.25
    estimate_sb_guardrail_tol: float = 0.05  # hard floor: est ≥ min(comp ppm²)×(1−tol)
    estimate_sb_mad_k: float = 3.5  # MAD-clip: drop comps с |ppm2−median| > k×MAD
    # ── #1966: honest calibrated prediction-interval для expected_sold range ─────
    # Старый expected_sold_range производился из IQR аналогов (asking-IQR × ratio):
    # ~55% реальных продаж попадали в заявленный «диапазон оценки» (де-факто 50%-й
    # интервал, выданный за полный). Эмпирически отношение actual_sold/expected_sold
    # по 2366 прод-сделкам имеет p10=0.649, p90=1.392 → band
    # expected_sold × [low_mult, high_mult] = настоящий ~80% prediction interval
    # (проверено: 80.0% coverage на тех же 2366).
    estimate_pi_low_mult: float = 0.649  # empirical p10 of sold/expected_sold (#1966, n=2366)
    estimate_pi_high_mult: float = 1.392  # empirical p90 of sold/expected_sold (#1966, n=2366)
    # ── #2002: hedonic year+area correction на точку expected_sold ─────────────
    # Диагноз: estimator систематически промахивается по эре дома + размеру —
    # недооценивает новостройки, плохо держит крупные лоты. Held-out fit (n=2366
    # прод-сделок, 2026-06-27) регрессии log(actual_sold/expected_sold) ~ year +
    # ln(area) даёт мультипликативный фактор, применяемый к expected_sold.
    #   factor = exp(b0 + b_year*(year-2000)/20 + b_larea*ln(area)), clamp [min,max].
    # ВАЖНО: цифры ниже — метрики ТОГО ЖЕ 2366-сделочного held-out FIT, а НЕ
    # 277-сделочного frozen backtest-фикстура, на котором гоняется regression-gate
    # (там overall expected_sold MAPE 18.63→14.24):
    #   held-out median-abs-error 18.5%→16.1%; бизнес bias −22%→−14% (MAPE 22.3→15.5),
    #   эконом/комфорт/премиум лучше, элит без изменений (no harm).
    # После фактора заново применяется le_asking-кламп (expected_sold ≤ asking).
    # OFF ⇒ точно старое поведение expected_sold.
    estimate_hedonic_correction_enabled: bool = True
    estimate_hedonic_b0: float = 0.6146  # fit log(sold/es) ~ year + ln(area), n=2366 (#2002)
    estimate_hedonic_year_coef: float = 0.1220  # per (year-2000)/20
    estimate_hedonic_larea_coef: float = -0.1603  # per ln(area_m2)
    estimate_hedonic_first_floor_coef: float = -0.1248  # floor==1 ground-floor ≈ -12%; #2002 n=2366
    estimate_hedonic_factor_min: float = 0.75
    estimate_hedonic_factor_max: float = 1.30
    # ── #1795: premium headline anti-inflation (4 фикса, каждый за флагом) ──────
    # Диагноз: бизнес/премиум headline завышается ~2× vs медиана реальных ДКП
    # (Малышева 30 = 296k при median сделок 138k). Эконом/комфорт сходятся ±5%.
    # Каждый флаг в no-op/OFF положении восстанавливает ТОЧНО старое поведение.
    #
    # Шаг 1 — soft-кламп headline к коридору ДКП-сделок Росреестра. Когда
    # median_ppm2 > high_ppm2×(1+slack) И count≥min_n И anchor_tier != "A"
    # (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline
    # к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold.
    estimate_corridor_clamp_min_n: int = 10
    # cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов
    # (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
    # (tier-C гейт ×1.5 ловит явную контаминацию выше).
    estimate_corridor_clamp_slack: float = 0.40
    # Нижний floor для radius-пути: симметрично corridor-clamp сверху, но снизу.
    # Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor.
    # Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw.
    # factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома
    # (избегаем ложных подъёмов) и ловит явный undershoot.
    # ENV: ESTIMATE_RADIUS_FLOOR_FACTOR.
    estimate_radius_floor_factor: float = 0.8
    # Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для
    # trade-in (ожидаемая цена сделки не должна превышать цену объявления).
    # Диагноз: в high-price tier asking->sold ratio > 1.0 (product artefact, не реальные
    # сделки выше прайса) -> expected_sold = headline x ratio > headline.
    # При флаге True: если ratio > 1.0 — клампаем до 1.0 и логируем. Применяется
    # к point И range (expected_sold_low/high/price) консистентно.
    # False -> старое поведение без clamp (backward-compat).
    # ENV: ESTIMATE_EXPECTED_SOLD_LE_ASKING.
    estimate_expected_sold_le_asking: bool = Field(
        default=True, validation_alias="ESTIMATE_EXPECTED_SOLD_LE_ASKING"
    )
    # Шаг 2 — ужесточённый MAD-clip на малых выборках в same-building anchor:
    # при n < small_n_threshold используем mad_k_small вместо estimate_sb_mad_k
    # (3.5 слишком мягкий при n=7 → элитные хвосты не срезаются, mean тянется вверх).
    # mad_k_small >= estimate_sb_mad_k → no-op (старое поведение).
    estimate_sb_mad_k_small_n: float = 2.5
    estimate_sb_small_n_threshold: int = 10
    # Шаг 3 — гейт Tier C: micro-radius anchor (НЕ тот же дом) с
    # anchor_ppm2 > corridor_high×mult НЕ заменяет консервативную радиусную медиану.
    # Очень большой mult (напр. 1e9) → гейт никогда не срабатывает (старое поведение).
    estimate_anchor_tier_c_corridor_mult: float = 1.5
    # Шаг 4 — жёстче Tukey outlier-cut на малых выборках: при n < threshold
    # k уменьшается с 1.5 до tukey_k_small. threshold=0 → выключено (старое поведение).
    estimate_outlier_small_n_threshold: int = 15
    estimate_outlier_tukey_k_small: float = 1.0
    # #1774: в Tier A (тот же дом) впускаем novostroyki-листинги ТОЛЬКО если в этом же
    # доме есть ≥1 вторичный (vtorichka/NULL) листинг — признак сданного дома, где
    # "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
    # Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
    # ratio — не затрагиваются.
    asking_to_sold_haircut: float = 0.05  # дефолтная asking→sold скидка (banded по ppm²)
    estimate_fsd_k: float = 1.65  # множитель FSD → полуширина диапазона

    # ── #audit-1: anchor low-confidence gate ─────────────────────────────────
    # Якорь с низкой уверенностью (confidence="low" ИЛИ n < min_n И FSD > max_fsd)
    # НЕ заменяет headline — fallback на radius-median. Дефолты подобраны так, что
    # здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
    # estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
    # estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь
    estimate_sb_gate_min_n: int = 3
    estimate_sb_gate_max_fsd: float = 0.20

    # ── #audit-3: price_trend freshness filter ────────────────────────────────
    # Исключать items старше N месяцев из price_trend (house_placement_history).
    # Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
    estimate_price_trend_max_age_months: int = 6

    # ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
    # True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
    # когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
    # Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
    # cian_valuation, avito_imv) могут дать median без единого реального рыночного
    # аналога → headline выглядит достоверным при нулевой реальной базе.

    # ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
    # Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
    # человеком. Research: элит/премиум-премия unit-level и под-доверена (зависит
    # от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
    # высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
    # Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable).
    estimate_manual_review_high_value_rub: int = 20_000_000  # ≥ этого — ручная оценка
    estimate_manual_review_wide_range_ratio: float = 1.9  # range_high/range_low ≥ — неопределённо
    # asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
    # (премия за отделку/вид/класс — unit-level, отсутствует в данных сделок).
    estimate_manual_review_elite_ppm2: int = 250000

    # ── #1871 P2: radius-tier (source, source_id) dedup ───────────────────────
    # Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address
    # (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
    # address и выживают на разных rn_addr рангах → раздувают n_analogs (prod
    # 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по
    # (source, source_id) — radius нет. Добавляет rn_dup=1 фильтр в каждом тире
    # (freshest scraped_at на (source, source_id|source_url|ctid)).

    # ── #2087 H4: кросс-source физический дедуп аналогов ──────────────────────
    # Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id).
    # Один физический лот кросс-постится на avito+cian+domklik (разные source, разные
    # source_id) → radius-дедуп его НЕ схлопывает → он считается несколько раз →
    # раздувает n_analogs И cv (→ шире коридор), может смещать медиану. Прод-аудит
    # #2087: лот 80м²/265000₽/м² = N1+Домклик+Циан (×3); «14 аналогов» → ~6-7 уникальных.
    # True схлопывает дубли по ФИЗИЧЕСКОМУ ключу до подсчёта n_analogs/median/cv:
    #   building (building_cadastral_number | нормализованный address)
    #   + floor + area_bucket (round(area_m2), ~±0.5 м²)
    #   + price_bucket (round(price_rub / 100000), ~±0.5% @21М / ~±2% @2.5М).
    # Из группы остаётся ОДИН представитель (свежайший scraped_at), НЕ суммируем;
    # n_analogs/median/cv/source_counts/sources_used считаются по физическим лотам
    # («лот считается один раз»).
    #
    # Бэктест #1966 (400 ДКП, radius-путь, full spine, OFF vs ON): MAPE 13.89% →
    # 13.89%, coverage 83.33% → 83.33%, bias −3.83% → −3.83%, median width 0.743 →
    # 0.743, median cv 0.0988 → 0.0988; avg n_analogs 27.64 → 27.57. Дедуп отработал
    # 107× на 335 оценках, но снимает лишь identical-price кросс-посты (дубли имеют
    # ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс
    # ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по
    # физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор —
    # post-weight MAD-clip, уже ON). Default ON (#2173): бэктест #1966 OFF vs
    # ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%, median width/cv
    # без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым
    # кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает).
    estimate_dedup_analogs_enabled: bool = True

    # ── #2012: kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring ──
    # Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки
    # estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 /
    # ceiling_height_m / is_apartments НЕ читал вовсе для отбора/скоринга
    # аналогов. Три НЕЗАВИСИМЫХ флага (по одному на фичу, все default OFF —
    # см. scripts/backtest_estimator.py --engine full для A/B измерения; каждый
    # PR/issue #2012 обязан задокументировать MAPE/coverage/calibration до
    # включения любого в default ON):
    #
    # kitchen_area_m2 / ceiling_height_m ("мягкие корректировки"): в отличие от
    # house_type/year_built (сравниваются с target_house_type/target_year,
    # известными из payload или OSM house_metadata fallback) — у kitchen/ceiling
    # НЕТ target-значения: ни TradeInEstimateInput (форма пользователя), ни
    # `deals` (backtest ground truth, rosreestr ДКП) их не несут. Поэтому
    # релевантность штрафуется отклонением кандидата от МЕДИАНЫ САМОГО ПУЛА
    # кандидатов текущего запроса (self-referential), а НЕ сравнением с внешней
    # "типичной" константой — так сигнал не зависит от непроверенных допущений
    # о типичном размере кухни/высоте потолка. NULL-safe и sparse-safe вдвойне:
    # (1) кандидат без значения колонки не штрафуется и не участвует в подсчёте
    # медианы пула; (2) если кандидатов пула с непустым значением меньше
    # estimate_kitchen_ceiling_signal_min_n — сигнал пропускается ЦЕЛИКОМ для
    # всего пула (слишком мало данных для честной "типичной" медианы — риск шума
    # на sparse-колонках, который явно называет issue #2012: kitchen 4-99%,
    # ceiling ~10% покрытия по источникам).
    # ENV: ESTIMATE_KITCHEN_AREA_SIGNAL_ENABLED, ESTIMATE_CEILING_HEIGHT_SIGNAL_ENABLED.
    estimate_kitchen_area_signal_enabled: bool = False
    estimate_ceiling_height_signal_enabled: bool = False
    # Масштаб (м² / м): во сколько "единиц отклонения" превращается 1.0 очко
    # relevance_score — симметрично house_type-штрафу (1.5 очка за несовпадение)
    # и year_built-штрафу (abs(delta)/12.0). Кухня ~3м² и потолок ~0.3м —
    # консервативные масштабы, дающие умеренный штраф на типичном разбросе пула.
    estimate_kitchen_area_scale: float = 3.0
    estimate_ceiling_height_scale: float = 0.3
    # Максимальный штраф за отклонение (та же единица очков, что house_type=1.5) —
    # клампим, чтобы редкий выброс пула (напр. кухня 25м² в студийной подборке)
    # не выбрасывал кандидата из top-50 целиком одним лишь этим сигналом.
    estimate_kitchen_ceiling_signal_max_penalty: float = 1.0
    # Минимум кандидатов пула с НЕ-NULL значением колонки, чтобы доверять её
    # медиане как "типичной" для этого пула (иначе сигнал пропускается — см. риск
    # sparse-coverage выше).
    estimate_kitchen_ceiling_signal_min_n: int = 5
    #
    # is_apartments (#2008): концептуально ОТДЕЛЬНАЯ фича — не "мягкая
    # корректировка", а hard-filter сегмент-guard, симметричный novostroyki-guard
    # #1186 (`listing_segment`) в _COMMON_WHERE. Апартаменты — юридически иной
    # статус недвижимости (не жилое помещение, нет постоянной регистрации по
    # месту жительства), заметно иная ценовая модель vs обычная квартира. Target
    # trade-in объект почти всегда обычная квартира (TradeInEstimateInput не
    # даёт признака "апартаменты"), поэтому при включении флага HARD-исключаем
    # явно known is_apartments=true кандидатов из вторичка-пула. NULL-safe:
    # неизвестный статус (подавляющее большинство строк, sparse coverage)
    # участвует БЕЗ штрафа — фильтруются только явные True.
    # ENV: ESTIMATE_IS_APARTMENTS_FILTER_ENABLED.
    estimate_is_apartments_filter_enabled: bool = False

    # ── #1871 P2: split-дома wide-corridor disclosure (default ON, порог 1.2) ──
    # Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
    # дробится на несколько house_id). На split-доме разной этажности comp_min..max
    # растягивается через несколько ценовых режимов → коридор range_low/high
    # 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A +
    # corridor_pct > threshold → понижаем confidence на ступень и дописываем
    # disclosure в explanation. НЕ трогает point/median/range.
    # Порог ширины коридора (range_high-range_low)/median_price для disclosure.
    # 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
    # фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
    # Genuine split-дома из аудита = 148-170% (1.48-1.70) → 1.2 ловит только
    # экстремальный хвост (>p99), не трогая нормальную оценочную неопределённость.
    estimate_wide_corridor_threshold: float = 1.2

    # ── Mera-audit fix-1: Cian valuation sanity bounds ────────────────────────
    # API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999).
    # sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется).
    # low_price > high_price или отрицательные значения → также сброс.
    # Дефолты: 500_000 (мин. рыночная квартира ЕКБ) и 500_000_000 (500 М — абс. потолок).
    # ENV: CIAN_VALUATION_MIN_RUB, CIAN_VALUATION_MAX_RUB.
    cian_valuation_min_rub: float = 500_000
    cian_valuation_max_rub: float = 500_000_000

    # ── #audit-5: data-age guards ─────────────────────────────────────────────
    # sber_index_max_age_days: максимальный допустимый возраст последнего месяца
    # СберИндекс-серии (дней). Если latest месяц старее — логируем warning.
    sber_index_max_age_days: int = 35
    # avito_imv_thin_market_threshold: если market_count < порога — IMV-оценка
    # на тонком рынке (thin_market=True в AvitoImvSummary) + warning.
    avito_imv_thin_market_threshold: int = 10
    # #915 Stage 3: route IMV backfill через /fetch-json sidecar (обходит
    # datacenter-403, #562). Dormant по умолчанию (ENV: AVITO_IMV_USE_BROWSER_FETCHER).
    avito_imv_use_browser_fetcher: bool = False

    # ── #764: per-cadastral-quarter price index correction ───────────────────
    # Gap-correction: квартальный индекс применяется ТОЛЬКО в pure-radius пути
    # (когда same-building anchor и IMV-blend не сработали). Корректирует РАЗРЫВ
    # между квартальным уровнем целевого объекта и усреднённым квартальным уровнем
    # аналогов — не дублирует location, уже заложенный в медиану аналогов.
    # Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
    # Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
    estimate_quarter_index_min_n_deals: int = 10
    # Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —
    # аналоги уже несут локацию квартала → skip (location in median).
    estimate_quarter_match_skip_ratio: float = 0.6
    # Bimodal/nominal guard (backtest 2026-05-31): структурно неоднородные кварталы
    # дают индекс > 2.0 при малой выборке → no-op чтобы избежать регрессию.
    estimate_quarter_index_max_for_small_n: float = 2.0
    estimate_quarter_index_small_n_threshold: int = 50
    # Sanity-clamp на factor = target_index / avg_analog_index (#859).
    # Belt-and-suspenders против патологичных FDW-данных. Нормальные квартальные
    # индексы РФ лежат в [0.6, 1.8]; за этими порогами — артефакт, а не сигнал.
    estimate_quarter_index_factor_min: float = 0.6
    estimate_quarter_index_factor_max: float = 1.8

    # ── Сегментная поправка эстиматора по ценовому бэнду (#2255) ──────────────
    # Эстиматор систематически занижает верхние сегменты (live-бэктест n=561,
    # 2026-07-03): эконом +3.2%, комфорт −4.5%, бизнес −16.3% (n=76),
    # элит −25.7% (n=16). Множитель применяется к median_price/median_ppm2 и
    # пропорционально к range_low/range_high СРАЗУ ПЕРЕД min-width floor, после
    # IMV-blend / corridor-clamp / quarter-index / hedonic / PI. Бэнд — по
    # median_ppm2 границами PRICE_SEGMENTS_PPM2 (единый источник в estimator.py).
    # Флаг default OFF → путь байт-в-байт идентичен (frozen gate не двигается).
    estimate_segment_multiplier_enabled: bool = False
    # Множители: калибровка live-бэктест OFF/ON sample=300 2026-07-03 (#2255).
    # Поправка применяется ТОЛЬКО к бизнес/элит — где занижение крупное и
    # однонаправленное. эконом/комфорт = 1.00 (no-op).
    #
    # Бэнд считается по PREDICTED ppm² (при оценке истинный сегмент неизвестен),
    # а точность меряется по SOLD ppm² → band-mismatch: эконом-sold сделки,
    # PREDICTED в бизнес (и так завышаемые +7.7%), получают ×множитель и
    # завышаются дальше. Поэтому агрессивный v4 (бизнес 1.12 / элит 1.10)
    # ОТВЕРГНУТ: overall MAPE +3.08pp, эконом MAPE 17.9→20.2.
    #
    # V2 (бизнес 1.08 / элит 1.06) — лучший трейд-офф: overall MAPE +1.87pp,
    # бизнес bias −10.4→−3.2, элит −30→−24.6, эконом почти intact (MAPE 18.0).
    # Дальнейшее сжатие bias без роста MAPE — только с confidence-гейтом
    # (не применять множитель на low-confidence предсказаниях) → follow-up issue.
    # Пересчёт биасов — см. scripts/backtest_estimator.py --calibrate-segments.
    estimate_segment_multipliers: dict[str, float] = {
        "эконом": 1.00,
        "комфорт": 1.00,
        "бизнес": 1.08,
        "элит": 1.06,
    }

    # ── Estimate enrichment time-budgets (#654) ──────────────────────────────
    # POST /estimate делает несколько ПОСЛЕДОВАТЕЛЬНЫХ блокирующих сетевых
    # вызовов (geocode → Overpass → Yandex valuation → IMV → Cian). Yandex
    # valuation (внутренний httpx timeout 30s) НЕ gated на наличие floor и
    # выполняется на каждой оценке — главный подозреваемый на gateway-таймаут
    # (Caddy 502/504). Эти budget'ы оборачивают самые медленные ungated-вызовы
    # в asyncio.wait_for(): при превышении источник деградирует в None (тот же
    # graceful-путь что и сетевая ошибка), а НЕ роняет весь /estimate в 5xx.
    # Держать суммарный budget ниже Caddy read/write timeout (см.
    # deploy/Caddyfile.tradein-fragment). ENV: ESTIMATE_YANDEX_VALUATION_TIMEOUT_S,
    # ESTIMATE_CIAN_VALUATION_TIMEOUT_S, ESTIMATE_GEOCODE_BUDGET_S,
    # ESTIMATE_HOUSE_META_TIMEOUT_S.
    estimate_yandex_valuation_timeout_s: float = 8.0
    estimate_cian_valuation_timeout_s: float = 8.0
    estimate_geocode_budget_s: float = 12.0
    estimate_house_meta_timeout_s: float = 8.0

    # Лимит успешных оценок trade-in за календарный месяц на аккаунт (#658).
    # Конфигурируется через env ESTIMATE_QUOTA_LIMIT. Default 15.
    estimate_quota_limit: int = 15

    # Фильтр junk-/премиум-порога для asking→sold derivation (#767).
    # Нижняя граница 30 000 ₽/м² отсекает нежилые/технические сделки; менять не стоит.
    # Верхняя граница — поднята с 600 000 до 1 200 000 ₽/м², чтобы покрыть ЕКБ-premium
    # (>600k). Точное значение стоит сверить с `SELECT max(price_per_m2) FROM deals
    # WHERE source='rosreestr'` на проде — см. QA-note в asking_to_sold_ratio.py.
    # ENV: ASKING_RATIO_PPM2_MAX.
    asking_ratio_ppm2_max: int = 1_200_000

    # SSRF-защита для admin scrape endpoints (#756).
    # Список хостов которым разрешено передавать абсолютные URL в параметрах *_url.
    # Относительные пути (без netloc) проходят без проверки — хост подставляется
    # фиксированным в самом scraper'е. Задаётся через env SCRAPE_ALLOWED_HOSTS
    # (comma-separated). По умолчанию — все хосты используемые scrapers/*.py.
    scrape_allowed_hosts: set[str] = {
        "www.avito.ru",
        "avito.ru",
        "www.cian.ru",
        "cian.ru",
        "ekb.cian.ru",
        "realty.yandex.ru",
        "domclick.ru",
        "www.domclick.ru",
    }

    # ── Scraper mobile proxy (#806) ──────────────────────────────────────────
    # Мобильный прокси (RU, mobileproxy.space) используется ВСЕМИ scraper-сессиями:
    # Avito (#623) + Cian (#806). Datacenter-IP блокируется обоими сайтами.
    # Пусто = прямое подключение (dev/staging без прокси).
    #
    # Приоритет ENV-переменных (precedence):
    #   1. SCRAPER_PROXY_URL — новый общий ENV; когда задан — используется первым.
    #   2. AVITO_PROXY_URL   — legacy ENV; fallback, чтобы prod-серверы с уже
    #      настроенным AVITO_PROXY_URL работали без изменений .env.runtime (#806).
    # property `scraper_proxy_url` реализует эту логику; используй его везде.
    # validation_alias привязывает поле к env SCRAPER_PROXY_URL (без него
    # pydantic-settings читал бы SCRAPER_PROXY_URL_ENV по имени поля — #806 fixup).
    scraper_proxy_url_env: str | None = Field(default=None, validation_alias="SCRAPER_PROXY_URL")
    avito_proxy_url: str | None = None  # ENV: AVITO_PROXY_URL (legacy fallback)

    @property
    def scraper_proxy_url(self) -> str | None:
        """Единый прокси URL для всех scraper-сессий (Avito + Cian).

        Приоритет: SCRAPER_PROXY_URL > AVITO_PROXY_URL > None (прямое подключение).
        Prod-серверы с существующим AVITO_PROXY_URL работают без изменений env.
        """
        return self.scraper_proxy_url_env or self.avito_proxy_url

    # changeip-ссылка mobileproxy: GET меняет мобильный IP за ~9с. Дёргается при
    # детекте бана Avito перед повтором. Пусто = ротация выключена (raise сразу).
    # ENV: AVITO_PROXY_ROTATE_URL.
    avito_proxy_rotate_url: str | None = None
    # Сколько раз сменить IP при блоке прежде чем сдаться (на одну страницу).
    # #1731: 2→4 — больше шансов восстановиться mid-sweep после проактивной
    # ротации на старте (Datadome ban recovery).
    avito_proxy_max_rotations: int = 4
    # Settle-sleep после changeip-вызова: мобильный модем поднимает новый IP.
    # ~9с по умолчанию (эмпирика mobileproxy.space). ENV: AVITO_PROXY_ROTATE_SETTLE_S.
    avito_proxy_rotate_settle_s: float = 9.0
    # #1950: retry-параметры changeip-GET (_rotate_proxy_ip). Вместо одношотного 30s-timeout
    # делаем proxy_rotate_attempts попыток по proxy_rotate_attempt_timeout_s каждая.
    # Короткий timeout (8s) означает, что зависший changeip не блокирует весь run на 30s.
    # ENV: PROXY_ROTATE_ATTEMPT_TIMEOUT_S / PROXY_ROTATE_ATTEMPTS.
    proxy_rotate_attempt_timeout_s: float = 8.0
    proxy_rotate_attempts: int = 3

    # #1950: если SERP уже сохранил лоты (ins+upd > 0) и упали только detail/houses,
    # ставим 'done' а не 'banned' — partial intake сохранён, 'banned' лишний.
    # False = старое поведение. ENV: AVITO_SERP_OK_NOT_BANNED.
    avito_serp_ok_not_banned: bool = True

    # ── Cian dedicated mobile proxy (separate egress from Avito) ──────────────
    # Cian и Avito делят один мобильный IP при общем scraper_proxy_url → конкуренция
    # за единственный egress → взаимные таймауты/баны при параллельных прогонах.
    # Отдельный прокси для Cian устраняет contention. Если не задан — fallback на
    # общий scraper_proxy_url (backward-compat). ENV: CIAN_PROXY_URL.
    cian_proxy_url_env: str | None = Field(default=None, validation_alias="CIAN_PROXY_URL")
    # changeip-ссылка для Cian-прокси (ротация IP при бане/таймауте). Если не задан —
    # fallback на avito_proxy_rotate_url. ENV: CIAN_PROXY_ROTATE_URL.
    cian_proxy_rotate_url: str | None = None
    # Максимум IP-ротаций для Cian на один sweep-прогон. Аналог avito_proxy_max_rotations.
    # ENV: CIAN_PROXY_MAX_ROTATIONS.
    cian_proxy_max_rotations: int = 4

    # #1949: per-fetch hard-cancel для browser-fetch'ей внутри bucket-gather Cian full_load.
    # asyncio.wait_for(fetch, timeout=X) отменяет зависший fetch → TimeoutError →
    # _fetch_page_html ловит как Exception → None → _one_page → [] → gather завершается.
    # 0 = отключить (старое поведение). Дефолт 90.0s щедрее nормального fetch ~12s.
    # ENV: CIAN_FULL_LOAD_PER_FETCH_TIMEOUT_S.
    cian_full_load_per_fetch_timeout_s: float = 90.0

    # #1949: если run_cian_city_sweep уже сохранил лоты (ins+upd > 0) и abort случился
    # из-за anchor-timeout/consecutive SERP failures ПОСЛЕ этого, ставим 'done' а не
    # 'banned' — основной сбор состоялся, 'banned' лишний шум в мониторинге.
    # Зеркалит avito_serp_ok_not_banned (#1950), но отдельный флаг — не смешиваем
    # cian/avito семантику. False = старое поведение. ENV: CIAN_SWEEP_OK_NOT_BANNED.
    cian_sweep_ok_not_banned: bool = True

    @property
    def cian_proxy_url(self) -> str | None:
        """Прокси для Cian-скраперов. CIAN_PROXY_URL > scraper_proxy_url (fallback)."""
        return self.cian_proxy_url_env or self.scraper_proxy_url

    # ── Yandex dedicated mobile proxy (separate egress from Avito/Cian) ────────
    # Отдельный прокси для Yandex устраняет contention при параллельных прогонах.
    # Если не задан — fallback на общий scraper_proxy_url (backward-compat).
    # ENV: YANDEX_PROXY_URL.
    yandex_proxy_url_env: str | None = Field(default=None, validation_alias="YANDEX_PROXY_URL")
    # changeip-ссылка для Yandex-прокси (ротация IP при капче/таймауте). Если не задан —
    # fallback на avito_proxy_rotate_url. ENV: YANDEX_PROXY_ROTATE_URL.
    yandex_proxy_rotate_url: str | None = None
    # Максимум IP-ротаций для Yandex на один sweep-прогон. Аналог avito_proxy_max_rotations.
    # ENV: YANDEX_PROXY_MAX_ROTATIONS.
    yandex_proxy_max_rotations: int = 4

    @property
    def yandex_proxy_url(self) -> str | None:
        """Прокси для Yandex-скраперов. YANDEX_PROXY_URL > scraper_proxy_url (fallback)."""
        return self.yandex_proxy_url_env or self.scraper_proxy_url

    # full_load повторный прогон в день пропускает листинги уже обновлённые сегодня
    # (last_seen_at MSK) — экономит upsert + price-trigger churn; False = всегда
    # обновлять (старое поведение). ENV: SCRAPER_SKIP_SEEN_TODAY.
    scraper_skip_seen_today: bool = True

    # ── #759: deactivate stale avito listings ───────────────────────────────
    # Avito-объявления, не виденные scraper'ом более avito_stale_ttl_days дней,
    # помечаются is_active=false ночной задачей deactivate_stale_avito_listings.
    # TTL=10 — баланс между "снятое объявление" (обычно 3-7 дней молчания) и
    # допуском на перерыв в работе scraper'а. ENV: AVITO_STALE_TTL_DAYS.
    avito_stale_ttl_days: int = 10

    # ── Avito SERP ЕКБ гео-фильтр (per-card city-slug) ─────────────────────
    # Avito при редких/дорогих комбо (4+ комн.) добивает выдачу «по всей России»
    # (Москва/Челябинск/Омск и т.д.). Каждая карточка несёт СВОЙ href с city-slug
    # (/ekaterinburg/, /moskva/, /ufa/ и пр.). True = отбрасывать карточки, у
    # которых source_url не содержит /ekaterinburg/ (padding по России). Карточки
    # без распознанного city-slug (href без /city/) пропускаются консервативно.
    # False = старое поведение (без фильтра). ENV: AVITO_SERP_EKB_ONLY.
    avito_serp_ekb_only: bool = Field(default=True, validation_alias="AVITO_SERP_EKB_ONLY")

    # ── Yandex SERP cookies (#801/T4) ───────────────────────────────────────
    # Путь к JSON-файлу с cookies браузера (формат: [{name, value, ...}, ...]).
    # Если задан и файл существует — cookies передаются в curl_cffi-сессию при
    # Yandex SERP-запросах; снижает вероятность captcha на datacenter IP.
    # Пусто / файл не найден = запросы без cookies (не падаем, только warning).
    # ENV: YANDEX_COOKIES_FILE.
    yandex_cookies_file: str | None = None

    # ── #639: Cian browser auto-login (Variant B) ────────────────────────────
    # Провалидировано вживую 2026-05-31: email+пароль, без SMS/капчи. Флоу 2-шаговый
    # (после 1-го сабмита экран «Введите пароль» → повтор). Селекторы env-overridable.
    cian_login_email: str | None = None
    cian_login_password: str | None = None
    cian_login_url: str = "https://ekb.cian.ru/"
    # Последовательность кликов до формы: открыть модалку → (опц.) другой аккаунт →
    # переключить на email-вход. AnotherAccountBtn на fresh headless отсутствует (скипнется).
    cian_login_pre_click_selectors: list[str] = [
        "[data-name='LoginButton']",
        "[data-name='AnotherAccountBtn']",
        "[data-name='SwitchToEmailAuthBtn']",
    ]
    cian_login_email_selector: str = "input[name='username']"
    cian_login_password_selector: str = "input[name='password']"
    cian_login_submit_selector: str = "button[data-name='ContinueAuthBtn']"
    cian_login_success_cookie: str = "DMIR_AUTH"
    cian_login_wait_ms: int = 4000

    # detail_backfill через curl_cffi+backconnect (mproxy) вместо браузера/auv.
    # SERP (full_load/city_sweep) и detail_backfill делят один прокси-аккаунт auv
    # (~5 параллельных коннектов); browser-фетч в backfill открывает десятки коннектов
    # → cap превышается → HTTP 500 / краши. Backconnect (mproxy, авто-ротация,
    # 1 коннект/запрос) развязывает прокси-аккаунты.
    # True (дефолт) = curl_cffi через settings.scraper_proxy_url (backconnect mproxy).
    # False = старое browser-поведение (BrowserFetcher/auv, как scraper_fetch_mode).
    # ENV: AVITO_DETAIL_BACKFILL_USE_CURL.
    avito_detail_backfill_use_curl: bool = Field(
        default=True, validation_alias="AVITO_DETAIL_BACKFILL_USE_CURL"
    )

    # #1950: hard-timeout на один detail-fetch внутри avito_detail_backfill. Зависший
    # fetch_detail (camoufox/browser hang или curl-stall) блокирует loop навсегда →
    # budget-guard (раз в итерацию) не срабатывает → heartbeat не обновляется → run
    # reaped как zombie (run 423 завис 7.7ч). asyncio.wait_for(fetch, timeout=X)
    # отменяет зависший fetch → TimeoutError → листинг failed, loop идёт дальше.
    # 90s щедрее нормального detail-fetch (~10-15s). ENV: AVITO_DETAIL_FETCH_TIMEOUT_S.
    avito_detail_fetch_timeout_s: float = Field(
        default=90.0, validation_alias="AVITO_DETAIL_FETCH_TIMEOUT_S"
    )

    # ── #884/#905/#1805: BrowserFetcher — HTTP-клиент к tradein-browser ─────────
    # scraper_fetch_mode: "browser" (дефолт с #1805 — HTTP POST к tradein-browser
    # /fetch через per-provider camoufox + ротирующий backconnect-прокси) или
    # "curl_cffi" (legacy TLS-impersonate путь). Phase 2 epic #883: avito SERP-фетч
    # по умолчанию через браузер; curl_cffi сохранён как fallback (см.
    # AvitoScraper._fetch_serp_html) и явный opt-out через ENV SCRAPER_FETCH_MODE.
    scraper_fetch_mode: Literal["curl_cffi", "browser"] = "browser"
    # HTTP-эндпоинт tradein-browser сервиса. В Docker-сети — имя сервиса из compose.
    # ENV: BROWSER_HTTP_ENDPOINT.
    browser_http_endpoint: str = "http://tradein-browser:3000"
    # Сколько страниц обработать в одном browser-сеансе перед перезапуском браузера
    # (ограничение утечек памяти). Читается сервером из env BROWSER_RECYCLE_PAGES.
    # Оставлено для справки / compat. ENV: BROWSER_RECYCLE_PAGES.
    browser_recycle_pages: int = 15
    # Таймаут навигации (page.goto) в мс. Читается сервером из env BROWSER_NAV_TIMEOUT_MS.
    # ENV: BROWSER_NAV_TIMEOUT_MS.
    browser_nav_timeout_ms: int = 60000
    # Ожидание после DOMContentLoaded для JS-гидрации в мс. Читается сервером.
    # ENV: BROWSER_WAIT_MS.
    browser_wait_ms: int = 6000

    # ── #1995: sell-time-sensitivity — честная маркировка малой выборки ────────
    # /estimate/{id}/sell-time-sensitivity бьёт лоты на 4 price-бакета (cheap/
    # median/plus5/plus10) и считает median/p25/p75 exposure_days по каждому.
    # При n_lots ниже порога результат бакета шумный (замечена немонотонность:
    # +10% продаётся быстрее +5% — артефакт малой выборки, не data-баг). Вместо
    # тихого шума бакет с n_lots < порога помечается insufficient_data=True
    # (SellTimeBucket) — тот же паттерн, что и AvitoImvSummary.thin_market
    # (avito_imv_thin_market_threshold). НЕ сглаживаем/не выдумываем статистику —
    # честная маркировка.
    sell_time_sensitivity_min_n_lots: int = 10

    # ── Telegram support bridge (@MERAsupport_bot) ───────────────────────────
    # Клиент пишет боту в личку → зеркалится в топик support-группы → оператор
    # отвечает реплаем в топике → бот доставляет ответ клиенту. Standalone
    # long-polling воркер (app.tgbot_main), НЕ webhook — см. app/services/tgbot/.
    # Пусто/0 = бот выключен: tgbot_main логирует «disabled» и выходит с кодом 0
    # (чтобы контейнер без секрета не крутил рестарт-луп). ENV: TELEGRAM_BOT_TOKEN,
    # TELEGRAM_SUPPORT_CHAT_ID, TELEGRAM_SUPPORT_TOPIC_ID.
    telegram_bot_token: str = Field(default="", validation_alias="TELEGRAM_BOT_TOKEN")
    # Telegram id форум-группы (супергруппы с включёнными топиками), куда
    # зеркалятся обращения клиентов. Отрицательный для supergroup id (напр. -100...).
    telegram_support_chat_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_CHAT_ID")
    # message_thread_id топика внутри support-группы, в который идут зеркала.
    telegram_support_topic_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_TOPIC_ID")


settings = Settings()
