"""Cross-customer isolation, against a real database (spec event-management, design D2, D3).

This is the file that would have caught the bug this change exists to prevent: a Host reading
another customer's guest list. The unit tests in `test_scope.py` assert the shape of the SQL
fragment; only a real database proves the fragment is actually in the query that ran.

Every "cannot reach" assertion checks the **status code and the body**, not just that it
failed. A 403 where a 404 belongs is a working endpoint and a broken design — it tells a Host
that the id they guessed is real (design D3).

Skips cleanly without Postgres, like the rest of the integration suite.
"""

import uuid
from collections.abc import AsyncIterator
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.models.enums import AdminRole, AuthMethod
from app.services.auth import CurrentAdmin, get_current_admin

from .conftest import requires_db

pytestmark = [requires_db, pytest.mark.asyncio]


async def _make_admin(session: AsyncSession, role: AdminRole, *, tag: str) -> tuple[uuid.UUID, str]:
    admin_id = uuid.uuid4()
    email = f"{tag}-{admin_id.hex[:8]}@example.test"
    await session.execute(
        text(
            "INSERT INTO admin_user (id, email, name, role, status, auth_method, "
            "                        must_change_password, failed_login_count) "
            "VALUES (:id, :email, :name, :role, 'active', 'google', false, 0)"
        ),
        {"id": admin_id, "email": email, "name": tag, "role": role.value},
    )
    await session.commit()
    return admin_id, email


def _as(admin_id: uuid.UUID, email: str, role: AdminRole, **kw: Any) -> CurrentAdmin:
    return CurrentAdmin(
        id=admin_id,
        email=email,
        name="Test",
        role=role,
        auth_method=kw.get("auth_method", AuthMethod.GOOGLE),
        must_change_password=kw.get("must_change_password", False),
    )


@pytest_asyncio.fixture
async def other_host(
    session: AsyncSession, app_engine_per_loop: None
) -> AsyncIterator[dict[str, Any]]:
    """A Host who owns nothing in `fixture_event` — the whole point of these tests."""
    from app.main import app

    admin_id, email = await _make_admin(session, AdminRole.HOST, tag="other-host")
    app.dependency_overrides[get_current_admin] = lambda: _as(admin_id, email, AdminRole.HOST)
    yield {"id": admin_id, "email": email}
    app.dependency_overrides.pop(get_current_admin, None)
    await session.execute(text("DELETE FROM audit_log WHERE admin_user_id = :id"), {"id": admin_id})
    await session.execute(text("DELETE FROM admin_user WHERE id = :id"), {"id": admin_id})
    await session.commit()


@pytest_asyncio.fixture
async def client() -> AsyncIterator[AsyncClient]:
    from app.main import app

    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
        yield c


# ------------------------------------------------------- a host cannot reach what it owns not


async def test_a_host_sees_none_of_another_owners_events(
    client: AsyncClient, fixture_event: dict[str, Any], other_host: dict[str, Any]
) -> None:
    response = await client.get("/api/admin/events")
    assert response.status_code == 200
    ids = {e["id"] for e in response.json()}
    assert str(fixture_event["event_id"]) not in ids


@pytest.mark.parametrize(
    "path_template",
    [
        "/api/admin/events/{event_id}/delete-impact",
        "/api/admin/events/{event_id}/guests",
        "/api/admin/events/{event_id}/guest-ids",
        "/api/admin/events/{event_id}/card-designs",
    ],
)
async def test_a_host_reaching_a_foreign_event_gets_the_same_404_as_a_fake_id(
    client: AsyncClient,
    fixture_event: dict[str, Any],
    other_host: dict[str, Any],
    path_template: str,
) -> None:
    """Real id and fabricated id must be indistinguishable (design D3).

    Comparing the two responses rather than asserting `== 404` on one: a design that answers
    404 for both is correct, and one that answers 403 for the real id leaks the difference
    however tidy the status code looks on its own.
    """
    real = await client.get(path_template.format(event_id=fixture_event["event_id"]))
    fake = await client.get(path_template.format(event_id=uuid.uuid4()))

    assert real.status_code == fake.status_code == 404
    assert real.json() == fake.json()


async def test_a_host_cannot_delete_another_owners_event(
    client: AsyncClient,
    fixture_event: dict[str, Any],
    other_host: dict[str, Any],
    session: AsyncSession,
) -> None:
    response = await client.delete(f"/api/admin/events/{fixture_event['event_id']}")
    assert response.status_code == 404

    still_there = await session.scalar(
        text("SELECT count(*) FROM event WHERE id = :id").bindparams(id=fixture_event["event_id"])
    )
    assert still_there == 1


async def test_a_host_cannot_read_another_owners_guest_by_id(
    client: AsyncClient,
    fixture_event: dict[str, Any],
    other_host: dict[str, Any],
    session: AsyncSession,
) -> None:
    guest_id = await session.scalar(
        text("SELECT id FROM guest WHERE event_id = :e LIMIT 1").bindparams(
            e=fixture_event["event_id"]
        )
    )
    real = await client.get(f"/api/admin/guests/{guest_id}")
    fake = await client.get(f"/api/admin/guests/{uuid.uuid4()}")
    assert real.status_code == fake.status_code == 404


async def test_a_host_cannot_reach_another_owners_invitation_qr(
    client: AsyncClient, fixture_event: dict[str, Any], other_host: dict[str, Any]
) -> None:
    """A QR encodes the guest's bearer token — handing one over is handing over the link."""
    invitation_id = fixture_event["invitation_ids"][0]
    response = await client.get(f"/api/admin/qr/guest/{invitation_id}")
    assert response.status_code == 404


async def test_a_hosts_cross_event_guest_list_is_empty_of_foreign_guests(
    client: AsyncClient, fixture_event: dict[str, Any], other_host: dict[str, Any]
) -> None:
    response = await client.get("/api/admin/guests")
    assert response.status_code == 200
    assert response.json()["total"] == 0


async def test_filtering_by_a_foreign_event_id_does_not_widen_the_result(
    client: AsyncClient, fixture_event: dict[str, Any], other_host: dict[str, Any]
) -> None:
    """Scope is applied before the filters, so a filter can only ever narrow (design D2)."""
    response = await client.get(
        "/api/admin/guests", params={"event_id": str(fixture_event["event_id"])}
    )
    assert response.status_code == 200
    assert response.json()["total"] == 0


async def test_a_hosts_export_contains_no_foreign_guests(
    client: AsyncClient, fixture_event: dict[str, Any], other_host: dict[str, Any]
) -> None:
    """The widest read in the admin surface, and the one nobody watches."""
    response = await client.get("/api/admin/export")
    assert response.status_code == 200
    body = response.content.decode("utf-8-sig")
    assert "Guest 0" not in body
    assert fixture_event["tag"] not in body


async def test_a_hosts_dashboard_counts_exclude_foreign_events(
    client: AsyncClient, fixture_event: dict[str, Any], other_host: dict[str, Any]
) -> None:
    response = await client.get("/api/admin/stats")
    assert response.status_code == 200
    payload = response.json()
    assert payload["events"] == []
    assert payload["aggregate"]["guest_records"] == 0
    assert payload["aggregate"]["total_headcount"] == 0
    # An empty state, not a row of zeroes implying the system holds nothing.
    assert payload["owns_nothing"] is True


async def test_a_host_cannot_read_another_owners_message_log(
    client: AsyncClient, fixture_event: dict[str, Any], other_host: dict[str, Any]
) -> None:
    """Message rows carry the recipient's address and the failure reason."""
    response = await client.get("/api/admin/messages")
    assert response.status_code == 200
    assert response.json()["total"] == 0


async def test_a_host_cannot_reach_the_roster(
    client: AsyncClient, other_host: dict[str, Any]
) -> None:
    """403, not 404: the roster plainly exists, and a Host's own account is on it."""
    response = await client.get("/api/admin/users")
    assert response.status_code == 403


async def test_a_host_cannot_change_a_wedding_wide_setting(
    client: AsyncClient, fixture_event: dict[str, Any], other_host: dict[str, Any]
) -> None:
    # A deliverable-looking address on purpose: FastAPI validates the body before the
    # handler runs, so a malformed one would 422 and never reach the guard being tested.
    response = await client.patch("/api/admin/wedding", json={"host_email": "hijack@example.com"})
    assert response.status_code == 403
    assert "Super Admin" in response.json()["detail"]


# --------------------------------------------------------------- the owner still works


@pytest_asyncio.fixture
async def owning_host(
    session: AsyncSession, fixture_event: dict[str, Any], app_engine_per_loop: None
) -> AsyncIterator[dict[str, Any]]:
    """The fixture event's own owner, demoted to Host.

    The control for every test above: the same request that a foreign Host is refused must
    succeed for the owner, or the tests are only proving the endpoint is broken.
    """
    from app.main import app

    owner_id = fixture_event["owner_admin_id"]
    await session.execute(
        text("UPDATE admin_user SET role = 'host' WHERE id = :id"), {"id": owner_id}
    )
    await session.commit()
    app.dependency_overrides[get_current_admin] = lambda: _as(
        owner_id, "owner@example.test", AdminRole.HOST
    )
    yield {"id": owner_id}
    app.dependency_overrides.pop(get_current_admin, None)


async def test_the_owning_host_sees_their_own_event(
    client: AsyncClient, fixture_event: dict[str, Any], owning_host: dict[str, Any]
) -> None:
    response = await client.get("/api/admin/events")
    assert response.status_code == 200
    assert str(fixture_event["event_id"]) in {e["id"] for e in response.json()}


async def test_the_owning_host_sees_their_own_guests_and_counts(
    client: AsyncClient, fixture_event: dict[str, Any], owning_host: dict[str, Any]
) -> None:
    guests = await client.get(f"/api/admin/events/{fixture_event['event_id']}/guests")
    assert guests.status_code == 200
    assert guests.json()["total"] == fixture_event["guest_count"]

    stats = await client.get("/api/admin/stats")
    assert stats.status_code == 200
    assert stats.json()["owns_nothing"] is False
    assert len(stats.json()["events"]) == 1


async def test_the_owning_host_may_delete_their_own_guest(
    client: AsyncClient,
    fixture_event: dict[str, Any],
    owning_host: dict[str, Any],
    session: AsyncSession,
) -> None:
    """A widening from the old Co-host, and the reason it is safe: they own the list."""
    guest_id = await session.scalar(
        text("SELECT id FROM guest WHERE event_id = :e LIMIT 1").bindparams(
            e=fixture_event["event_id"]
        )
    )
    response = await client.delete(f"/api/admin/guests/{guest_id}")
    assert response.status_code in (200, 204)


# ----------------------------------------------------------- the temporary-password gate


@pytest_asyncio.fixture
async def confined_super_admin(
    session: AsyncSession, app_engine_per_loop: None
) -> AsyncIterator[None]:
    """A Super Admin who holds a temporary password — maximum role, minimum reach."""
    from app.main import app

    admin_id, email = await _make_admin(session, AdminRole.SUPER_ADMIN, tag="confined")
    app.dependency_overrides[get_current_admin] = lambda: _as(
        admin_id,
        email,
        AdminRole.SUPER_ADMIN,
        auth_method=AuthMethod.PASSWORD,
        must_change_password=True,
    )
    yield
    app.dependency_overrides.pop(get_current_admin, None)
    await session.execute(text("DELETE FROM audit_log WHERE admin_user_id = :id"), {"id": admin_id})
    await session.execute(text("DELETE FROM admin_user WHERE id = :id"), {"id": admin_id})
    await session.commit()


@pytest.mark.parametrize(
    "method,path",
    [
        ("get", "/api/admin/stats"),
        ("get", "/api/admin/events"),
        ("get", "/api/admin/guests"),
        ("get", "/api/admin/users"),
        ("get", "/api/admin/export"),
        ("get", "/api/admin/messages"),
    ],
)
async def test_a_confined_session_reaches_no_admin_surface(
    client: AsyncClient, confined_super_admin: None, method: str, path: str
) -> None:
    """Enforced by the API, so navigating straight to a screen cannot escape it (D14)."""
    response = await getattr(client, method)(path)
    assert response.status_code == 403
    assert response.json()["detail"] == "password_change_required"


async def test_a_confined_session_can_still_read_its_own_identity(
    client: AsyncClient, confined_super_admin: None
) -> None:
    """One of the three deliberate exemptions — the frontend has to learn it is confined."""
    response = await client.get("/api/auth/me")
    assert response.status_code == 200
    assert response.json()["must_change_password"] is True
