import datetime as dt
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, Field

# ── #105 Phase 5: Recent permits schemas ──────────────────────────────────────


class RecentPermit(BaseModel):
    """Одно строительное разрешение (РНС или РВЭ) из ekburg_construction_permits."""

    permit_type: str
    permit_number: str
    issue_date: str | None
    developer_name: str | None
    developer_inn: str | None
    object_name: str | None
    object_type: str | None
    construction_address: str | None
    total_area_sqm: float | None


class PermitsSummary(BaseModel):
    """Агрегированная сводка по разрешениям в квартале."""

    rns_count: int
    rve_count: int
    rns_total_area_sqm: float
    by_developer: list[dict[str, Any]]


# ── Connection points schemas (issue #115) ────────────────────────────────────


class EngineeringStructure(BaseModel):
    name: str | None
    type: str | None
    cad_num: str | None
    # Назначение сооружения из НСПД-дампа (params_purpose / object_type_value),
    # напр. «1.1. Сооружения электроэнергетики». Заполняется ТОЛЬКО когда отличается
    # от `type` (иначе дубль-шум). ADDITIVE (default None).
    purpose: str | None = None
    distance_to_boundary_m: float
    geometry_geojson: dict[str, Any]
    readable_address: str | None
    # Доп. характеристики из НСПД-дампа (год постройки / длина / собственность),
    # собранные в одну читаемую строку. None — данных нет. ADDITIVE (default None).
    characteristics: str | None = None
    raw_props: dict[str, Any]
    source: str


class ZouitOverlap(BaseModel):
    reg_numb_border: str | None
    type_zone: str | None
    subcategory: int | None
    intersects_parcel: bool
    geometry_geojson: dict[str, Any]
    raw_props: dict[str, Any]
    source: str


class ConnectionPointsSummary(BaseModel):
    nearest_structure_distance_m: float | None
    in_protection_zone: bool
    protection_zones_intersecting: int
    total_structures_in_radius: int
    # Epic #2445 A1: честные флаги усечения — True, когда истинный count (COUNT(*) без
    # LIMIT, по той же выборке) больше длины отдаваемого списка (LIMIT 100/50 в SQL).
    # ADDITIVE (default False) — старые клиенты не ломаются.
    protection_zones_truncated: bool = False
    structures_truncated: bool = False


class ConnectionPointsResponse(BaseModel):
    engineering_structures: list[EngineeringStructure]
    zouit_engineering_overlaps: list[ZouitOverlap]
    summary: ConnectionPointsSummary
    dump_available: bool
    dump_fetched_at: str | None


# ── OSM engineering-networks schemas (#1746) ─────────────────────────────────
# No-B2B open-data слой инженерных сетей из OSM (Overpass): power/water/gas/heat/
# communication/sewage. Источник — osm_utility_infrastructure_ekb.


class UtilityInfrastructureFeature(BaseModel):
    osm_id: int
    osm_type: str
    # 'power' | 'water' | 'gas' | 'heat' | 'communication' | 'sewage'
    infrastructure_kind: str
    name: str | None
    source_tag: str | None
    distance_m: float
    geometry_geojson: dict[str, Any]
    # Гуманизированные характеристики сети из OSM tags (напряжение / оператор /
    # диаметр / материал), собранные в одну строку. None — данных нет.
    # ADDITIVE (default None) → не ломает существующих потребителей.
    characteristics: str | None = None


class UtilityInfrastructureSummary(BaseModel):
    total_features: int
    nearest_distance_m: float | None
    # Карта вид сети → расстояние до ближайшего объекта данного вида (м), либо null.
    nearest_by_kind: dict[str, float | None]
    # Epic #2445 A2: True, когда истинный count (COUNT(*) без LIMIT, по той же
    # ST_DWithin-выборке) больше длины отдаваемого `features` (LIMIT :lim, default 200).
    # ADDITIVE (default False) — старые клиенты не ломаются.
    features_truncated: bool = False


class UtilityInfrastructureResponse(BaseModel):
    features: list[UtilityInfrastructureFeature]
    summary: UtilityInfrastructureSummary


# ── Connection-capacity schemas (Forgejo #2119 Phase A) ──────────────────────
# «Точки подключения СО СВОБОДНОЙ МОЩНОСТЬЮ» для ПТИЦА §3: центры питания
# (power_supply_centers) с резервом + city-level резервы воды (water_supply_reserves).


class PowerConnectionPoint(BaseModel):
    """Центр питания (ЦП, ПС 35/110 кВ) вблизи участка со свободной мощностью."""

    name: str
    dzo_name: str | None
    voltage_class: str | None  # напр. '110/35/10'
    load_index: str | None  # 'open'|'limited'|'closed'|None
    installed_capacity_mva: float | None
    current_load_mva: float | None
    reserve_mva: float | None  # свободная мощность, МВА
    reserve_asof: str | None  # ISO-дата актуальности резерва
    distance_m: float
    lat: float | None
    lon: float | None
    # Пометка для ПС ЕЭСК без опубликованного резерва: свободная мощность у АО «ЕЭСК»
    # (городская сеть ЕКБ) не раскрывается WFS/xlsx — берётся по запросу. #2119 B1.
    reserve_note: str | None = None
    # Единица reserve_mva/installed_capacity_mva: 'МВА' (Россети) или 'МВт'
    # (ЕЭСК ПС 35-220). district — адм. район ЕКБ (из xlsx ЕЭСК; NULL у Россети).
    # ADDITIVE (default None). #2119 Phase B2.
    reserve_unit: str | None = None
    district: str | None = None


class PowerCapacitySummary(BaseModel):
    """Сводка по электрике: ближайший ЦП с резервом + count по индексу загрузки."""

    total_power_points: int
    # Карта индекс загрузки ('open'|'limited'|'closed'|'unknown') → count.
    by_load_index: dict[str, int]
    # Ближайший ЦП с положительным резервом (или null, если резерв нигде не задан).
    nearest_with_reserve: PowerConnectionPoint | None


class WaterCapacityRow(BaseModel):
    """Строка резерва ЦСВ/ЦСК Водоканала (city-level) за последний период."""

    system_kind: str  # 'water'|'sewerage'
    system_name: str  # ЦСВ/ЦСК, напр. «ЗФС, ГСВ»
    reserve_thousand_m3_day: float | None  # может быть отрицательным (дефицит)
    note: str | None
    period: str | None


class GasGrsRow(BaseModel):
    """Свободная мощность одной городской ГРС (агрегация по выходам). #2119 B1."""

    grs_name: str
    design_capacity_th_m3_h: float | None  # проектная (сумма по выходам), тыс. м³/ч
    free_capacity_th_m3_h: float | None  # свободная (сумма по выходам), тыс. м³/ч
    free_capacity_pct: float | None  # средняя свободная, %
    upgrade_due: str | None  # срок расшивки (макс. по выходам, свободный текст)
    outputs_count: int  # число строк-выходов ГРС, вошедших в агрегацию


class GasCapacityBlock(BaseModel):
    """Газовый блок: городские ГРС ЕКБ со свободной мощностью. #2119 Фаза B1.

    + счётчики точек выхода газосети (gas_grs_outlet_points, форма 6 ФАС №960 АО
    «Екатеринбурггаз») за последний срез. #2119 Фаза B2 PR-4.
    """

    # Городские ГРС (Свердловск/Екатеринбург) — агрегация по grs_name. Пусто, если
    # таблица gas_grs_capacity ещё не наполнена (weekly-sync, RUN-ON-PROD).
    city_grs: list[GasGrsRow]
    # Суммарная свободная мощность городских ГРС, тыс. м³/ч (None если данных нет).
    total_free_th_m3_h: float | None
    # Счётчики точек выхода последнего period_month (по ВСЕМ строкам среза, city-level —
    # см. _query_gas_outlet_counts). ADDITIVE (default 0): 0, если таблица
    # gas_grs_outlet_points ещё не наполнена (миграция 184 / monthly-sync). #2119 B2 PR-4.
    # ВНИМАНИЕ: свободная мощность точек выхода — млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!).
    outlets_total: int = 0  # всего точек выхода в последнем срезе
    outlets_deficit: int = 0  # из них с дефицитом (free_capacity_mln_m3 < 0)
    outlets_needs_calc: int = 0  # из них «0.000*» — нужен гидравлический расчёт


class GasOutletPoint(BaseModel):
    """Гео-точка выхода газосети в радиусе от участка (для слоя карты §3). #2119 B2 PR-4."""

    outlet_name: str  # «Точка выхода» — юрлицо/объект/промплощадка (полный текст)
    consumer_type: str | None  # котельная / ТЭЦ / площадка …
    # Свободная мощность точки выхода, млн. м³/МЕС (НЕ тыс. м³/ч как у ГРС!). Может быть
    # <0 (дефицит со знаком). NULL, когда в источнике «0.000*» (needs_calc=True).
    free_capacity_mln_m3: float | None
    needs_calc: bool  # «0.000*» — свободная мощность не определена, нужен гидрорасчёт
    distance_m: float  # расстояние от центроида участка, м
    lat: float | None
    lon: float | None


class HeatSystemRow(BaseModel):
    """Резерв тепловой мощности одной системы теплоснабжения. #2119 Phase B2."""

    org: str  # организация-держатель (ЕТК / Екатеринбургэнерго)
    system_name: str  # имя системы теплоснабжения (напр. «СТ №54»)
    reserve_gcal_h: float | None  # свободная мощность, Гкал/ч (может быть <0 — дефицит)
    period: str | None  # напр. '2026-Q2'


class HeatCapacityBlock(BaseModel):
    """Тепловой блок: резервы систем теплоснабжения за последний период. #2119 B2."""

    # Строки heat_system_reserves за MAX(period) ПО КАЖДОЙ организации. Пусто, если
    # таблица heat_system_reserves ещё не наполнена (weekly-sync, RUN-ON-PROD).
    systems: list[HeatSystemRow]
    # Суммарная свободная тепловая мощность, Гкал/ч (None если данных нет).
    total_reserve_gcal_h: float | None


class NearbyNetworkZone(BaseModel):
    """Сетевая охранная зона cad_zouit рядом с участком — позитив-сигнал §3. #2119 C2/C3.

    Охранная зона сети = коридор, где физически проходит сеть → «теплотрасса в 24 м,
    врезка дешёвая». Зона, ПЕРЕСЕКАЮЩАЯ участок, отдаётся с distance_m=0.
    """

    # Вид сети: thermal/electricity/gas/water/communication/pipeline/general.
    # general дочищен по name_by_doc (инж.коммуникации на проде часто реально тепло/газ).
    net_kind: str
    distance_m: int  # расстояние от участка до охранной зоны, м (0 = пересекает участок)
    name_by_doc: str | None  # сырое детальное имя из cad_zouit (для тултипа/дебага)
    # Гуманизированная подпись (C3): «Тепловая сеть (по охранной зоне), кад. № …» /
    # «Ввод 10 кВ от ТП 3164 (по охранной зоне)» / «Инженерные коммуникации (вид не уточнён)».
    human_label: str


class ConnectionCapacityResponse(BaseModel):
    power_points: list[PowerConnectionPoint]
    power_summary: PowerCapacitySummary
    water: list[WaterCapacityRow]
    # Газовый блок аддитивен (Phase B1): None если ключ 'gas' не собран.
    gas: GasCapacityBlock | None = None
    # Гео-точки выхода газосети в радиусе от участка (слой карты §3). ADDITIVE (Phase B2
    # PR-4): [] если таблица gas_grs_outlet_points пуста / нет geom в радиусе.
    gas_outlet_points: list[GasOutletPoint] = []
    # Тепловой блок аддитивен (Phase B2): None если ключ 'heat' не собран.
    heat: HeatCapacityBlock | None = None
    # Позитив-разрез (Phase C2/C3): сетевые охранные зоны cad_zouit в радиусе 300 м —
    # до 2 ближайших на net_kind, гуманизированы. ADDITIVE (default []): [] если сеть
    # рядом не найдена / нет geom участка.
    nearby_network_zones: list[NearbyNetworkZone] = []


# ── Full PDF report (эпик #2259 PR-D) ────────────────────────────────────────


class ReportBuildResponse(BaseModel):
    """Ответ POST /{cad}/report — enqueue сборки полного PDF-отчёта (#2259 PR-D).

    status="building" (202) — таска поставлена в очередь, PDF собирается в фоне;
    status="ready" (200) — готовый кэш (тот же analyze+forecast ран) уже на диске,
    качать сразу через /report/download.
    """

    status: Literal["building", "ready"]
    # Даты базовых ранов (ISO) — контекст, по каким данным собирается/собран отчёт.
    analyze_run_at: str | None = None
    forecast_run_at: str | None = None
    # Дата генерации готового PDF (ISO) — только при status="ready" (кэш-хит); на
    # building-ветке None (файла ещё нет). Даёт fast-path кнопке дату без GET /status.
    report_generated_at: str | None = None


class ReportStatusResponse(BaseModel):
    """Ответ GET /{cad}/report/status — состояние полного PDF-отчёта (#2259 PR-D).

    status: "ready" (метадата-ран report-pdf-1.0 есть И файл на диске),
    "building" (есть analyze-ран, но готового отчёта нет — вероятно собирается / ещё не
    запускался), "none" (нет даже analyze-рана — отчётировать нечего).
    """

    status: Literal["ready", "building", "none"]
    # Даты базовых ранов (ISO) — по каким данным собран/будет собран отчёт.
    analyze_run_at: str | None = None
    forecast_run_at: str | None = None
    # Дата генерации готового PDF (ISO) — только при status="ready".
    report_generated_at: str | None = None


# ── NSPD Risk Zones schemas (issue #94 TIER 3) ───────────────────────────────


class RiskZone(BaseModel):
    """Одна риск-зона НСПД (TIER 3), пересекающая участок."""

    layer: str  # e.g. "risk_flooding", "risk_landslide"
    subtype: str | None  # человекочитаемое название из NSPD properties
    geom_wkt: str | None  # WKT геометрии risk-зоны в EPSG:4326
    intersection_area_sqm: float | None  # площадь пересечения с участком, м²


# ── NSPD Opportunity + Red Lines schemas (issue #94 TIER 4) ──────────────────


class OpportunityParcel(BaseModel):
    """TIER 4: opportunity ЗУ вблизи участка (auction/scheme/free/future/oopt).

    Поля:
        layer: тип opportunity — "auction_parcels" | "scheme_parcels" |
               "free_parcels" | "future_parcels" | "oopt"
        cad_num: кадастровый номер ЗУ (если доступен в NSPD properties)
        distance_m: расстояние от centroid анализируемого участка до ЗУ, м
        geom_wkt: WKT геометрии ЗУ в EPSG:4326
    """

    layer: str
    cad_num: str | None
    distance_m: float | None
    geom_wkt: str | None


class RedLine(BaseModel):
    """TIER 4: красная линия застройки (NSPD layer 879243).

    Поля:
        geom_wkt: WKT геометрии линии в EPSG:4326
        intersection_length_m: длина пересечения линии с участком, м
                               (null если только nearby, не intersect)
        distance_m: расстояние от ближайшей точки линии до участка, м
                    (null если линия пересекает участок)

    UI-семантика:
        intersection_length_m != null → красная линия ПЕРЕСЕКАЕТ участок (ALERT)
        distance_m != null → красная линия рядом (warning)
    """

    geom_wkt: str | None
    intersection_length_m: float | None  # null если только nearby, не intersect
    distance_m: float | None  # null если intersect


# ── Parcel search/detail schemas ──────────────────────────────────────────────


class ParcelFilter(BaseModel):
    vri: list[str] | None = None
    area_min_sqm: float | None = None
    area_max_sqm: float | None = None
    region: str | None = None
    polygon_geojson: dict[str, Any] | None = None
    text_search: str | None = None


class ScoringWeights(BaseModel):
    distance_to_center: float = 0.4
    poi_density: float = 0.3
    neighborhood_price: float = 0.3


class ParcelSearchRequest(BaseModel):
    filters: ParcelFilter = Field(default_factory=ParcelFilter)
    weights: ScoringWeights = Field(default_factory=ScoringWeights)
    limit: int = 50


class ScoreBreakdown(BaseModel):
    total: float
    distance_to_center: float
    poi_density: float
    neighborhood_price: float


class ParcelSummary(BaseModel):
    id: str
    cadastral_number: str
    area_sqm: float
    vri: str
    address: str | None
    score: ScoreBreakdown


class ParcelSearchResponse(BaseModel):
    items: list[ParcelSummary]
    total: int


# ── Competitors endpoint schemas ──────────────────────────────────────────────

TimeWindow = Literal["last_month", "last_quarter", "last_year"]
ObjClassFilter = Literal["economy", "comfort", "business"]


class CompetitorsRequest(BaseModel):
    radius_km: float = Field(default=1.0, ge=0.1, le=1.5)
    time_window: TimeWindow = "last_quarter"
    obj_class_filter: ObjClassFilter | None = None
    exclude_obj_ids: list[int] = Field(default_factory=list)
    # #949 PR B (ТЗ §9.1): горизонт запуска (мес.) для horizon-aware
    # relevance_weight. Конкурент, который к нашему запуску будет распродан,
    # менее релевантен (см. stage_at_horizon в competitors.py).
    horizon_months: int = Field(default=12, ge=1, le=60)


class Competitor(BaseModel):
    obj_id: int
    comm_name: str | None
    dev_name: str | None
    obj_class: str | None
    distance_m: float
    lat: float
    lng: float
    stage: str | None
    site_status: str | None = None
    ready_dt: dt.date | None = None
    flats_total: int | None
    flats_sold: int | None
    sold_pct: float | None
    velocity_per_month: float
    avg_price_per_m2: float | None
    # OBJ-3 (#307): источник avg_price_per_m2 для прозрачности инвест-рекомендаций.
    # "domrf" — per-flat цена domrf_kn_flats (приоритет); "objective" — медиана
    # objective_lots.price_per_m2_rub через objective_complex_mapping (additive
    # fallback, заполняет пробелы где domrf пуст); None — цены нет ни там, ни там.
    price_source: str | None = None
    is_active: bool
    # #949 PR B (ТЗ §9.1, §16): детерминированный relevance_weight ∈ [0,1] —
    # взвешенная комбинация geo_proximity / class_similarity / price_similarity /
    # stage_at_horizon. None только если расчёт недоступен (backward-safe).
    # relevance_breakdown — explainability: 4 саб-скора (каждый 0..1).
    relevance_weight: float | None = None
    relevance_breakdown: dict[str, float] | None = None


class CompetitorsSummary(BaseModel):
    total_competitors: int
    active_count: int
    weighted_avg_velocity: float
    radius_km: float
    time_window: str


class CompetitorsResponse(BaseModel):
    competitors: list[Competitor]
    summary: CompetitorsSummary


# ── #96 wiring: parking_ratio конкурентов (lazy-endpoint, НЕ inline в analyze) ──
# domrf-конкуренты живут в lat/lon без cad_num → geom-match на cad_buildings →
# on-demand НСПД premises-lookup. Дорого (N×НСПД ~0.6-2с) → отдельный ленивый
# эндпоинт /{cad}/competitors-parking, строгий top-N, фронт дёргает после
# отрисовки конкурентов. НЕ блокирует analyze/forecast p95.


class CompetitorParking(BaseModel):
    """parking_ratio одного конкурента (#96). obj_id связывает с Competitor.

    matched_cad_num — здание cad_buildings, на которое сматчился domrf-центроид
    (None если geom-match не нашёл здание в радиусе). parking_ratio /
    parking_count / flats_count — из НСПД tab-group (None если здание не
    сматчилось ИЛИ НСПД недоступен — graceful, НЕ нулевой паркинг).
    match_distance_m — расстояние domrf-центроид → footprint (диагностика).
    """

    obj_id: int
    matched_cad_num: str | None = None
    match_distance_m: float | None = None
    parking_ratio: float | None = None
    parking_count: int | None = None
    flats_count: int | None = None


class CompetitorsParkingResponse(BaseModel):
    """Ответ ленивого /{cad}/competitors-parking (#96).

    items — по одному CompetitorParking на top-N конкурентов (по relevance_weight
    DESC из get_competitors). matched_count — сколько реально получили
    parking_ratio (диагностика покрытия для фронта / мониторинга).
    """

    items: list[CompetitorParking]
    matched_count: int


# ── Developer attribution (#1088 «GG-форсайт») ─────────────────────────────────
# Детерминированная атрибуция «пятно земли ↔ застройщик»: канон-застройщик участка
# + его track-record (РНС/РВЭ + домрф ЖК/квартиры) из fn_developer_for_parcel /
# developer_registry (миграция 149). Источник истины формы — fn_developer_for_parcel
# SETOF + developer_registry track-record; схемы зеркалят его поля 1:1.


class DeveloperAttribution(BaseModel):
    """Топ-1 застройщик участка + его track-record (#1088).

    developer_inn — нормализованный ИНН (10/12-знач), стабильный кросс-источниковый
    ключ. match_method — лучшая сработавшая ступень резолвера
    (exact_cadastral > spatial_150m > quarter). track-record-поля денормализованы
    из developer_registry по ИНН (могут быть None для застройщика, известного только
    одной стороне). project/status/obj_class — из конкретного матча (РНС или домрф-ЖК).
    """

    developer_inn: str
    developer_name: str | None
    source: str | None  # 'permits' | 'domrf'
    match_method: str | None  # 'exact_cadastral' | 'spatial_150m' | 'quarter'
    distance_m: float | None
    project: str | None
    status: str | None
    obj_class: str | None
    # track-record (developer_registry, по норм-ИНН)
    permits_total: int | None
    domrf_objects_total: int | None
    domrf_active_objects: int | None
    in_both_sources: bool | None


class NearbyDeveloper(BaseModel):
    """Соседний застройщик участка (дедупнутый, match_method spatial_150m/quarter).

    Контекст «кто ещё строит рядом / в квартале». Тот же набор полей, что primary,
    но без exact-кадастра (это всегда вторичный матч). Дедупнут по developer_inn —
    один застройщик в nearby представлен своим лучшим (первым по ORDER BY) матчем.
    """

    developer_inn: str
    developer_name: str | None
    source: str | None
    match_method: str | None
    distance_m: float | None
    project: str | None
    status: str | None
    obj_class: str | None
    permits_total: int | None
    domrf_objects_total: int | None
    domrf_active_objects: int | None
    in_both_sources: bool | None


class DeveloperAttributionResult(BaseModel):
    """Результат атрибуции застройщика участка (#1088).

    primary — лучший матч (топ-1 по ORDER BY резолвера: exact dist=0 → spatial →
    quarter). nearby_developers — остальные дедупнутые застройщики (spatial/quarter),
    «кто ещё строит рядом». Сервис возвращает None при пустом результате (нет матча /
    нет геометрии / миграция не применена) — analyze получает ключ только при наличии.
    """

    primary: DeveloperAttribution
    nearby_developers: list[NearbyDeveloper]


# ── Layout analysis (Issue #113) ───────────────────────────────────────────


class LayoutSignature(BaseModel):
    """Минимальная сигнатура планировки = (room_bucket, area_bin).

    Phase 2.1: layout_type/balcony_count в БД нет, ждут B2B Объектив (#52).
    """

    room_bucket: Literal["studio", "1", "2", "3", "4+"]
    area_bin: Literal["<25", "25-40", "40-60", "60-80", "80-100", "100+"]


class BestLayoutsRequest(BaseModel):
    """Параметры запроса top-планировок в радиусе вокруг участка."""

    radius_km: float = Field(default=1.0, ge=0.1, le=1.5)
    time_window: Literal["last_month", "last_quarter", "last_year"] = "last_quarter"
    filter_competitor_obj_ids: list[int] | None = None
    exclude_competitor_obj_ids: list[int] = Field(default_factory=list)
    min_velocity_per_month: float = Field(default=0.5, ge=0.0, le=100.0)
    obj_class_filter: Literal["economy", "comfort", "business"] | None = None
    target_total_flats: int | None = Field(default=None, ge=1, le=10000)


class TopLayoutRow(BaseModel):
    """Одна строка top-planirovok ranking'а."""

    rank: int
    room_bucket: str
    area_bin: str
    signature: str
    competitor_obj_ids: list[int]
    competitor_count: int
    total_sold_in_window: int
    velocity_per_month: float
    avg_price_per_m2_rub: float | None  # NULL если objective не покрывает obj
    avg_area_m2: float
    supply_units_in_radius: int
    sold_pct_of_supply: float | None  # NULL если supply=0; clamped at 100.0
    is_oversold: bool  # True когда raw sum_deals/supply > 100% (несопоставимые окна)


class SupplyOnlyLayoutRow(BaseModel):
    """Строка блока «по предложению» (#2177 шаг 3).

    Структура текущего предложения (комнатность × площадь × цена) для комплексов,
    которые сматчены на objective project_name по ЯДРУ имени, но у которых НЕТ
    сделок (velocity). Источник — objective_lots (снапшот предложения). Это НЕ
    velocity-ранжир: темпа продаж здесь нет, только структура остатков в продаже.
    """

    project_name: str
    room_bucket: str  # {studio,1,2,3,4+} — вокабуляр velocity-стороны
    area_bin: str
    lots_count: int
    median_price_m2: float | None  # NULL если цены не прошли price-sanity
    median_area_m2: float | None
    # Маркер источника: всегда 'objective_lots' — честная семантика «без темпа продаж».
    source: str = "objective_lots"


class LayoutTzMixRow(BaseModel):
    """Строка рекомендации unit-mix для ТЗ."""

    room_bucket: str
    pct: int  # 0..100, sum total = 100
    abs_units: int | None  # NULL если target_total_flats не задан
    avg_target_area_m2: float | None


class LayoutTzRecommendation(BaseModel):
    """Рекомендация для ТЗ на проектирование."""

    rationale_text: str
    mix: list[LayoutTzMixRow]
    weighted_avg_price_per_m2_rub: float | None
    based_on_obj_count: int
    based_on_total_deals: int
    data_window_start: dt.date
    data_window_end: dt.date
    # Fix SF-09 review: True если pathological case — все bucket'ы выше cap,
    # redistribute невозможен. Frontend использует для отображения warning banner.
    cap_skipped: bool = False


class LayoutDataQuality(BaseModel):
    """Метаданные качества данных (coverage).

    Issue #2177: coverage считается в ГРУППАХ-КОМПЛЕКСАХ, а не в сырых obj_id.
    domrf_kn_objects дробит один ЖК на несколько obj_id (разные очереди /
    дубли snapshot-строк / гео-кучки безымянных корпусов), из-за чего сырой
    знаменатель завышал число «конкурентов» (прод-кейс: 12 obj_id = 5 реальных
    комплексов → «2 из 12» вместо честных «2 из 5»). Поля objects_* теперь
    считают комплексы; raw_objects_total — старый счёт obj_id для прозрачности.
    """

    objects_with_velocity_data: int  # число комплексов с velocity-данными
    objects_total_in_radius: int  # число комплексов (сгруппированных obj_id)
    raw_objects_total: int  # сырое число obj_id в радиусе (до группировки)
    # Issue #2177 шаг 2b (core-attribution): сколько комплексов покрыто ИМЕННО по
    # нормализованному ядру имени (project_name-матч), а НЕ через mapping-obj_id —
    # прозрачность источника атрибуции velocity. Кейс «7 Ключей»: 2-я очередь в
    # радиусе резолвит сделки проекта по ядру, минуя single-obj_id mapping.
    groups_matched_by_core: int = 0
    # Issue #2177 шаг 3: сколько комплексов попало в блок «по предложению» —
    # сматчены по ядру на objective project_name, БЕЗ velocity, но с лотами в
    # продаже (objective_lots). Аддитивно, не пересекается с velocity-покрытием.
    groups_with_supply_only: int = 0
    velocity_coverage_pct: float
    confidence: Literal["high", "medium", "low"]


class BestLayoutsResponse(BaseModel):
    """Ответ /best-layouts endpoint'а."""

    top_layouts: list[TopLayoutRow]
    recommendation_for_tz: LayoutTzRecommendation
    data_quality: LayoutDataQuality
    # Issue #2177 шаг 3: fallback-блок «по предложению без темпа продаж» —
    # структура текущего предложения для сматченных по ядру комплексов БЕЗ velocity.
    # Аддитивно; НЕ смешивается с top_layouts (там velocity-ранжир). Пусто по умолчанию.
    supply_only_layouts: list[SupplyOnlyLayoutRow] = Field(default_factory=list)


# ── Analyze endpoint market price (#33) ──────────────────────────────────────


class MarketPrice(BaseModel):
    """Ценовая статистика квартала из mv_quarter_price_per_m2 (Issue #33).

    Источник: rosreestr_deals, фильтр realestate_type_code='002001003000' (ДДУ),
    скользящее окно 24 мес., 30K–800K руб/м², HAVING >= 3 сделок.
    """

    p25: float | None = None
    median: float | None = None
    p75: float | None = None
    mean: float | None = None
    deals_count: int = 0
    median_6m: float | None = None
    median_12m: float | None = None
    median_24m: float | None = None
    last_deal_date: str | None = None  # ISO date
    source: Literal["quarter_mv", "no_data"] = "no_data"


# ── Analyze endpoint parcel meta (#29 G2) ────────────────────────────────────


class ParcelMeta(BaseModel):
    """Кадастровые метаданные участка из cad_parcels (#29 G2).

    Поля берутся из NSPD bulk-ingest (issue #168). Строки сырые, без нормализации.
    """

    permitted_use: str | None = None  # permitted_use_established_by_document
    land_category: str | None = None  # land_record_category_type
    land_subtype: str | None = None  # land_record_subtype
    cad_cost: float | None = None  # cost_value (кадастровая стоимость, руб.)
    source: str = "cad_parcels"


# ── Analyze endpoint inline weights (#201) ────────────────────────────────────


class AnalyzeRequest(BaseModel):
    """Опциональное тело запроса POST /analyze.

    Позволяет передать inline POI-веса напрямую в запросе без сохранения
    профиля. Если задан weights — применяется с наивысшим приоритетом
    (выше profile_id и user default).
    """

    weights: dict[str, float] | None = Field(
        default=None,
        description=(
            "Inline POI weights override (категория → weight). "
            "Если задан — применяется к scoring, без обязательного profile save. "
            "Validated против ALLOWED_CATEGORIES + MIN_WEIGHT/MAX_WEIGHT."
        ),
    )


# ── SF-B1: by-bbox map entry schemas (#307) ──────────────────────────────────


class ParcelMapMarker(BaseModel):
    """Один маркер участка на карте — облегчённый ответ для bbox-запроса.

    status берётся из parcel_user_status если передан user_id, иначе null.
    last_analysis_date — placeholder до реализации B2 auth.
    """

    cad_num: str
    centroid_lat: float
    centroid_lon: float
    area_m2: float | None
    land_category: str | None
    status: str | None  # 'in_work' | 'favorite' | 'dismissed' | null
    last_analysis_date: str | None  # placeholder, real after B2 auth


class ParcelBboxResponse(BaseModel):
    """Ответ GET /parcels/by-bbox."""

    parcels: list[ParcelMapMarker]
    count: int
    limit: int
    bbox_area_km2: float


# ── #994 (961-C3, EPIC 961): run-history read endpoints ───────────────────────
#
# Два typed-ответа поверх analysis_runs (миграция 127). Summary — LIGHT-список
# (без большого result-блоба) для GET /{cad_num}/runs; Detail — полная строка
# (с result) для GET /runs/{run_id}. Зеркалят колонки из repository.list_runs_for /
# get_run (см. analysis_runs/repository.py).


class AnalysisRunSummary(BaseModel):
    """Облегчённая карточка одного рана для списка истории по участку.

    БЕЗ тяжёлого `result`-блоба (~90 ключей) — только метаданные для UI-списка
    «история анализов». Источник колонок: repository.list_runs_for (LIGHT SELECT).
    """

    id: int
    cad_num: str
    created_at: dt.datetime
    status: str | None = None
    schema_version: str | None = None
    district: str | None = None
    confidence: str | None = None
    created_by: str | None = None


class AnalysisRunDetail(BaseModel):
    """Полная строка одного рана (включая `result`) для re-open / детального view.

    `result` — JSONB-блоб analyze (schema_version "analyze-1.0", форма ParcelAnalysis)
    ИЛИ §22 SiteFinderReport ("1.0") — отдаём как есть (dict), форму не навязываем
    (модель истории, не контракт analyze). Источник: repository.get_run (full SELECT).
    """

    id: int
    cad_num: str
    created_at: dt.datetime
    status: str | None = None
    schema_version: str | None = None
    district: str | None = None
    confidence: str | None = None
    created_by: str | None = None
    advisory: bool | None = None
    params: dict[str, Any] | None = None
    segment: dict[str, Any] | None = None
    result: dict[str, Any] | None = None


class AnalysisRunListResponse(BaseModel):
    """Ответ GET /parcels/{cad_num}/runs — список (пустой, если ранов нет)."""

    runs: list[AnalysisRunSummary]


# ── #992 (EPIC 961): typed AnalyzeResponse + response_model ────────────────────


class AnalyzeResponse(BaseModel):
    """Типизированный контракт ответа POST /parcels/{cad_num}/analyze (#992).

    КРИТИЧЕСКИЕ инварианты (EPIC 961 — наивысший blast radius PR):

    1) `extra="allow"` — ЛЮБОЙ ключ из `result_payload` (parcels.py), НЕ перечисленный
       здесь явно, ПОПАДАЕТ в ответ без изменений (Pydantic v2: уходит в
       __pydantic_extra__ → включается в model_dump → FastAPI отдаёт в body).
       Это backstop против silent-drop: даже если новый ключ добавят в payload и
       забудут сюда — frontend его получит. Без этого response_model молча выкинул
       бы неперечисленные ключи и сломал Site Finder.

    2) ВСЕ поля Optional с дефолтом — модель НИЧЕГО не требует. Эндпоинт имеет
       graceful 202 fetch-stub ({status:"fetching", job_id, eta_seconds, message},
       без score/competitors/...). Если бы тут были required-поля, FastAPI словил бы
       ValidationError → 500 при сериализации 202-стаба через response_model. Все
       поля nullable → 202-стаб проходит без ошибок (недостающие → null, additive,
       frontend ветвится по HTTP-коду 202, не по телу — useSiteAnalysis.ts).

    Перечень ниже документирует ~90 известных top-level ключей (зеркало
    frontend ParcelAnalysis + result_payload) для OpenAPI / codegen — но НЕ
    ограничивает ответ (extra="allow" + Optional). Источник истины формы — всё
    ещё result_payload в parcels.py; модель его описывает, а не сужает.
    """

    model_config = ConfigDict(extra="allow")

    # — идентификация / геометрия —
    cad_num: str | None = None
    source: str | None = None
    geom_geojson: dict[str, Any] | None = None
    district: dict[str, Any] | None = None

    # — score-блок —
    score: float | None = None
    score_without_center: float | None = None
    score_label: str | None = None
    score_max_reference: float | None = None
    score_explanation: str | None = None
    # #1926: машиночитаемый advisory-флаг — score это POI-density-эвристика
    # (0..score_max_reference), а не абсолютный инвест-рейтинг. Optional для
    # обратной совместимости (старые моки/202-стаб → null).
    score_is_advisory: bool | None = None
    score_advisory: dict[str, Any] | None = None
    score_breakdown: dict[str, Any] | None = None
    score_breakdown_detailed: list[dict[str, Any]] | None = None
    score_top_3_positives: list[dict[str, Any]] | None = None
    score_top_3_negatives: list[dict[str, Any]] | None = None
    score_by_group: list[dict[str, Any]] | None = None
    poi_count: int | None = None
    location: dict[str, Any] | None = None

    # — рынок / конкуренты / темп —
    competitors: list[dict[str, Any]] | None = None
    market_pulse: dict[str, Any] | None = None
    market_avg_price_per_m2: float | None = None
    market_data_coverage_pct: float | None = None
    pipeline_24mo: dict[str, Any] | None = None
    velocity: dict[str, Any] | None = None
    # market_trend: {status: ok|offer_only|source_stale|no_deals, ...}. #2178 добавил
    # вложенный offer_trend (динамика цен предложения из objective_lots_history) и
    # status='offer_only'. Схема loose (dict) — новые ключи проходят без правки модели.
    market_trend: dict[str, Any] | None = None
    market_price: dict[str, Any] | None = None

    # — среда (шум / воздух / погода / геология) —
    noise: dict[str, Any] | None = None
    air_quality: dict[str, Any] | None = None
    weather: dict[str, Any] | None = None
    seasonal_weather: dict[str, Any] | None = None
    wind: dict[str, Any] | None = None
    geology: dict[str, Any] | None = None
    hydrology: dict[str, Any] | None = None
    utilities: dict[str, Any] | None = None
    geotech_risk: dict[str, Any] | None = None

    # — пригодность участка —
    geometry_suitability: dict[str, Any] | None = None
    neighbors_summary: dict[str, Any] | None = None

    # — кадастр / разрешения / зонирование —
    parcel_meta: dict[str, Any] | None = None
    recent_permits_in_quarter: list[dict[str, Any]] | None = None
    permits_summary: dict[str, Any] | None = None
    permits_nearby: dict[str, Any] | None = None
    zoning: dict[str, Any] | None = None
    success_recommendation: dict[str, Any] | None = None
    isochrones_available: bool | None = None

    # — confidence-индикатор —
    confidence: float | None = None
    confidence_label: str | None = None
    confidence_breakdown: dict[str, Any] | None = None
    confidence_caveats: list[str] | None = None

    # — NSPD-слои —
    nspd_zoning: dict[str, Any] | None = None
    nspd_zouit_overlaps: list[dict[str, Any]] | None = None
    nspd_engineering_nearby: list[dict[str, Any]] | None = None
    nspd_risk_zones: list[RiskZone] | None = None
    nspd_opportunity_parcels: list[OpportunityParcel] | None = None
    nspd_red_lines: list[RedLine] | None = None
    nspd_dump: dict[str, Any] | None = None
    gate_verdict: dict[str, Any] | None = None

    # — веса / custom POI —
    weights_profile: dict[str, Any] | None = None
    custom_poi_score_items: list[dict[str, Any]] | None = None

    # — SF-B5: ЕГРН / обременения / красные линии / метро / цены района / риски —
    egrn: dict[str, Any] | None = None
    encumbrance: dict[str, Any] | None = None
    red_lines: dict[str, Any] | None = None
    metro: dict[str, Any] | None = None
    district_price_per_m2_min: float | None = None
    district_price_per_m2_max: float | None = None
    district_price_per_m2_median: float | None = None
    district_price_sample_size: int | None = None
    risks: dict[str, Any] | None = None

    # — §22-форсайт stub (#995): pending|unavailable, добавляется после persist —
    forecast: dict[str, Any] | None = None

    # — ИРД-слой (#1067 D9b, flag enable_ird_analyze): ird_overlaps (м.132, incl opportunity) +
    #   functional_zone/krt (геопортал WFS) + zone_regulation (C8b). Отсутствует при флаге off —
    ird: dict[str, Any] | None = None

    # — Атрибуция застройщика (#1088 «GG-форсайт»): топ-1 застройщик участка + track-record +
    #   nearby_developers (fn_developer_for_parcel / developer_registry, миграция 149). Additive,
    #   без флага (чистый DB-lookup, graceful). Отсутствует при пустом матче / pre-migration —
    developer_attribution: dict[str, Any] | None = None

    # — 202 fetch-stub поля (#93): present ТОЛЬКО на 202-ответе, иначе отсутствуют —
    status: str | None = None
    job_id: int | None = None
    eta_seconds: int | None = None
    message: str | None = None
