"""The two manual-send routes, through ASGI against real Postgres.

`test_manual_invitation_send.py` covers composition as a unit. This covers the half a unit
test cannot: that the endpoint refuses what it must refuse *before* anything is queued, that
the quiet-hours override needs a confirmation the server asked for, and that a dry run is
reported as a rehearsal rather than dressed up as a delivery.

The suppression cases matter most. `do_not_contact` is a consent record, and an endpoint that
lets a confirmation flag past it is indistinguishable from one that has no check at all.
"""

import uuid
from typing import Any

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

from app.main import app
from app.models.enums import AdminRole
from app.services.auth import CurrentAdmin, get_current_admin

from .conftest import requires_db

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

MESSAGE = {
    "subject": "You're invited",
    "header": "Dear Guest 0",
    "body": "Please come to the Walima.",
    "footer": "Regards\nTest & Case",
}


async def _first_invitation(session: AsyncSession, fixture_event: dict[str, Any]) -> uuid.UUID:
    invitation_id: uuid.UUID = fixture_event["invitation_ids"][0]
    return invitation_id


async def _suppress(session: AsyncSession, invitation_id: uuid.UUID, column: str) -> None:
    """Set one suppression flag on the guest behind this invitation."""
    await session.execute(
        text(
            f"UPDATE guest SET {column} = true WHERE id = "
            "(SELECT guest_id FROM invitation WHERE id = :id)"
        ),
        {"id": invitation_id},
    )
    await session.commit()


async def _clear_email(session: AsyncSession, invitation_id: uuid.UUID) -> None:
    """Leave the guest reachable by phone only.

    `ck_guest_has_contact` requires one contact method, so the phone goes in as the email
    comes out — which is also the real shape of this case: a guest invited by printed card
    who has a number on file and no address.
    """
    await session.execute(
        text(
            "UPDATE guest SET email = NULL, phone_e164 = :phone WHERE id = "
            "(SELECT guest_id FROM invitation WHERE id = :id)"
        ),
        {"id": invitation_id, "phone": f"+88017{uuid.uuid4().int % 10**8:08d}"},
    )
    await session.commit()


# ------------------------------------------------------------------ composing


async def test_compose_returns_the_parts_an_admin_edits(
    session: AsyncSession, fixture_event: dict[str, Any]
) -> None:
    invitation_id = await _first_invitation(session, fixture_event)
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        response = await client.get(f"/api/admin/invitations/{invitation_id}/message")

    assert response.status_code == 200, response.text
    body = response.json()
    assert body["header"] == "Dear Guest 0"
    assert body["footer"] == "Regards\nTest & Case"
    assert "You are cordially invited" in body["body"]
    assert f"/i/tok-{fixture_event['tag']}-0" in body["invite_url"]
    assert body["invite_url"] in body["body"]
    assert body["to_email"].startswith("guest0-")
    assert body["blocked_reason"] is None
    assert body["last_sent_at"] is None


async def test_compose_reports_why_a_guest_cannot_be_emailed(
    session: AsyncSession, fixture_event: dict[str, Any]
) -> None:
    """The panel disables its send control on this, so it must be populated, not inferred."""
    invitation_id = await _first_invitation(session, fixture_event)
    await _suppress(session, invitation_id, "do_not_contact")

    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        response = await client.get(f"/api/admin/invitations/{invitation_id}/message")

    assert response.json()["blocked_reason"] == "This guest unsubscribed from this event."


async def test_compose_404s_for_an_invitation_that_does_not_exist() -> None:
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        response = await client.get(f"/api/admin/invitations/{uuid.uuid4()}/message")
    assert response.status_code == 404


# ------------------------------------------------------------------ sending


async def test_a_send_reports_its_real_outcome(
    session: AsyncSession, fixture_event: dict[str, Any]
) -> None:
    """D7: an admin watching a button press gets the outcome, not a queue receipt."""
    invitation_id = await _first_invitation(session, fixture_event)
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        response = await client.post(
            f"/api/admin/invitations/{invitation_id}/send",
            json={**MESSAGE, "confirmed_quiet_hours": True},
        )

    assert response.status_code == 200, response.text
    body = response.json()
    assert body["status"] == "sent"
    assert body["sent_at"] is not None
    assert body["error_message"] is None
    # The dev stack rehearses rather than sending, and says so rather than claiming delivery.
    assert body["dry_run"] is True


async def test_a_sent_message_becomes_the_last_sent_time_on_the_next_compose(
    session: AsyncSession, fixture_event: dict[str, Any]
) -> None:
    """D8: the panel warns about a duplicate using this, so it has to reflect a real send."""
    invitation_id = await _first_invitation(session, fixture_event)
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        await client.post(
            f"/api/admin/invitations/{invitation_id}/send",
            json={**MESSAGE, "confirmed_quiet_hours": True},
        )
        again = await client.get(f"/api/admin/invitations/{invitation_id}/message")

    assert again.json()["last_sent_at"] is not None


async def test_resending_is_allowed(session: AsyncSession, fixture_event: dict[str, Any]) -> None:
    """D2: "they never got it" is the case this feature exists for."""
    invitation_id = await _first_invitation(session, fixture_event)
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        first = await client.post(
            f"/api/admin/invitations/{invitation_id}/send",
            json={**MESSAGE, "confirmed_quiet_hours": True},
        )
        second = await client.post(
            f"/api/admin/invitations/{invitation_id}/send",
            json={**MESSAGE, "confirmed_quiet_hours": True},
        )

    assert first.status_code == second.status_code == 200, second.text
    assert first.json()["job_id"] != second.json()["job_id"]
    assert second.json()["status"] == "sent"


@pytest.mark.parametrize(
    ("column", "expected"),
    [
        ("do_not_contact", "This guest unsubscribed from this event."),
        ("email_invalid", "This address hard-bounced, so email to it is disabled."),
    ],
)
async def test_a_suppressed_guest_is_refused_even_with_confirmation(
    session: AsyncSession, fixture_event: dict[str, Any], column: str, expected: str
) -> None:
    """D6: no flag in the request body may talk its way past a consent record."""
    invitation_id = await _first_invitation(session, fixture_event)
    await _suppress(session, invitation_id, column)

    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        response = await client.post(
            f"/api/admin/invitations/{invitation_id}/send",
            json={**MESSAGE, "confirmed_quiet_hours": True},
        )

    assert response.status_code == 409
    detail = response.json()["detail"]
    assert detail["code"] == "suppressed"
    assert detail["message"] == expected


async def test_a_guest_with_no_address_is_refused(
    session: AsyncSession, fixture_event: dict[str, Any]
) -> None:
    invitation_id = await _first_invitation(session, fixture_event)
    await _clear_email(session, invitation_id)

    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        response = await client.post(
            f"/api/admin/invitations/{invitation_id}/send",
            json={**MESSAGE, "confirmed_quiet_hours": True},
        )

    assert response.status_code == 409
    assert response.json()["detail"]["message"] == "This guest has no email address."


async def test_no_job_is_recorded_for_a_refused_send(
    session: AsyncSession, fixture_event: dict[str, Any]
) -> None:
    """A refusal must leave nothing behind for the worker to pick up later."""
    invitation_id = await _first_invitation(session, fixture_event)
    await _suppress(session, invitation_id, "do_not_contact")

    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        await client.post(
            f"/api/admin/invitations/{invitation_id}/send",
            json={**MESSAGE, "confirmed_quiet_hours": True},
        )

    await session.rollback()
    count = await session.scalar(
        text("SELECT count(*) FROM message_job WHERE invitation_id = :id").bindparams(
            id=invitation_id
        )
    )
    assert count == 0


@pytest.mark.parametrize(
    ("payload", "missing"),
    [
        ({"subject": "   "}, "a subject"),
        ({"header": "", "body": "  ", "footer": ""}, "a body"),
    ],
)
async def test_an_empty_message_is_rejected(
    session: AsyncSession, fixture_event: dict[str, Any], payload: dict[str, str], missing: str
) -> None:
    invitation_id = await _first_invitation(session, fixture_event)
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        response = await client.post(
            f"/api/admin/invitations/{invitation_id}/send",
            json={**MESSAGE, **payload, "confirmed_quiet_hours": True},
        )

    assert response.status_code == 422
    assert missing in response.json()["detail"]["message"]


async def test_quiet_hours_ask_before_sending_then_send_on_confirmation(
    session: AsyncSession, fixture_event: dict[str, Any], monkeypatch: pytest.MonkeyPatch
) -> None:
    """D5: the server owns the clock — the browser only relays the question.

    Quiet hours are forced on rather than waited for; `test_reminders` covers the arithmetic.
    """
    from app.routers import admin_guests

    monkeypatch.setattr(admin_guests.messaging, "in_quiet_hours", lambda *a, **k: True)
    invitation_id = await _first_invitation(session, fixture_event)

    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        unconfirmed = await client.post(
            f"/api/admin/invitations/{invitation_id}/send", json=MESSAGE
        )
        confirmed = await client.post(
            f"/api/admin/invitations/{invitation_id}/send",
            json={**MESSAGE, "confirmed_quiet_hours": True},
        )

    assert unconfirmed.status_code == 409
    detail = unconfirmed.json()["detail"]
    assert detail["code"] == "quiet_hours"
    assert detail["local_time"] in detail["message"]

    # Confirmed, it goes out now rather than being parked until 08:00.
    assert confirmed.status_code == 200, confirmed.text
    assert confirmed.json()["status"] == "sent"


async def test_the_compose_response_flags_quiet_hours_for_the_panel(
    session: AsyncSession, fixture_event: dict[str, Any], monkeypatch: pytest.MonkeyPatch
) -> None:
    from app.routers import admin_guests

    monkeypatch.setattr(admin_guests.messaging, "in_quiet_hours", lambda *a, **k: True)
    invitation_id = await _first_invitation(session, fixture_event)

    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        response = await client.get(f"/api/admin/invitations/{invitation_id}/message")

    assert response.json()["in_quiet_hours"] is True
    assert len(response.json()["local_time"]) == 5  # HH:MM


# ------------------------------------------------------------------ authorization


@pytest.mark.parametrize("method,suffix", [("GET", "message"), ("POST", "send")])
async def test_a_host_who_does_not_own_the_event_is_refused_by_the_api(
    session: AsyncSession, fixture_event: dict[str, Any], method: str, suffix: str
) -> None:
    """The frontend hides these controls; that is not the same as enforcing them.

    Replaces the Viewer case. A Host holds `send_messages`, so nothing in the capability
    matrix stops this — what stops it is that the invitation belongs to somebody else's
    event, and the answer is a 404 so the id itself is not confirmed (design D3).
    """
    invitation_id = await _first_invitation(session, fixture_event)
    app.dependency_overrides[get_current_admin] = lambda: CurrentAdmin(
        id=uuid.uuid4(), email="stranger@example.com", name="Stranger", role=AdminRole.HOST
    )
    try:
        async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
            response = await client.request(
                method, f"/api/admin/invitations/{invitation_id}/{suffix}", json=MESSAGE
            )
    finally:
        app.dependency_overrides.pop(get_current_admin, None)

    assert response.status_code == 404


@pytest.mark.parametrize("method,suffix", [("GET", "message"), ("POST", "send")])
async def test_a_host_who_owns_the_event_may_send(
    session: AsyncSession, fixture_event: dict[str, Any], method: str, suffix: str
) -> None:
    """Sending invitations is exactly what a host is for — on their own event."""
    invitation_id = await _first_invitation(session, fixture_event)
    admin_id = uuid.uuid4()
    await session.execute(
        text(
            "INSERT INTO admin_user (id, email, name, role, status, auth_method, "
            "                        must_change_password, failed_login_count) "
            "VALUES (:id, :email, 'Owning Host', 'host', 'active', 'google', false, 0)"
        ),
        {"id": admin_id, "email": f"host-{admin_id.hex[:8]}@example.com"},
    )
    # Ownership is the half that decides it; the capability was never in doubt.
    await session.execute(
        text("UPDATE event SET owner_admin_id = :admin WHERE id = :event"),
        {"admin": admin_id, "event": fixture_event["event_id"]},
    )
    await session.commit()

    app.dependency_overrides[get_current_admin] = lambda: CurrentAdmin(
        id=admin_id, email="host@example.com", name="Owning Host", role=AdminRole.HOST
    )
    try:
        async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
            response = await client.request(
                method,
                f"/api/admin/invitations/{invitation_id}/{suffix}",
                json={**MESSAGE, "confirmed_quiet_hours": True},
            )
    finally:
        app.dependency_overrides.pop(get_current_admin, None)
        await session.rollback()
        # Hand the event back before removing the host: `owner_admin_id` is ON DELETE
        # RESTRICT, so the delete below is refused while it still points here.
        await session.execute(
            text("UPDATE event SET owner_admin_id = :owner WHERE id = :event"),
            {"owner": fixture_event["owner_admin_id"], "event": fixture_event["event_id"]},
        )
        await session.execute(
            text("DELETE FROM audit_log WHERE admin_user_id = :id"), {"id": admin_id}
        )
        await session.execute(
            text("UPDATE message_job SET sent_by_admin_id = NULL WHERE sent_by_admin_id = :id"),
            {"id": admin_id},
        )
        await session.execute(text("DELETE FROM admin_user WHERE id = :id"), {"id": admin_id})
        await session.commit()

    assert response.status_code == 200, response.text
