"""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
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],
    ]],
}

# ~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) -> PlotType:
    pt = PlotType(
        tenant_id=account.id, name="Standard single",
        default_width_m=1.2, default_length_m=2.4, default_depth_m=1.8,
        capacity=1, default_price=4200,
    )
    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_allowed_when_no_cemetery_boundary(client: AsyncClient, db_session):
    """BR-04: with no cemetery boundary, section creation is allowed (allow-with-warning)."""
    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 == 201, resp.text


# ── 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².
    resp = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 5, "columns": 4, "plot_width_m": 5, "plot_length_m": 5,
              "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)
    resp = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 5, "columns": 4, "plot_width_m": 5, "plot_length_m": 5},
        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.
    resp = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 5, "columns": 4, "plot_width_m": 1, "plot_length_m": 1},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 201, resp.text
    assert resp.json()["data"]["created"] == 20


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)
    resp = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 5, "columns": 4, "plot_width_m": 5, "plot_length_m": 5,
              "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


# ── 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)
    pt = await _make_plot_type(db_session, acc)
    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, {"plot_width_m": 5, "plot_length_m": 5, "gap_m": 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, {"plot_width_m": 5, "plot_length_m": 5, "gap_m": 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, {"plot_width_m": 1, "plot_length_m": 1, "gap_m": 0.3})
    assert max_fit_zero == 0
