import os
import warnings
from typing import Annotated

from pydantic import field_validator, model_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict


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

    database_url: str = "postgresql+psycopg://gendesign:gendesign@localhost:5432/gendesign"
    redis_url: str = "redis://localhost:6379/0"
    cors_origins: list[str] = ["http://localhost:3000"]
    # GlitchTip error tracking (Sentry-compatible self-hosted).
    # Формат DSN: https://<key>@errors.gendsgn.ru/<project_id>
    # Пустая строка / None = SDK не инициализируется (no-op).
    glitchtip_dsn: str | None = None
    glitchtip_traces_sample_rate: float = 0.05
    environment: str = "dev"
    # Test-mode flag (env TESTING=1). СТРОГО default False — в проде RBAC-гейт
    # (app/main.py rbac_guard) активен. True только в pytest (tests/conftest.py),
    # где запросы идут по app мимо Caddy и не несут X-Authenticated-User.
    testing: bool = False

    # ── §25.3 «own portfolio» (#1169) ─────────────────────────────────────────
    # Целочисленные DOM.РФ developer-id «наших» застройщиков (env OWN_DEVELOPER_IDS,
    # comma-separated → list[int]; пусто/не задано → []). Источник «текущих» проектов
    # для движка каннибализации §25.3: domrf_kn_objects фильтруется по этим id (по
    # числовому префиксу composite dev_id '<devId>_<suffix>', см.
    # site_finder/own_portfolio.py).
    #
    # СТРОГО default [] и НИ ОДНОГО реального id в коде — конфигурируется только через
    # окружение прод-контейнера. Пустой список = «текущих» проектов нет: §25.3 own-
    # portfolio деградирует ИЗЯЩНО (get_own_portfolio вернёт только manual/future-строки,
    # а при их отсутствии — пустой список; PR2-движок честно отдаёт прокси/None вместо
    # фабрикации), а НЕ падает.
    #
    # NoDecode: отключает дефолтный JSON-предпарсинг сложного типа (pydantic-settings для
    # list[int] иначе ждёт JSON-литерал '[1,2]' и падает на «человеческой» строке
    # '12345,67890'). С NoDecode сырое env-значение доходит до _parse_own_developer_ids
    # (mode='before'), где и парсится comma-separated.
    own_developer_ids: Annotated[list[int], NoDecode] = []

    @field_validator("own_developer_ids", mode="before")
    @classmethod
    def _parse_own_developer_ids(cls, value: object) -> object:
        """Распарсить OWN_DEVELOPER_IDS из comma-separated env-строки в list[int].

        pydantic-settings по умолчанию ждёт для list[int] JSON-литерал ('[1,2]'),
        поэтому «человеческую» строку '12345,67890' нужно распарсить вручную. Правила:
          • пусто / не задано / '' → [] (graceful default, §25.3 деградирует изящно);
          • '12345, 67890 ,42' → [12345, 67890, 42] (trim + пропуск пустых токенов);
          • уже list (тесты/JSON-env) → нормализуем элементы в int as-is.
        Нечисловой токен → ValueError (явная ошибка конфигурации, а не тихий дроп).
        """
        if value is None:
            return []
        if isinstance(value, str):
            stripped = value.strip()
            if not stripped:
                return []
            return [int(tok.strip()) for tok in stripped.split(",") if tok.strip()]
        if isinstance(value, list | tuple):
            return [int(item) for item in value]
        return value

    @model_validator(mode="after")
    def _promote_legacy_sentry_dsn(self) -> "Settings":
        """Backward-compat: legacy SENTRY_DSN → glitchtip_dsn ТОЛЬКО для self-hosted.

        Старый VPS .env.runtime мог содержать SENTRY_DSN=https://...@sentry.io/...
        (legacy SaaS Sentry). Промоутить его НЕЛЬЗЯ — события пойдут в чужой проект.
        Принимаем только URLs указывающие на наш errors.gendsgn.ru host.
        """
        if not self.glitchtip_dsn:
            legacy = os.getenv("SENTRY_DSN")
            if legacy:
                if "errors.gendsgn.ru" in legacy:
                    warnings.warn(
                        "SENTRY_DSN is set but ignored by pydantic; rename to GLITCHTIP_DSN. "
                        "Auto-promoting for backward compat.",
                        DeprecationWarning,
                        stacklevel=2,
                    )
                    self.glitchtip_dsn = legacy
                else:
                    warnings.warn(
                        f"SENTRY_DSN points to non-GlitchTip host "
                        f"({legacy.split('@', 1)[-1][:40]}...) — ignoring. "
                        "Set GLITCHTIP_DSN explicitly.",
                        UserWarning,
                        stacklevel=2,
                    )
        return self

    # External APIs (Stage 2)
    rosreestr_pkk_base_url: str = "https://pkk.rosreestr.ru/api/features/1"
    overpass_url: str = "https://overpass-api.de/api/interpreter"

    # Scraper schedule (наш.дом.рф kn-API).
    # Crontab format: "minute hour day_of_month month day_of_week" (Celery crontab).
    # Default: каждый понедельник в окне 04:00–05:00 по МСК.
    scrape_kn_cron: str = "15 4 * * mon"
    # Random delay window in seconds added on top of scheduled start so the worker
    # does not hit DOM.РФ at exactly the same minute every cycle. 0 = disabled.
    scrape_kn_jitter_seconds: int = 1800
    # Default region(s) for scheduled sweeps. Comma-separated.
    scrape_kn_default_regions: str = "66"
    # Path to a pre-captured Playwright storage_state.json (committed in repo,
    # used by worker to skip cold-start WAF challenge).
    scrape_kn_state_path: str = "data/playwright_state.json"
    # Token to authorize ad-hoc /api/v1/admin/scrape/* trigger calls.
    # Empty string = endpoint disabled.
    scrape_admin_token: str = ""

    # ── #1945 KN-loader anti-ban (throttle + optional proxy) ──────────────────
    # DOM.РФ WAF банит IP по volume/rate (HTTP 403 «Доступ заблокирован», БЕЗ
    # captcha — подтверждено): per-region sweep гонит ~1548 объектов × 11
    # endpoint'ов ≈ 17k запросов через один браузер. На старой concurrency=8
    # WAF банил VPS-IP mid-sweep. Лечим двумя рычагами.
    #
    # Рычаг 1 — throttle. Ограничивает число одновременных in-page fetch().
    # Изначально вводился ТОЛЬКО для KN-sweep BrowserSession; с #2445 D2
    # (2026-07) domrf_catalog.py / domrf_catalog_object.py (catalog-flat /
    # catalog-object scrapers) тоже переиспользуют эти же значения — они бьют
    # по тому же /сервисы/* path family, что и вызвало WAF hard-ban 2026-05-24
    # (#2443). nspd и прочие скраперы вне этого path family продолжают
    # использовать модульный дефолт _BROWSER_CONCURRENCY=8 без изменений.
    # 2 — эмпирически безопасный потолок против volume-бана.
    # ENV: SCRAPE_KN_BROWSER_CONCURRENCY.
    scrape_kn_browser_concurrency: int = 2
    # Окно случайной паузы (мс) между запросами KN-sweep (и, с #2445 D2, catalog-
    # flat/catalog-object scrape'ов — см. комментарий выше). Шире дефолта
    # (600–1500), чтобы размазать запросы во времени и не триггерить rate-ban.
    # min < max обязателен (иначе random.uniform отдаст границу). При throttle
    # ширим до 1200–3000. ENV: SCRAPE_KN_REQUEST_JITTER_MIN_MS / _MAX_MS.
    scrape_kn_request_jitter_min_ms: int = 1200
    scrape_kn_request_jitter_max_ms: int = 3000
    # Рычаг 2 — прокси (ОПЦИОНАЛЬНО, default None → прямое подключение, поведение
    # без изменений). Когда задан — KN-sweep BrowserSession запускает Chromium
    # через этот прокси (формат http://user:pass@host:port; парсится в
    # Playwright proxy={server,username,password}). Сильнейший рычаг против
    # IP-бана: переиспользует тот же mobile-proxy паттерн, что tradein-стек
    # (SCRAPER_PROXY_URL). Заводится ТОЛЬКО через окружение прод-контейнера.
    # ENV: SCRAPE_KN_PROXY_URL.
    scrape_kn_proxy_url: str | None = None
    # Рычаг 3 (ГЛАВНЫЙ unblock #1945) — изоляция flats от extras.
    # Эмпирически (prod, 2026-06-27): flats endpoint /portal-kn/api/sales/portal/table
    # на concurrency=2 = 0 WAF-бан на 100+ объектах; extras /сервисы/api/object/{id}/*
    # отдают 403 СРАЗУ (volume-independent, мертвы с 2026-06-03) И ТРАВЯТ cookies
    # сессии → последующие flats на той же сессии тоже 403. flats_count (метрика
    # #1945) рухнул 3670→9 именно из-за этого.
    #
    # True (дефолт, NEW): flats тянутся в ЧИСТОЙ flats-only сессии (extras на ней
    # НЕ дёргаются НИКОГДА) → flats_count восстанавливается; extras идут отдельным
    # best-effort проходом с recycle сессии на каждый WAF-403 (яд не накапливается
    # и НЕ касается flats). False: старое поведение (flats+extras в одной сессии,
    # poison-prone). ENV: SCRAPE_KN_EXTRAS_ISOLATED.
    scrape_kn_extras_isolated: bool = True

    # NSPD-scraper (Playwright) УДАЛЁН 2026-05-11. Сменён на bulk geo-fetcher
    # через rosreestr2coord — запускается вручную через /admin/scrape/geo.
    # Settings оставлены deprecated на случай отката (можно удалить позже).
    scrape_nspd_cron: str = "30 3 20 2,5,8,11 *"  # DEPRECATED
    scrape_nspd_default_regions: str = "66"  # DEPRECATED
    scrape_nspd_rate_ms: int = 600  # DEPRECATED — see nspd_lite_rate_ms

    # Objective.ru API (api.objctv.ru) — платная аналитика первички/вторички.
    # Лежит наряду с DOM.РФ kn + rosreestr CSV как 3-й источник истины с
    # самой богатой моделью (per-flat per-day, escrow/банк-долг, ЕГРН-ИНН).
    # Получить ключ: личный закрытый ApiKey от Объектива (тариф per-территория).
    # Tank-схема:
    #   1. GET /Users/User/GetToken?apiKey=<KEY> → JSON с Bearer-токеном
    #   2. GET /v2/Report/GetReport?... + Authorization: Bearer <token>
    objective_api_key: str | None = None
    objective_base_url: str = "https://api.objctv.ru"
    # Группа = «настройка территории» внутри Объектива. Чаще всего совпадает с
    # городом/областью. Доступные группы определяются тарифом ключа.
    objective_default_group: str = "Екатеринбург"
    # Список групп для еженедельного auto-sync (csv). Эмпирически проверено
    # 2026-05-10 что эти группы доступны на нашем тарифе:
    #   - "Свердловская область" → 1626 corp_sum/3д (= ЕКБ + спутники + регион)
    #   - "Челябинск" → 355
    #   - "Тюмень" → 985
    #   - "Пермь" → 423
    # Стратегия Variant B (включить наш sync параллельно Антоновому ETL):
    # Антон тянет только Екб (303k lots). Наш sync даёт +30k Свердл.обл
    # за пределами Екб + ~700k квартир УрФО (Челябинск, Тюмень, Пермь).
    # Итог в PG: ~1М квартир уральского + западносибирского рынка.
    # Между группами task делает паузу _OBJECTIVE_INTER_GROUP_DELAY = 30с
    # чтобы не упереться в rate-limit Объектива.
    objective_sync_groups: str = "Свердловская область,Челябинск,Тюмень,Пермь"
    # Токен Bearer короткоживущий (предположительно 30 мин — уточнить).
    # Кешируем в Redis с этим TTL (с запасом 5 мин).
    objective_token_ttl_seconds: int = 25 * 60
    # Расписание sync — еженедельно по вторникам в 06:00 МСК.
    # ПОЧЕМУ ВТОРНИК (а не понедельник как раньше): Антон тянет в понедельник,
    # к утру вторника его SQLite уже обновлён — наш sync захватывает свежий
    # snapshot за тот же отчётный период.
    objective_sync_cron: str = "0 6 * * tue"

    # NSPD lite (urllib через WAF) — feature toggle для переключения с
    # Playwright-based nspd_kn.py на минималистичный nspd_lite.py.
    # 2026-05-11: эмпирически проверено что urllib проходит WAF nspd.gov.ru
    # (TLS-fingerprint stdlib не блокируется). Старый Playwright-путь
    # остаётся как fallback на случай возврата WAF-проблем.
    use_nspd_lite: bool = True
    # Default rate-limit для nspd_lite fetcher (мс между запросами).
    nspd_lite_rate_ms: int = 600

    # Каталог хранения собранных PDF-отчётов ПТИЦА (эпик #2259 PR-D). Worker пишет
    # PDF сюда, backend читает файл для /report/download. На проде — общий writable
    # bind-mount `./reports:/app/reports` у сервисов backend+worker (docker-compose.prod.yml),
    # иначе backend не увидит файл, записанный воркером. Для локальной разработки —
    # относительный путь ОК (тест мокает render_full_report_pdf, файл не пишется).
    reports_dir: str = "/app/reports"

    # ETL Антоновского /sf/api/* SQLite → нашу PG (objective_* таблицы).
    # На проде монтируется bind-mount-ом docker-compose:
    #   /opt/gendesign/site-finder/analysis.db -> /data/anton-sqlite/analysis.db
    # Триггерится вручную через POST /api/v1/admin/scrape/objective.
    # Для локальной разработки — указать абсолютный путь к скачанному snapshot:
    #   OBJECTIVE_ANTON_SQLITE_PATH=C:/Users/user/source/repos/gendesign/sf_anton_snapshot.db
    objective_anton_sqlite_path: str = "/data/anton-sqlite/analysis.db"

    # Cross-load ETL tradein→gendesign (#976 950-E5).
    # Прямой psycopg-коннект к tradein-postgres через gendesign_shared network.
    # Пример: postgresql://gendesign_reader:<pw>@tradein-postgres:5432/tradein
    # Если пусто — ETL отключён (warn-log, задача возвращает {"disabled": true}).
    tradein_database_url: str = ""

    # OpenRouteService API (https://openrouteservice.org/dev/#/signup).
    # Free tier: 2000 запросов/день. Используется в /parcels/{cad}/isochrones.
    # Если не задан — endpoint вернёт 503 с инструкцией по регистрации.
    openrouteservice_api_key: str = ""

    # ── OSRM road-distance в /analyze (#39 A2) ────────────────────────────────
    # Реальное дорожное расстояние центроид→POI из локального OSRM-сервиса
    # (self-hosted, /table GET с annotations=distance → метры) вместо
    # straight-line ST_Distance в POI-скоринге.
    #
    # use_osrm_distances СТРОГО default False: включение сдвигает ВСЕ POI-score
    # на ~20-30% (дорога длиннее прямой), что есть осознанное ПОЗДНЕЕ решение
    # (после валидации), а НЕ этот деплой. При OFF /analyze байт-в-байт как
    # сегодня — OSRM не дёргается вообще. При ON любой сбой OSRM (HTTP/timeout/
    # битый ответ) → graceful fallback на straight-line, /analyze НЕ падает.
    use_osrm_distances: bool = False
    # URL локального OSRM (docker-compose service `osrm`, DRIVING-граф). /table:
    #   GET {url}/table/v1/driving/{coords}?sources=0&annotations=distance
    osrm_local_url: str = "http://osrm:5000"
    # Таймаут одного OSRM-вызова (сек). Воркер не должен висеть на недоступном
    # сервисе — при превышении → fallback на straight-line.
    osrm_distance_timeout_s: float = 12.0

    # ── Per-category OSRM routing (#39 A3) ────────────────────────────────────
    # Валидация показала: DRIVING-расстояние ПЕРЕОЦЕНИВАЕТ пешеходно-релевантные
    # POI (школа/магазин/парк) — медиана 1.6–2.9× straight-line. Фикс: walk-POI
    # маршрутизируем по FOOT-графу (отдельный сервис `osrm-walk`), car-POI — по
    # существующему DRIVING-графу.
    #
    # URL локального OSRM с FOOT-графом (docker-compose service `osrm-walk`):
    #   GET {url}/table/v1/foot/{coords}?sources=0&annotations=distance
    osrm_walk_local_url: str = "http://osrm-walk:5000"
    # Категории POI, маршрутизируемые по FOOT-графу (пешая доступность). Всё, чего
    # НЕТ в этом множестве — incl. `shop_mall`, `hospital` и любая неизвестная/None
    # категория — идёт через DRIVING-граф как безопасный дефолт (drive-side).
    osrm_walk_categories: frozenset[str] = frozenset(
        {
            "shop_small",
            "pharmacy",
            "shop_supermarket",
            "kindergarten",
            "school",
            "park",
            "bus_stop",
            "tram_stop",
            "metro_stop",
        }
    )

    # ИРД-слой в analyze (#1067 D9b «GG-форсайт»): поле `ird` в ответе analyze —
    # parcel_ird_overlaps (м.132, incl opportunity) + КРТ (геопортал WFS) +
    # ПЗЗ-регламент зоны (C8b). Включён 2026-06-07 после B6-harvest + прогрева
    # zone_regulation_cache (#1102). Override через env ENABLE_IRD_ANALYZE=false остаётся
    # возможен. functional_zone выключен — слой пуст в геопортале ЕКБ (0 фич, #1058).
    enable_ird_analyze: bool = True

    # РИАСУРТ Свердл gate в analyze (#108, multi-city scaling): поле `gate.riasurt` в ответе
    # analyze — пересечения участка с зонами РИАСУРТ Свердл (тер.зона/функц.зона/красные линии/
    # СЗЗ/ЗСО/затопление/КРТ). ТОЛЬКО для участков в агломерации ЕКБ, НЕ в самом ЕКБ-сити
    # (is_in_aglomeration_but_not_ekb). По умолчанию OFF: таблица riasurt_sverdl наполняется
    # post-deploy harvest'ом на уточнённых bbox МО (см. riasurt_sverdl_harvest.MO_BBOXES TODO).
    # Override через env ENABLE_RIASURT_GATE=true.
    enable_riasurt_gate: bool = False

    # РИАСУРТ Свердл harvest kill-switch (#108 review): ежеквартальный beat
    # `harvest_all_riasurt_sverdl` прогоняет grid-walk по MO_BBOXES. Эти bbox — ПЛЕЙСХОЛДЕРЫ
    # (грубые ±6 км вокруг центров МО), реальные административные границы резолвятся post-deploy.
    # По умолчанию OFF, иначе beat на следующем тике дёргает WMS с мусорными bbox.
    # Включать ТОЛЬКО после замены MO_BBOXES на реальные границы. Override через
    # env ENABLE_RIASURT_HARVEST=true. Single-MO harvest_riasurt_sverdl_for_mo не гейтится
    # (callable вручную для smoke-тестирования конкретного bbox).
    enable_riasurt_harvest: bool = False

    # Реальный ПЗЗ-градрегламент зоны (КСИТ/max_far, высота, этажность, %застройки,
    # min площадь ЗУ) в ответе analyze: поле `nspd_zoning` дополняется числовыми
    # предельными параметрами из zone_regulation_cache (Route 2 — coordinate-based
    # resolver get_or_fetch_zone_regulation, cache-first + bounded live geoportal на
    # miss). Кэш прогрет для ЕКБ (33 зоны), поэтому live-вызовы редки и закапаны
    # коротким timeout'ом. Hot-path-safe: любой сбой/таймаут → поля None, analyze не
    # падает. По умолчанию ON; выключение (env ENABLE_ZONING_REGULATION_IN_ANALYZE=false)
    # полностью пропускает резолв — поведение analyze без изменений.
    enable_zoning_regulation_in_analyze: bool = True

    # Единый bounded-timeout (сек) для live geoportal-вызовов резолвера ПЗЗ-регламента в
    # hot-пути analyze (#1850). Раньше было два расходящихся значения: parcels.py=3s,
    # ird_analyze.py=4s для ОДНОГО и того же резолвера. Унифицировано в 4s (более безопасный
    # запас). Оба колл-сайта работают cache-first (+ мемоизация coord→zone_index), поэтому
    # живые вызовы редки. Дефолт клиента EKBGeoportalClient (20s) слишком долог для sync-хэндлера.
    # Override через env GEOPORTAL_TIMEOUT_S.
    geoportal_timeout_s: int = 4

    # Area-gate для blocker'а «инженерная/утилитарная охранная зона» в gate_verdict.
    # ЗОУИТ охранной зоны инж.сети (ЛЭП/газ/трубопровод/тепло/электро) в РФ ограничивает
    # застройку ВНУТРИ полосы (отступы, запрет капстроя над линией), а НЕ стерилизует весь
    # участок — МКД сажается на необременённом остатке. Поэтому такая зона блокирует МКД
    # ТОЛЬКО когда покрывает > этой доли площади участка (фактически стерилизует его);
    # ниже порога — warning «учесть при посадке». Default 0.6: на проде ЕКБ медиана покрытия
    # ~6%, p90 ~47% → блокирует лишь экстремальный хвост (1/17 участков). Override через env
    # GATE_ZOUIT_ENGINEERING_BLOCKER_MIN_COVERAGE.
    gate_zouit_engineering_blocker_min_coverage: float = 0.6

    # ── LLM infrastructure (#960) ────────────────────────────────────────────
    # ОПЦИОНАЛЬНЫЙ слой поверх детерминированного движка. Forecasting НИКОГДА не
    # зависит от LLM — при любом сбое/выключенности возвращается детерминированный
    # fallback (см. app/services/llm/client.py).
    #
    # llm_enabled СТРОГО default False: пока секреты не настроены в проде, клиент
    # НЕ делает сетевых вызовов вообще (guard #2 в client.complete). Включать только
    # после того как OPENAI_API_KEY заведён в окружение прод-контейнера И принято
    # решение по §19 data-residency (провайдер внешний — данные покидают РФ).
    llm_enabled: bool = False
    # Ключ читается ТОЛЬКО отсюда (env OPENAI_API_KEY). Нигде в коде/тестах нет
    # литерала ключа. None = ключ не задан → клиент ведёт себя как при llm_enabled=False.
    openai_api_key: str | None = None
    llm_model: str = "gpt-4o-mini"
    llm_base_url: str = "https://api.openai.com/v1"
    # Таймаут одного HTTP-вызова к провайдеру (сек). Воркер не должен висеть.
    llm_timeout_s: float = 30.0
    llm_max_output_tokens: int = 1024
    # Верхняя граница числа LLM-вызовов в рамках одной логической операции
    # (граддок-extraction / chat-turn) — защита от случайного цикла у консьюмера.
    llm_max_calls_per_request: int = 4
    # Бюджетный потолок (USD) — пока только логируется как оценка (tokens→$).
    # None = без потолка. Жёсткий enforcement добавит консьюмер при необходимости.
    llm_daily_cost_cap_usd: float | None = None
    # Сколько ретраев на 429/5xx до деградации в fallback (циркуит-брейкер-lite).
    llm_max_retries: int = 2

    # ── DaData /clean/address геокод (objective_backfill geo-pass, #2177) ──────
    # Токен + секрет для DaData /clean/address (обогащение адреса → geo_lat/geo_lon).
    # На gendesign-проде уже заведены в окружении контейнера (проверено 2026-07-03).
    # Оба нужны для clean_address (в отличие от suggest, которому хватает токена).
    # None/пусто → dadata_client.clean_address graceful-возвращает None (geo-pass
    # тогда reject'ит всё «нет геокода», не падает). ENV: DADATA_API_TOKEN /
    # DADATA_API_SECRET. Литерала ключа в коде/тестах нет.
    dadata_api_token: str | None = None
    dadata_api_secret: str | None = None
    # Таймаут одного HTTP-вызова к DaData (сек). Воркер geo-pass не должен висеть
    # на недоступном сервисе. ENV: DADATA_TIMEOUT_S.
    dadata_timeout_s: float = 8.0


settings = Settings()
