"""Events as records, guests scoped to them (task 3.9, design D11, D12).

This is the migration that changed what a guest *is*. Before it, one guest row could hold
invitations to three ceremonies; after it, the same person attending three ceremonies is
three independent rows. That inversion is easy to half-implement — a single query left
unscoped restores the old behaviour silently, and nothing else in the suite would notice,
because every other test uses one event.

So these tests assert the boundary from both sides: what must now be allowed (the same phone
under two events, two events of one type) and what must still be refused (a duplicate inside
one event). The pair matters more than either half — checking only the refusal would pass
just as happily on a global unique index, which is exactly the constraint that was removed.
"""

import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime, timedelta
from typing import Any

import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession

from app.main import app

from .conftest import requires_db

pytestmark = [
    requires_db,
    pytest.mark.usefixtures("app_engine_per_loop", "super_admin"),
]


@pytest_asyncio.fixture
async def client() -> AsyncIterator[AsyncClient]:
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
        yield c


@pytest_asyncio.fixture
async def created_events(session: AsyncSession, client: AsyncClient) -> AsyncIterator[list[str]]:
    """Ids of events created through the admin route, deleted afterwards.

    Teardown goes through SQL rather than the delete endpoint: a test that breaks the delete
    route must still clean up after itself, or every later run inherits its debris.
    """
    ids: list[str] = []
    yield ids
    await session.rollback()
    for event_id in ids:
        await session.execute(text("DELETE FROM event WHERE id = :id"), {"id": event_id})
    await session.commit()


def _event_body(title: str, **overrides: Any) -> dict[str, Any]:
    body: dict[str, Any] = {
        "type": "walima",
        "title_en": title,
        "title_bn": "ওয়ালিমা",
        "starts_at": (datetime.now(UTC) + timedelta(days=40)).isoformat(),
        "venue_name": "Test Hall",
        "venue_address": "Dhaka",
        # Required since add-event-host-contact: an event with no host cannot render the
        # "Invited By" block its invitation now ends with.
        "host_name_1": "Abdul Karim",
        "host_phone": "+8801711223344",
        "is_published": True,
    }
    body.update(overrides)
    return body


def _guest_body(name: str, **overrides: Any) -> dict[str, Any]:
    body: dict[str, Any] = {
        "full_name": name,
        "phone": "01712345000",
        "email": f"{uuid.uuid4().hex[:10]}@example.com",
        "side": "bride",
        "max_guests": 2,
    }
    body.update(overrides)
    return body


async def _create_event(
    client: AsyncClient, created: list[str], title: str, **overrides: Any
) -> dict[str, Any]:
    response = await client.post("/api/admin/events", json=_event_body(title, **overrides))
    assert response.status_code == 201, response.text
    body: dict[str, Any] = response.json()
    created.append(body["id"])
    return body


# ------------------------------------------------------------------ events


async def test_two_events_of_one_type_coexist(
    client: AsyncClient, created_events: list[str]
) -> None:
    """The old `uq_event_wedding_type` index encoded "one wedding, exactly three ceremonies"
    into the schema. A family holding two receptions is not a data error (design D12)."""
    tag = uuid.uuid4().hex[:6]
    first = await _create_event(client, created_events, f"Walima Dhaka {tag}")
    second = await _create_event(client, created_events, f"Walima Sylhet {tag}")

    assert first["id"] != second["id"]
    assert first["type"] == second["type"] == "walima"
    assert first["slug"] != second["slug"]


async def test_slug_collision_resolves_without_rejecting_the_create(
    client: AsyncClient, created_events: list[str]
) -> None:
    """Two events with the same name must both be creatable — the admin is not asked to
    rename their event to satisfy a URL constraint they never chose."""
    title = f"Walima {uuid.uuid4().hex[:6]}"
    first = await _create_event(client, created_events, title)
    second = await _create_event(client, created_events, title)

    assert second["slug"] != first["slug"]
    assert second["slug"].startswith(first["slug"])


async def test_a_name_with_no_ascii_still_yields_a_usable_slug(
    client: AsyncClient, created_events: list[str]
) -> None:
    """A Bangla-only name transliterates to nothing, so the slug falls back to the event
    type plus a suffix rather than to an empty string that would break the open route."""
    event = await _create_event(client, created_events, "ওয়ালিমা", title_en="ওয়ালিমা")

    assert event["slug"]
    assert event["slug"].startswith("walima")


async def test_deleting_an_event_states_and_destroys_its_cost(
    client: AsyncClient, session: AsyncSession, created_events: list[str]
) -> None:
    """Delete cascades to the guests and invitations that belong to the event, and the
    impact endpoint reports the same numbers the confirmation dialog shows."""
    event = await _create_event(client, created_events, f"Walima Del {uuid.uuid4().hex[:6]}")
    event_id = event["id"]

    created = await client.post(
        f"/api/admin/events/{event_id}/guests", json=_guest_body("Doomed Guest")
    )
    assert created.status_code == 201, created.text
    guest_id = created.json()["id"]

    impact = await client.get(f"/api/admin/events/{event_id}/delete-impact")
    assert impact.status_code == 200
    assert impact.json()["guests"] == 1

    deleted = await client.delete(f"/api/admin/events/{event_id}")
    assert deleted.status_code == 204

    await session.rollback()  # see the other connection's committed state
    remaining = await session.scalar(
        text("SELECT count(*) FROM guest WHERE id = :id"), {"id": guest_id}
    )
    invitations = await session.scalar(
        text("SELECT count(*) FROM invitation WHERE guest_id = :id"), {"id": guest_id}
    )
    assert remaining == 0
    assert invitations == 0


# ------------------------------------------------------------------ guests


async def test_the_same_phone_is_accepted_under_a_different_event(
    client: AsyncClient, created_events: list[str]
) -> None:
    """The half of the change that a global unique index would silently break."""
    tag = uuid.uuid4().hex[:6]
    walima = await _create_event(client, created_events, f"Walima {tag}")
    mehedi = await _create_event(client, created_events, f"Mehedi {tag}", type="mehedi")
    phone = "01712345901"

    first = await client.post(
        f"/api/admin/events/{walima['id']}/guests", json=_guest_body("Rahim Uddin", phone=phone)
    )
    assert first.status_code == 201, first.text

    second = await client.post(
        f"/api/admin/events/{mehedi['id']}/guests", json=_guest_body("Rahim Uddin", phone=phone)
    )
    assert second.status_code == 201, second.text

    # Two records, not one guest holding two invitations.
    assert second.json()["id"] != first.json()["id"]
    assert len(second.json()["invitations"]) == 1
    assert second.json()["event_id"] == mehedi["id"]


async def test_a_duplicate_within_one_event_is_still_refused(
    client: AsyncClient, created_events: list[str]
) -> None:
    """Scoping uniqueness to the event must not amount to dropping it."""
    event = await _create_event(client, created_events, f"Walima Dup {uuid.uuid4().hex[:6]}")
    phone = "01712345902"

    first = await client.post(
        f"/api/admin/events/{event['id']}/guests", json=_guest_body("First Entry", phone=phone)
    )
    assert first.status_code == 201, first.text

    second = await client.post(
        f"/api/admin/events/{event['id']}/guests", json=_guest_body("Second Entry", phone=phone)
    )
    assert second.status_code == 409
    assert "First Entry" in second.json()["detail"]


async def test_the_event_guest_list_shows_only_its_own_guests(
    client: AsyncClient, created_events: list[str]
) -> None:
    tag = uuid.uuid4().hex[:6]
    mine = await _create_event(client, created_events, f"Walima Mine {tag}")
    theirs = await _create_event(client, created_events, f"Walima Theirs {tag}")

    await client.post(f"/api/admin/events/{mine['id']}/guests", json=_guest_body(f"Mine {tag}"))
    await client.post(f"/api/admin/events/{theirs['id']}/guests", json=_guest_body(f"Theirs {tag}"))

    listing = await client.get(f"/api/admin/events/{mine['id']}/guests")
    assert listing.status_code == 200
    names = [g["full_name"] for g in listing.json()["items"]]
    assert f"Mine {tag}" in names
    assert f"Theirs {tag}" not in names


# ------------------------------------------------------------------ CSV import


def _csv(rows: list[tuple[str, str]]) -> bytes:
    header = "full_name,phone,email\n"
    body = "".join(f"{name},{phone},\n" for name, phone in rows)
    return (header + body).encode()


async def test_csv_import_dedupes_within_the_event_only(
    client: AsyncClient, created_events: list[str]
) -> None:
    """A phone already on another event's list is a new guest here, not a re-import.

    The sheet is also the place where the old `events` column could place guests on a list
    the admin was not looking at; the destination now comes from the route alone.
    """
    tag = uuid.uuid4().hex[:6]
    walima = await _create_event(client, created_events, f"Walima CSV {tag}")
    mehedi = await _create_event(client, created_events, f"Mehedi CSV {tag}", type="mehedi")
    phone = "01712345903"

    sheet = _csv([(f"Imported {tag}", phone)])

    into_walima = await client.post(
        f"/api/admin/events/{walima['id']}/guests/import",
        files={"file": ("guests.csv", sheet, "text/csv")},
    )
    assert into_walima.status_code == 200, into_walima.text
    assert into_walima.json()["imported"] == 1

    # Same sheet, different event: a fresh import, not a merge.
    into_mehedi = await client.post(
        f"/api/admin/events/{mehedi['id']}/guests/import",
        files={"file": ("guests.csv", sheet, "text/csv")},
    )
    assert into_mehedi.status_code == 200, into_mehedi.text
    assert into_mehedi.json()["imported"] == 1
    assert into_mehedi.json()["merged"] == 0

    # Same sheet, same event: now it is a merge.
    again = await client.post(
        f"/api/admin/events/{walima['id']}/guests/import",
        files={"file": ("guests.csv", sheet, "text/csv")},
    )
    assert again.status_code == 200, again.text
    assert again.json()["imported"] == 0
    assert again.json()["merged"] == 1


# ------------------------------------------------------------------ suppression


async def test_unsubscribing_suppresses_one_event_and_not_another(
    client: AsyncClient, created_events: list[str]
) -> None:
    """The consent exposure design D11 accepted, asserted rather than assumed.

    A person on two lists who opts out of one keeps receiving the other. The test exists to
    make that behaviour deliberate and visible: if it ever needs to change, this is the test
    that fails and forces the conversation.
    """
    tag = uuid.uuid4().hex[:6]
    walima = await _create_event(client, created_events, f"Walima Unsub {tag}")
    mehedi = await _create_event(client, created_events, f"Mehedi Unsub {tag}", type="mehedi")
    phone = "01712345904"

    on_walima = await client.post(
        f"/api/admin/events/{walima['id']}/guests", json=_guest_body("Both Lists", phone=phone)
    )
    on_mehedi = await client.post(
        f"/api/admin/events/{mehedi['id']}/guests", json=_guest_body("Both Lists", phone=phone)
    )
    assert on_walima.status_code == 201 and on_mehedi.status_code == 201
    walima_token = on_walima.json()["invitations"][0]["token"]

    preview = await client.get(f"/api/unsubscribe/{walima_token}")
    assert preview.status_code == 200
    # The copy has to name the event, or the guest is being told something untrue.
    assert preview.json()["event_title_en"] == f"Walima Unsub {tag}"
    assert preview.json()["already_unsubscribed"] is False

    # A GET must not act — a link scanner fetching the URL cannot opt anybody out.
    still = await client.get(f"/api/unsubscribe/{walima_token}")
    assert still.json()["already_unsubscribed"] is False

    acted = await client.post(f"/api/unsubscribe/{walima_token}", json={"confirm": True})
    assert acted.status_code == 200
    assert acted.json()["already_unsubscribed"] is True

    suppressed = await client.get(f"/api/admin/guests/{on_walima.json()['id']}")
    untouched = await client.get(f"/api/admin/guests/{on_mehedi.json()['id']}")
    assert suppressed.json()["do_not_contact"] is True
    assert untouched.json()["do_not_contact"] is False


async def test_unsubscribe_without_confirmation_is_refused(
    client: AsyncClient, created_events: list[str]
) -> None:
    event = await _create_event(client, created_events, f"Walima Conf {uuid.uuid4().hex[:6]}")
    created = await client.post(
        f"/api/admin/events/{event['id']}/guests", json=_guest_body("Careful Guest")
    )
    token = created.json()["invitations"][0]["token"]

    response = await client.post(f"/api/unsubscribe/{token}", json={"confirm": False})
    assert response.status_code == 400
