"""INDL-49 — Settings: Cemetery Map Boundary, Sections & Plot Grid Setup.

Backend coverage for map-config, section boundaries, grid autogeneration, and the
geocode proxy — including the PRD's security acceptance criteria (S-01..S-12).
"""
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 small closed polygon near Ottawa (lng, lat) — ~15m × 22m.
VALID_POLYGON = {
    "type": "Polygon",
    "coordinates": [[
        [-75.6972, 45.4215],
        [-75.6970, 45.4215],
        [-75.6970, 45.4217],
        [-75.6972, 45.4217],
        [-75.6972, 45.4215],
    ]],
}

# A large cemetery-grounds polygon that contains VALID_POLYGON (and any other
# small section polygon near the same coordinates) — used to satisfy the
# "cemetery boundary must be drawn before a section can be created" rule.
ACCOUNT_BOUNDARY = {
    "type": "Polygon",
    "coordinates": [[
        [-75.7000, 45.4200],
        [-75.6900, 45.4200],
        [-75.6900, 45.4250],
        [-75.7000, 45.4250],
        [-75.7000, 45.4200],
    ]],
}


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

async def _make_account(db: AsyncSession) -> Account:
    uid = uuid4().hex[:8]
    acc = Account(
        organization_name=f"Map Cemetery {uid}",
        subdomain=f"map-{uid}",
        contact_email=f"map-{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, section: Section, *,
    width: float = 1.0, length: float = 1.0, gap: float = 0.0, name: str = "Standard",
) -> 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=1000, sections=[section],
    )
    db.add(pt)
    await db.flush()
    return pt


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


# ── AC-02 / S-01 / S-06: map-config ───────────────────────────────────────────

async def test_admin_sets_boundary_recomputes_area(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    resp = await client.patch(
        "/api/v1/accounts/me/map-config",
        json={"boundary": VALID_POLYGON, "map_mode": "geo"},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 200, resp.text
    data = resp.json()["data"]
    assert data["map_mode"] == "geo"
    assert data["boundary"] is not None
    assert data["total_area_m2"] and float(data["total_area_m2"]) > 0
    assert data["centroid"] is not None


async def test_map_config_clear_boundary(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    await client.patch("/api/v1/accounts/me/map-config",
                       json={"boundary": VALID_POLYGON}, headers=_headers(token, acc))
    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


@pytest.mark.parametrize("role", ["manager", "staff", "view_only"])
async def test_map_config_forbidden_for_non_admin(client: AsyncClient, db_session, role):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, role)
    resp = await client.patch("/api/v1/accounts/me/map-config",
                              json={"map_mode": "geo"}, headers=_headers(token, acc))
    assert resp.status_code == 403  # S-01


async def test_map_config_rejects_mass_assignment(client: AsyncClient, db_session):
    """S-06: extra='forbid' blocks smuggling protected account fields."""
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    resp = await client.patch(
        "/api/v1/accounts/me/map-config",
        json={"boundary": VALID_POLYGON, "plan": "enterprise", "status": "active"},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 422
    # plan unchanged
    fresh = (await db_session.execute(select(Account).where(Account.id == acc.id))).scalar_one()
    assert fresh.plan == "starter"


async def test_map_config_invalid_geometry(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    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


# ── AC-04 / AC-06 / AC-07 / T-06: section boundaries ───────────────────────────

async def test_create_section_with_boundary(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "manager")
    admin_token = await _make_user(db_session, acc, "administrator")
    await _set_boundary(client, acc, admin_token)
    resp = await client.post(
        "/api/v1/sections",
        json={"code": "A", "name": "Section A", "display_color": "#4F46E5",
              "boundary": VALID_POLYGON},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 201, resp.text
    data = resp.json()["data"]
    assert data["boundary"] is not None
    assert data["display_color"] == "#4F46E5"
    assert data["area_m2"] and float(data["area_m2"]) > 0
    assert data["plot_number_prefix"] == "A"


async def test_section_response_suggests_orientation_from_rotated_boundary(client: AsyncClient, db_session):
    """The section response derives suggested_orientation_deg from the boundary's
    own shape, so the grid-generator modal can default to it instead of 0°."""
    from shapely.geometry import mapping

    from src.core.utils.geometry import build_plot_grid

    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "manager")
    admin_token = await _make_user(db_session, acc, "administrator")
    await _set_boundary(client, acc, admin_token)
    # A rectangle rotated 30° from east — longer edge orientation is 30+90=120°.
    rotated = 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]
    resp = await client.post(
        "/api/v1/sections",
        json={"code": "A", "name": "Section A", "boundary": mapping(rotated)},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 201, resp.text
    suggested = resp.json()["data"]["suggested_orientation_deg"]
    assert suggested is not None
    diff = abs((suggested - 120.0) % 180.0)
    diff = min(diff, 180.0 - diff)
    assert diff < 1.0


async def test_section_response_suggested_orientation_null_without_boundary(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "manager")
    admin_token = await _make_user(db_session, acc, "administrator")
    await _set_boundary(client, acc, admin_token)
    resp = await client.post(
        "/api/v1/sections",
        json={"code": "A", "name": "Section A"},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 201, resp.text
    assert resp.json()["data"]["suggested_orientation_deg"] is None


async def test_duplicate_section_name_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)
    await _make_section(db_session, acc, code="A", name="Section A")
    resp = await client.post(
        "/api/v1/sections",
        json={"code": "B", "name": "section a"},  # case-insensitive dup
        headers=_headers(token, acc),
    )
    assert resp.status_code == 409
    assert "already in use" in resp.json()["message"].lower()


async def test_clear_section_boundary_keeps_row(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "manager")
    sec = await _make_section(db_session, acc)
    # give it a boundary
    await client.patch(f"/api/v1/sections/{sec.id}",
                       json={"boundary": VALID_POLYGON}, headers=_headers(token, acc))
    # clear it
    resp = await client.patch(f"/api/v1/sections/{sec.id}",
                              json={"boundary": None}, headers=_headers(token, acc))
    assert resp.status_code == 200
    assert resp.json()["data"]["boundary"] is None
    # row still exists
    still = (await db_session.execute(select(Section).where(Section.id == sec.id))).scalar_one_or_none()
    assert still is not None


async def test_rename_and_recolor_section(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "manager")
    sec = await _make_section(db_session, acc)
    resp = await client.patch(
        f"/api/v1/sections/{sec.id}",
        json={"name": "Renamed", "display_color": "#10B981"},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 200
    assert resp.json()["data"]["name"] == "Renamed"
    assert resp.json()["data"]["display_color"] == "#10B981"


# ── AC-08 / AC-09 / AC-10 / S-02 / S-08: grid autogenerate ─────────────────────

async def test_autogenerate_grid_creates_numbered_plots(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, code="A", name="Section A")
    pt = await _make_plot_type(db_session, acc, sec, width=1.2, length=2.4)
    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
    data = resp.json()["data"]
    assert data["created"] == 20
    assert data["plot_ref_range"] == "A-1 … A-20"
    # 20 plots persisted, sequential refs, all with geometry
    plots = (await db_session.execute(
        select(Plot).where(Plot.section_id == sec.id))).scalars().all()
    assert len(plots) == 20
    refs = sorted(p.plot_ref for p in plots)
    assert "A-1" in refs and "A-20" in refs
    assert all(p.geometry is not None for p in plots)
    # Plot type's default_price seeds every generated plot's price_override —
    # otherwise a bulk-generated batch shows no price until edited by hand.
    assert all(p.price_override == pt.default_price for p in plots)
    # next_plot_seq advanced
    fresh = (await db_session.execute(select(Section).where(Section.id == sec.id))).scalar_one()
    assert fresh.next_plot_seq == 21


async def test_autogenerate_second_call_continues_numbering(client: AsyncClient, db_session):
    """Bugfix — a second generation on the same section extends the existing
    lattice (rows 2→4, same size) rather than re-centring a fresh, differently
    -offset grid; plot numbering continues from where the first call left off,
    and only the genuinely new cells (rows 2-3) are created."""
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    sec = await _make_section(db_session, acc, code="A", name="Section A")
    pt = await _make_plot_type(db_session, acc, sec, width=1, length=2)
    body1 = {"rows": 2, "columns": 2, "plot_type_id": str(pt.id),
             "origin_lat": 45.4215, "origin_lng": -75.6972}
    await client.post(f"/api/v1/sections/{sec.id}/plots/autogenerate", json=body1,
                      headers=_headers(token, acc))
    body2 = {"rows": 4, "columns": 2, "plot_type_id": str(pt.id)}
    resp2 = await client.post(f"/api/v1/sections/{sec.id}/plots/autogenerate", json=body2,
                              headers=_headers(token, acc))
    assert resp2.status_code == 201
    assert resp2.json()["data"]["created"] == 4
    assert resp2.json()["data"]["plot_ref_range"] == "A-5 … A-8"
    count = (await db_session.execute(
        select(func.count(Plot.id)).where(Plot.section_id == sec.id))).scalar_one()
    assert count == 8


async def test_autogenerate_no_anchor_rejected(client: AsyncClient, db_session):
    """No section/account boundary and no origin → 422 (never silent null-island plots)."""
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    sec = await _make_section(db_session, acc)  # no boundary
    pt = await _make_plot_type(db_session, acc, sec)
    resp = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 2, "columns": 2, "plot_type_id": str(pt.id)},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 422
    assert "boundary" in resp.json()["message"].lower()


async def test_autogenerate_exceeds_cap(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, sec)
    resp = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 50, "columns": 50, "plot_type_id": str(pt.id)},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 422  # AC-10: 2500 > 2000


@pytest.mark.parametrize("role", ["staff", "view_only"])
async def test_autogenerate_forbidden_low_role(client: AsyncClient, db_session, role):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, role)
    sec = await _make_section(db_session, acc)
    pt = await _make_plot_type(db_session, acc, sec)
    resp = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 2, "columns": 2, "plot_type_id": str(pt.id)},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 403  # AC-12


async def test_autogenerate_cross_tenant_section_404(client: AsyncClient, db_session):
    """S-02 / API1 (IDOR): tenant B cannot autogenerate on tenant A's section."""
    acc_a = await _make_account(db_session)
    sec_a = await _make_section(db_session, acc_a)
    pt_a = await _make_plot_type(db_session, acc_a, sec_a)
    acc_b = await _make_account(db_session)
    token_b = await _make_user(db_session, acc_b, "administrator")
    resp = await client.post(
        f"/api/v1/sections/{sec_a.id}/plots/autogenerate",
        json={"rows": 2, "columns": 2, "plot_type_id": str(pt_a.id)},
        headers=_headers(token_b, acc_b),
    )
    assert resp.status_code == 404  # not 200, not 403


async def test_autogenerate_duplicate_ref_guard(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, code="A", name="Section A")
    pt = await _make_plot_type(db_session, acc, sec)
    # Pre-create a plot that will collide with A-1
    db_session.add(Plot(tenant_id=acc.id, plot_ref="A-1", section_id=sec.id, status="vacant"))
    await db_session.flush()
    resp = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 2, "columns": 2, "plot_type_id": str(pt.id)},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 409


# ── AC-16..AC-19 / S-03 / S-04 / S-05 / S-09: geocode proxy ────────────────────

async def test_geocode_disabled_without_key(client: AsyncClient, db_session, monkeypatch):
    """AC-18: no key → 404, config reports geocoding_enabled false."""
    monkeypatch.setattr("src.core.config.settings.GOOGLE_MAPS_API_KEY", "")
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    cfg = await client.get("/api/v1/settings/cemetery-map", headers=_headers(token, acc))
    assert cfg.json()["data"]["geocoding_enabled"] is False
    resp = await client.get("/api/v1/settings/cemetery-map/geocode?address=123+Main+St",
                            headers=_headers(token, acc))
    assert resp.status_code == 404


async def test_geocode_requires_auth(client: AsyncClient, db_session):
    """S-04: no Authorization header → 401."""
    resp = await client.get("/api/v1/settings/cemetery-map/geocode?address=123+Main+St")
    assert resp.status_code == 401


async def test_geocode_view_only_forbidden(client: AsyncClient, db_session, monkeypatch):
    monkeypatch.setattr("src.core.config.settings.GOOGLE_MAPS_API_KEY", "SENTINEL_KEY")
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "view_only")
    resp = await client.get("/api/v1/settings/cemetery-map/geocode?address=123+Main+St",
                            headers=_headers(token, acc))
    assert resp.status_code == 403  # S-04


async def test_geocode_success(client: AsyncClient, db_session, monkeypatch):
    """AC-16 / T-14."""
    monkeypatch.setattr("src.core.config.settings.GOOGLE_MAPS_API_KEY", "SENTINEL_KEY")

    async def fake_call(self, address):
        return {"status": "OK", "results": [{
            "formatted_address": "123 Maple St, Ottawa, ON, Canada",
            "geometry": {"location": {"lat": 45.4215, "lng": -75.6972}},
        }]}
    monkeypatch.setattr(
        "src.apps.cemetery_map.services.geocoding_service.GeocodingService._call_google",
        fake_call,
    )
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    resp = await client.get(
        "/api/v1/settings/cemetery-map/geocode?address=123+Maple+St",
        headers=_headers(token, acc),
    )
    assert resp.status_code == 200, resp.text
    data = resp.json()["data"]
    assert data["lat"] == 45.4215 and data["lng"] == -75.6972
    assert "Maple" in data["formatted_address"]
    # S-05: the API key never appears in the response body
    assert "SENTINEL_KEY" not in resp.text


async def test_geocode_zero_results(client: AsyncClient, db_session, monkeypatch):
    """AC-17 / T-15."""
    monkeypatch.setattr("src.core.config.settings.GOOGLE_MAPS_API_KEY", "SENTINEL_KEY")

    async def fake_call(self, address):
        return {"status": "ZERO_RESULTS", "results": []}
    monkeypatch.setattr(
        "src.apps.cemetery_map.services.geocoding_service.GeocodingService._call_google",
        fake_call,
    )
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    resp = await client.get("/api/v1/settings/cemetery-map/geocode?address=zzzzzzzzz",
                            headers=_headers(token, acc))
    assert resp.status_code == 404
    assert "no matching address" in resp.json()["message"].lower()


async def test_geocode_upstream_error_503(client: AsyncClient, db_session, monkeypatch):
    monkeypatch.setattr("src.core.config.settings.GOOGLE_MAPS_API_KEY", "SENTINEL_KEY")

    async def fake_call(self, address):
        return {"status": "OVER_QUERY_LIMIT"}
    monkeypatch.setattr(
        "src.apps.cemetery_map.services.geocoding_service.GeocodingService._call_google",
        fake_call,
    )
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    resp = await client.get("/api/v1/settings/cemetery-map/geocode?address=somewhere+ok",
                            headers=_headers(token, acc))
    assert resp.status_code == 503


async def test_image_upload_rejects_non_image(client: AsyncClient, db_session):
    """S-11: MIME is validated by magic bytes, not the client Content-Type."""
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    files = {"file": ("evil.jpg", b"<?php echo 1; ?>", "image/jpeg")}
    resp = await client.post("/api/v1/settings/cemetery-map/image",
                             files=files, headers=_headers(token, acc))
    assert resp.status_code == 422
    assert "jpg or png" in resp.json()["message"].lower()


async def test_image_upload_accepts_png_magic_bytes(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32  # valid PNG signature
    files = {"file": ("map.png", png, "image/png")}
    resp = await client.post("/api/v1/settings/cemetery-map/image",
                             files=files, headers=_headers(token, acc))
    # 201 whether or not S3 is configured (url may be null in dev)
    assert resp.status_code == 201, resp.text


async def test_image_upload_forbidden_for_manager(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "manager")
    png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32
    files = {"file": ("map.png", png, "image/png")}
    resp = await client.post("/api/v1/settings/cemetery-map/image",
                             files=files, headers=_headers(token, acc))
    assert resp.status_code == 403


async def test_geocode_ssrf_blocked_before_network(client: AsyncClient, db_session, monkeypatch):
    """S-09 / A10: a URL/IP address is rejected (422) before any outbound call."""
    monkeypatch.setattr("src.core.config.settings.GOOGLE_MAPS_API_KEY", "SENTINEL_KEY")
    called = {"hit": False}

    async def fake_call(self, address):
        called["hit"] = True
        return {"status": "OK", "results": []}
    monkeypatch.setattr(
        "src.apps.cemetery_map.services.geocoding_service.GeocodingService._call_google",
        fake_call,
    )
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    resp = await client.get(
        "/api/v1/settings/cemetery-map/geocode",
        params={"address": "http://169.254.169.254/latest/meta-data/"},
        headers=_headers(token, acc),
    )
    assert resp.status_code == 422
    assert called["hit"] is False
