"""Тесты API «доля квартир дома в продаже» (мигр. 143).

Two layers:
  1. SQL-билдеры (чистые функции) — shape, CAST(:x AS type), whitelist-сортировка,
     clamp лимита, фильтры. БД не нужна.
  2. Contract — минимальный app с замоканным get_db: 200 + правильная форма ответа
     (RBAC middleware не монтируем, mirror trade_in_app из test_estimate_idor).
"""

from __future__ import annotations

import os
from unittest.mock import MagicMock

# psycopg v3 driver требует DATABASE_URL до импорта app.core.db.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient

from app.services.buildings_query import (
    ALLOWED_SORTS,
    HISTOGRAM_BUCKETS,
    build_listings_query,
    build_sale_share_query,
    build_summary_histogram_query,
    build_summary_scalars_query,
)

# ── SQL-билдеры ───────────────────────────────────────────────────────────────


def test_sale_share_minimal_defaults():
    sql, args = build_sale_share_query()
    assert "FROM v_building_sale_share" in sql
    # building qualifies on EITHER % — OR-фильтр, без жёсткого IS NOT NULL.
    assert "sale_share_pct >= CAST(:min_pct AS numeric)" in sql
    assert "sale_share_pct_45d >= CAST(:min_pct AS numeric)" in sql
    assert "OR (sale_share_pct_45d >= CAST(:min_pct AS numeric))" in sql
    # оба % выставлены в SELECT; over_100 — по любому из них.
    assert "sale_share_pct_45d, " in sql
    assert "listings_45d, " in sql
    assert "(sale_share_pct > 100 OR sale_share_pct_45d > 100) AS over_100" in sql
    assert "COALESCE(short_address, full_address, address) AS address" in sql
    assert args["min_pct"] == 5.0
    assert args["limit"] == 200
    # default sort = share_desc
    assert "ORDER BY sale_share_pct DESC NULLS LAST" in sql


def test_sale_share_or_filter_includes_churn_only():
    """45d-only дом (0 активных, но sale_share_pct_45d>=min) проходит через OR-ветку."""
    sql, _ = build_sale_share_query(min_pct=10)
    # обе ветки порога присутствуют, объединены через OR в одной скобке
    assert "((sale_share_pct >= CAST(:min_pct AS numeric))" in sql
    assert "(sale_share_pct_45d >= CAST(:min_pct AS numeric)))" in sql
    # жёсткого `sale_share_pct IS NOT NULL` больше нет — он отрезал бы 45d-only дома
    assert "sale_share_pct IS NOT NULL" not in sql


def test_sale_share_max_pct_bounds_both_metrics():
    """max_pct ограничивает КАЖДУЮ ветку отдельно (per-metric [min,max])."""
    sql, args = build_sale_share_query(min_pct=5, max_pct=40)
    assert "sale_share_pct <= CAST(:max_pct AS numeric)" in sql
    assert "sale_share_pct_45d <= CAST(:max_pct AS numeric)" in sql
    assert args["max_pct"] == 40


def test_sale_share_all_filters():
    sql, args = build_sale_share_query(
        min_pct=10,
        max_pct=80,
        city="Екатеринбург",
        price_min=3_000_000,
        price_max=12_000_000,
        year_min=1990,
        year_max=2015,
        house_type="panel",
        sort="price_asc",
    )
    assert "sale_share_pct <= CAST(:max_pct AS numeric)" in sql
    assert "ILIKE CAST(:city_like AS text)" in sql
    assert args["city_like"] == "%Екатеринбург%"
    assert "median_price_rub >= CAST(:price_min AS bigint)" in sql
    assert "median_price_rub <= CAST(:price_max AS bigint)" in sql
    assert "year_built >= CAST(:year_min AS integer)" in sql
    assert "year_built <= CAST(:year_max AS integer)" in sql
    assert "house_type = CAST(:house_type AS text)" in sql
    assert "ORDER BY median_price_rub ASC NULLS LAST" in sql


def test_sale_share_no_double_colon_cast():
    """psycopg v3: ни одного `:name::type` — только CAST(...)."""
    sql, _ = build_sale_share_query(city="x", price_min=1, year_min=2000, house_type="brick")
    import re

    assert re.search(r":[a-z_]+::[a-z]", sql) is None


def test_sale_share_limit_clamped():
    _, args_hi = build_sale_share_query(limit=99999)
    assert args_hi["limit"] == 1000
    _, args_lo = build_sale_share_query(limit=0)
    assert args_lo["limit"] == 1
    _, args_neg = build_sale_share_query(limit=-5)
    assert args_neg["limit"] == 1


@pytest.mark.parametrize("sort", sorted(ALLOWED_SORTS))
def test_sale_share_sort_whitelisted(sort: str):
    sql, _ = build_sale_share_query(sort=sort)
    assert "ORDER BY" in sql
    # tiebreaker house_id присутствует во всех сортировках
    assert "house_id" in sql.split("ORDER BY", 1)[1]


def test_sale_share_unknown_sort_falls_back():
    """Несуществующий sort не интерполируется — fallback на share_desc."""
    sql, _ = build_sale_share_query(sort="DROP TABLE houses")
    assert "DROP TABLE" not in sql
    assert "ORDER BY sale_share_pct DESC NULLS LAST" in sql


def test_listings_query_shape():
    sql, args = build_listings_query(42)
    assert "FROM listings" in sql
    assert "house_id_fk = CAST(:house_id AS bigint)" in sql
    assert "listing_segment = 'vtorichka'" in sql
    assert "is_active" in sql
    assert "JOIN houses" in sql
    assert "ST_DistanceSphere(l.geom, h.geom) <= 300" in sql
    # floors-consistency guard (мигр.152): отсекает соседей с расхождением этажности >3,
    # NULL-этажность листинга/дома — КЕЕП.
    assert "abs(l.total_floors - COALESCE(h.zhkh_floors, h.total_floors)) <= 3" in sql
    assert "id AS listing_id" in sql
    assert args["house_id"] == 42


def test_listings_query_dedups_by_signature():
    """Дедуп кросс-площадочных дублей: одна строка на сигнатуру (rooms, round(area_m2),
    floor) — та же, что в v_building_sale_share мигр.148."""
    sql, _ = build_listings_query(42)
    # DISTINCT ON по сигнатуре; round(area_m2), не raw area_m2 — как в мигр.148.
    assert "DISTINCT ON (a.rooms, round(a.area_m2), a.floor)" in sql
    # представитель — самый дешёвый: price_rub ASC NULLS LAST в лидирующем ORDER BY,
    # с теми же ведущими ключами, что и DISTINCT ON (требование DISTINCT ON).
    assert "ORDER BY a.rooms, round(a.area_m2), a.floor, a.price_rub ASC NULLS LAST, a.id" in sql
    # массив площадок, где видна квартира → колонка sources.
    assert "array_agg(DISTINCT source ORDER BY source) AS sources" in sql
    assert "s.sources" in sql
    # NULL-safe join по сигнатуре, чтобы NULL-компоненты не выпадали из дедупа.
    assert "s.sig_area IS NOT DISTINCT FROM round(a.area_m2)" in sql
    # внешняя сортировка панели — по цене (дешёвые сверху).
    assert "ORDER BY price_rub ASC NULLS LAST, listing_id" in sql


def test_listings_query_no_double_colon_cast():
    """psycopg v3: ни одного `:name::type` в дедуп-запросе — только CAST(...)."""
    import re

    sql, _ = build_listings_query(42)
    assert re.search(r":[a-z_]+::[a-z]", sql) is None


def test_summary_scalars_query_shape():
    sql = build_summary_scalars_query()
    assert "total_secondary_buildings" in sql
    # знаменатель/coverage — по ЛЮБОМУ из двух %
    assert "FILTER (WHERE sale_share_pct IS NOT NULL OR sale_share_pct_45d IS NOT NULL)" in sql
    assert "NULLIF(count(*), 0)" in sql
    assert "percentile_cont(0.95)" in sql
    # max/p95 для обоих процентов
    assert "max(sale_share_pct_45d) AS max_pct_45d" in sql
    assert "AS p95_pct_45d" in sql


def test_summary_histogram_column_whitelisted():
    """build_summary_histogram_query: только whitelist-колонка, чужое → fallback."""
    sql_now = build_summary_histogram_query("sale_share_pct")
    assert "WHERE sale_share_pct IS NOT NULL" in sql_now
    sql_45d = build_summary_histogram_query("sale_share_pct_45d")
    assert "WHEN sale_share_pct_45d < 5 THEN '0-5'" in sql_45d
    assert "WHERE sale_share_pct_45d IS NOT NULL" in sql_45d
    # инъекция в имя колонки не проходит — fallback на sale_share_pct
    sql_bad = build_summary_histogram_query("sale_share_pct; DROP TABLE houses")
    assert "DROP TABLE" not in sql_bad
    assert "WHERE sale_share_pct IS NOT NULL" in sql_bad


def test_new_sorts_whitelisted():
    """share_45d_desc / count_45d_desc добавлены в whitelist и дают корректный ORDER BY."""
    assert "share_45d_desc" in ALLOWED_SORTS
    assert "count_45d_desc" in ALLOWED_SORTS
    sql_share_45d, _ = build_sale_share_query(sort="share_45d_desc")
    assert "ORDER BY sale_share_pct_45d DESC NULLS LAST" in sql_share_45d
    sql_count_45d, _ = build_sale_share_query(sort="count_45d_desc")
    assert "ORDER BY listings_45d DESC NULLS LAST" in sql_count_45d


def test_summary_histogram_buckets_cover_spec():
    sql = build_summary_histogram_query()
    for boundary in ("'0-5'", "'5-10'", "'10-20'", "'20-30'", "'30-50'", "'50-100'", "'100+'"):
        assert boundary in sql
    assert HISTOGRAM_BUCKETS == ("0-5", "5-10", "10-20", "20-30", "30-50", "50-100", "100+")


# ── Contract (минимальный app, замоканный get_db) ─────────────────────────────


@pytest.fixture()
def buildings_app() -> FastAPI:
    """FastAPI mounting только buildings router (без RBAC middleware)."""
    from app.api.v1 import buildings as buildings_module

    application = FastAPI()
    application.include_router(buildings_module.router, prefix="/api/v1/buildings")
    return application


def _client_with_rows(app: FastAPI, *execute_results: object) -> TestClient:
    """Override get_db → mock, где db.execute даёт execute_results по порядку."""
    from app.core.db import get_db

    db = MagicMock()
    if len(execute_results) == 1:
        db.execute.return_value = execute_results[0]
    else:
        db.execute.side_effect = list(execute_results)

    def _override_db():
        yield db

    app.dependency_overrides[get_db] = _override_db
    return TestClient(app)


def _mapped(rows: object) -> MagicMock:
    m = MagicMock()
    m.mappings.return_value.all.return_value = rows
    m.mappings.return_value.one.return_value = rows
    return m


def test_get_sale_share_list_200(buildings_app: FastAPI):
    row = {
        "house_id": 7,
        "address": "ул. Ленина, 1",
        "lat": 56.8,
        "lon": 60.6,
        "sale_share_pct": 12.5,
        "sale_share_pct_45d": 16.7,
        "over_100": False,
        "active_secondary": 3,
        "listings_45d": 4,
        "flat_count_effective": 24,
        "gar_match_method": None,
        "median_price_rub": 5_000_000,
        "median_price_per_m2": 95_000,
        "avg_days_on_market": 41.0,
        "year_built": 1999,
        "house_type": "panel",
        "total_floors": 9,
        "series_name": None,
        "is_emergency": False,
    }
    client = _client_with_rows(buildings_app, _mapped([row]))
    resp = client.get("/api/v1/buildings/sale-share?min_pct=10")
    assert resp.status_code == 200
    body = resp.json()
    assert len(body) == 1
    assert body[0]["house_id"] == 7
    assert body[0]["over_100"] is False
    assert body[0]["sale_share_pct"] == 12.5
    assert body[0]["sale_share_pct_45d"] == 16.7
    assert body[0]["listings_45d"] == 4


def test_get_sale_share_churn_only_row(buildings_app: FastAPI):
    """Дом с 0 активных, но 45d-листингами проходит OR-фильтр и несёт оба поля."""
    row = {
        "house_id": 99,
        "address": "Удельная, 1/1",
        "lat": 56.9,
        "lon": 60.7,
        "sale_share_pct": 0.0,
        "sale_share_pct_45d": 22.7,
        "over_100": False,
        "active_secondary": 0,
        "listings_45d": 5,
        "flat_count_effective": 22,
        "gar_match_method": "canon",
        "median_price_rub": None,
        "median_price_per_m2": None,
        "avg_days_on_market": None,
        "year_built": 1975,
        "house_type": "brick",
        "total_floors": 9,
        "series_name": None,
        "is_emergency": False,
    }
    client = _client_with_rows(buildings_app, _mapped([row]))
    resp = client.get("/api/v1/buildings/sale-share?min_pct=10&sort=share_45d_desc")
    assert resp.status_code == 200
    body = resp.json()
    assert body[0]["house_id"] == 99
    assert body[0]["active_secondary"] == 0
    assert body[0]["sale_share_pct_45d"] == 22.7
    assert body[0]["listings_45d"] == 5


def test_get_sale_share_empty_when_no_gar(buildings_app: FastAPI):
    """ГАР не загружен → пустой список, но 200 (форма, не падение)."""
    client = _client_with_rows(buildings_app, _mapped([]))
    resp = client.get("/api/v1/buildings/sale-share")
    assert resp.status_code == 200
    assert resp.json() == []


def test_get_building_listings_200(buildings_app: FastAPI):
    row = {
        "listing_id": 101,
        "source": "avito",
        "source_url": "https://avito.ru/101",
        "price_rub": 4_900_000,
        "price_per_m2": 92_000,
        "rooms": 2,
        "area_m2": 53.2,
        "floor": 4,
        "total_floors": 9,
        "days_on_market": 30,
        "listing_date": "2026-05-01",
        "address": "ул. Ленина, 1",
    }
    client = _client_with_rows(buildings_app, _mapped([row]))
    resp = client.get("/api/v1/buildings/7/listings")
    assert resp.status_code == 200
    body = resp.json()
    assert body[0]["listing_id"] == 101
    assert body[0]["source"] == "avito"


def test_get_summary_zero_fills_histogram(buildings_app: FastAPI):
    scalars = _mapped(
        {
            "total_secondary_buildings": 10,
            "buildings_with_denominator": 4,
            "coverage_pct": 40.0,
            "max_pct": 95.0,
            "p95_pct": 33.3,
            "max_pct_45d": 98.0,
            "p95_pct_45d": 38.1,
        }
    )
    # handler делает 3 запроса: scalars, histogram(now), histogram(45d).
    hist = _mapped([{"bucket": "0-5", "count": 2}, {"bucket": "100+", "count": 1}])
    hist_45d = _mapped([{"bucket": "5-10", "count": 3}, {"bucket": "50-100", "count": 1}])
    client = _client_with_rows(buildings_app, scalars, hist, hist_45d)
    resp = client.get("/api/v1/buildings/sale-share/summary")
    assert resp.status_code == 200
    body = resp.json()
    assert body["total_secondary_buildings"] == 10
    assert body["buildings_with_denominator"] == 4
    assert body["coverage_pct"] == 40.0
    assert body["max_pct_45d"] == 98.0
    assert body["p95_pct_45d"] == 38.1
    # обе гистограммы: все 7 корзин, недостающие — count 0, порядок фиксирован
    assert [b["bucket"] for b in body["histogram"]] == list(HISTOGRAM_BUCKETS)
    assert [b["bucket"] for b in body["histogram_45d"]] == list(HISTOGRAM_BUCKETS)
    counts = {b["bucket"]: b["count"] for b in body["histogram"]}
    assert counts["0-5"] == 2
    assert counts["100+"] == 1
    assert counts["20-30"] == 0
    counts_45d = {b["bucket"]: b["count"] for b in body["histogram_45d"]}
    assert counts_45d["5-10"] == 3
    assert counts_45d["50-100"] == 1
    assert counts_45d["0-5"] == 0
