"""Stage 1a tests — parcel parsing, setback buffer, placement grid.

Deterministic geometry: a known WGS84 rectangle near ЕКБ is parsed into metres, the
setback shrinks it, and the grid covers the buildable area. No network / no DB.
"""

from __future__ import annotations

import math

import pytest
from shapely.geometry import Polygon

from app.schemas.concept import ConceptInput
from app.services.generative import geometry
from app.services.generative.geometry import ParcelGeometryError, build_placement_grid

# ~450 m x 310 m rectangle near Екатеринбург (WGS84). Area ~ 0.86 ha.
_PARCEL_COORDS = [
    [60.60, 56.830],
    [60.6045, 56.830],
    [60.6045, 56.8328],
    [60.60, 56.8328],
    [60.60, 56.830],
]


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


def test_parse_parcel_reprojects_to_metres() -> None:
    parcel = geometry.parse_parcel(_payload())
    # Площадь в кв.м должна быть на масштабе квартала (десятки тысяч кв.м), не градусов.
    assert 50_000 < parcel.site_area_sqm < 150_000
    # Buildable меньше участка ровно за счёт отступа.
    assert parcel.buildable_area_sqm < parcel.site_area_sqm
    assert parcel.setback_m == geometry.DEFAULT_SETBACK_M


def test_setback_shrinks_area_by_expected_band() -> None:
    setback = 6.0
    parcel = geometry.parse_parcel(_payload(), setback_m=setback)
    # Грубая нижняя граница убыли: периметр * setback (внутренний буфер).
    perimeter = parcel.polygon_m.length
    expected_loss = perimeter * setback
    actual_loss = parcel.site_area_sqm - parcel.buildable_area_sqm
    # Внутренний буфер срезает примерно полосу шириной setback по периметру (±50%).
    assert 0.5 * expected_loss < actual_loss < 1.5 * expected_loss


def test_grid_cells_lie_inside_buildable() -> None:
    parcel = geometry.parse_parcel(_payload(), grid_step_m=6.0)
    assert len(parcel.grid) > 0
    for cell in parcel.grid:
        assert parcel.buildable_m.covers(cell.as_polygon().centroid)


def test_parse_is_deterministic() -> None:
    a = geometry.parse_parcel(_payload())
    b = geometry.parse_parcel(_payload())
    assert a.site_area_sqm == b.site_area_sqm
    assert a.buildable_area_sqm == b.buildable_area_sqm
    assert len(a.grid) == len(b.grid)
    assert [(c.cx, c.cy) for c in a.grid] == [(c.cx, c.cy) for c in b.grid]


def test_feature_geojson_is_accepted() -> None:
    # GeoJSON Feature (а не голая geometry) тоже парсится.
    payload = _payload(
        parcel_geojson={
            "type": "Feature",
            "properties": {},
            "geometry": {"type": "Polygon", "coordinates": [_PARCEL_COORDS]},
        }
    )
    parcel = geometry.parse_parcel(payload)
    assert parcel.site_area_sqm > 0


def test_tiny_parcel_rejected_after_setback() -> None:
    # ~16 m x 16 m: отступ 6 м с каждой стороны схлопывает пятно застройки.
    tiny = [
        [60.60, 56.830],
        [60.60015, 56.830],
        [60.60015, 56.83015],
        [60.60, 56.83015],
        [60.60, 56.830],
    ]
    payload = _payload(parcel_geojson={"type": "Polygon", "coordinates": [tiny]})
    with pytest.raises(ParcelGeometryError):
        geometry.parse_parcel(payload)


def test_non_polygon_rejected() -> None:
    payload = _payload(parcel_geojson={"type": "Point", "coordinates": [60.60, 56.83]})
    with pytest.raises(ParcelGeometryError):
        geometry.parse_parcel(payload)


def test_linestring_rejected() -> None:
    # LineString не имеет площади — прежняя ошибка сохраняется.
    payload = _payload(
        parcel_geojson={
            "type": "LineString",
            "coordinates": [[60.60, 56.830], [60.6045, 56.8328]],
        }
    )
    with pytest.raises(ParcelGeometryError):
        geometry.parse_parcel(payload)


def test_projected_coordinates_rejected_with_clear_message() -> None:
    # Регресс эпик #2445 B1: координаты в проекции (МСК-66/UTM-like метры, напр.
    # [500000, 6200000]) вместо WGS84 lon/lat — синтаксически валидный Polygon, но
    # centroid.y (~6_200_000) далеко за пределами ±90° -> раньше падало неперехваченным
    # pyproj.exceptions.CRSError внутри _metric_transformers (opaque 500). Теперь
    # ловится ДО построения проекции с понятным ParcelGeometryError (-> 422).
    projected = [
        [500_000.0, 6_200_000.0],
        [500_450.0, 6_200_000.0],
        [500_450.0, 6_200_310.0],
        [500_000.0, 6_200_310.0],
        [500_000.0, 6_200_000.0],
    ]
    payload = _payload(parcel_geojson={"type": "Polygon", "coordinates": [projected]})
    with pytest.raises(ParcelGeometryError, match="WGS84"):
        geometry.parse_parcel(payload)


# Второй, заведомо меньший контур (~90 m x 45 m) поодаль от основного участка.
_SMALL_CONTOUR = [
    [60.610, 56.840],
    [60.6112, 56.840],
    [60.6112, 56.8404],
    [60.610, 56.8404],
    [60.610, 56.840],
]


def test_multipolygon_picks_largest_contour() -> None:
    # Многоконтурный участок: основной прямоугольник + мелкое вкрапление поодаль.
    # Берётся крупнейший контур → площадь == площади одиночного основного полигона.
    multi = _payload(
        parcel_geojson={
            "type": "MultiPolygon",
            "coordinates": [[_PARCEL_COORDS], [_SMALL_CONTOUR]],
        }
    )
    single = _payload(parcel_geojson={"type": "Polygon", "coordinates": [_PARCEL_COORDS]})
    parcel_multi = geometry.parse_parcel(multi)
    parcel_single = geometry.parse_parcel(single)
    # Результат многоконтурного == основному контуру (крупнейший выбран).
    assert math.isclose(parcel_multi.site_area_sqm, parcel_single.site_area_sqm, rel_tol=1e-6)
    # Крупнейший контур на масштабе квартала, мелкое вкрапление отброшено.
    assert parcel_multi.site_area_sqm > 50_000


def test_multipolygon_single_contour_equals_polygon() -> None:
    # MultiPolygon с одним контуром эквивалентен голому Polygon.
    multi = _payload(
        parcel_geojson={
            "type": "MultiPolygon",
            "coordinates": [[_PARCEL_COORDS]],
        }
    )
    single = _payload(parcel_geojson={"type": "Polygon", "coordinates": [_PARCEL_COORDS]})
    parcel_multi = geometry.parse_parcel(multi)
    parcel_single = geometry.parse_parcel(single)
    assert math.isclose(parcel_multi.site_area_sqm, parcel_single.site_area_sqm, rel_tol=1e-9)
    assert math.isclose(
        parcel_multi.buildable_area_sqm,
        parcel_single.buildable_area_sqm,
        rel_tol=1e-9,
    )


def test_empty_multipolygon_rejected() -> None:
    payload = _payload(parcel_geojson={"type": "MultiPolygon", "coordinates": []})
    with pytest.raises(ParcelGeometryError):
        geometry.parse_parcel(payload)


def test_build_placement_grid_anchors_are_step_aligned() -> None:
    # Простой метрический квадрат 30x30 м, шаг 10 -> 3x3 = 9 ячеек.
    square = Polygon([(0, 0), (30, 0), (30, 30), (0, 30)])
    cells = build_placement_grid(square, 10.0)
    assert len(cells) == 9
    # Центры на полушаге от кратных шагу.
    for cell in cells:
        assert math.isclose((cell.cx - 5.0) % 10.0, 0.0, abs_tol=1e-6)
        assert math.isclose((cell.cy - 5.0) % 10.0, 0.0, abs_tol=1e-6)
