"""INDL-59 — Section-Capacity Guard for single-plot create/update.

Extends the INDL-55 bulk-grid capacity guard (``SectionService._grid_capacity``)
with a shared, section-scoped helper — ``SectionService.section_capacity`` —
used by both the bulk grid path (refactored, behaviour-preserving) and the
single-plot ``PlotService.create()`` / ``PlotService.update()`` paths (new).

Per-plot footprint rule ("Plot area"): a plot's drawn geometry area
(``ST_Area``) when it has one; otherwise its plot type's nominal area
(``default_width_m × default_length_m``); otherwise 0.
"""
from decimal import Decimal
from uuid import uuid4

import pytest
from geoalchemy2 import WKTElement
from httpx import AsyncClient
from shapely.geometry import mapping
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from src.apps.auth.models.user import User
from src.apps.plots.models.plot import Plot
from src.apps.plots.models.plot_type import PlotType
from src.apps.sections.models.section import Section
from src.apps.sections.services.section_service import SectionService
from src.apps.tenants.models.account import Account
from src.core.security import build_token_payload, create_access_token
from src.core.utils.geometry import build_plot_grid

pytestmark = pytest.mark.asyncio


# ── helpers (mirrors test_indl55_map_enhancements.py / test_indl58 conventions) ──

async def _make_account(db: AsyncSession) -> Account:
    uid = uuid4().hex[:8]
    acc = Account(
        organization_name=f"INDL59 {uid}",
        subdomain=f"i59-{uid}",
        contact_email=f"i59-{uid}@test.ca",
        plan="starter",
        status="active",
    )
    db.add(acc)
    await db.flush()
    return acc


async def _make_user(db: AsyncSession, account: Account, role: str = "administrator") -> str:
    user = User(
        tenant_id=account.id,
        email=f"{role}-{uuid4().hex[:6]}@test.ca",
        password_hash="x",
        first_name="T",
        last_name="U",
        role=role,
        status="active",
    )
    db.add(user)
    await db.flush()
    return create_access_token(build_token_payload(user, account))


def _headers(token: str, account: Account) -> dict:
    return {"Authorization": f"Bearer {token}", "X-Tenant-ID": str(account.id)}


async def _make_section(
    db: AsyncSession, account: Account, code="A", name="Section A", area_m2=None
) -> Section:
    sec = Section(
        tenant_id=account.id, code=code, name=name,
        plot_number_prefix=code, next_plot_seq=1,
        area_m2=Decimal(str(area_m2)) if area_m2 is not None else None,
    )
    db.add(sec)
    await db.flush()
    return sec


async def _make_plot_type(
    db: AsyncSession, account: Account, width_m=None, length_m=None, sections=None
) -> PlotType:
    pt = PlotType(
        tenant_id=account.id, name=f"Type {uuid4().hex[:4]}",
        default_width_m=width_m, default_length_m=length_m, default_depth_m=1.8,
        default_gap_m=0.3, default_price=1000, sections=sections or [],
    )
    db.add(pt)
    await db.flush()
    return pt


async def _make_plot(
    db: AsyncSession, account: Account, section: Section,
    plot_ref="A-1", plot_type_id=None, geometry_wkt=None, status="vacant",
) -> Plot:
    plot = Plot(
        tenant_id=account.id, plot_ref=plot_ref, section_id=section.id,
        plot_type_id=plot_type_id, status=status, price_override=100,
    )
    if geometry_wkt is not None:
        plot.geometry = WKTElement(geometry_wkt, srid=4326, extended=False)
    db.add(plot)
    await db.flush()
    return plot


def _square_polygon(width_m: float, length_m: float):
    """A single-cell shapely Polygon near lat 45.42N (same anchor used across
    the INDL-55/58 fixtures) — ``ST_Area`` of this polygon is, at this small
    scale, effectively ``width_m × length_m`` (matches the existing
    ``_grid_capacity`` unit test's ~28.1 m² for a 5.3×5.3 cell)."""
    return build_plot_grid(
        origin_lat=45.4215, origin_lng=-75.6972,
        rows=1, cols=1, plot_width_m=width_m, plot_length_m=length_m, gap_m=0.0,
    )[0]


def _square_wkt(width_m: float, length_m: float) -> str:
    return _square_polygon(width_m, length_m).wkt


def _square_geojson(width_m: float, length_m: float) -> dict:
    return mapping(_square_polygon(width_m, length_m))


# ── Shared helper in isolation: SectionService.section_capacity ─────────────

async def test_capacity_none_when_no_boundary(db_session):
    acc = await _make_account(db_session)
    sec = await _make_section(db_session, acc, area_m2=None)
    capacity = await SectionService(db_session).section_capacity(sec)
    assert capacity is None


async def test_capacity_full_available_with_no_plots(db_session):
    acc = await _make_account(db_session)
    sec = await _make_section(db_session, acc, area_m2=100.0)
    capacity = await SectionService(db_session).section_capacity(sec)
    assert capacity is not None
    assert capacity.total_m2 == 100.0
    assert capacity.allocated_m2 == 0.0
    assert capacity.available_m2 == 100.0


async def test_capacity_sums_geometry_and_plot_type_footprints(db_session):
    acc = await _make_account(db_session)
    sec = await _make_section(db_session, acc, area_m2=200.0)
    pt = await _make_plot_type(db_session, acc, width_m=2, length_m=3)  # 6 m² nominal
    await _make_plot(db_session, acc, sec, plot_ref="A-1",
                      geometry_wkt=_square_wkt(5, 5))  # ~25 m² drawn
    await _make_plot(db_session, acc, sec, plot_ref="A-2", plot_type_id=pt.id)  # 6 m² nominal
    # A plot with neither geometry nor plot type contributes 0.
    await _make_plot(db_session, acc, sec, plot_ref="A-3")

    capacity = await SectionService(db_session).section_capacity(sec)
    assert capacity is not None
    assert abs(capacity.allocated_m2 - 31.0) < 0.5
    assert abs(capacity.available_m2 - 169.0) < 0.5


async def test_capacity_over_allocated_reports_negative_available(db_session):
    """The helper itself never clamps — an already over-allocated section (e.g.
    legacy data) just reports a negative ``available_m2``; it's the callers
    (create/update) that turn "footprint > available" into a rejection."""
    acc = await _make_account(db_session)
    sec = await _make_section(db_session, acc, area_m2=5.0)
    pt = await _make_plot_type(db_session, acc, width_m=2, length_m=5)  # 10 m²
    await _make_plot(db_session, acc, sec, plot_ref="A-1", plot_type_id=pt.id)

    capacity = await SectionService(db_session).section_capacity(sec)
    assert capacity is not None
    assert capacity.allocated_m2 == 10.0
    assert capacity.available_m2 == -5.0


async def test_capacity_exclude_plot_id_self_exclusion(db_session):
    acc = await _make_account(db_session)
    sec = await _make_section(db_session, acc, area_m2=100.0)
    pt = await _make_plot_type(db_session, acc, width_m=2, length_m=5)  # 10 m² each
    plot1 = await _make_plot(db_session, acc, sec, plot_ref="A-1", plot_type_id=pt.id)
    await _make_plot(db_session, acc, sec, plot_ref="A-2", plot_type_id=pt.id)

    service = SectionService(db_session)
    capacity_all = await service.section_capacity(sec)
    assert capacity_all.allocated_m2 == 20.0

    capacity_excl = await service.section_capacity(sec, exclude_plot_id=plot1.id)
    assert capacity_excl.allocated_m2 == 10.0
    assert capacity_excl.available_m2 == 90.0


# ── create(): section-capacity guard ─────────────────────────────────────────

async def test_create_plot_within_capacity_succeeds(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)
    sec = await _make_section(db_session, acc, area_m2=100.0)
    pt = await _make_plot_type(db_session, acc, width_m=2, length_m=5, sections=[sec])  # 10 m²

    resp = await client.post(
        "/api/v1/plots",
        json={"plot_ref": "A-1", "section_id": str(sec.id),
              "plot_type_id": str(pt.id), "price_override": 100},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 201, resp.text


async def test_create_plot_over_capacity_rejected(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)
    sec = await _make_section(db_session, acc, area_m2=5.0)
    pt = await _make_plot_type(db_session, acc, width_m=2, length_m=5, sections=[sec])  # 10 m² > 5 m²

    resp = await client.post(
        "/api/v1/plots",
        json={"plot_ref": "A-1", "section_id": str(sec.id),
              "plot_type_id": str(pt.id), "price_override": 100},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 422, resp.text
    assert "available area" in resp.json()["message"].lower()

    total = (await db_session.execute(
        select(Plot).where(Plot.tenant_id == acc.id))).scalars().all()
    assert total == []


async def test_create_plot_no_boundary_skips_capacity_check(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)
    sec = await _make_section(db_session, acc, area_m2=None)  # no boundary drawn
    pt = await _make_plot_type(db_session, acc, width_m=500, length_m=500, sections=[sec])  # huge

    resp = await client.post(
        "/api/v1/plots",
        json={"plot_ref": "A-1", "section_id": str(sec.id),
              "plot_type_id": str(pt.id), "price_override": 100},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 201, resp.text


async def test_create_plot_accounts_for_existing_plots_in_section(
    client: AsyncClient, db_session,
):
    """Creating a second plot must be checked against what's LEFT after the
    first one's footprint — not the section's raw total (regression guard for
    the INDL-55 grid-only inline check this replaces)."""
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)
    sec = await _make_section(db_session, acc, area_m2=15.0)
    pt = await _make_plot_type(db_session, acc, width_m=2, length_m=5, sections=[sec])  # 10 m² each

    first = await client.post(
        "/api/v1/plots",
        json={"plot_ref": "A-1", "section_id": str(sec.id),
              "plot_type_id": str(pt.id), "price_override": 100},
        headers=_headers(token, acc),
    )
    assert first.status_code == 201, first.text

    # 5 m² left; a second 10 m² plot must be rejected.
    second = await client.post(
        "/api/v1/plots",
        json={"plot_ref": "A-2", "section_id": str(sec.id),
              "plot_type_id": str(pt.id), "price_override": 100},
        headers=_headers(token, acc),
    )
    assert second.status_code == 422, second.text


# ── update(): section-capacity guard ─────────────────────────────────────────

async def test_update_unrelated_field_never_triggers_capacity_check(
    client: AsyncClient, db_session,
):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)
    sec = await _make_section(db_session, acc, area_m2=10.0)
    pt = await _make_plot_type(db_session, acc, width_m=2, length_m=5)  # exactly fills section
    plot = await _make_plot(db_session, acc, sec, plot_ref="A-1", plot_type_id=pt.id)

    resp = await client.patch(
        f"/api/v1/plots/{plot.id}",
        json={"notes": "just a note"},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 200, resp.text


async def test_update_resize_excludes_own_prior_footprint(client: AsyncClient, db_session):
    """Regression guard against naive double counting: a plot being resized
    must not have its OWN prior footprint held against it."""
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)
    sec = await _make_section(db_session, acc, area_m2=10.0)
    pt = await _make_plot_type(db_session, acc, width_m=2, length_m=5)  # 10 m², fills section
    plot = await _make_plot(db_session, acc, sec, plot_ref="A-1", plot_type_id=pt.id)

    # New drawn geometry (~8 m²) fits once the plot's own prior 10 m² nominal
    # footprint is excluded from the section's allocated sum.
    resp = await client.patch(
        f"/api/v1/plots/{plot.id}",
        json={"geometry": _square_geojson(2, 4)},  # ~8 m²
        headers=_headers(token, acc),
    )
    assert resp.status_code == 200, resp.text


async def test_update_rejects_plot_type_change(client: AsyncClient, db_session):
    """plot_type_id is permanent once a plot exists — retyping via PATCH is no
    longer possible at all (independent of whether the new type would fit)."""
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)
    sec = await _make_section(db_session, acc, area_m2=10.0)
    small_pt = await _make_plot_type(db_session, acc, width_m=2, length_m=2.5)  # 5 m²
    big_pt = await _make_plot_type(db_session, acc, width_m=5, length_m=5, sections=[sec])  # 25 m² > 10 m²
    plot = await _make_plot(db_session, acc, sec, plot_ref="A-1", plot_type_id=small_pt.id)

    resp = await client.patch(
        f"/api/v1/plots/{plot.id}",
        json={"plot_type_id": str(big_pt.id)},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 422, resp.text
    assert "plot_type_id" in resp.json()["message"].lower()


async def test_update_rejects_section_change(client: AsyncClient, db_session):
    """section_id is permanent once a plot exists — moving a plot to another
    section via PATCH is no longer possible at all."""
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)
    sec_a = await _make_section(db_session, acc, code="A", name="Section A", area_m2=100.0)
    sec_b = await _make_section(db_session, acc, code="B", name="Section B", area_m2=100.0)
    pt = await _make_plot_type(db_session, acc, width_m=2, length_m=5, sections=[sec_a, sec_b])  # 10 m²
    plot = await _make_plot(db_session, acc, sec_a, plot_ref="A-1", plot_type_id=pt.id)

    resp = await client.patch(
        f"/api/v1/plots/{plot.id}",
        json={"section_id": str(sec_b.id)},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 422, resp.text
    assert "section_id" in resp.json()["message"].lower()


# ── Section detail response: allocated_m2 / available_m2 ────────────────────

async def test_section_detail_reports_allocated_and_available(
    client: AsyncClient, db_session,
):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)
    sec = await _make_section(db_session, acc, area_m2=100.0)
    pt = await _make_plot_type(db_session, acc, width_m=2, length_m=5)  # 10 m²
    await _make_plot(db_session, acc, sec, plot_ref="A-1", plot_type_id=pt.id)

    resp = await client.get(f"/api/v1/sections/{sec.id}", headers=_headers(token, acc))
    assert resp.status_code == 200, resp.text
    data = resp.json()["data"]
    assert float(data["allocated_m2"]) == 10.0
    assert float(data["available_m2"]) == 90.0


async def test_section_detail_reports_null_capacity_without_boundary(
    client: AsyncClient, db_session,
):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)
    sec = await _make_section(db_session, acc, area_m2=None)

    resp = await client.get(f"/api/v1/sections/{sec.id}", headers=_headers(token, acc))
    assert resp.status_code == 200, resp.text
    data = resp.json()["data"]
    assert data["allocated_m2"] is None
    assert data["available_m2"] is None


# ── Section LIST response: allocated_m2 / available_m2 (the AddPlotPage path) ──
# AddPlotPage's section dropdown is populated from GET /sections (list), not
# the single-section detail read above — the capacity fields must be exposed
# there too, via the batched SectionService.capacities_for_sections helper.

async def test_section_list_reports_allocated_and_available(
    client: AsyncClient, db_session,
):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)
    sec = await _make_section(db_session, acc, area_m2=100.0)
    pt = await _make_plot_type(db_session, acc, width_m=2, length_m=5)  # 10 m²
    await _make_plot(db_session, acc, sec, plot_ref="A-1", plot_type_id=pt.id)

    resp = await client.get("/api/v1/sections", headers=_headers(token, acc))
    assert resp.status_code == 200, resp.text
    row = next(s for s in resp.json()["data"] if s["id"] == str(sec.id))
    # area_m2 must serialize as a JSON number, not a string — the client
    # calls .toFixed() on it directly (Decimal fields left un-cast serialize
    # as strings under pydantic v2, unlike allocated_m2/available_m2 which
    # are already plain floats via SectionCapacity).
    assert isinstance(row["area_m2"], (int, float))
    assert float(row["allocated_m2"]) == 10.0
    assert float(row["available_m2"]) == 90.0


async def test_section_list_exclude_plot_id_self_exclusion(
    client: AsyncClient, db_session,
):
    """The Edit Plot form passes ?exclude_plot_id=<plot> so the plot being
    edited doesn't count its own current footprint against its own section."""
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)
    sec = await _make_section(db_session, acc, area_m2=100.0)
    pt = await _make_plot_type(db_session, acc, width_m=2, length_m=5)  # 10 m² each
    plot1 = await _make_plot(db_session, acc, sec, plot_ref="A-1", plot_type_id=pt.id)
    await _make_plot(db_session, acc, sec, plot_ref="A-2", plot_type_id=pt.id)

    resp = await client.get(
        "/api/v1/sections",
        params={"exclude_plot_id": str(plot1.id)},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 200, resp.text
    row = next(s for s in resp.json()["data"] if s["id"] == str(sec.id))
    assert float(row["allocated_m2"]) == 10.0
    assert float(row["available_m2"]) == 90.0


async def test_section_list_reports_null_capacity_without_boundary(
    client: AsyncClient, db_session,
):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)
    sec = await _make_section(db_session, acc, area_m2=None)

    resp = await client.get("/api/v1/sections", headers=_headers(token, acc))
    assert resp.status_code == 200, resp.text
    row = next(s for s in resp.json()["data"] if s["id"] == str(sec.id))
    assert row["allocated_m2"] is None
    assert row["available_m2"] is None
