"""INDL-55 — Cemetery Map Enhancements (backend coverage).

Covers the service/schema-layer enhancements introduced by INDL-55 over the
shipped INDL-41/42/49/51 map surfaces:

- E-01  Boundary clear preserves dependent sections/plots (no cascade); invalid
        boundary geometry is rejected and leaves the prior boundary intact.
- E-05  Section boundary must be contained within the cemetery boundary
        (ST_CoveredBy); allowed when no cemetery boundary exists yet (BR-04).
- E-07  Grid autogenerate is capped by the section's physical capacity; a
        preview/dry-run reports max_plots_fit without creating anything.
- E-13/E-15  Price and Plot Type are mandatory when creating a plot.
"""
from uuid import uuid4

import pytest
from httpx import AsyncClient
from sqlalchemy import func, select, text
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.tenants.models.account import Account
from src.core.security import build_token_payload, create_access_token

pytestmark = pytest.mark.asyncio

# A large cemetery-grounds polygon near Ottawa (lng, lat) — ~780 m × ~555 m.
BIG_BOUNDARY = {
    "type": "Polygon",
    "coordinates": [[
        [-75.7000, 45.4200],
        [-75.6900, 45.4200],
        [-75.6900, 45.4250],
        [-75.7000, 45.4250],
        [-75.7000, 45.4200],
    ]],
}

# A small section polygon that lies fully inside BIG_BOUNDARY.
INSIDE_SECTION = {
    "type": "Polygon",
    "coordinates": [[
        [-75.6970, 45.4210],
        [-75.6968, 45.4210],
        [-75.6968, 45.4212],
        [-75.6970, 45.4212],
        [-75.6970, 45.4210],
    ]],
}

# A section polygon far outside BIG_BOUNDARY.
OUTSIDE_SECTION = {
    "type": "Polygon",
    "coordinates": [[
        [-75.6000, 45.5000],
        [-75.5998, 45.5000],
        [-75.5998, 45.5002],
        [-75.6000, 45.5002],
        [-75.6000, 45.5000],
    ]],
}

# A right-triangle section (~15 m × 22 m legs, ~175 m²). A rectangular grid
# centred on its centroid necessarily overhangs the sloped hypotenuse, so the
# containment clip must drop the cells that fall outside (INDL-55 / section F).
TRIANGLE_SECTION = {
    "type": "Polygon",
    "coordinates": [[
        [-75.6972, 45.4215],  # SW (right angle)
        [-75.6970, 45.4215],  # SE
        [-75.6972, 45.4217],  # NW
        [-75.6972, 45.4215],
    ]],
}

# ~15 m × 22 m closed polygon (~349 m²) — used to give a section a measured area.
SMALL_POLYGON = {
    "type": "Polygon",
    "coordinates": [[
        [-75.6972, 45.4215],
        [-75.6970, 45.4215],
        [-75.6970, 45.4217],
        [-75.6972, 45.4217],
        [-75.6972, 45.4215],
    ]],
}


# ── helpers ─────────────────────────────────────────────────────────────────

async def _make_account(db: AsyncSession) -> Account:
    uid = uuid4().hex[:8]
    acc = Account(
        organization_name=f"INDL55 {uid}",
        subdomain=f"i55-{uid}",
        contact_email=f"i55-{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") -> Section:
    sec = Section(
        tenant_id=account.id, code=code, name=name,
        plot_number_prefix=code, next_plot_seq=1,
    )
    db.add(sec)
    await db.flush()
    return sec


async def _make_plot_type(
    db: AsyncSession, account: Account, sections: list = None, *,
    width: float = 1.2, length: float = 2.4, gap: float = 0.3, name: str = "Standard single",
) -> PlotType:
    pt = PlotType(
        tenant_id=account.id, name=name,
        default_width_m=width, default_length_m=length, default_depth_m=1.8,
        default_gap_m=gap, default_price=4200, sections=sections or [],
    )
    db.add(pt)
    await db.flush()
    return pt


async def _set_boundary(client, acc, token, geojson) -> None:
    resp = await client.patch(
        "/api/v1/accounts/me/map-config",
        json={"boundary": geojson}, headers=_headers(token, acc),
    )
    assert resp.status_code == 200, resp.text


# ── E-01: boundary clear preserves dependent data (AC-02 / BR-01 / R-07) ──────

async def test_clear_boundary_preserves_sections_and_plots(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    await _set_boundary(client, acc, token, BIG_BOUNDARY)
    sec = await _make_section(db_session, acc)
    pt = await _make_plot_type(db_session, acc)
    plot = Plot(tenant_id=acc.id, plot_ref="A-1", section_id=sec.id,
                plot_type_id=pt.id, status="vacant")
    db_session.add(plot)
    await db_session.flush()

    # Clear the cemetery boundary.
    resp = await client.patch("/api/v1/accounts/me/map-config",
                              json={"boundary": None}, headers=_headers(token, acc))
    assert resp.status_code == 200
    assert resp.json()["data"]["boundary"] is None
    assert resp.json()["data"]["total_area_m2"] is None

    # Section + plot are untouched (no cascade).
    sec_count = (await db_session.execute(
        select(func.count(Section.id)).where(Section.tenant_id == acc.id))).scalar_one()
    plot_count = (await db_session.execute(
        select(func.count(Plot.id)).where(Plot.tenant_id == acc.id))).scalar_one()
    assert sec_count == 1
    assert plot_count == 1


async def test_invalid_boundary_leaves_prior_intact(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    await _set_boundary(client, acc, token, BIG_BOUNDARY)

    bad = {"type": "Polygon", "coordinates": [[[0, 0], [1, 1]]]}  # too few points
    resp = await client.patch("/api/v1/accounts/me/map-config",
                              json={"boundary": bad}, headers=_headers(token, acc))
    assert resp.status_code == 422

    # Prior boundary is still present and measurable.
    fresh = await client.get("/api/v1/settings/cemetery-map", headers=_headers(token, acc))
    data = fresh.json()["data"]
    assert data["boundary"] is not None
    assert data["total_area_m2"] and float(data["total_area_m2"]) > 0


# ── E-05: section-in-boundary containment (AC-10/AC-11/AC-12, BR-03/BR-04) ─────

async def test_section_inside_boundary_allowed(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    await _set_boundary(client, acc, token, BIG_BOUNDARY)
    resp = await client.post(
        "/api/v1/sections",
        json={"code": "A", "name": "Inside", "boundary": INSIDE_SECTION},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 201, resp.text
    assert resp.json()["data"]["boundary"] is not None


async def test_section_outside_boundary_rejected(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    await _set_boundary(client, acc, token, BIG_BOUNDARY)
    resp = await client.post(
        "/api/v1/sections",
        json={"code": "B", "name": "Outside", "boundary": OUTSIDE_SECTION},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 422
    assert "inside the cemetery boundary" in resp.json()["message"].lower()


async def test_section_update_boundary_outside_rejected(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    await _set_boundary(client, acc, token, BIG_BOUNDARY)
    sec = await _make_section(db_session, acc)
    resp = await client.patch(
        f"/api/v1/sections/{sec.id}",
        json={"boundary": OUTSIDE_SECTION}, headers=_headers(token, acc),
    )
    assert resp.status_code == 422
    assert "inside the cemetery boundary" in resp.json()["message"].lower()


async def test_section_rejected_when_no_cemetery_boundary(client: AsyncClient, db_session):
    """Cemetery-map settings rule (supersedes the old BR-04 allow-with-warning):
    a section can't be created — even a boundary-less one — until the cemetery's
    own boundary has been drawn."""
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "manager")
    resp = await client.post(
        "/api/v1/sections",
        json={"code": "A", "name": "No boundary yet", "boundary": OUTSIDE_SECTION},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 422
    assert "cemetery boundary" in resp.json()["message"].lower()

    resp2 = await client.post(
        "/api/v1/sections",
        json={"code": "A", "name": "No boundary yet"},  # no boundary at all
        headers=_headers(token, acc),
    )
    assert resp2.status_code == 422


# ── E-07: grid capacity cap + preview (AC-16a, BR-07a, T-13a) ──────────────────

async def _section_with_area(client, acc, token, db_session):
    """Create a section and give it a boundary (~349 m²) so area_m2 is measured."""
    sec = await _make_section(db_session, acc)
    resp = await client.patch(f"/api/v1/sections/{sec.id}",
                              json={"boundary": SMALL_POLYGON}, headers=_headers(token, acc))
    assert resp.status_code == 200, resp.text
    return sec


async def test_grid_preview_reports_max_plots_fit(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    sec = await _section_with_area(client, acc, token, db_session)
    # 5 m × 5 m plots (+0.3 gap) → cell ≈ 28 m² → ~12 fit in ~349 m².
    pt = await _make_plot_type(db_session, acc, [sec], width=5, length=5)
    resp = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 5, "columns": 4, "plot_type_id": str(pt.id),
              "preview": True},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 200, resp.text
    data = resp.json()["data"]
    assert data["preview"] is True
    assert data["requested"] == 20
    assert data["max_plots_fit"] is not None and data["max_plots_fit"] < 20
    assert data["fits"] is False
    # Nothing was created.
    plots = (await db_session.execute(
        select(func.count(Plot.id)).where(Plot.section_id == sec.id))).scalar_one()
    assert plots == 0


async def test_grid_exceeds_capacity_rejected(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    sec = await _section_with_area(client, acc, token, db_session)
    pt = await _make_plot_type(db_session, acc, [sec], width=5, length=5)
    resp = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 5, "columns": 4, "plot_type_id": str(pt.id)},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 422
    assert "capacity" in resp.json()["message"].lower()
    # No plots created (rejected before commit).
    plots = (await db_session.execute(
        select(func.count(Plot.id)).where(Plot.section_id == sec.id))).scalar_one()
    assert plots == 0


async def test_grid_within_capacity_creates_plots(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    sec = await _section_with_area(client, acc, token, db_session)
    # 1 m × 1 m plots → cell ≈ 1.7 m² → ~200 fit; 5×4=20 is well within.
    pt = await _make_plot_type(db_session, acc, [sec], width=1, length=1)
    resp = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 5, "columns": 4, "plot_type_id": str(pt.id)},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 201, resp.text
    assert resp.json()["data"]["created"] == 20


async def test_grid_plots_land_inside_section_boundary(client: AsyncClient, db_session):
    """INDL-55 bug fix: with no explicit origin the grid anchors at the section
    centroid and must be CENTRED there, so every generated plot's centroid lies
    inside the section boundary instead of spilling off the north/east edges."""
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    sec = await _section_with_area(client, acc, token, db_session)
    pt = await _make_plot_type(db_session, acc, [sec], width=1, length=1)
    resp = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 5, "columns": 4, "plot_type_id": str(pt.id)},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 201, resp.text
    assert resp.json()["data"]["created"] == 20

    # Every plot centroid must be covered by the section boundary (PostGIS).
    outside = (await db_session.execute(
        text(
            "SELECT count(*) FROM plots p JOIN sections s ON s.id = p.section_id "
            "WHERE p.section_id = :sid AND s.boundary IS NOT NULL AND NOT "
            "ST_Covers(s.boundary::geometry, "
            "          ST_SetSRID(ST_MakePoint(p.longitude, p.latitude), 4326))"
        ),
        {"sid": str(sec.id)},
    )).scalar_one()
    assert outside == 0, f"{outside} generated plots fell outside the section boundary"


async def test_grid_clips_cells_outside_triangular_section(client: AsyncClient, db_session):
    """INDL-55 (section F): for a non-rectangular boundary the centred grid still
    overhangs the sloped edges, so the containment clip drops the outside cells —
    fewer plots are created than requested, and every created plot lies inside."""
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    sec = await _make_section(db_session, acc, code="F", name="Section F")
    resp = await client.patch(f"/api/v1/sections/{sec.id}",
                              json={"boundary": TRIANGLE_SECTION}, headers=_headers(token, acc))
    assert resp.status_code == 200, resp.text

    pt = await _make_plot_type(db_session, acc, [sec], width=1, length=1)
    requested = 12 * 8
    resp = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 12, "columns": 8, "plot_type_id": str(pt.id)},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 201, resp.text
    data = resp.json()["data"]
    # Some cells were clipped away — fewer created than the full rectangle.
    assert 0 < data["created"] < requested
    assert data["requested"] == requested

    outside = (await db_session.execute(
        text(
            "SELECT count(*) FROM plots p JOIN sections s ON s.id = p.section_id "
            "WHERE p.section_id = :sid AND s.boundary IS NOT NULL AND NOT "
            "ST_Covers(s.boundary::geometry, "
            "          ST_SetSRID(ST_MakePoint(p.longitude, p.latitude), 4326))"
        ),
        {"sid": str(sec.id)},
    )).scalar_one()
    assert outside == 0, f"{outside} generated plots fell outside the triangular section"


async def test_grid_no_area_skips_capacity_cap(client: AsyncClient, db_session):
    """Section with no boundary has no measured area → no capacity cap (unchanged
    INDL-49 behaviour); generation still succeeds with a supplied origin."""
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    sec = await _make_section(db_session, acc)
    pt = await _make_plot_type(db_session, acc, [sec], width=5, length=5)
    resp = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 5, "columns": 4, "plot_type_id": str(pt.id),
              "origin_lat": 45.4215, "origin_lng": -75.6972},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 201, resp.text
    assert resp.json()["data"]["created"] == 20


# ── Bugfix: regenerating a grid extends the existing lattice, never drifts ────

async def test_regenerate_grid_extends_existing_lattice(client: AsyncClient, db_session):
    """A second "Generate grid" call on a section that already has plots must
    land on the SAME lattice as the first call, not a differently-centred one
    (the reported "plots scattered instead of a clean grid" bug)."""
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    sec = await _make_section(db_session, acc)
    pt = await _make_plot_type(db_session, acc, [sec], width=1, length=1)

    first = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 2, "columns": 2, "plot_type_id": str(pt.id),
              "origin_lat": 45.4215, "origin_lng": -75.6972},
        headers=_headers(token, acc),
    )
    assert first.status_code == 201, first.text
    assert first.json()["data"]["created"] == 4

    # Second call: bigger grid, no origin — must anchor onto the existing 2×2
    # lattice rather than recentring on the section/account centroid.
    second = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 4, "columns": 4, "plot_type_id": str(pt.id)},
        headers=_headers(token, acc),
    )
    assert second.status_code == 201, second.text
    # 16 cells requested, 4 already exist → 12 new ones.
    assert second.json()["data"]["created"] == 12

    rows = (await db_session.execute(
        select(Plot.latitude, Plot.longitude).where(Plot.section_id == sec.id)
    )).all()
    assert len(rows) == 16

    # A clean 4×4 grid has exactly 4 distinct latitudes and 4 distinct longitudes,
    # and every (lat, lng) pair is unique — no overlaps, no stray offsets.
    lats = {round(float(r[0]), 6) for r in rows}
    lngs = {round(float(r[1]), 6) for r in rows}
    pairs = {(round(float(r[0]), 6), round(float(r[1]), 6)) for r in rows}
    assert len(lats) == 4
    assert len(lngs) == 4
    assert len(pairs) == 16


async def test_regenerate_grid_skips_cells_that_already_exist(client: AsyncClient, db_session):
    """Requesting exactly the same grid twice must not duplicate/overlap plots —
    every cell already occupied is skipped."""
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    sec = await _make_section(db_session, acc)
    pt = await _make_plot_type(db_session, acc, [sec], width=1, length=1)

    payload = {"rows": 2, "columns": 2, "plot_type_id": str(pt.id),
               "origin_lat": 45.4215, "origin_lng": -75.6972}
    first = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate", json=payload,
        headers=_headers(token, acc),
    )
    assert first.status_code == 201, first.text
    assert first.json()["data"]["created"] == 4

    second = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate", json=payload,
        headers=_headers(token, acc),
    )
    assert second.status_code == 201, second.text
    assert second.json()["data"]["created"] == 0

    total = (await db_session.execute(
        select(func.count(Plot.id)).where(Plot.section_id == sec.id))).scalar_one()
    assert total == 4


async def test_regenerate_grid_rejects_dimension_mismatch(client: AsyncClient, db_session):
    """A second call with a different plot size than what's already on the
    ground is rejected (409) rather than silently producing a second,
    differently-spaced sub-grid."""
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    sec = await _make_section(db_session, acc)
    pt1 = await _make_plot_type(db_session, acc, [sec], width=1, length=1, name="Type 1")
    pt2 = await _make_plot_type(db_session, acc, [sec], width=2.5, length=2.5, name="Type 2")

    first = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 2, "columns": 2, "plot_type_id": str(pt1.id),
              "origin_lat": 45.4215, "origin_lng": -75.6972},
        headers=_headers(token, acc),
    )
    assert first.status_code == 201, first.text

    mismatched = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 2, "columns": 2, "plot_type_id": str(pt2.id)},
        headers=_headers(token, acc),
    )
    assert mismatched.status_code == 409
    assert "already has plots" in mismatched.json()["message"].lower()

    total = (await db_session.execute(
        select(func.count(Plot.id)).where(Plot.section_id == sec.id))).scalar_one()
    assert total == 4  # rejected before creating anything


# ── E-13 / E-15: mandatory plot fields (AC-24/AC-28) ───────────────────────────

async def test_create_plot_requires_price(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    pt = await _make_plot_type(db_session, acc)
    resp = await client.post(
        "/api/v1/plots",
        json={"plot_ref": "A-1", "plot_type_id": str(pt.id)},  # no price
        headers=_headers(token, acc),
    )
    assert resp.status_code == 422
    assert "price" in resp.text.lower()


async def test_create_plot_requires_plot_type(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    resp = await client.post(
        "/api/v1/plots",
        json={"plot_ref": "A-1", "price_override": 4200},  # no plot type
        headers=_headers(token, acc),
    )
    assert resp.status_code == 422
    assert "plot_type_id" in resp.text.lower()


async def test_create_plot_with_all_mandatory_fields_succeeds(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    sec = await _make_section(db_session, acc)
    # INDL-58 E-03 — plot type must be linked to the section it's being assigned
    # to (plot_type_sections); link it here so this pre-existing INDL-55 test
    # still exercises "all mandatory fields succeed", not the new pairing rule.
    pt = await _make_plot_type(db_session, acc, sections=[sec])
    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": 4200, "status": "vacant"},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 201, resp.text
    data = resp.json()["data"]
    assert data["plot_ref"] == "A-1"


async def test_inline_edit_patch_updates_fields(client: AsyncClient, db_session):
    """E-06: inline edit persists via PATCH /plots/{id} with full validation."""
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    sec = await _make_section(db_session, acc)
    pt = await _make_plot_type(db_session, acc)
    plot = Plot(tenant_id=acc.id, plot_ref="A-1", section_id=sec.id,
                plot_type_id=pt.id, status="vacant", price_override=100)
    db_session.add(plot)
    await db_session.flush()
    resp = await client.patch(
        f"/api/v1/plots/{plot.id}",
        json={"label_manual": "Walsh family", "price_override": 5000,
              "status": "reserved"},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 200, resp.text
    data = resp.json()["data"]
    assert data["status"] == "reserved"
    assert data.get("label_manual") == "Walsh family"
    # Price persisted (re-fetch confirms the PATCH took effect).
    fresh = (await db_session.execute(select(Plot).where(Plot.id == plot.id))).scalar_one()
    assert float(fresh.price_override) == 5000


async def test_inline_edit_rejects_out_of_range_price(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    pt = await _make_plot_type(db_session, acc)
    plot = Plot(tenant_id=acc.id, plot_ref="A-1", plot_type_id=pt.id,
                status="vacant", price_override=100)
    db_session.add(plot)
    await db_session.flush()
    resp = await client.patch(
        f"/api/v1/plots/{plot.id}",
        json={"price_override": -5}, headers=_headers(token, acc),
    )
    assert resp.status_code == 422


# ── E-07: grid tiling correctness — pure unit checks (AC-15/AC-16, BR-07) ──────

def test_build_plot_grid_non_overlapping():
    from src.apps.sections.services.section_service import SectionService
    from src.core.utils.geometry import build_plot_grid

    polys = build_plot_grid(
        origin_lat=45.4215, origin_lng=-75.6972,
        rows=5, cols=4, plot_width_m=1.2, plot_length_m=2.4, gap_m=0.3,
    )
    assert len(polys) == 20
    # No two generated plots share interior area (BR-07 / AC-16).
    for i in range(len(polys)):
        for j in range(i + 1, len(polys)):
            inter = polys[i].intersection(polys[j])
            assert inter.area == 0, f"plots {i} and {j} overlap"

    # Capacity helper: a ~349 m² section fits ~12 of the 5.3×5.3 m cells.
    cell_area, max_fit = SectionService._grid_capacity(349.0, 5, 5, 0.3)
    assert round(cell_area, 1) == 28.1
    assert max_fit == 12
    # No section area (no boundary) → no cap.
    _, max_fit_none = SectionService._grid_capacity(None, 5, 5, 0.3)
    assert max_fit_none is None
    # A measured area of exactly 0 (degenerate boundary) → 0 fit, NOT "no cap"
    # (falsy-zero guard — a zero-area section must reject every grid).
    _, max_fit_zero = SectionService._grid_capacity(0.0, 1, 1, 0.3)
    assert max_fit_zero == 0


def test_infer_lattice_anchor_reproduces_source_cell():
    """Bugfix — infer_lattice_anchor must invert build_plot_grid: feeding its
    output back into build_plot_grid(anchor="sw") for a 1×1 grid reproduces the
    same polygon it was derived from (to floating-point precision)."""
    from src.core.utils.geometry import build_plot_grid, infer_lattice_anchor

    # A rotated cell somewhere in the middle of an imaginary 5×4 grid (r=2, c=3).
    grid = build_plot_grid(
        origin_lat=45.4215, origin_lng=-75.6972,
        rows=5, cols=4, plot_width_m=1.2, plot_length_m=2.4, gap_m=0.3,
        orientation_deg=15.0, anchor="sw",
    )
    source = grid[2 * 4 + 3]  # r=2, c=3 (row-major)

    anchor = infer_lattice_anchor(source)
    assert anchor["width_m"] == pytest.approx(1.2, abs=1e-4)
    assert anchor["length_m"] == pytest.approx(2.4, abs=1e-4)
    assert anchor["orientation_deg"] == pytest.approx(15.0, abs=1e-4)

    rebuilt = build_plot_grid(
        origin_lat=anchor["origin_lat"], origin_lng=anchor["origin_lng"],
        rows=1, cols=1, plot_width_m=1.2, plot_length_m=2.4, gap_m=0.3,
        orientation_deg=15.0, anchor="sw",
    )[0]
    assert source.centroid.distance(rebuilt.centroid) < 1e-8  # < ~1mm at this latitude


def test_resolve_lattice_sw_anchor_extends_from_any_existing_subset():
    """Bugfix — anchoring a NEW, larger grid on the SW corner resolved from the
    *whole* existing set (not one arbitrarily-picked cell) must reproduce every
    original cell, regardless of which subset of the original grid is passed
    in (mirrors the containment clip dropping some cells before regeneration)."""
    from src.core.utils.geometry import build_plot_grid, resolve_lattice_sw_anchor

    original = build_plot_grid(
        origin_lat=45.4215, origin_lng=-75.6972,
        rows=2, cols=3, plot_width_m=1.0, plot_length_m=1.0, gap_m=0.3,
        anchor="center",
    )
    # The SW-most cell (r=0, c=0) is missing from "existing" — e.g. it was
    # dropped by an earlier section-boundary containment clip — but its exact
    # position is still recoverable from the footprint of the remaining cells
    # (min column comes from one surviving cell, min row from another).
    existing_subset = [c for i, c in enumerate(original) if i != 0]

    anchor = resolve_lattice_sw_anchor(existing_subset)
    extended = build_plot_grid(
        origin_lat=anchor["origin_lat"], origin_lng=anchor["origin_lng"],
        rows=4, cols=5, plot_width_m=1.0, plot_length_m=1.0, gap_m=0.3,
        anchor="sw",
    )

    for orig_cell in original:
        best = min(extended, key=lambda c: orig_cell.centroid.distance(c.centroid))
        assert orig_cell.centroid.distance(best.centroid) < 1e-8, (
            "an original cell is missing from the extended lattice"
        )


def test_dominant_orientation_deg_matches_known_rotation():
    """dominant_orientation_deg must recover the rotation a rectangular boundary
    was built with, so the grid modal can pre-fill orientation from a section's
    boundary shape instead of always defaulting to 0°."""
    from src.core.utils.geometry import build_plot_grid, dominant_orientation_deg

    # A single rectangle (10m short edge, 20m long edge) rotated 30° from east —
    # its longer edge should therefore read as 30+90=120° (mod 180, since a
    # rectangle's orientation is only meaningful up to a 180° flip).
    rect = build_plot_grid(
        origin_lat=45.4215, origin_lng=-75.6972,
        rows=1, cols=1, plot_width_m=10.0, plot_length_m=20.0, gap_m=0.0,
        orientation_deg=30.0, anchor="sw",
    )[0]

    angle = dominant_orientation_deg(rect)
    diff = abs((angle - 120.0) % 180.0)
    diff = min(diff, 180.0 - diff)
    assert diff < 1e-2

    # An axis-aligned square: any longer/shorter edge distinction is arbitrary,
    # but the result must still be a multiple of 90° (mod 180 -> 0 or 90).
    square = build_plot_grid(
        origin_lat=45.4215, origin_lng=-75.6972,
        rows=1, cols=1, plot_width_m=5.0, plot_length_m=5.0, gap_m=0.0,
        orientation_deg=0.0, anchor="sw",
    )[0]
    square_angle = dominant_orientation_deg(square) % 90.0
    assert square_angle < 1e-2 or square_angle > 89.98
