"""Stage 3a tests (#1965) — program-driven placement (типовые дома каталога).

Covers the three core guarantees of the ``building_program`` path:

(a) ``building_program=None`` reproduces today's greedy output unchanged (backward-compat).
(b) A 2-item program places EXACTLY the requested sections (when they fit) and the TEAP
    reflects them (GFA = Σ(площадь_i × floors_i), apartments tied to placed footprints).
(c) An over-packed program reports partial placement (placed < requested) WITHOUT raising.

Plus structural checks: program footprints respect the collision/setback machinery, the
per-feature GeoJSON carries the section's own floors + catalog type, and an empty-fit
program is rejected (degenerate parcel) rather than returning a lying zero variant.
"""

from __future__ import annotations

import pytest
from shapely.geometry import shape

from app.schemas.concept import BuildingProgramItem, ConceptInput
from app.services.generative import catalog, geometry, placement
from app.services.generative.geometry import ParcelGeometryError

# Крупный квартальный участок (~7.9 га buildable) — вмещает десятки секций.
_BIG_PARCEL = [
    [60.60, 56.830],
    [60.6045, 56.830],
    [60.6045, 56.8328],
    [60.60, 56.8328],
    [60.60, 56.830],
]

# Маленький участок (~3.8 тыс. кв.м buildable) — вмещает лишь ~2 крупные секции.
_SMALL_PARCEL = [
    [60.600, 56.8300],
    [60.6010, 56.8300],
    [60.6010, 56.8308],
    [60.600, 56.8308],
    [60.600, 56.8300],
]


def _payload(coords: list[list[float]], **overrides: object) -> ConceptInput:
    base: dict[str, object] = {
        "parcel_geojson": {"type": "Polygon", "coordinates": [coords]},
        "housing_class": "comfort",
        "target_floors": 9,
        "development_type": "mid_rise",
    }
    base.update(overrides)
    return ConceptInput(**base)  # type: ignore[arg-type]


# ── (a) backward-compat: greedy output is byte-identical after the _Placer refactor ──

# GOLDEN PIN — детерминированный жадный выход на фиксированном участке _BIG_PARCEL
# (comfort / 9 эт. / mid_rise). Захвачен ПОСЛЕ извлечения _Placer и сверен ревьювером
# как идентичный до-рефактора. Любой будущий ДРЕЙФ жадной геометрии (число секций /
# пятно / GFA / квартиры) уронит этот тест — это и есть anti-regression guard, которого
# не давали инвариантные тесты в test_placement.py. Формат на стратегию:
#   (features, built_area_sqm, total_floor_area_sqm, apartments_count).
_GREEDY_GOLDEN: dict[str, tuple[int, float, float, int]] = {
    "max_area": (83, 35856.0, 358560.0, 4830),
    "max_insolation": (70, 15120.0, 120960.0, 1629),
    "balanced": (88, 27720.0, 249480.0, 3361),
}


def test_greedy_output_matches_golden_pin() -> None:
    # Реальный golden-pin: жадный путь (building_program не задан) должен выдать РОВНО
    # замороженные литералы. Так фиксируем, что _Placer-рефактор не сдвинул раскладку.
    payload = _payload(_BIG_PARCEL)
    parcel = geometry.parse_parcel(payload)
    variants = {v.strategy: v for v in placement.place_all_strategies(parcel, payload)}
    assert set(variants) == set(_GREEDY_GOLDEN)
    for name, (features, built, gfa, apts) in _GREEDY_GOLDEN.items():
        v = variants[name]
        assert len(v.buildings_geojson["features"]) == features, name
        assert round(v.teap.built_area_sqm, 1) == built, name
        assert round(v.teap.total_floor_area_sqm, 1) == gfa, name
        assert v.teap.apartments_count == apts, name
        # Greedy-режим не выставляет partial-fit сигнал (остаётся None).
        assert v.placed_count is None
        assert v.requested_count is None


def test_explicit_none_program_equals_default_greedy() -> None:
    # building_program=None (явно) и дефолт (поле опущено) идут одним greedy-путём →
    # должны совпасть покомпонентно. Дополняет golden-pin: ветка None не разветвляется.
    payload_default = _payload(_BIG_PARCEL)
    payload_none = _payload(_BIG_PARCEL, building_program=None)
    variants_default = placement.place_all_strategies(
        geometry.parse_parcel(payload_default), payload_default
    )
    variants_none = placement.place_all_strategies(
        geometry.parse_parcel(payload_none), payload_none
    )
    by_default = {v.strategy: v for v in variants_default}
    by_none = {v.strategy: v for v in variants_none}
    assert set(by_default) == set(by_none)
    for name, dv in by_default.items():
        nv = by_none[name]
        assert dv.teap == nv.teap
        assert dv.financial == nv.financial
        assert len(dv.buildings_geojson["features"]) == len(nv.buildings_geojson["features"])


# ── (b) 2-item program places EXACTLY the requested sections; TEAP reflects them ─────


def test_two_item_program_places_exactly_requested() -> None:
    program = [
        BuildingProgramItem(section_type="monolith_comfort", floors=14, count=3),
        BuildingProgramItem(section_type="tower_business", floors=20, count=2),
    ]
    payload = _payload(_BIG_PARCEL, building_program=program)
    parcel = geometry.parse_parcel(payload)
    placed = placement.place_program(parcel, program)

    # Все 5 секций влезли в крупный участок — разместилось ровно столько, сколько просили.
    assert placed.requested_count == 5
    assert placed.placed_count == 5
    assert len(placed.sections) == 5
    # Каждая секция несёт свой тип/этажность из программы.
    monolith = [s for s in placed.sections if s.section_type == "monolith_comfort"]
    tower = [s for s in placed.sections if s.section_type == "tower_business"]
    assert len(monolith) == 3 and all(s.floors == 14 for s in monolith)
    assert len(tower) == 2 and all(s.floors == 20 for s in tower)


def test_two_item_program_variant_teap_reflects_program() -> None:
    program = [
        BuildingProgramItem(section_type="monolith_comfort", floors=14, count=3),
        BuildingProgramItem(section_type="tower_business", floors=20, count=2),
    ]
    payload = _payload(_BIG_PARCEL, building_program=program)
    parcel = geometry.parse_parcel(payload)
    variant = placement.place_program_variant(parcel, payload)
    assert variant is not None

    # Partial-fit сигнал заполнен (program-режим): разместилось 5 из 5.
    assert variant.placed_count == 5
    assert variant.requested_count == 5

    # ТЭП отражает именно размещённую программу:
    #   built = Σ площадей пятен каталога (3×monolith 21×18 + 2×tower 18×18).
    mono = catalog.get_house_type("monolith_comfort")
    tower = catalog.get_house_type("tower_business")
    expected_built = 3 * mono.footprint_sqm + 2 * tower.footprint_sqm
    assert variant.teap.built_area_sqm == pytest.approx(expected_built, abs=1.0)

    #   GFA = Σ(площадь_i × floors_i) — ТОЧНО (per-floor-group аггрегация, без дрейфа
    #   от округления «средней» этажности).
    expected_gfa = 3 * mono.footprint_sqm * 14 + 2 * tower.footprint_sqm * 20
    assert variant.teap.total_floor_area_sqm == pytest.approx(expected_gfa, abs=1.0)

    assert variant.teap.apartments_count > 0
    assert variant.financial.revenue_rub > 0
    # Один program-вариант (не три жадные стратегии) идёт через place_all_strategies.
    variants = placement.place_all_strategies(parcel, payload)
    assert len(variants) == 1
    assert variants[0].placed_count == 5


def test_program_geojson_features_carry_own_floors_and_type() -> None:
    program = [
        BuildingProgramItem(section_type="monolith_comfort", floors=14, count=2),
        BuildingProgramItem(section_type="townhouse", floors=3, count=2),
    ]
    payload = _payload(_BIG_PARCEL, building_program=program)
    parcel = geometry.parse_parcel(payload)
    variant = placement.place_program_variant(parcel, payload)
    assert variant is not None
    features = variant.buildings_geojson["features"]
    assert isinstance(features, list)
    floors_seen = set()
    types_seen = set()
    for feat in features:
        geom = shape(feat["geometry"])
        assert geom.geom_type == "Polygon" and geom.is_valid
        props = feat["properties"]
        assert props["strategy"] == "program"
        assert props["section_type"] in {"monolith_comfort", "townhouse"}
        floors_seen.add(props["floors"])
        types_seen.add(props["section_type"])
    # Смешанная этажность/типы сохранены пер-секционно в GeoJSON.
    assert floors_seen == {14, 3}
    assert types_seen == {"monolith_comfort", "townhouse"}


def test_program_footprints_non_overlapping_inside_buildable() -> None:
    program = [BuildingProgramItem(section_type="monolith_comfort", floors=14, count=6)]
    payload = _payload(_BIG_PARCEL, building_program=program)
    parcel = geometry.parse_parcel(payload)
    placed = placement.place_program(parcel, program)
    fps = [s.footprint for s in placed.sections]
    assert len(fps) > 1
    for fp in fps:
        assert parcel.buildable_m.buffer(0.01).covers(fp)
    for i, a in enumerate(fps):
        for b in fps[i + 1 :]:
            assert not a.buffer(-0.01).intersects(b.buffer(-0.01))


# ── (c) over-packed program reports partial placement WITHOUT raising ───────────────


def test_overpacked_program_reports_partial_without_raising() -> None:
    # Маленький участок вмещает ~2 крупные секции, просим 20 — НЕ 422, честный N<M.
    program = [BuildingProgramItem(section_type="monolith_comfort", floors=14, count=20)]
    payload = _payload(_SMALL_PARCEL, building_program=program)
    parcel = geometry.parse_parcel(payload)
    placed = placement.place_program(parcel, program)
    assert placed.requested_count == 20
    assert 0 < placed.placed_count < 20  # разместилось N из M

    # И через полный конвейер: вариант строится, partial-fit сигнал проброшен, без raise.
    variants = placement.place_all_strategies(parcel, payload)
    assert len(variants) == 1
    v = variants[0]
    assert v.requested_count == 20
    assert 0 < v.placed_count < 20
    assert v.teap.apartments_count > 0


def test_program_with_zero_fit_raises_parcel_error() -> None:
    # Участок настолько мал, что НИ ОДНА секция программы не влезает → ParcelGeometryError
    # (API мапит в 422) — лучше отказ, чем лживый нулевой вариант.
    tiny = [
        [60.60, 56.830],
        [60.60015, 56.830],
        [60.60015, 56.83015],
        [60.60, 56.83015],
        [60.60, 56.830],
    ]
    program = [BuildingProgramItem(section_type="tower_business", floors=25, count=5)]
    payload = _payload(tiny, building_program=program)
    with pytest.raises(ParcelGeometryError):
        parcel = geometry.parse_parcel(payload)
        placement.place_all_strategies(parcel, payload)
