"""
Tests for INDL-40 — Tenant Portal Dashboard.
Covers:
  T-01  GET /api/v1/dashboard requires authentication (401)
  T-02  GET /api/v1/dashboard allowed for view_only role (AC-13)
  T-03  GET /api/v1/dashboard response shape — kpis/weekly_services/capacity/public_site_url
  T-04  Plots available KPI counts vacant plots and links to /portal/map
  T-05  Revenue this month KPI sums current-month payments only
  T-06  Upcoming services (7d) KPI counts only in-range, non-deleted events
  T-07  Pending memorial approvals KPI counts only pending tributes
  T-08  Weekly services strip always has exactly 7 days (Mon-Sun), is_past correct
  T-09  Weekly services strip excludes soft-deleted service events
  T-10  Cemetery capacity breakdown totals and section fill_pct (incl. zero-plot section)
  T-11  public_site_url derived from tenant subdomain
  T-12  Tenant isolation — two tenants never see each other's aggregates
  T-13  Response is cached in Redis under dashboard:{tenant_id} with a TTL <= 60s
  T-14  Rate limiting returns 429 once the per-user limit is exceeded
  T-15  Method not allowed — POST is rejected with 405
"""
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal
from uuid import uuid4

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

from src.apps.auth.models.user import User
from src.apps.billing.models.invoice import Invoice
from src.apps.billing.models.invoice_payment import InvoicePayment
from src.apps.memorials.models.memorial import Memorial
from src.apps.memorials.models.tribute import Tribute
from src.apps.plots.models.plot import Plot
from src.apps.records.models.record import Record
from src.apps.scheduling.models.service_event import ServiceEvent
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, hash_password

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


async def _make_account(db: AsyncSession) -> Account:
    uid = str(uuid4())[-8:]
    acc = Account(
        organization_name=f"Dashboard Cemetery {uid}",
        subdomain=f"dash-{uid}",
        contact_email=f"dash-{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"user-{role}-{uuid4().hex[:6]}@test.ca",
        password_hash=hash_password("Test1234!"),
        first_name="Test",
        last_name="User",
        role=role,
        status="active",
    )
    db.add(user)
    await db.flush()
    return create_access_token(build_token_payload(user, account))


async def _make_section(db: AsyncSession, account: Account, *, name: str = "Section A") -> Section:
    section = Section(tenant_id=account.id, code=name.replace(" ", "").upper(), name=name)
    db.add(section)
    await db.flush()
    return section


async def _make_plot(db: AsyncSession, account: Account, *, status: str = "vacant", section=None) -> Plot:
    plot = Plot(
        tenant_id=account.id,
        plot_ref=f"P-{uuid4().hex[:6]}",
        status=status,
        section_id=section.id if section else None,
    )
    db.add(plot)
    await db.flush()
    return plot


async def _make_service_event(
    db: AsyncSession,
    account: Account,
    *,
    scheduled_date: date,
    scheduled_time: time = time(9, 0),
    service_type: str = "interment",
    family_contact_name: str = "Test Family",
    deleted: bool = False,
) -> ServiceEvent:
    event = ServiceEvent(
        tenant_id=account.id,
        service_type=service_type,
        scheduled_date=scheduled_date,
        scheduled_time=scheduled_time,
        family_contact_name=family_contact_name,
        deleted_at=datetime.now(timezone.utc) if deleted else None,
    )
    db.add(event)
    await db.flush()
    return event


async def _make_tribute(db: AsyncSession, account: Account, *, status: str = "pending") -> Tribute:
    record = Record(tenant_id=account.id, first_name="Jane", last_name="Doe")
    db.add(record)
    await db.flush()

    memorial = Memorial(
        tenant_id=account.id,
        record_id=record.id,
        slug=f"jane-doe-{uuid4().hex[:6]}",
    )
    db.add(memorial)
    await db.flush()

    tribute = Tribute(
        tenant_id=account.id,
        memorial_id=memorial.id,
        submitter_name="A Visitor",
        message="Resting in peace.",
        status=status,
    )
    db.add(tribute)
    await db.flush()
    return tribute


async def _make_payment(db: AsyncSession, account: Account, *, amount: str, received_on: date) -> InvoicePayment:
    invoice = Invoice(
        tenant_id=account.id,
        invoice_number=f"INV-{uuid4().hex[:8]}",
        status="paid",
        total_amount=Decimal(amount),
        paid_amount=Decimal(amount),
    )
    db.add(invoice)
    await db.flush()

    payment = InvoicePayment(
        tenant_id=account.id,
        invoice_id=invoice.id,
        amount=Decimal(amount),
        received_on=received_on,
    )
    db.add(payment)
    await db.flush()
    return payment


def _headers(token: str, account: Account) -> dict:
    return {"Authorization": f"Bearer {token}", "X-Tenant-ID": str(account.id)}


# ── T-01: authentication required ──────────────────────────────────────────────


@pytest.mark.asyncio
async def test_dashboard_requires_auth(client: AsyncClient):
    resp = await client.get("/api/v1/dashboard")
    assert resp.status_code == 401


# ── T-02: view_only role is allowed (AC-13) ────────────────────────────────────


@pytest.mark.asyncio
async def test_dashboard_allowed_for_view_only(client: AsyncClient, db_session: AsyncSession):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc, role="view_only")

    resp = await client.get("/api/v1/dashboard", headers=_headers(token, acc))
    assert resp.status_code == 200


# ── T-03: response shape ────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_dashboard_response_shape(client: AsyncClient, db_session: AsyncSession):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)

    resp = await client.get("/api/v1/dashboard", headers=_headers(token, acc))
    assert resp.status_code == 200
    data = resp.json()["data"]

    assert set(data.keys()) == {"public_site_url", "kpis", "weekly_services", "capacity"}
    assert len(data["kpis"]) == 4
    kpi_keys = {kpi["key"] for kpi in data["kpis"]}
    assert kpi_keys == {
        "plots_available",
        "revenue_this_month",
        "upcoming_services_7d",
        "pending_memorial_approvals",
    }
    for kpi in data["kpis"]:
        assert set(kpi.keys()) == {"key", "label", "value", "delta", "delta_direction", "link"}
        assert kpi["delta_direction"] in ("up", "down", "neutral")


# ── T-04: plots available KPI ──────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_plots_available_kpi(client: AsyncClient, db_session: AsyncSession):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)
    await _make_plot(db_session, acc, status="vacant")
    await _make_plot(db_session, acc, status="vacant")
    await _make_plot(db_session, acc, status="occupied")
    await _make_plot(db_session, acc, status="reserved")

    resp = await client.get("/api/v1/dashboard", headers=_headers(token, acc))
    kpis = {kpi["key"]: kpi for kpi in resp.json()["data"]["kpis"]}

    assert kpis["plots_available"]["value"] == 2
    assert kpis["plots_available"]["link"] == "/portal/map"


# ── T-05: revenue this month KPI ───────────────────────────────────────────────


@pytest.mark.asyncio
async def test_revenue_this_month_kpi_excludes_other_months(client: AsyncClient, db_session: AsyncSession):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)

    today = date.today()
    curr_month_start = today.replace(day=1)
    last_month = (curr_month_start - timedelta(days=1)).replace(day=1)

    await _make_payment(db_session, acc, amount="500.00", received_on=curr_month_start)
    await _make_payment(db_session, acc, amount="1000.00", received_on=last_month)

    resp = await client.get("/api/v1/dashboard", headers=_headers(token, acc))
    kpis = {kpi["key"]: kpi for kpi in resp.json()["data"]["kpis"]}

    assert kpis["revenue_this_month"]["value"] == 500.0
    # curr (500) vs prev (1000) is a decrease — must not report "up"
    assert kpis["revenue_this_month"]["delta_direction"] == "down"


# ── T-06: upcoming services (7d) KPI ───────────────────────────────────────────


@pytest.mark.asyncio
async def test_upcoming_services_kpi_range_and_soft_delete(client: AsyncClient, db_session: AsyncSession):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)

    today = date.today()
    await _make_service_event(db_session, acc, scheduled_date=today + timedelta(days=2))
    await _make_service_event(db_session, acc, scheduled_date=today + timedelta(days=6))
    # Out of the 7-day window — must not be counted
    await _make_service_event(db_session, acc, scheduled_date=today + timedelta(days=10))
    # Soft-deleted — must not be counted
    await _make_service_event(db_session, acc, scheduled_date=today + timedelta(days=1), deleted=True)

    resp = await client.get("/api/v1/dashboard", headers=_headers(token, acc))
    kpis = {kpi["key"]: kpi for kpi in resp.json()["data"]["kpis"]}

    assert kpis["upcoming_services_7d"]["value"] == 2


# ── T-07: pending memorial approvals KPI ───────────────────────────────────────


@pytest.mark.asyncio
async def test_pending_approvals_kpi_only_counts_pending(client: AsyncClient, db_session: AsyncSession):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)

    await _make_tribute(db_session, acc, status="pending")
    await _make_tribute(db_session, acc, status="pending")
    await _make_tribute(db_session, acc, status="approved")
    await _make_tribute(db_session, acc, status="rejected")

    resp = await client.get("/api/v1/dashboard", headers=_headers(token, acc))
    kpis = {kpi["key"]: kpi for kpi in resp.json()["data"]["kpis"]}

    assert kpis["pending_memorial_approvals"]["value"] == 2
    assert kpis["pending_memorial_approvals"]["link"] == "/portal/memorials?status=pending"


# ── T-08: weekly services strip shape ──────────────────────────────────────────


@pytest.mark.asyncio
async def test_weekly_services_always_seven_days(client: AsyncClient, db_session: AsyncSession):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)

    resp = await client.get("/api/v1/dashboard", headers=_headers(token, acc))
    days = resp.json()["data"]["weekly_services"]

    assert len(days) == 7
    assert [d["day_label"] for d in days] == ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]

    today = date.today()
    for day in days:
        day_date = date.fromisoformat(day["date"])
        assert day["is_past"] == (day_date < today)


# ── T-09: weekly services excludes soft-deleted events ─────────────────────────


@pytest.mark.asyncio
async def test_weekly_services_excludes_soft_deleted(client: AsyncClient, db_session: AsyncSession):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)

    today = date.today()
    monday = today - timedelta(days=today.weekday())
    visible = await _make_service_event(db_session, acc, scheduled_date=monday, family_contact_name="Visible Family")
    await _make_service_event(db_session, acc, scheduled_date=monday, family_contact_name="Deleted Family", deleted=True)

    resp = await client.get("/api/v1/dashboard", headers=_headers(token, acc))
    days = {d["date"]: d for d in resp.json()["data"]["weekly_services"]}
    monday_services = days[monday.isoformat()]["services"]

    names = [s["name"] for s in monday_services]
    assert "Visible Family" in names
    assert "Deleted Family" not in names
    assert monday_services[0]["service_detail_url"] == f"/portal/scheduling/{visible.id}"


# ── T-16: a stale/incompatible cache entry is treated as a cache miss ─────────


@pytest.mark.asyncio
async def test_corrupt_cache_entry_falls_back_to_fresh_compute(client: AsyncClient, db_session: AsyncSession):
    """A cache entry left over from a previous response schema (e.g. mid-deploy)
    must not 500 the request — it should be treated as a cache miss."""
    from src.database.session import get_redis

    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)
    await _make_plot(db_session, acc, status="vacant")

    redis = await get_redis()
    if redis is None:
        pytest.skip("Redis not available in this test environment")
    try:
        await redis.set(f"dashboard:{acc.id}", '{"this": "does not match DashboardResponse"}', ex=60)

        resp = await client.get("/api/v1/dashboard", headers=_headers(token, acc))

        assert resp.status_code == 200
        kpis = {k["key"]: k for k in resp.json()["data"]["kpis"]}
        assert kpis["plots_available"]["value"] == 1
    finally:
        await redis.aclose()


# ── T-10: capacity breakdown ────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_capacity_breakdown_totals_and_section_fill(client: AsyncClient, db_session: AsyncSession):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)

    section_full = await _make_section(db_session, acc, name="Section A")
    await _make_section(db_session, acc, name="Section B")

    await _make_plot(db_session, acc, status="occupied", section=section_full)
    await _make_plot(db_session, acc, status="reserved", section=section_full)
    await _make_plot(db_session, acc, status="vacant", section=section_full)

    resp = await client.get("/api/v1/dashboard", headers=_headers(token, acc))
    capacity = resp.json()["data"]["capacity"]

    assert capacity["total"] == 3
    assert capacity["interred"] == 1
    assert capacity["reserved"] == 1
    assert capacity["available"] == 1
    assert capacity["interred_pct"] == 33  # round(1/3 * 100)

    sections = {s["name"]: s["fill_pct"] for s in capacity["sections"]}
    assert sections["Section A"] == 67  # 2 of 3 plots non-vacant
    # A section with zero plots must not raise a ZeroDivisionError
    assert sections["Section B"] == 0


# ── T-11: public_site_url ──────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_public_site_url_defaults_to_production_domain(client: AsyncClient, db_session: AsyncSession, monkeypatch):
    import src.apps.dashboard.services.dashboard_service as dashboard_service

    monkeypatch.setattr(dashboard_service.settings, "PUBLIC_SITE_URL_TEMPLATE", "")
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)

    resp = await client.get("/api/v1/dashboard", headers=_headers(token, acc))
    assert resp.json()["data"]["public_site_url"] == f"https://{acc.subdomain}.indelis.com"


@pytest.mark.asyncio
async def test_public_site_url_uses_environment_template(client: AsyncClient, db_session: AsyncSession, monkeypatch):
    import src.apps.dashboard.services.dashboard_service as dashboard_service

    monkeypatch.setattr(
        dashboard_service.settings,
        "PUBLIC_SITE_URL_TEMPLATE",
        "https://{subdomain}-ui-dev.dreamztesting.com",
    )
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)

    resp = await client.get("/api/v1/dashboard", headers=_headers(token, acc))
    assert (
        resp.json()["data"]["public_site_url"]
        == f"https://{acc.subdomain}-ui-dev.dreamztesting.com"
    )


# ── T-12: tenant isolation ──────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_tenant_isolation_between_two_accounts(client: AsyncClient, db_session: AsyncSession):
    acc_a = await _make_account(db_session)
    acc_b = await _make_account(db_session)
    token_a = await _make_user(db_session, acc_a)
    token_b = await _make_user(db_session, acc_b)

    await _make_plot(db_session, acc_a, status="vacant")
    await _make_plot(db_session, acc_a, status="vacant")
    await _make_plot(db_session, acc_b, status="vacant")

    resp_a = await client.get("/api/v1/dashboard", headers=_headers(token_a, acc_a))
    resp_b = await client.get("/api/v1/dashboard", headers=_headers(token_b, acc_b))

    kpis_a = {k["key"]: k for k in resp_a.json()["data"]["kpis"]}
    kpis_b = {k["key"]: k for k in resp_b.json()["data"]["kpis"]}

    assert kpis_a["plots_available"]["value"] == 2
    assert kpis_b["plots_available"]["value"] == 1
    assert resp_a.json()["data"]["public_site_url"] != resp_b.json()["data"]["public_site_url"]


# ── T-13: Redis caching (AC-15) ─────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_dashboard_response_is_cached(client: AsyncClient, db_session: AsyncSession):
    from src.database.session import get_redis

    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)

    resp = await client.get("/api/v1/dashboard", headers=_headers(token, acc))
    assert resp.status_code == 200

    redis = await get_redis()
    if redis is None:
        pytest.skip("Redis not available in this test environment")
    try:
        raw = await redis.get(f"dashboard:{acc.id}")
        ttl = await redis.ttl(f"dashboard:{acc.id}")
        assert raw is not None
        assert 0 < ttl <= 60
    finally:
        await redis.aclose()


# ── T-14: rate limiting ─────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_dashboard_rate_limit_returns_429(client: AsyncClient, db_session: AsyncSession, monkeypatch):
    import src.apps.dashboard.router as dashboard_router

    monkeypatch.setattr(dashboard_router, "_RATE_LIMIT_MAX_CALLS", 2)

    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)
    headers = _headers(token, acc)

    r1 = await client.get("/api/v1/dashboard", headers=headers)
    r2 = await client.get("/api/v1/dashboard", headers=headers)
    r3 = await client.get("/api/v1/dashboard", headers=headers)

    assert r1.status_code == 200
    assert r2.status_code == 200
    if r3.status_code == 429:
        assert "retry" in r3.json()["message"].lower() or r3.json().get("message")
    else:
        # Redis unavailable in this environment — limiter fails open by design.
        assert r3.status_code == 200


# ── T-15: method not allowed ─────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_dashboard_post_not_allowed(client: AsyncClient, db_session: AsyncSession):
    acc = await _make_account(db_session)
    token = await _make_user(db_session, acc)

    resp = await client.post("/api/v1/dashboard", headers=_headers(token, acc))
    assert resp.status_code == 405
