"""Trade-In Estimator — endpoints (TI-2 PDF, photos, history).

Реальная оценка делается через app.services.estimator.estimate_quality().
"""

from __future__ import annotations

import calendar
import logging
from datetime import UTC, date, datetime, timedelta
from typing import Annotated, Any
from uuid import UUID

from fastapi import APIRouter, Depends, File, Header, HTTPException, Request, Response, UploadFile
from sqlalchemy import text
from sqlalchemy.orm import Session

from app.core.config import settings
from app.core.db import get_db
from app.core.ratelimit import _client_ip
from app.schemas.trade_in import (
    AggregatedEstimate,
    AnalogLot,
    AvitoImvSummary,
    CianPriceChangeStats,
    DkpCorridor,
    HouseAnalyticsKpi,
    HouseAnalyticsResponse,
    HouseInfoForEstimate,
    IMVBenchmarkResponse,
    LocationIndexResponse,
    NearbyPoiOut,
    PhotoMeta,
    PlacementHistoryEntry,
    PriceHistoryYearPoint,
    PriceTrendPoint,
    QuotaStatus,
    RecentSoldEntry,
    SalesListingPair,
    SalesVsListingsResponse,
    SellTimeBucket,
    SellTimeSensitivityResponse,
    StreetDealsResponse,
    TradeInEstimateInput,
)
from app.services import account_quota
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
from app.services.user_events import schedule_event

logger = logging.getLogger(__name__)

router = APIRouter()


def _assert_estimate_access(created_by: str | None, x_authenticated_user: str | None) -> None:
    """IDOR guard (#690): только владелец оценки или admin могут её читать.

    Зеркало скоупинга /history (#656). 401 — нет заголовка X-Authenticated-User
    (Caddy basic_auth обязателен); 403 — юзер отсутствует в roles.yaml; 404 —
    pilot читает чужую оценку (скрываем существование, не подтверждаем чужой id).
    """
    if not x_authenticated_user:
        raise HTTPException(
            status_code=401,
            detail="no authenticated user (Caddy basic_auth required)",
        )

    from app.core.auth import get_role

    try:
        role = get_role(x_authenticated_user)
    except KeyError:
        logger.warning(
            "user %r authenticated via Caddy but missing from roles.yaml",
            x_authenticated_user,
        )
        raise HTTPException(status_code=403, detail="user not in roles config") from None

    if role == "admin":
        return
    if created_by is None or created_by != x_authenticated_user:
        raise HTTPException(status_code=404, detail="estimate not found")


def _assert_estimate_access_by_id(
    db: Session, estimate_id: UUID, x_authenticated_user: str | None
) -> None:
    """IDOR guard для derived-роутов, не читающих саму оценку (#690).

    Тянет created_by оценки и применяет _assert_estimate_access. 404 если оценки
    нет (как и owner-mismatch — существование не подтверждаем).
    """
    row = db.execute(
        text("SELECT created_by FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
        {"id": str(estimate_id)},
    ).fetchone()
    if row is None:
        raise HTTPException(status_code=404, detail="estimate not found")
    _assert_estimate_access(row.created_by, x_authenticated_user)


def _resolve_target_house_id(
    db: Session, address: str | None, lat: float | None, lon: float | None
) -> int | None:
    """Резолвит house_id целевого дома по адресу (geo fallback) — для GET-rehydrate.

    Тот же паттерн, что в placement-history / house-analytics: нормализованный
    short_address → houses, иначе tight 100м geo-fallback. Берём первый матч
    (детерминированно — для price_trend/avito_imv достаточно одного дома).
    Best-effort: None при отсутствии адреса/координат/матча (graceful).
    """
    if address:
        row = db.execute(
            text(
                """
                SELECT id FROM houses
                WHERE short_address = tradein_normalize_short_addr(:addr)
                   OR tradein_normalize_short_addr(address) = tradein_normalize_short_addr(:addr)
                LIMIT 1
                """
            ),
            {"addr": address},
        ).fetchone()
        if row is not None:
            return row.id

    if lat is not None and lon is not None:
        row = db.execute(
            text(
                """
                SELECT id FROM houses
                WHERE geom IS NOT NULL
                  AND ST_DWithin(
                          geom::geography, ST_MakePoint(:lon, :lat)::geography, 100
                      )
                ORDER BY ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
                LIMIT 1
                """
            ),
            {"lat": lat, "lon": lon},
        ).fetchone()
        if row is not None:
            return row.id

    return None


@router.post("/estimate", response_model=AggregatedEstimate)
async def estimate(
    payload: TradeInEstimateInput,
    request: Request,
    db: Annotated[Session, Depends(get_db)],
    x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> AggregatedEstimate:
    """Реальная оценка через SQL aggregation поверх listings + deals.

    1. Geocode address → lat/lon
    2. PostGIS ST_DWithin радиус 800м (или 2км fallback)
    3. Tukey IQR outlier filter
    4. Median + Q1 + Q3 + confidence с explanation

    Применяется лимит 15 успешных оценок в месяц на аккаунт (кроме admin/kopylov).
    """
    account_quota.check_and_raise(db, x_authenticated_user)
    from app.services.estimator import estimate_quality

    # #654: ранее любое исключение estimate_quality всплывало необработанным и
    # маскировалось апстрим-прокси (Caddy) как непрозрачный 502. Ловим, логируем
    # через logger.exception (→ GlitchTip/Sentry получает stack trace) и отдаём
    # явный 503 — так любая БУДУЩАЯ реальная ошибка становится видимой, а не
    # «глотается» шлюзом. HTTPException пробрасываем как есть (это не сбой).
    # created_by (#656) прокидываем в estimate_quality для скоупа /history.
    try:
        result = await estimate_quality(payload, db, created_by=x_authenticated_user)
    except HTTPException:
        raise
    except Exception:
        logger.exception("estimate failed for address=%r", payload.address)
        raise HTTPException(
            status_code=503,
            detail="estimate temporarily unavailable — try again shortly",
        ) from None
    # #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise
    # выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение
    # тут: при гонке двух /estimate на used=lim-1 второй получит False.
    # Не списываем квоту за пустой результат (нерезолвящийся адрес и т.п.) — иначе
    # платный слот сгорает за insufficient_data=True (median=0, n_analogs=0) с HTTP 200.
    if not result.insufficient_data and not account_quota.increment(db, x_authenticated_user):
        raise HTTPException(status_code=429, detail=account_quota.LIMIT_EXHAUSTED_MESSAGE)

    # Feature 2/3 foundation: "что искали" — обогащённая estimate_request-запись
    # в user_events (адрес/площадь/комнаты + estimate_id для join с trade_in_estimates).
    # schedule_event сам никогда не raises — сбой аудита не должен ронять ответ.
    schedule_event(
        event_type="estimate_request",
        username=x_authenticated_user or "",
        ip=_client_ip(request),
        user_agent=request.headers.get("user-agent"),
        path=str(request.url.path),
        method="POST",
        estimate_id=str(result.estimate_id),
        payload={
            "address": payload.address,
            "area_m2": payload.area_m2,
            "rooms": payload.rooms,
        },
    )
    return result


@router.get("/quota", response_model=QuotaStatus)
def get_quota(
    db: Annotated[Session, Depends(get_db)],
    x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> QuotaStatus:
    """Статус квоты оценок для текущего аккаунта.

    Возвращает limit / used / remaining / unlimited для X-Authenticated-User.
    Без заголовка (dev-режим без Caddy) — unlimited True, used 0.
    """
    status = account_quota.get_status(db, x_authenticated_user)
    return QuotaStatus(**status)


@router.get("/estimate/{estimate_id}", response_model=AggregatedEstimate)
def get_estimate(
    estimate_id: UUID,
    db: Annotated[Session, Depends(get_db)],
    x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> AggregatedEstimate:
    """Получить сохранённую оценку по UUID (для генерации PDF).

    Возвращает 404 если оценка не найдена или TTL истёк.
    """
    row = db.execute(
        text(
            """
            SELECT id, median_price, range_low, range_high, median_price_per_m2,
                   confidence, confidence_explanation, n_analogs,
                   analogs, actual_deals, sources_used, data_freshness_minutes,
                   expires_at, address, lat, lon,
                   area_m2, rooms, floor, total_floors,
                   year_built, house_type, repair_state, has_balcony,
                   canonical_address, house_cadnum, house_fias_id,
                   dadata_qc_geo, dadata_metro,
                   expected_sold_price, expected_sold_range_low,
                   expected_sold_range_high, expected_sold_per_m2,
                   asking_to_sold_ratio, ratio_basis, created_by, created_at
            FROM trade_in_estimates
            WHERE id = CAST(:id AS uuid)
              AND expires_at > NOW()
            """
        ),
        {"id": str(estimate_id)},
    ).fetchone()

    if row is None:
        raise HTTPException(status_code=404, detail="estimate not found or expired")

    _assert_estimate_access(row.created_by, x_authenticated_user)

    from app.services.estimator import (
        _canonical_sources,
        _cv_from_ppm2,
        _fetch_dkp_corridor,
        _fetch_house_imv_anchor,
        _fetch_price_trend,
        _qc_geo_to_precision,
        _resolve_target_city,
        _source_counts,
    )

    analogs = [AnalogLot(**a) for a in (row.analogs or [])]
    actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])]

    # #2043 (BE-1): CV / счётчики источников на rehydrate — best-effort из
    # сохранённых analogs (top-N, усечённо: полная выборка не персистится). На
    # свежей оценке (POST) считаются по полной выборке; здесь — по тому, что есть
    # в строке. created_at берём из колонки (persisted).
    cv = _cv_from_ppm2([a.price_per_m2 for a in analogs])
    source_counts = _source_counts([a.source for a in analogs])
    # #2087 (M1): sources_used рехайдрейтим тем же helper'ом, что и POST — из ТЕХ ЖЕ
    # persisted analogs (листинговая часть) ∪ оценочных флагов, вычитанных из
    # persisted-колонки sources_used (avito_imv/yandex_valuation/cian_valuation).
    # Так один estimate_id → идентичный sources_used на POST/GET/reload, а
    # source_counts.keys() ⊆ sources_used (обе части — из одного набора analogs).
    # Чинит рассинхром и на СТАРЫХ строках (где колонка хранит радиусный набор +
    # quarter_index): листинговая часть пересобирается из analogs, служебный шум
    # отбрасывается фильтром helper'а.
    sources_used = _canonical_sources((a.source for a in analogs), (row.sources_used or []))

    # #696: POST-only производные поля (price_trend / avito_imv / dkp_corridor /
    # last_scraped_at) на GET-rehydrate ранее были null → на shared-link / PDF /
    # ?id= restore пропадали графики тренда, IMV-якорь, коридор ДКП и метка
    # свежести. Пересчитываем их здесь из персистированного состояния (house_id
    # ререзолвится по адресу/гео; last_scraped_at реконструируется из
    # created_at − data_freshness_minutes — оба поля персистятся). Всё best-effort:
    # None при отсутствии данных (без регрессий по сравнению с прежним поведением).
    area_f = float(row.area_m2) if row.area_m2 is not None else None
    target_house_id = _resolve_target_house_id(db, row.address, row.lat, row.lon)

    price_trend_raw = _fetch_price_trend(db, target_house_id=target_house_id)
    price_trend = (
        [PriceTrendPoint(month=p["month"], ppm2=p["ppm2"]) for p in price_trend_raw]
        if price_trend_raw
        else None
    )

    # (oblast C2): city-scope корридора на GET-rehydrate. NB: row.address здесь =
    # geo.full_address (персистится на estimate-time), который РОНЯЕТ город для
    # не-ЕКБ («Ленина, 1») → target_city=None → corridor unscoped/None для не-ЕКБ
    # (KNOWN LIMITATION: POST-path чинит через payload.address; GET получит паритет
    # когда raw payload.address начнёт персиститься — follow-up). Для ЕКБ ок; reorder
    # ниже безвреден (оба source city-stripped для не-ЕКБ, оба екб для ЕКБ).
    target_city = _resolve_target_city(row.address) or _resolve_target_city(row.canonical_address)
    dkp_raw = _fetch_dkp_corridor(
        db, address=row.address, rooms=row.rooms, area=area_f, city=target_city
    )
    dkp_corridor = DkpCorridor(**dkp_raw) if dkp_raw else None

    imv_raw = _fetch_house_imv_anchor(
        db, target_house_id=target_house_id, rooms=row.rooms, area=area_f
    )
    avito_imv = (
        AvitoImvSummary(
            recommended_price=int(imv_raw["recommended_price"]),
            lower_price=int(imv_raw["lower_price"]) if imv_raw.get("lower_price") else None,
            higher_price=int(imv_raw["higher_price"]) if imv_raw.get("higher_price") else None,
            market_count=int(imv_raw["market_count"]) if imv_raw.get("market_count") else None,
        )
        if imv_raw is not None and imv_raw.get("recommended_price")
        else None
    )

    last_scraped_at = (
        row.created_at - timedelta(minutes=row.data_freshness_minutes)
        if row.created_at is not None and row.data_freshness_minutes is not None
        else None
    )

    # ВАЖНО: возвращаем ПОЛНЫЙ набор полей. Раньше эндпоинт отдавал огрызок
    # без sources_used / confidence_explanation / координат — и при открытии
    # оценки по ссылке (?id=) карточка источников пустела до «0/7».
    # DaData-обогащёнка (canonical/cadnum/fias/precision/metro) тоже
    # rehydrate'ится из row — иначе бейдж точности + метро не показывались
    # на shared-link reopen / в PDF.
    return AggregatedEstimate(
        estimate_id=row.id,
        median_price_rub=row.median_price,
        range_low_rub=row.range_low,
        range_high_rub=row.range_high,
        median_price_per_m2=row.median_price_per_m2,
        confidence=row.confidence,
        confidence_explanation=row.confidence_explanation,
        n_analogs=row.n_analogs,
        period_months=12,
        analogs=analogs,
        actual_deals=actual_deals,
        expires_at=row.expires_at,
        target_address=row.address,
        target_lat=row.lat,
        target_lon=row.lon,
        sources_used=sources_used,
        data_freshness_minutes=row.data_freshness_minutes,
        # #696 — POST-only производные поля, пересчитанные на rehydrate (см. выше).
        last_scraped_at=last_scraped_at,
        price_trend=price_trend,
        avito_imv=avito_imv,
        dkp_corridor=dkp_corridor,
        # #648 Stage 3 — sold-correction columns rehydrated for shared-link reopen.
        # (numeric ratio → float for the Pydantic field.)
        expected_sold_price_rub=row.expected_sold_price,
        expected_sold_range_low_rub=row.expected_sold_range_low,
        expected_sold_range_high_rub=row.expected_sold_range_high,
        expected_sold_per_m2=row.expected_sold_per_m2,
        asking_to_sold_ratio=(
            float(row.asking_to_sold_ratio) if row.asking_to_sold_ratio is not None else None
        ),
        ratio_basis=row.ratio_basis,
        area_m2=row.area_m2,
        rooms=row.rooms,
        floor=row.floor,
        total_floors=row.total_floors,
        year_built=row.year_built,
        house_type=row.house_type,
        repair_state=row.repair_state,
        has_balcony=row.has_balcony,
        canonical_address=row.canonical_address,
        house_cadnum=row.house_cadnum,
        house_fias_id=row.house_fias_id,
        address_precision=_qc_geo_to_precision(row.dadata_qc_geo),
        metro_nearest=(row.dadata_metro or []),
        # #2043 (BE-1): достоверность выборки — CV, счётчики источников, дата создания.
        cv=cv,
        source_counts=source_counts,
        created_at=row.created_at,
    )


@router.get("/estimate/{estimate_id}/pdf")
def estimate_pdf(
    estimate_id: UUID,
    db: Annotated[Session, Depends(get_db)],
    x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> Response:
    """Скачать 4-страничный PDF-отчёт для оценки trade-in.

    Бренд PDF определяется по владельцу оценки (estimate.created_by → brand slug),
    а НЕ из ?brand= query param (#7 brand-spoofing fix).

    Возвращает application/pdf attachment.
    404 — оценка не найдена.
    410 — оценка просрочена (TTL 24ч).
    """
    row = db.execute(
        text(
            """
            SELECT id, median_price, range_low, range_high, median_price_per_m2,
                   confidence, confidence_explanation, n_analogs,
                   analogs, actual_deals, sources_used, data_freshness_minutes,
                   expires_at,
                   address, lat, lon, area_m2, rooms, floor, total_floors,
                   year_built, house_type, repair_state, has_balcony,
                   canonical_address, house_cadnum, house_fias_id,
                   dadata_qc_geo, dadata_metro,
                   expected_sold_price, expected_sold_range_low,
                   expected_sold_range_high, expected_sold_per_m2,
                   asking_to_sold_ratio, ratio_basis, created_by
            FROM trade_in_estimates
            WHERE id = CAST(:id AS uuid)
            """
        ),
        {"id": str(estimate_id)},
    ).fetchone()

    if row is None:
        raise HTTPException(status_code=404, detail="estimate not found")

    _assert_estimate_access(row.created_by, x_authenticated_user)

    if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC):
        raise HTTPException(status_code=410, detail="estimate expired (24h TTL)")

    from app.services.estimator import _qc_geo_to_precision

    analogs = [AnalogLot(**a) for a in (row.analogs or [])]
    actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])]

    estimate = AggregatedEstimate(
        estimate_id=row.id,
        median_price_rub=row.median_price,
        range_low_rub=row.range_low,
        range_high_rub=row.range_high,
        median_price_per_m2=row.median_price_per_m2,
        confidence=row.confidence,
        confidence_explanation=row.confidence_explanation,
        n_analogs=row.n_analogs,
        # #1351: окно сделок — 12 мес (estimator.DEALS_PERIOD_MONTHS), как в POST
        # /estimate и GET /estimate/{id}. Раньше PDF-ветка хардкодила 24 →
        # экспортёр рисовал ложный ~2-летний диапазон в клиентском документе.
        period_months=12,
        analogs=analogs,
        actual_deals=actual_deals,
        expires_at=row.expires_at,
        target_address=row.address,
        target_lat=row.lat,
        target_lon=row.lon,
        sources_used=row.sources_used or [],
        data_freshness_minutes=row.data_freshness_minutes,
        # #648 Stage 3 — sold-correction columns rehydrated so the PDF carries them.
        expected_sold_price_rub=row.expected_sold_price,
        expected_sold_range_low_rub=row.expected_sold_range_low,
        expected_sold_range_high_rub=row.expected_sold_range_high,
        expected_sold_per_m2=row.expected_sold_per_m2,
        asking_to_sold_ratio=(
            float(row.asking_to_sold_ratio) if row.asking_to_sold_ratio is not None else None
        ),
        ratio_basis=row.ratio_basis,
        canonical_address=row.canonical_address,
        house_cadnum=row.house_cadnum,
        house_fias_id=row.house_fias_id,
        address_precision=_qc_geo_to_precision(row.dadata_qc_geo),
        metro_nearest=(row.dadata_metro or []),
    )
    input_snapshot = {
        "address": row.address,
        "area_m2": row.area_m2,
        "rooms": row.rooms,
        "floor": row.floor,
        "total_floors": row.total_floors,
        "year_built": row.year_built,
        "house_type": row.house_type,
        "repair_state": row.repair_state,
        "has_balcony": row.has_balcony,
    }

    from app.core.auth import get_brand_for_user as _brand_for_user
    from app.services.brand import get_brand as _resolve_brand

    # #7 brand-spoofing fix: бренд определяется по владельцу оценки,
    # а не по ?brand= query param (который был удалён из сигнатуры).
    owner_brand_slug = _brand_for_user(row.created_by) if row.created_by else None
    brand_obj = _resolve_brand(owner_brand_slug, db)
    pdf_bytes = generate_trade_in_pdf(estimate, input_snapshot, brand=brand_obj)
    filename = f"trade-in-{brand_obj.slug}-{estimate_id}.pdf"
    logger.info(
        "PDF generated estimate_id=%s brand=%s size=%d",
        estimate_id,
        brand_obj.slug,
        len(pdf_bytes),
    )
    return Response(
        content=pdf_bytes,
        media_type="application/pdf",
        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
    )


# ── Фото квартиры (#394) ─────────────────────────────────────────────────────
_MAX_PHOTO_BYTES = 10 * 1024 * 1024  # 10 МБ на фото
_MAX_PHOTOS_PER_ESTIMATE = 12
_ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/heic"}


@router.post("/estimate/{estimate_id}/photos", response_model=PhotoMeta)
async def upload_photo(
    estimate_id: UUID,
    db: Annotated[Session, Depends(get_db)],
    file: Annotated[UploadFile, File()],
    x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> PhotoMeta:
    """Загрузить фото квартиры к оценке (#394). Хранение в estimate_photos (bytea)."""
    estimate = db.execute(
        text("SELECT created_by FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
        {"id": str(estimate_id)},
    ).fetchone()
    if estimate is None:
        raise HTTPException(status_code=404, detail="estimate not found")
    _assert_estimate_access(estimate.created_by, x_authenticated_user)

    ctype = (file.content_type or "").lower()
    if ctype not in _ALLOWED_IMAGE_TYPES:
        raise HTTPException(
            status_code=415, detail=f"unsupported content-type: {ctype or 'unknown'}"
        )

    count = db.execute(
        text("SELECT count(*) FROM estimate_photos WHERE estimate_id = CAST(:id AS uuid)"),
        {"id": str(estimate_id)},
    ).scalar_one()
    if count >= _MAX_PHOTOS_PER_ESTIMATE:
        raise HTTPException(
            status_code=409, detail=f"photo limit reached ({_MAX_PHOTOS_PER_ESTIMATE})"
        )

    # #2233: читаем тело чанками с жёстким капом, а не await file.read() целиком —
    # иначе multi-GB аплоад буферизуется в RAM и OOM-killed backend (mem_limit 768m, #2214).
    # 413 бросается СРАЗУ при превышении, остаток тела запроса НЕ читается.
    chunks: list[bytes] = []
    total = 0
    while chunk := await file.read(64 * 1024):
        total += len(chunk)
        if total > _MAX_PHOTO_BYTES:
            raise HTTPException(status_code=413, detail="file too large (max 10 MB)")
        chunks.append(chunk)
    content = b"".join(chunks)
    if not content:
        raise HTTPException(status_code=400, detail="empty file")

    # Sanitize: re-encode through Pillow to drop EXIF, kill polyglot payloads,
    # cap dimensions. Closes finding #6 from 2026-05-24 audit.
    try:
        sanitized_bytes, sanitized_ctype = sanitize_image(content)
    except ImageSanitizationError as e:
        raise HTTPException(status_code=400, detail=str(e)) from e

    row = (
        db.execute(
            text(
                """
            INSERT INTO estimate_photos
                (estimate_id, filename, content_type, content, size_bytes)
            VALUES (CAST(:eid AS uuid), :fn, :ct, :content, :sz)
            RETURNING id, filename, content_type, size_bytes, uploaded_at
            """
            ),
            {
                "eid": str(estimate_id),
                "fn": file.filename,
                "ct": sanitized_ctype,
                "content": sanitized_bytes,
                "sz": len(sanitized_bytes),
            },
        )
        .mappings()
        .fetchone()
    )
    db.commit()
    logger.info(
        "photo uploaded: estimate=%s photo=%s orig_size=%d sanitized_size=%d ctype=%s",
        estimate_id,
        row["id"],
        len(content),
        len(sanitized_bytes),
        sanitized_ctype,
    )
    return PhotoMeta(**row)


@router.get("/estimate/{estimate_id}/photos", response_model=list[PhotoMeta])
def list_photos(
    estimate_id: UUID,
    db: Annotated[Session, Depends(get_db)],
    x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> list[PhotoMeta]:
    """Список фото оценки — метаданные, без содержимого (#394)."""
    _assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
    rows = (
        db.execute(
            text(
                """
            SELECT id, filename, content_type, size_bytes, uploaded_at
            FROM estimate_photos
            WHERE estimate_id = CAST(:id AS uuid)
            ORDER BY uploaded_at
            """
            ),
            {"id": str(estimate_id)},
        )
        .mappings()
        .all()
    )
    return [PhotoMeta(**r) for r in rows]


@router.get("/estimate/{estimate_id}/photos/{photo_id}")
def get_photo(
    estimate_id: UUID,
    photo_id: UUID,
    db: Annotated[Session, Depends(get_db)],
    x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> Response:
    """Отдать содержимое фото — image bytes (#394)."""
    _assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
    row = db.execute(
        text(
            """
            SELECT content, content_type
            FROM estimate_photos
            WHERE id = CAST(:pid AS uuid) AND estimate_id = CAST(:eid AS uuid)
            """
        ),
        {"pid": str(photo_id), "eid": str(estimate_id)},
    ).fetchone()
    if row is None:
        raise HTTPException(status_code=404, detail="photo not found")
    return Response(
        content=bytes(row.content),
        media_type=row.content_type,
        headers={"Cache-Control": "private, max-age=3600"},
    )


# ── История и кэш (#399) ─────────────────────────────────────────────────────
@router.get("/history")
def estimate_history(
    db: Annotated[Session, Depends(get_db)],
    limit: int = 50,
    account: str | None = None,
    x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> list[dict[str, object]]:
    """История оценок (#399) — последние N записей trade_in_estimates.

    Скоупинг (#656 — закрывает cross-pilot data-leak): non-admin видит ТОЛЬКО
    свои оценки (created_by = X-Authenticated-User); legacy NULL-строки без
    владельца не попадают. Admin видит все строки, либо фильтрует по ?account=<user>.
    401 если заголовок отсутствует (mirror /me — Caddy basic_auth обязателен).

    #2043 (BE-1): в проекцию добавлен sources_used (jsonb-массив источников) —
    разлочивает колонку «ИСТОЧНИКОВ N/7» в CacheView (len(sources_used) на строку).

    #2417: добавлены floor/total_floors/year_built/house_type/repair_state/
    has_balcony — уже персистятся в trade_in_estimates при создании оценки
    (см. TradeInEstimateInput / AggregatedEstimate), но раньше не выбирались
    здесь. Nullable — legacy строки / незаполненные поля отдаются как None.
    """
    if not x_authenticated_user:
        raise HTTPException(
            status_code=401,
            detail="no authenticated user (Caddy basic_auth required)",
        )

    from app.core.auth import get_role

    try:
        role = get_role(x_authenticated_user)
    except KeyError:
        logger.warning(
            "user %r authenticated via Caddy but missing from roles.yaml",
            x_authenticated_user,
        )
        raise HTTPException(status_code=403, detail="user not in roles config") from None

    params: dict[str, object] = {"limit": min(max(limit, 1), 200)}
    where = ""
    if role == "admin":
        # Admin: все строки, либо фильтр по конкретному аккаунту.
        if account:
            where = "WHERE created_by = :account"
            params["account"] = account
    else:
        # Non-admin: жёстко скоупим на свои строки; ?account игнорируется.
        where = "WHERE created_by = :owner"
        params["owner"] = x_authenticated_user

    rows = (
        db.execute(
            text(
                f"""
            SELECT id, address, rooms, area_m2, median_price,
                   confidence, n_analogs, created_at,
                   COALESCE(sources_used, '[]'::jsonb) AS sources_used,
                   floor, total_floors, year_built,
                   house_type, repair_state, has_balcony
            FROM trade_in_estimates
            {where}
            ORDER BY created_at DESC
            LIMIT :limit
            """
            ),
            params,
        )
        .mappings()
        .all()
    )
    return [dict(r) for r in rows]


@router.get("/cache-stats")
def cache_stats(db: Annotated[Session, Depends(get_db)]) -> dict[str, object]:
    """Состояние данных и кэшей (#399) — для страницы «Кэш».

    #2043 (BE-1) KPI-расширение для CacheView:
      - avg_median_price — средняя headline-медиана по НЕпустым оценкам
        (median_price > 0, чтобы insufficient-data нули не занижали среднее).
        NULL если непустых оценок нет.
      - repeat_address_pct — доля строк-оценок, чей address встречается ≥2 раз
        (индикатор потенциала кэш-хитов «повторный адрес»). Считается по
        trade_in_estimates с непустым address; NULL при отсутствии адресов.
        NB: это честный best-effort по persisted оценкам, а не hit-rate реального
        кэша (отдельного счётчика попаданий не ведём).
    """
    row = (
        db.execute(
            text(
                """
            SELECT
              (SELECT count(*) FROM geocode_cache) AS geocode_cache,
              (SELECT count(*) FROM geocode_cache WHERE expires_at > NOW())
                  AS geocode_cache_fresh,
              (SELECT count(*) FROM listings WHERE is_active) AS listings_active,
              (SELECT max(scraped_at) FROM listings) AS listings_last_scraped,
              (SELECT count(*) FROM deals) AS deals,
              (SELECT count(*) FROM gendesign_cad_buildings) AS cad_buildings,
              (SELECT count(*) FROM house_metadata) AS house_metadata,
              (SELECT count(*) FROM trade_in_estimates) AS estimates_total,
              (SELECT round(avg(median_price))
                 FROM trade_in_estimates WHERE median_price > 0) AS avg_median_price,
              (SELECT round(
                        100.0 * count(*) FILTER (WHERE addr_count > 1)
                        / NULLIF(count(*), 0), 1)
                 FROM (
                   SELECT count(*) OVER (PARTITION BY address) AS addr_count
                   FROM trade_in_estimates
                   WHERE address IS NOT NULL AND address <> ''
                 ) t) AS repeat_address_pct
            """
            )
        )
        .mappings()
        .fetchone()
    )
    return dict(row) if row else {}


# ── Stage 4a: house info + IMV benchmark для UI ───────────────────────────────


_HOUSE_SELECT_COLS = """
    h.id AS house_id, h.source, h.ext_house_id, h.address, h.short_address,
    h.lat, h.lon, h.year_built, h.total_floors, h.house_type,
    h.passenger_elevators, h.cargo_elevators,
    h.has_concierge, h.closed_yard, h.has_playground, h.parking_type,
    h.developer_name, h.rating, h.reviews_count,
    COALESCE(h.raw_characteristics, '[]'::jsonb) AS raw_characteristics
"""


@router.get("/estimate/{estimate_id}/houses", response_model=list[HouseInfoForEstimate])
def get_estimate_houses(
    estimate_id: UUID,
    db: Annotated[Session, Depends(get_db)],
    x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> list[HouseInfoForEstimate]:
    """House(s) информация для estimate.

    Логика (двойной поиск, union-deduplicate):
      1. Прямое совпадение по нормализованному адресу (tradein_normalize_short_addr).
      2. Geo-nearby — ST_DWithin 500м, любой source (avito/derived/etc.).

    Возвращаем прямой матч + nearby (dedup by id), up to ~6 домов.
    Пустой список если нет matches.
    """
    _assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
    target = db.execute(
        text(
            """
            SELECT lat, lon, address FROM trade_in_estimates
            WHERE id = CAST(:id AS uuid)
            """
        ),
        {"id": str(estimate_id)},
    ).fetchone()
    if target is None:
        raise HTTPException(status_code=404, detail="estimate not found")

    # Path 1: прямой матч по нормализованному адресу
    direct: list[Any] = []
    if target.address:
        direct = list(
            db.execute(
                text(
                    f"""
                    SELECT DISTINCT {_HOUSE_SELECT_COLS},
                           0 AS distance_m
                    FROM houses h
                    WHERE h.short_address = tradein_normalize_short_addr(:addr)
                       OR tradein_normalize_short_addr(h.address)
                              = tradein_normalize_short_addr(:addr)
                    LIMIT 1
                    """
                ),
                {"addr": target.address},
            )
            .mappings()
            .all()
        )

    # Path 2: geo-nearby (any source, 500м radius)
    nearby: list[Any] = []
    if target.lat is not None and target.lon is not None:
        nearby = list(
            db.execute(
                text(
                    f"""
                    SELECT DISTINCT {_HOUSE_SELECT_COLS},
                           ST_Distance(
                               h.geom::geography,
                               ST_MakePoint(:lon, :lat)::geography
                           )::int AS distance_m
                    FROM houses h
                    WHERE h.geom IS NOT NULL
                      AND ST_DWithin(
                              h.geom::geography,
                              ST_MakePoint(:lon, :lat)::geography,
                              500
                          )
                    ORDER BY distance_m
                    LIMIT 5
                    """
                ),
                {"lat": target.lat, "lon": target.lon},
            )
            .mappings()
            .all()
        )

    # Merge: direct первым, затем nearby (dedup by house_id)
    seen_ids = {r["house_id"] for r in direct}
    merged = list(direct) + [r for r in nearby if r["house_id"] not in seen_ids]

    return [
        HouseInfoForEstimate(**{k: v for k, v in row.items() if k != "distance_m"})
        for row in merged
    ]


@router.get("/estimate/{estimate_id}/placement-history", response_model=list[PlacementHistoryEntry])
def get_estimate_placement_history(
    estimate_id: UUID,
    db: Annotated[Session, Depends(get_db)],
    x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> list[PlacementHistoryEntry]:
    """Историческая продажная активность по дому(ам) target estimate.

    Возвращает rows из house_placement_history для всех houses связанных с
    target адресом. Сортировано по last_price_date DESC.
    """
    _assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
    target = db.execute(
        text(
            """
            SELECT lat, lon, address FROM trade_in_estimates
            WHERE id = CAST(:id AS uuid)
            """
        ),
        {"id": str(estimate_id)},
    ).fetchone()
    if target is None:
        raise HTTPException(status_code=404, detail="estimate not found")

    # Поиск house_ids по нормализованному адресу
    house_ids: list[int] = []
    if target.address:
        rows = db.execute(
            text(
                """
                SELECT id FROM houses
                WHERE short_address = tradein_normalize_short_addr(:addr)
                   OR tradein_normalize_short_addr(address) = tradein_normalize_short_addr(:addr)
                """
            ),
            {"addr": target.address},
        ).all()
        house_ids = [r.id for r in rows]

    if not house_ids and target.lat is not None and target.lon is not None:
        # Geo fallback: 100м (tight radius чтобы не смешать соседние дома)
        rows = db.execute(
            text(
                """
                SELECT id FROM houses
                WHERE geom IS NOT NULL
                  AND ST_DWithin(
                          geom::geography,
                          ST_MakePoint(:lon, :lat)::geography,
                          100
                      )
                LIMIT 3
                """
            ),
            {"lat": target.lat, "lon": target.lon},
        ).all()
        house_ids = [r.id for r in rows]

    if not house_ids:
        return []

    history = (
        db.execute(
            text(
                """
            SELECT id, source, house_id, ext_item_id, title, rooms, area_m2,
                   floor, total_floors, start_price, start_price_date,
                   last_price, last_price_date, removed_date, exposure_days,
                   notes
            FROM house_placement_history
            WHERE house_id = ANY(:house_ids)
            ORDER BY COALESCE(last_price_date, start_price_date) DESC NULLS LAST
            LIMIT 50
            """
            ),
            {"house_ids": house_ids},
        )
        .mappings()
        .all()
    )

    return [PlacementHistoryEntry(**dict(r)) for r in history]


@router.get("/estimate/{estimate_id}/house-analytics", response_model=HouseAnalyticsResponse)
def get_estimate_house_analytics(
    estimate_id: UUID,
    db: Annotated[Session, Depends(get_db)],
    x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
    radius_m: int | None = None,
) -> HouseAnalyticsResponse:
    """House-level analytics from house_placement_history backfill.

    Resolves target house(s) — если в самом доме <8 hist rows — расширяем поиск до 300м.
    Возвращает: price-history by year (median ₽/м²), recent sold (12mo), KPI.

    #2044 (BE-2): optional query-param radius_m (100–5000) явно задаёт радиус
    расширения выборки домов. None → текущая авто-логика (расширяем до 300 м
    только если в самом доме <8 записей). radius_m возвращается в ответе.
    """
    _assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
    target = db.execute(
        text("SELECT lat, lon, address FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
        {"id": str(estimate_id)},
    ).fetchone()
    if target is None:
        raise HTTPException(status_code=404, detail="estimate not found")

    # 1. Resolve target house_ids (same as placement-history endpoint)
    house_ids: list[int] = []
    if target.address:
        rows = db.execute(
            text(
                "SELECT id FROM houses WHERE short_address = tradein_normalize_short_addr(:addr) "
                "OR tradein_normalize_short_addr(address) = tradein_normalize_short_addr(:addr)"
            ),
            {"addr": target.address},
        ).all()
        house_ids = [r.id for r in rows]
    if not house_ids and target.lat is not None and target.lon is not None:
        rows = db.execute(
            text(
                "SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin("
                "geom::geography, ST_MakePoint(:lon, :lat)::geography, 100) LIMIT 3"
            ),
            {"lat": target.lat, "lon": target.lon},
        ).all()
        house_ids = [r.id for r in rows]

    # 2. Expand search radius. #2044 (BE-2): explicit radius_m query-param
    #    overrides the auto 0→300 heuristic. None → byte-identical current
    #    behaviour (расширяем до 300 м только при <8 in-house rows).
    radius_used = 0
    if radius_m is not None:
        expand_radius = max(100, min(radius_m, 5000))
        if target.lat is not None and target.lon is not None:
            rows = db.execute(
                text(
                    "SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin("
                    "geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius) LIMIT 30"
                ),
                {"lat": target.lat, "lon": target.lon, "radius": expand_radius},
            ).all()
            house_ids = sorted(set(house_ids) | {r.id for r in rows})
        radius_used = expand_radius
    else:
        n_in_house = 0
        if house_ids:
            n_in_house = (
                db.execute(
                    text("SELECT COUNT(*) FROM house_placement_history WHERE house_id = ANY(:ids)"),
                    {"ids": house_ids},
                ).scalar()
                or 0
            )
        if n_in_house < 8 and target.lat is not None and target.lon is not None:
            rows = db.execute(
                text(
                    "SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin("
                    "geom::geography, ST_MakePoint(:lon, :lat)::geography, 300) LIMIT 30"
                ),
                {"lat": target.lat, "lon": target.lon},
            ).all()
            house_ids = sorted(set(house_ids) | {r.id for r in rows})
            radius_used = 300

    if not house_ids:
        return HouseAnalyticsResponse(
            house_ids=[],
            radius_m=0,
            price_history=[],
            recent_sold=[],
            kpi=HouseAnalyticsKpi(
                total_lots=0,
                sold_count=0,
                sold_rate_pct=0.0,
                median_exposure_days=None,
                median_bargain_pct=None,
            ),
        )

    # 3. Price history by year × source (median ₽/м²)
    price_history_rows = (
        db.execute(
            text(
                """
            SELECT
                EXTRACT(YEAR FROM COALESCE(last_price_date, start_price_date))::int AS year,
                source,
                COUNT(*) AS n_lots,
                percentile_cont(0.5) WITHIN GROUP (ORDER BY last_price / NULLIF(area_m2, 0))::int
                    AS median_price_per_m2,
                percentile_cont(0.5) WITHIN GROUP (ORDER BY last_price)::int AS median_price_rub
            FROM house_placement_history
            WHERE house_id = ANY(:ids)
              AND last_price IS NOT NULL AND last_price > 100000
              AND area_m2 IS NOT NULL AND area_m2 > 10
              AND COALESCE(last_price_date, start_price_date) IS NOT NULL
            GROUP BY year, source
            ORDER BY year ASC, source ASC
            """
            ),
            {"ids": house_ids},
        )
        .mappings()
        .all()
    )

    # 4. Recent sold (12 months, with removed_date)
    recent_sold_rows = (
        db.execute(
            text(
                """
            SELECT id, source, rooms, area_m2, floor, start_price, last_price,
                   removed_date, exposure_days,
                   CASE WHEN start_price > 0 AND last_price IS NOT NULL
                        THEN ROUND((start_price - last_price)::numeric / start_price * 100, 1)
                        ELSE NULL END AS discount_pct
            FROM house_placement_history
            WHERE house_id = ANY(:ids)
              AND removed_date IS NOT NULL
              AND removed_date > (NOW() - INTERVAL '12 months')::date
            ORDER BY removed_date DESC
            LIMIT 20
            """
            ),
            {"ids": house_ids},
        )
        .mappings()
        .all()
    )

    # 5. KPI aggregate
    kpi_row = (
        db.execute(
            text(
                """
            SELECT
                COUNT(*) AS total_lots,
                COUNT(*) FILTER (WHERE removed_date IS NOT NULL) AS sold_count,
                CASE WHEN COUNT(*) > 0
                     THEN ROUND(
                              COUNT(*) FILTER (WHERE removed_date IS NOT NULL)::numeric
                              / COUNT(*) * 100,
                              1
                          )
                     ELSE 0 END AS sold_rate_pct,
                percentile_cont(0.5) WITHIN GROUP (ORDER BY exposure_days)
                    FILTER (WHERE exposure_days IS NOT NULL) AS median_exposure_days,
                ROUND(
                    percentile_cont(0.5) WITHIN GROUP (
                        ORDER BY (start_price - last_price)::numeric / NULLIF(start_price, 0) * 100
                    ) FILTER (
                        WHERE start_price > 0
                          AND last_price IS NOT NULL
                          AND last_price != start_price
                    )::numeric,
                    1
                ) AS median_bargain_pct
            FROM house_placement_history
            WHERE house_id = ANY(:ids)
            """
            ),
            {"ids": house_ids},
        )
        .mappings()
        .first()
    )

    return HouseAnalyticsResponse(
        house_ids=house_ids,
        radius_m=radius_used,
        price_history=[PriceHistoryYearPoint(**dict(r)) for r in price_history_rows],
        recent_sold=[RecentSoldEntry(**dict(r)) for r in recent_sold_rows],
        kpi=HouseAnalyticsKpi(
            total_lots=kpi_row["total_lots"] or 0 if kpi_row else 0,
            sold_count=kpi_row["sold_count"] or 0 if kpi_row else 0,
            sold_rate_pct=float(kpi_row["sold_rate_pct"] or 0) if kpi_row else 0.0,
            median_exposure_days=(
                int(kpi_row["median_exposure_days"])
                if kpi_row and kpi_row["median_exposure_days"] is not None
                else None
            ),
            median_bargain_pct=(
                float(kpi_row["median_bargain_pct"])
                if kpi_row and kpi_row["median_bargain_pct"] is not None
                else None
            ),
        ),
    )


@router.get(
    "/estimate/{estimate_id}/cian-price-changes",
    response_model=list[CianPriceChangeStats],
)
def get_estimate_cian_price_changes(
    estimate_id: UUID,
    db: Annotated[Session, Depends(get_db)],
    x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> list[CianPriceChangeStats]:
    """История изменений цены для Cian-аналогов из estimate.

    Для каждого cian-аналога в estimate.analogs:
      - extract cian_id из URL (/sale/flat/<id>/)
      - JOIN listings (source='cian', source_id IN cian_ids)
      - JOIN offer_price_history per listing_id
      - Aggregate: n_changes, last_change_time, last_diff_percent, total_change_pct

    Возвращает только аналоги с хотя бы одним изменением цены.
    Пустой список если нет cian-аналогов или нет истории.
    """
    _assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
    rows = (
        db.execute(
            text(
                """
            WITH cian_analogs AS (
                SELECT DISTINCT
                    substring(a->>'source_url' from '/sale/flat/(\\d+)/') AS cian_id
                FROM trade_in_estimates, jsonb_array_elements(analogs) a
                WHERE id = CAST(:eid AS uuid)
                  AND a->>'source' = 'cian'
                  AND a->>'source_url' IS NOT NULL
                  AND substring(a->>'source_url' from '/sale/flat/(\\d+)/') IS NOT NULL
            ),
            listings_resolved AS (
                SELECT l.id AS listing_id, l.source_id AS cian_id,
                       l.price_rub::int AS current_price
                FROM listings l
                JOIN cian_analogs c ON l.source_id = c.cian_id
                WHERE l.source = 'cian'
            ),
            changes_agg AS (
                SELECT
                    oph.listing_id,
                    COUNT(*) AS n_changes,
                    MAX(oph.change_time) AS last_change_time,
                    (array_agg(oph.diff_percent ORDER BY oph.change_time DESC))[1]
                        AS last_diff_percent,
                    (
                        SELECT oph2.price_rub::int
                        FROM offer_price_history oph2
                        WHERE oph2.listing_id = oph.listing_id
                        ORDER BY oph2.change_time ASC
                        LIMIT 1
                    ) AS first_seen_price
                FROM offer_price_history oph
                WHERE oph.listing_id IN (SELECT listing_id FROM listings_resolved)
                GROUP BY oph.listing_id
            )
            SELECT
                lr.cian_id,
                lr.listing_id,
                ca.n_changes::int,
                ca.last_change_time,
                ca.last_diff_percent::float AS last_diff_percent,
                ca.first_seen_price,
                lr.current_price,
                CASE
                    WHEN ca.first_seen_price IS NOT NULL AND ca.first_seen_price > 0
                    THEN ROUND(
                        (lr.current_price - ca.first_seen_price)::numeric
                        / ca.first_seen_price * 100,
                        1
                    )::float
                    ELSE NULL
                END AS total_change_pct
            FROM listings_resolved lr
            JOIN changes_agg ca ON ca.listing_id = lr.listing_id
            WHERE ca.n_changes > 0
            ORDER BY ca.last_change_time DESC
            """
            ),
            {"eid": str(estimate_id)},
        )
        .mappings()
        .all()
    )

    return [CianPriceChangeStats(**dict(r)) for r in rows]


@router.get(
    "/estimate/{estimate_id}/sell-time-sensitivity",
    response_model=SellTimeSensitivityResponse,
)
def get_estimate_sell_time_sensitivity(
    estimate_id: UUID,
    db: Annotated[Session, Depends(get_db)],
    x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
    radius_m: int | None = None,
) -> SellTimeSensitivityResponse:
    """Срок продажи в зависимости от цены к медиане дома/района.

    4 бакета: -5% / медиана (±3%) / +5% / +10%. Median exposure_days + p25/p75.
    Filter last_price > start_price * 0.7 — отбрасываем подозрительно
    заниженные лоты (выбросы, ошибки парсинга).

    #2044 (BE-2): optional query-param radius_m (100–5000) явно задаёт радиус
    расширения выборки домов. None → текущая авто-логика (300 м при <8 записях).
    """
    _assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
    # 1. Resolve house_ids (same logic as house-analytics)
    target = db.execute(
        text("SELECT lat, lon, address FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
        {"id": str(estimate_id)},
    ).fetchone()
    if target is None:
        raise HTTPException(status_code=404, detail="estimate not found")

    house_ids: list[int] = []
    if target.address:
        rows = db.execute(
            text(
                "SELECT id FROM houses WHERE short_address = tradein_normalize_short_addr(:addr) "
                "OR tradein_normalize_short_addr(address) = tradein_normalize_short_addr(:addr)"
            ),
            {"addr": target.address},
        ).all()
        house_ids = [r.id for r in rows]
    if not house_ids and target.lat is not None and target.lon is not None:
        rows = db.execute(
            text(
                "SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin("
                "geom::geography, ST_MakePoint(:lon, :lat)::geography, 100) LIMIT 3"
            ),
            {"lat": target.lat, "lon": target.lon},
        ).all()
        house_ids = [r.id for r in rows]

    # Expand search radius (same threshold as house-analytics). #2044 (BE-2):
    # explicit radius_m overrides the auto 0→300 heuristic; None → byte-identical.
    radius_used = 0
    if radius_m is not None:
        expand_radius = max(100, min(radius_m, 5000))
        if target.lat is not None and target.lon is not None:
            rows = db.execute(
                text(
                    "SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin("
                    "geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius) LIMIT 30"
                ),
                {"lat": target.lat, "lon": target.lon, "radius": expand_radius},
            ).all()
            house_ids = sorted(set(house_ids) | {r.id for r in rows})
        radius_used = expand_radius
    else:
        n_in_house = 0
        if house_ids:
            n_in_house = (
                db.execute(
                    text("SELECT COUNT(*) FROM house_placement_history WHERE house_id = ANY(:ids)"),
                    {"ids": house_ids},
                ).scalar()
                or 0
            )
        if n_in_house < 8 and target.lat is not None and target.lon is not None:
            rows = db.execute(
                text(
                    "SELECT id FROM houses WHERE geom IS NOT NULL AND ST_DWithin("
                    "geom::geography, ST_MakePoint(:lon, :lat)::geography, 300) LIMIT 30"
                ),
                {"lat": target.lat, "lon": target.lon},
            ).all()
            house_ids = sorted(set(house_ids) | {r.id for r in rows})
            radius_used = 300

    if not house_ids:
        return SellTimeSensitivityResponse(
            house_ids=[],
            radius_m=0,
            target_median_price_per_m2=None,
            buckets=[],
        )

    # 2. Compute benchmark median ₽/м² for last 2 years
    target_median = db.execute(
        text(
            """
            SELECT percentile_cont(0.5) WITHIN GROUP (
                ORDER BY last_price / NULLIF(area_m2, 0)
            )::int AS median_ppm2
            FROM house_placement_history
            WHERE house_id = ANY(:ids)
              AND last_price IS NOT NULL AND last_price > 100000
              AND area_m2 > 10
              AND COALESCE(last_price_date, start_price_date) > (NOW() - INTERVAL '2 years')::date
              AND (start_price = 0 OR last_price > start_price * 0.7)
            """
        ),
        {"ids": house_ids},
    ).scalar()

    # 3. Per-year median (для расчёта premium per lot); используем CTE для bucket-расчёта
    bucket_rows = (
        db.execute(
            text(
                """
            WITH year_medians AS (
                SELECT
                    EXTRACT(YEAR FROM COALESCE(last_price_date, start_price_date))::int AS year,
                    percentile_cont(0.5) WITHIN GROUP (
                        ORDER BY last_price / NULLIF(area_m2, 0)
                    ) AS median_ppm2
                FROM house_placement_history
                WHERE house_id = ANY(:ids)
                  AND last_price IS NOT NULL AND area_m2 > 10
                  AND (start_price = 0 OR last_price > start_price * 0.7)
                GROUP BY year
            ),
            lots_with_premium AS (
                SELECT
                    hph.exposure_days,
                    CASE
                        WHEN ym.median_ppm2 IS NULL OR ym.median_ppm2 = 0 THEN NULL
                        ELSE ((hph.last_price / NULLIF(hph.area_m2, 0)) - ym.median_ppm2)
                             / ym.median_ppm2 * 100
                    END AS premium_pct
                FROM house_placement_history hph
                JOIN year_medians ym ON ym.year = EXTRACT(YEAR FROM
                    COALESCE(hph.last_price_date, hph.start_price_date))::int
                WHERE hph.house_id = ANY(:ids)
                  AND hph.removed_date IS NOT NULL
                  AND hph.exposure_days IS NOT NULL
                  AND hph.area_m2 > 10
                  AND (hph.start_price = 0 OR hph.last_price > hph.start_price * 0.7)
            ),
            bucketed AS (
                SELECT
                    CASE
                        WHEN premium_pct BETWEEN -10 AND -3 THEN 'cheap'
                        WHEN premium_pct BETWEEN -3 AND 3 THEN 'median'
                        WHEN premium_pct BETWEEN 3 AND 8 THEN 'plus5'
                        WHEN premium_pct BETWEEN 8 AND 15 THEN 'plus10'
                        ELSE NULL
                    END AS bucket,
                    exposure_days
                FROM lots_with_premium
                WHERE premium_pct IS NOT NULL
            )
            SELECT
                bucket,
                COUNT(*) AS n_lots,
                percentile_cont(0.5) WITHIN GROUP (ORDER BY exposure_days)::int
                    AS median_exposure_days,
                percentile_cont(0.25) WITHIN GROUP (ORDER BY exposure_days)::int AS p25_days,
                percentile_cont(0.75) WITHIN GROUP (ORDER BY exposure_days)::int AS p75_days
            FROM bucketed
            WHERE bucket IS NOT NULL
            GROUP BY bucket
            """
            ),
            {"ids": house_ids},
        )
        .mappings()
        .all()
    )

    # 4. Build buckets — гарантируем все 4 даже если данных нет в bucket
    bucket_map = {r["bucket"]: dict(r) for r in bucket_rows}
    bucket_definitions = [
        ("cheap", -5.0),
        ("median", 0.0),
        ("plus5", 5.0),
        ("plus10", 10.0),
    ]
    buckets: list[SellTimeBucket] = []
    for label, pct in bucket_definitions:
        r = bucket_map.get(label)
        n_lots = r["n_lots"] if r else 0
        buckets.append(
            SellTimeBucket(
                price_premium_label=label,
                price_premium_pct=pct,
                median_exposure_days=r["median_exposure_days"] if r else None,
                p25_days=r["p25_days"] if r else None,
                p75_days=r["p75_days"] if r else None,
                n_lots=n_lots,
                # #1995: малая выборка → median/p25/p75 шумные (наблюдалась
                # немонотонность между бакетами, напр. +10% быстрее +5%). Честный
                # флаг вместо тихого шума — фронт решает, как показать.
                insufficient_data=n_lots < settings.sell_time_sensitivity_min_n_lots,
            )
        )

    return SellTimeSensitivityResponse(
        house_ids=house_ids,
        radius_m=radius_used,
        target_median_price_per_m2=int(target_median) if target_median else None,
        buckets=buckets,
    )


@router.get("/estimate/{estimate_id}/imv-benchmark", response_model=IMVBenchmarkResponse)
def get_estimate_imv_benchmark(
    estimate_id: UUID,
    db: Annotated[Session, Depends(get_db)],
    x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> IMVBenchmarkResponse:
    """Avito IMV benchmark для estimate (для UI badge «наша 6.4М · Avito 6.29М»).

    Источники lookup:
      1. avito_imv_evaluations WHERE estimate_id = :id (если linked в estimator)
      2. Если не linked — fallback: most recent IMV для same address (TTL 24h)
    """
    _assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)
    # Сначала пытаемся найти directly linked
    row = db.execute(
        text(
            """
            SELECT cache_key, recommended_price, lower_price, higher_price,
                   market_count, fetched_at
            FROM avito_imv_evaluations
            WHERE estimate_id = CAST(:id AS uuid)
            ORDER BY fetched_at DESC
            LIMIT 1
            """
        ),
        {"id": str(estimate_id)},
    ).fetchone()

    if row is None:
        # Fallback: same address за 24h (на случай если link не успел)
        est = db.execute(
            text(
                """
                SELECT address FROM trade_in_estimates
                WHERE id = CAST(:id AS uuid)
                """
            ),
            {"id": str(estimate_id)},
        ).fetchone()
        if est is None:
            raise HTTPException(status_code=404, detail="estimate not found")
        if est.address:
            row = db.execute(
                text(
                    """
                    SELECT cache_key, recommended_price, lower_price, higher_price,
                           market_count, fetched_at
                    FROM avito_imv_evaluations
                    WHERE address = :address
                      AND fetched_at > NOW() - INTERVAL '24 hours'
                    ORDER BY fetched_at DESC
                    LIMIT 1
                    """
                ),
                {"address": est.address},
            ).fetchone()

    if row is None:
        return IMVBenchmarkResponse(available=False)

    # Get our_median_price для compare
    our = db.execute(
        text(
            """
            SELECT median_price FROM trade_in_estimates
            WHERE id = CAST(:id AS uuid)
            """
        ),
        {"id": str(estimate_id)},
    ).fetchone()
    our_median = our.median_price if our else None
    diff_pct = None
    if our_median and row.recommended_price:
        diff_pct = round((our_median - row.recommended_price) / row.recommended_price * 100, 1)

    return IMVBenchmarkResponse(
        available=True,
        cache_key=row.cache_key,
        recommended_price=row.recommended_price,
        lower_price=row.lower_price,
        higher_price=row.higher_price,
        market_count=row.market_count,
        fetched_at=row.fetched_at,
        our_median_price=our_median,
        diff_pct=diff_pct,
    )


# ── Location index (issue TBD, замена сломанного location-coef #2045) ────────


@router.get("/location-index", response_model=LocationIndexResponse)
def get_location_index(
    estimate_id: UUID,
    db: Annotated[Session, Depends(get_db)],
    x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
    radius_m: int | None = None,
) -> LocationIndexResponse:
    """Location index для оценки (замена сломанного location-coef, LocationDrawer).

    Резолвит lat/lon оценки, считает индекс через
    app.services.location_index.compute_location_index: % отклонения медианы ₽/м²
    сопоставимых активных листингов в радиусе точки от медианы ₽/м² по всему Екатеринбургу
    (percentile_cont(0.5) — устойчиво к выбросам). НЕ участвует в цене — estimator.py про
    этот показатель не знает (аналоги уже несут локацию в базовой цене).

    404 — оценки нет / IDOR (тот же _assert_estimate_access_by_id, что и у соседних
    derived-роутов). radius_m опционален (None → адаптивная лестница радиусов
    RADIUS_LADDER_M, расширяется пока выборка не наберёт MIN_SAMPLE_SIZE); явное значение
    клэмпится в [500, 3000] и используется РОВНО как задано (без расширения).

    Честная деградация (НЕ 500, НЕ сфабрикованные значения) — см. LocationIndexResponse:
      - status="out_of_coverage" — у оценки нет lat/lon, ИЛИ точка вне гео-охвата продукта
        (Екатеринбург).
      - status="insufficient_data" — даже на максимальном радиусе сопоставимых активных
        листингов меньше порога.
      - poi_status="unavailable" — osm_poi_ekb_local пуста/не отрефрешена (независимо от
        status выше — «что рядом» и числовой индекс деградируют раздельно).
    """
    _assert_estimate_access_by_id(db, estimate_id, x_authenticated_user)

    row = db.execute(
        text(
            """
            SELECT lat, lon
            FROM trade_in_estimates
            WHERE id = CAST(:id AS uuid)
            """
        ),
        {"id": str(estimate_id)},
    ).fetchone()
    if row is None:
        raise HTTPException(status_code=404, detail="estimate not found")

    if row.lat is None or row.lon is None:
        logger.info(
            "location_index: estimate=%s has no lat/lon — out_of_coverage fallback", estimate_id
        )
        return LocationIndexResponse(
            status="out_of_coverage",
            location_index_pct=None,
            local_median_price_per_m2=None,
            city_median_price_per_m2=None,
            sample_size=0,
            radius_m=radius_m or 0,
            nearby_poi=[],
            poi_status="unavailable",
        )

    from app.services.location_index import compute_location_index

    resolved_radius = None if radius_m is None else max(500, min(radius_m, 3000))
    result = compute_location_index(db, float(row.lat), float(row.lon), radius_m=resolved_radius)

    return LocationIndexResponse(
        status=result.status,
        location_index_pct=result.location_index_pct,
        local_median_price_per_m2=result.local_median_price_per_m2,
        city_median_price_per_m2=result.city_median_price_per_m2,
        sample_size=result.sample_size,
        radius_m=result.radius_m,
        nearby_poi=[
            NearbyPoiOut(poi_type=p.poi_type, name=p.name, distance_m=p.distance_m)
            for p in result.nearby_poi
        ],
        poi_status=result.poi_status,
    )


# ── Street-level deals (rosreestr open dataset) ───────────────────────────────


@router.get("/street-deals", response_model=StreetDealsResponse)
def get_street_deals(
    address: str,
    area_m2: float,
    rooms: int,
    db: Annotated[Session, Depends(get_db)] = None,  # type: ignore[assignment]
    period_months: int = 12,
    area_tolerance: float = 0.15,
) -> StreetDealsResponse:
    """ДКП-сделки Росреестра по улице целевого адреса.

    Open dataset Росреестра агрегирует адреса до улицы (без номера дома).
    Поэтому это per-street view, не per-house. Фильтр по rooms + area
    сужает выборку до квартир-аналогов.

    После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована
    в import-rosreestr.sh).
    """
    from app.services.estimator import (
        _deal_to_analog,
        _percentile,
        _resolve_target_city,
        extract_street_name,
    )

    now = datetime.now(tz=UTC)
    # #1381: отображаемое окно должно совпадать с SQL-фильтром ниже, который
    # использует календарный interval PostgreSQL (NOW() - N months), а не
    # фиксированные 30-дневные месяцы. Считаем cutoff календарной арифметикой.
    _from_month0 = (now.year * 12 + (now.month - 1)) - period_months
    _from_year, _from_month_idx = divmod(_from_month0, 12)
    _from_month = _from_month_idx + 1
    # Клампим день для коротких месяцев (как делает PostgreSQL interval).
    _from_day = min(now.day, calendar.monthrange(_from_year, _from_month)[1])
    period_from: date = date(_from_year, _from_month, _from_day)
    period_to: date = now.date()

    def _empty() -> StreetDealsResponse:
        return StreetDealsResponse(
            street=None,
            period_from=period_from,
            period_to=period_to,
            count=0,
            median_price_rub=0,
            median_price_per_m2=0,
            range_low_rub=0,
            range_high_rub=0,
            deals=[],
        )

    street_name = extract_street_name(address)
    if not street_name:
        logger.warning("street-deals: could not extract street from address=%r", address)
        return _empty()

    area_min = area_m2 * (1.0 - area_tolerance)
    area_max = area_m2 * (1.0 + area_tolerance)

    # #C1 city-scope (п.3, консистентно с estimator._fetch_dkp_corridor): резолвим
    # город целевого адреса через _resolve_target_city (словарь ~30 городов обл.66
    # вкл. ЕКБ + sweep-города) и фильтруем сделки по этому городу — одноимённые улицы
    # др. городов не контаминируют витрину. None (адрес вне словаря) → фильтр не
    # применяется. `city_filter` — литерал (не user-input), значение идёт bind-параметром.
    target_city = _resolve_target_city(address)
    city_filter = "AND LOWER(city) = CAST(:target_city AS text)" if target_city else ""

    rows = (
        db.execute(
            text(
                f"""
            SELECT address, area_m2, rooms, floor, total_floors,
                   price_rub, price_per_m2, deal_date, source
            FROM deals
            WHERE source = 'rosreestr'
              AND address ILIKE :street_pattern
              AND address ~* :street_regex
              {city_filter}
              AND rooms = CAST(:rooms AS integer)
              AND area_m2 BETWEEN :area_min AND :area_max
              AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval
              AND price_rub > 0
            ORDER BY deal_date DESC
            """
            ),
            {
                "street_pattern": "%" + street_name + "%",
                "street_regex": r"\m" + street_name + r"\M",
                "target_city": target_city.lower() if target_city else None,
                "rooms": rooms,
                "area_min": area_min,
                "area_max": area_max,
                "period_months": period_months,
            },
        )
        .mappings()
        .all()
    )

    if not rows:
        logger.info(
            "street-deals: no rows found street=%r rooms=%d area=%.1f±%.0f%%",
            street_name,
            rooms,
            area_m2,
            area_tolerance * 100,
        )
        return StreetDealsResponse(
            street=street_name,
            period_from=period_from,
            period_to=period_to,
            count=0,
            median_price_rub=0,
            median_price_per_m2=0,
            range_low_rub=0,
            range_high_rub=0,
            deals=[],
        )

    count = len(rows)
    prices_rub = sorted(float(r["price_rub"]) for r in rows)
    prices_ppm2 = sorted(float(r["price_per_m2"]) for r in rows if r["price_per_m2"])

    median_ppm2 = _percentile(prices_ppm2, 0.5) if prices_ppm2 else 0.0
    median_price_rub = (
        int(median_ppm2 * area_m2) if median_ppm2 else int(_percentile(prices_rub, 0.5))
    )
    range_low_rub = int(prices_rub[0])
    range_high_rub = int(prices_rub[-1])

    top10 = [_deal_to_analog(dict(r)) for r in rows[:10]]

    logger.info(
        "street-deals: street=%r rooms=%d area=%.1f count=%d median_ppm2=%.0f",
        street_name,
        rooms,
        area_m2,
        count,
        median_ppm2,
    )

    return StreetDealsResponse(
        street=street_name,
        period_from=period_from,
        period_to=period_to,
        count=count,
        median_price_rub=median_price_rub,
        median_price_per_m2=int(median_ppm2),
        range_low_rub=range_low_rub,
        range_high_rub=range_high_rub,
        deals=top10,
    )


# ── Sales vs Listings (PR K — Foundation Phase 1 of issue #564) ──────────────


@router.get("/sales-vs-listings", response_model=SalesVsListingsResponse)
def get_sales_vs_listings(
    address: str,
    area_m2: float,
    rooms: int,
    db: Annotated[Session, Depends(get_db)] = None,  # type: ignore[assignment]
    window_days: int = 180,
    area_tolerance: float = 0.15,
    period_months: int = 24,
) -> SalesVsListingsResponse:
    """Pairs (ДКП-сделка, listing) для улицы целевого адреса (PR K / #564).

    Для каждой ДКП-сделки Росреестра в окне `period_months` пытаемся найти
    matching listing на той же улице с такими же rooms / близкой area_m2 /
    listing_date в окне [deal_date - window_days, deal_date + 30d grace].

    Возвращаем LEFT JOIN: сделки без listing match сохраняются (listing_* = None),
    чтобы вычислить linkage_rate.

    discount_pct = (deal_price - listing_price) / listing_price * 100.
    Отрицательный = продали дешевле asking → reasoned discount от торга.

    Per-street view: Росреестр open dataset агрегирует адреса до улицы.
    """
    from app.services.estimator import _percentile, extract_street_name

    def _empty(reason_street: str | None = None) -> SalesVsListingsResponse:
        return SalesVsListingsResponse(
            street=reason_street,
            period_months=period_months,
            window_days=window_days,
            area_tolerance=area_tolerance,
            total_deals=0,
            deals_with_listings=0,
            linkage_rate_pct=0.0,
            median_discount_pct=None,
            data_quality="no_data",  # #721 ADR v3: нет сделок / улица не извлеклась
            pairs=[],
        )

    street_name = extract_street_name(address)
    if not street_name:
        logger.warning("sales-vs-listings: cannot extract street from %r", address)
        return _empty()

    rows = (
        db.execute(
            text(
                """
            SELECT
                deal_id, deal_date, deal_price_rub, deal_price_per_m2,
                deal_area_m2, deal_rooms, deal_floor, deal_address,
                listing_id, listing_source, listing_source_url,
                listing_date, listing_price_rub, listing_price_per_m2,
                listing_area_m2, days_listing_to_deal, discount_pct
            FROM street_sales_vs_listings(
                CAST(:street_pattern AS text),
                CAST(:area_m2 AS numeric),
                CAST(:rooms AS integer),
                CAST(:window_days AS integer),
                CAST(:area_tolerance AS numeric),
                CAST(:period_months AS integer)
            )
            """
            ),
            {
                "street_pattern": "%" + street_name + "%",
                "area_m2": area_m2,
                "rooms": rooms,
                "window_days": window_days,
                "area_tolerance": area_tolerance,
                "period_months": period_months,
            },
        )
        .mappings()
        .all()
    )

    if not rows:
        logger.info(
            "sales-vs-listings: no deals street=%r rooms=%d area=%.1f period_months=%d",
            street_name,
            rooms,
            area_m2,
            period_months,
        )
        return _empty(reason_street=street_name)

    pairs = [
        SalesListingPair(
            deal_id=r["deal_id"],
            deal_date=r["deal_date"],
            deal_price_rub=int(r["deal_price_rub"]),
            deal_price_per_m2=int(r["deal_price_per_m2"] or 0),
            deal_area_m2=float(r["deal_area_m2"]),
            deal_rooms=int(r["deal_rooms"]),
            deal_floor=r["deal_floor"],
            deal_address=r["deal_address"],
            listing_id=r["listing_id"],
            listing_source=r["listing_source"],
            listing_source_url=r["listing_source_url"],
            listing_date=r["listing_date"],
            listing_price_rub=(
                int(r["listing_price_rub"]) if r["listing_price_rub"] is not None else None
            ),
            listing_price_per_m2=(
                int(r["listing_price_per_m2"]) if r["listing_price_per_m2"] is not None else None
            ),
            listing_area_m2=(
                float(r["listing_area_m2"]) if r["listing_area_m2"] is not None else None
            ),
            days_listing_to_deal=r["days_listing_to_deal"],
            discount_pct=(float(r["discount_pct"]) if r["discount_pct"] is not None else None),
            # #1995: street_sales_vs_listings() фильтрует ТОЛЬКО source='rosreestr'
            # (067_v_street_sales_vs_listings.sql) → все pairs сейчас квартальной
            # precision (deal_date = period_start_date). Честная маркировка, не баг.
            deal_date_precision="quarter",
        )
        for r in rows
    ]

    total_deals = len(pairs)
    deals_with_listings = sum(1 for p in pairs if p.listing_id is not None)
    linkage_rate_pct = round(deals_with_listings / total_deals * 100, 1) if total_deals else 0.0

    discounts = sorted(p.discount_pct for p in pairs if p.discount_pct is not None)
    median_discount = round(_percentile(discounts, 0.5), 2) if discounts else None

    logger.info(
        "sales-vs-listings: street=%r deals=%d with_listings=%d linkage=%.1f%% median_disc=%s",
        street_name,
        total_deals,
        deals_with_listings,
        linkage_rate_pct,
        f"{median_discount:+.2f}%" if median_discount is not None else "n/a",
    )

    return SalesVsListingsResponse(
        street=street_name,
        period_months=period_months,
        window_days=window_days,
        area_tolerance=area_tolerance,
        total_deals=total_deals,
        deals_with_listings=deals_with_listings,
        linkage_rate_pct=linkage_rate_pct,
        median_discount_pct=median_discount,
        # street_sales_vs_listings матчит по УЛИЦЕ (не по дому, #721 ADR) →
        # даже при deals_with_listings>0 это street-level, не house. house_linked НЕ emit'им.
        data_quality="street_only" if total_deals > 0 else "no_data",
        pairs=pairs,
    )
