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


# ── 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


# ── 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")
    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_duplicate_section_name_rejected(client: AsyncClient, db_session):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, "administrator")
    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")
    resp = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 5, "columns": 4, "plot_width_m": 1.2, "plot_length_m": 2.4,
              "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)
    # 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):
    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")
    body = {"rows": 2, "columns": 2, "plot_width_m": 1, "plot_length_m": 2,
            "origin_lat": 45.4215, "origin_lng": -75.6972}
    await client.post(f"/api/v1/sections/{sec.id}/plots/autogenerate", json=body,
                      headers=_headers(token, acc))
    resp2 = await client.post(f"/api/v1/sections/{sec.id}/plots/autogenerate", json=body,
                              headers=_headers(token, acc))
    assert resp2.status_code == 201
    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
    resp = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 2, "columns": 2, "plot_width_m": 1, "plot_length_m": 1},
        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)
    resp = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 50, "columns": 50, "plot_width_m": 1, "plot_length_m": 1},
        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)
    resp = await client.post(
        f"/api/v1/sections/{sec.id}/plots/autogenerate",
        json={"rows": 2, "columns": 2, "plot_width_m": 1, "plot_length_m": 1},
        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)
    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_width_m": 1, "plot_length_m": 1},
        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")
    # 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_width_m": 1, "plot_length_m": 1},
        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
