"""
Tests for the INDL-56 public cemetery map:
  GET /api/public/map
  GET /api/public/map/plots/{plot_ref}

Focus: data minimization, server-side visibility gates (public record +
published memorial), tenant scoping (404-not-403 / cross-tenant), soft-delete
exclusion, the publicMap feature-flag gate, and rate limiting.
"""
import datetime as dt

import pytest
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession


# ── fixture builders ─────────────────────────────────────────────────────────
async def _section(db, tenant_id, *, code="A", name="Section A", color="#3366cc"):
    from src.apps.sections.models.section import Section

    s = Section(tenant_id=tenant_id, code=code, name=name, display_color=color)
    db.add(s)
    await db.flush()
    return s


async def _plot_type(db, tenant_id):
    from src.apps.plots.models.plot_type import PlotType

    pt = PlotType(tenant_id=tenant_id, name="Standard", default_price=4800, default_gap_m=0.3)
    db.add(pt)
    await db.flush()
    return pt


async def _plot(db, tenant_id, *, plot_ref, section, plot_type, status="vacant",
                lat=45.42, lng=-75.69, public_description=None):
    from src.apps.plots.models.plot import Plot

    p = Plot(
        tenant_id=tenant_id, plot_ref=plot_ref, section_id=section.id,
        plot_type_id=plot_type.id, status=status, latitude=lat, longitude=lng,
        public_description=public_description,
    )
    db.add(p)
    await db.flush()
    return p


async def _record(db, tenant_id, *, plot, first="Jane", last="Doe",
                  visibility="public", published_memorial=None, slug="jane-doe",
                  deleted=False, dob=dt.date(1940, 1, 1), dod=dt.date(2020, 6, 1)):
    from src.apps.records.models.record import Record
    from src.apps.memorials.models.memorial import Memorial

    rec = Record(
        tenant_id=tenant_id, plot_id=plot.id, first_name=first, last_name=last,
        date_of_birth=dob, date_of_death=dod, visibility_config=visibility,
    )
    if deleted:
        rec.deleted_at = dt.datetime(2021, 1, 1)
    db.add(rec)
    await db.flush()
    mem = None
    if published_memorial is not None:
        mem = Memorial(
            tenant_id=tenant_id, record_id=rec.id, slug=slug,
            is_published=published_memorial,
            biography_text="A long and generous life devoted to the community.",
        )
        db.add(mem)
        await db.flush()
    return rec, mem


@pytest_asyncio.fixture
async def map_world(db_session: AsyncSession, test_account):
    """A section + a mix of plots exercising every visibility branch."""
    section = await _section(db_session, test_account.id)
    pt = await _plot_type(db_session, test_account.id)

    vacant = await _plot(
        db_session, test_account.id, plot_ref="A-01", section=section,
        plot_type=pt, status="vacant", public_description="Sunlit lawn plot",
    )
    # Occupied + PUBLIC record + PUBLISHED memorial → full identity + slug.
    occ_public = await _plot(
        db_session, test_account.id, plot_ref="A-02", section=section,
        plot_type=pt, status="occupied",
    )
    await _record(
        db_session, test_account.id, plot=occ_public, first="Patricia",
        last="O'Brien", visibility="public", published_memorial=True,
        slug="patricia-obrien",
    )
    # Occupied + PRIVATE record + NO memorial → generic "Occupied", no name.
    occ_private = await _plot(
        db_session, test_account.id, plot_ref="A-03", section=section,
        plot_type=pt, status="occupied",
    )
    await _record(
        db_session, test_account.id, plot=occ_private, first="Secret",
        last="Person", visibility="hidden", slug="secret-person",
    )
    # Occupied + soft-deleted record → never surfaces identity.
    occ_deleted = await _plot(
        db_session, test_account.id, plot_ref="A-04", section=section,
        plot_type=pt, status="occupied",
    )
    await _record(
        db_session, test_account.id, plot=occ_deleted, first="Deleted",
        last="Record", visibility="public", deleted=True, slug="deleted-record",
    )
    return {"section": section, "vacant": vacant}


def _hdr(account):
    return {"X-Tenant-ID": str(account.id)}


# ── bootstrap endpoint ─────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_map_returns_collections(client: AsyncClient, map_world, test_account):
    r = await client.get("/api/public/map", headers=_hdr(test_account))
    assert r.status_code == 200
    data = r.json()["data"]
    assert data["sections"]["type"] == "FeatureCollection"
    assert data["plots"]["type"] == "FeatureCollection"
    assert len(data["plots"]["features"]) == 4
    assert len(data["sections"]["features"]) == 1
    assert len(data["statuses"]) >= 1
    # The cemetery boundary key is always present (null when undrawn, as here).
    assert "boundary" in data
    assert data["boundary"] is None
    # Cache-Control must prevent caching the data-minimized bootstrap.
    assert "no-store" in r.headers.get("cache-control", "")


@pytest.mark.asyncio
async def test_map_no_tenant_returns_404(client: AsyncClient):
    r = await client.get("/api/public/map")
    assert r.status_code == 404


@pytest.mark.asyncio
async def test_map_data_minimization(client: AsyncClient, map_world, test_account):
    """The whole payload must never contain staff-only fields (AC-12)."""
    r = await client.get("/api/public/map", headers=_hdr(test_account))
    body = r.text
    for forbidden in ("reserved_by", "reserved_at", "record_id", '"notes"',
                      "date_of_birth", "date_of_death", "visibility_config"):
        assert forbidden not in body, f"leaked field: {forbidden}"


@pytest.mark.asyncio
async def test_public_record_identity_shown(client: AsyncClient, map_world, test_account):
    r = await client.get("/api/public/map", headers=_hdr(test_account))
    feats = {f["properties"]["plot_ref"]: f["properties"]
             for f in r.json()["data"]["plots"]["features"]}
    pub = feats["A-02"]
    assert pub["occupant_name"] == "Patricia O'Brien"
    assert pub["year_of_birth"] == 1940
    assert pub["year_of_death"] == 2020
    assert pub["has_memorial"] is True
    assert pub["memorial_slug"] == "patricia-obrien"


@pytest.mark.asyncio
async def test_private_record_identity_hidden(client: AsyncClient, map_world, test_account):
    r = await client.get("/api/public/map", headers=_hdr(test_account))
    feats = {f["properties"]["plot_ref"]: f["properties"]
             for f in r.json()["data"]["plots"]["features"]}
    priv = feats["A-03"]
    assert priv["occupant_name"] is None
    assert priv["year_of_birth"] is None
    assert priv["has_memorial"] is False
    assert priv["memorial_slug"] is None
    assert priv["status"] == "occupied"


@pytest.mark.asyncio
async def test_private_record_with_published_memorial_hides_slug(
    client: AsyncClient, test_account, db_session
):
    """A record hidden by an operator must not be re-identified via its
    still-published memorial slug (M1 fix / AC-05)."""
    section = await _section(db_session, test_account.id, code="Z", name="Section Z")
    pt = await _plot_type(db_session, test_account.id)
    plot = await _plot(
        db_session, test_account.id, plot_ref="Z-01", section=section,
        plot_type=pt, status="occupied",
    )
    await _record(
        db_session, test_account.id, plot=plot, first="Hidden", last="Soul",
        visibility="hidden", published_memorial=True, slug="hidden-soul",
    )
    r = await client.get("/api/public/map", headers=_hdr(test_account))
    feats = {f["properties"]["plot_ref"]: f["properties"]
             for f in r.json()["data"]["plots"]["features"]}
    z = feats["Z-01"]
    assert z["occupant_name"] is None
    assert z["has_memorial"] is False
    assert z["memorial_slug"] is None
    assert "hidden-soul" not in r.text


@pytest.mark.asyncio
async def test_soft_deleted_record_excluded(client: AsyncClient, map_world, test_account):
    r = await client.get("/api/public/map", headers=_hdr(test_account))
    feats = {f["properties"]["plot_ref"]: f["properties"]
             for f in r.json()["data"]["plots"]["features"]}
    deleted = feats["A-04"]
    assert deleted["occupant_name"] is None
    assert deleted["has_memorial"] is False


# ── plot-detail endpoint ───────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_plot_detail_public(client: AsyncClient, map_world, test_account):
    r = await client.get("/api/public/map/plots/A-02", headers=_hdr(test_account))
    assert r.status_code == 200
    d = r.json()["data"]
    assert d["plot_ref"] == "A-02"
    assert d["occupant_name"] == "Patricia O'Brien"
    assert d["memorial_slug"] == "patricia-obrien"
    assert d["memorial_excerpt"]
    # No staff-only fields leaked.
    for forbidden in ("reserved_by", "record_id", "notes", "visibility_config"):
        assert forbidden not in r.text


@pytest.mark.asyncio
async def test_plot_detail_vacant_price(client: AsyncClient, map_world, test_account):
    r = await client.get("/api/public/map/plots/A-01", headers=_hdr(test_account))
    assert r.status_code == 200
    d = r.json()["data"]
    assert d["status"] == "vacant"
    assert d["price"] == 4800.0
    assert d["public_description"] == "Sunlit lawn plot"
    assert d["occupant_name"] is None


@pytest.mark.asyncio
async def test_plot_detail_unknown_404(client: AsyncClient, map_world, test_account):
    r = await client.get("/api/public/map/plots/ZZZ-999", headers=_hdr(test_account))
    assert r.status_code == 404


@pytest.mark.asyncio
async def test_plot_detail_malformed_ref_404(client: AsyncClient, map_world, test_account):
    # A ref that fails the pattern must 404 (no format oracle), not 422.
    r = await client.get("/api/public/map/plots/%40%40%40", headers=_hdr(test_account))
    assert r.status_code == 404


@pytest.mark.asyncio
async def test_plot_detail_overlong_ref_404_not_422(
    client: AsyncClient, map_world, test_account
):
    """An over-long ref must return the same 404, not a 422 length oracle."""
    r = await client.get(
        "/api/public/map/plots/" + ("A" * 120), headers=_hdr(test_account)
    )
    assert r.status_code == 404


@pytest.mark.asyncio
async def test_plot_detail_cross_tenant_404(
    client: AsyncClient, map_world, test_account, db_session
):
    """A plot_ref that exists in tenant A must 404 for tenant B (BOLA)."""
    from src.apps.tenants.models.account import Account

    other = Account(
        organization_name="Other Cemetery", subdomain="other-cem",
        contact_email="a@other.com", plan="starter", status="active",
    )
    db_session.add(other)
    await db_session.flush()
    r = await client.get("/api/public/map/plots/A-02", headers=_hdr(other))
    assert r.status_code == 404


# ── feature-flag gate ──────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_map_disabled_by_feature_flag(
    client: AsyncClient, map_world, test_account, db_session
):
    test_account.feature_flags = {"publicMap": False}
    db_session.add(test_account)
    await db_session.flush()

    r = await client.get("/api/public/map", headers=_hdr(test_account))
    assert r.status_code == 404
    r2 = await client.get("/api/public/map/plots/A-02", headers=_hdr(test_account))
    assert r2.status_code == 404


@pytest.mark.asyncio
async def test_by_subdomain_reflects_public_map_flag(
    client: AsyncClient, test_account, db_session
):
    # Default (unset) → enabled.
    r = await client.get(f"/api/public/tenants/by-subdomain/{test_account.subdomain}")
    assert r.json()["data"]["features"]["publicMap"] is True

    # Explicitly disabled → false.
    test_account.feature_flags = {"publicMap": False}
    db_session.add(test_account)
    await db_session.flush()
    r2 = await client.get(f"/api/public/tenants/by-subdomain/{test_account.subdomain}")
    assert r2.json()["data"]["features"]["publicMap"] is False


# ── rate limiting ──────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_map_rate_limited(client: AsyncClient, map_world, test_account, monkeypatch):
    from fastapi import HTTPException
    from src.apps.public import router as public_router

    async def _boom(*_a, **_k):
        raise HTTPException(status_code=429, detail="Too many requests")

    monkeypatch.setattr(public_router, "check_rate_limit", _boom)
    r = await client.get("/api/public/map", headers=_hdr(test_account))
    assert r.status_code == 429
