"""Интеграционные тесты `/api/v1/conferences`: создание, «мои», календарь, резолв,
вход (пользователь/гость), пароль закрытых, правки, rate limit.
"""

import json
import uuid
from collections.abc import Generator
from datetime import UTC, date, datetime, timedelta
from pathlib import Path

import httpx
import jwt
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from core.config import get_settings
from core.security import create_access_token, hash_password
from models.conference import Conference
from models.guest import GuestAccess
from models.invitee import ConferenceInvitee
from models.user import User
from services.conference_ids import generate_number, generate_slug
from services.instance_settings import InstanceSettingsService, SettingsUpdateIn

FUTURE = datetime.now(UTC) + timedelta(days=3)


def _iso(dt: datetime) -> str:
    return dt.isoformat()


async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
    user = User(
        email=f"{uuid.uuid4()}@example.com",
        name_user="Conference Tester",
        password_hash=await hash_password("password123"),
        email_verified=True,
        role=role,
    )
    session.add(user)
    await session.flush()
    return user


def _auth_headers(user: User) -> dict[str, str]:
    token = create_access_token(user.id, user.role)
    return {"Authorization": f"Bearer {token}"}


async def _make_conference(
    session: AsyncSession,
    *,
    owner_id: uuid.UUID | None = None,
    status: str = "active",
    is_pinned: bool = False,
    is_closed: bool = False,
    password: str | None = None,
    ended_at: datetime | None = None,
    scheduled_at: datetime | None = None,
    duration_minutes: int | None = None,
    recurrence: dict[str, object] | None = None,
) -> Conference:
    conference = Conference(
        number=generate_number(),
        slug=generate_slug(),
        owner_id=owner_id,
        status=status,
        is_pinned=is_pinned,
        is_closed=is_closed,
        password_hash=await hash_password(password) if password else None,
        ended_at=ended_at,
        scheduled_at=scheduled_at,
        duration_minutes=duration_minutes,
    )
    if recurrence is not None:
        conference.recurrence = recurrence
    session.add(conference)
    await session.flush()
    return conference


def _weekly_recurrence() -> dict[str, object]:
    """Правило еженедельного повторения — для регрессионных тестов «Моих конференций»."""
    return {
        "type": "weekly",
        "weekdays": [0],
        "anchor_date": "2026-08-03",
        "time_local": "09:00",
        "timezone": "UTC",
        "duration_minutes": 30,
    }


# --- Создание -----------------------------------------------------------------


async def test_create_instant_conference_returns_active_with_join(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    user = await _make_user(db_session)
    await db_session.commit()

    response = await client.post(
        "/api/v1/conferences", json={"title": "Standup"}, headers=_auth_headers(user)
    )
    assert response.status_code == 201, response.text
    body = response.json()
    assert body["status"] == "active"
    assert len(body["number"]) == 9
    assert body["number"].isdigit()
    assert body["slug"]
    assert body["join"] is not None
    assert body["join"]["conference_id"] == body["id"]
    assert body["join"]["room_name"] == body["slug"]
    assert body["join"]["token"]
    # Рычаги нагрузки медиа — дефолты без ограничения (существующие
    # инсталляции не должны получить внезапно ухудшенное качество).
    assert body["join"]["publish_quality_cap"] == "off"
    assert body["join"]["stage_max_tiles"] == 25
    # Замена фона — отключаемый модуль (сессия 35); значение нужно клиенту ДО
    # первого рендера комнаты, поэтому едет в join. Здесь проверяется только
    # НАЛИЧИЕ поля: тумблер живёт в общей dev-БД, и сравнение с дефолтом
    # ломалось бы от того, что разработчик включил модуль руками (см.
    # `test_join_reports_virtual_background_toggle` — там значение задаётся явно).
    assert isinstance(body["join"]["virtual_background_enabled"], bool)


async def test_create_instant_conference_join_metadata_contains_owner_avatar_url(
    client: httpx.AsyncClient, db_session: AsyncSession, media_root: Path
) -> None:
    """Мгновенное создание: владелец сразу входит — токен из `join` должен нести его аватар."""
    user = await _make_user(db_session)
    avatar_path = f"avatars/{user.id}.png"
    (media_root / "avatars").mkdir(parents=True, exist_ok=True)
    (media_root / avatar_path).write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16)
    user.avatar_path = avatar_path
    await db_session.commit()

    response = await client.post(
        "/api/v1/conferences", json={"title": "Standup"}, headers=_auth_headers(user)
    )
    assert response.status_code == 201, response.text
    payload = _decode_livekit_token(response.json()["join"]["token"])
    metadata = json.loads(str(payload["metadata"]))
    assert metadata["avatar_url"].startswith(f"/media/avatars/{user.id}.png?v=")


async def test_create_scheduled_conference_returns_scheduled_without_join(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    user = await _make_user(db_session)
    await db_session.commit()

    response = await client.post(
        "/api/v1/conferences",
        json={"title": "Planning", "scheduled_at": _iso(FUTURE), "duration_minutes": 30},
        headers=_auth_headers(user),
    )
    assert response.status_code == 201, response.text
    body = response.json()
    assert body["status"] == "scheduled"
    assert body["join"] is None


async def test_create_closed_conference_without_password_returns_422(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    user = await _make_user(db_session)
    await db_session.commit()

    response = await client.post(
        "/api/v1/conferences", json={"is_closed": True}, headers=_auth_headers(user)
    )
    assert response.status_code == 422


async def test_create_recurrence_without_pinned_returns_422(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    user = await _make_user(db_session)
    await db_session.commit()

    recurrence = {
        "type": "weekly",
        "weekdays": [0, 2, 4],
        "anchor_date": "2026-01-05",
        "time_local": "10:00",
        "timezone": "Europe/Moscow",
        "duration_minutes": 45,
    }
    response = await client.post(
        "/api/v1/conferences",
        json={"is_pinned": False, "recurrence": recurrence},
        headers=_auth_headers(user),
    )
    assert response.status_code == 422


async def test_create_scheduled_in_the_past_returns_422(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    user = await _make_user(db_session)
    await db_session.commit()

    past = datetime.now(UTC) - timedelta(hours=1)
    response = await client.post(
        "/api/v1/conferences",
        json={"title": "Past", "scheduled_at": _iso(past)},
        headers=_auth_headers(user),
    )
    assert response.status_code == 422


async def test_create_pinned_scheduled_in_the_past_is_allowed(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    """Закреплённая конференция — не разовое событие, прошлая дата для неё осмысленна."""
    user = await _make_user(db_session)
    await db_session.commit()

    past = datetime.now(UTC) - timedelta(hours=1)
    response = await client.post(
        "/api/v1/conferences",
        json={"title": "Pinned past", "scheduled_at": _iso(past), "is_pinned": True},
        headers=_auth_headers(user),
    )
    assert response.status_code == 201
    assert response.json()["is_pinned"] is True


# --- «Мои конференции» ---------------------------------------------------------


async def test_pinned_conference_visible_in_my_conferences_immediately(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    user = await _make_user(db_session)
    await db_session.commit()

    created = await client.post(
        "/api/v1/conferences",
        json={"title": "Weekly sync", "is_pinned": True},
        headers=_auth_headers(user),
    )
    assert created.status_code == 201
    conference_id = created.json()["id"]

    my_response = await client.get("/api/v1/conferences/my", headers=_auth_headers(user))
    assert my_response.status_code == 200
    ids = [c["id"] for c in my_response.json()]
    assert conference_id in ids


async def test_non_pinned_instant_conference_not_in_my_conferences(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    user = await _make_user(db_session)
    await db_session.commit()

    created = await client.post(
        "/api/v1/conferences", json={"title": "Ad hoc"}, headers=_auth_headers(user)
    )
    conference_id = created.json()["id"]

    my_response = await client.get("/api/v1/conferences/my", headers=_auth_headers(user))
    assert conference_id not in [c["id"] for c in my_response.json()]


# --- Регрессия: «Мои конференции» не должны быть пустыми ---------------------------------------


async def test_admin_my_conferences_shows_pinned_recurring_and_upcoming_one_off(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    """Сценарий из бага: admin с закреплённой повторяющейся + разовой предстоящей видит обе."""
    admin = await _make_user(db_session, role="admin")
    pinned_recurring = await _make_conference(
        db_session,
        owner_id=admin.id,
        status="scheduled",
        is_pinned=True,
        recurrence=_weekly_recurrence(),
    )
    upcoming_one_off = await _make_conference(
        db_session, owner_id=admin.id, status="scheduled", scheduled_at=FUTURE
    )
    await db_session.commit()

    response = await client.get("/api/v1/conferences/my", headers=_auth_headers(admin))
    assert response.status_code == 200, response.text
    ids = {c["id"] for c in response.json()}
    assert str(pinned_recurring.id) in ids
    assert str(upcoming_one_off.id) in ids


async def test_regular_user_my_conferences_shows_pinned_recurring_and_upcoming_one_off(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    """Тот же сценарий под обычным `user` — баг мог быть ролевым."""
    user = await _make_user(db_session, role="user")
    pinned_recurring = await _make_conference(
        db_session,
        owner_id=user.id,
        status="scheduled",
        is_pinned=True,
        recurrence=_weekly_recurrence(),
    )
    upcoming_one_off = await _make_conference(
        db_session, owner_id=user.id, status="scheduled", scheduled_at=FUTURE
    )
    await db_session.commit()

    response = await client.get("/api/v1/conferences/my", headers=_auth_headers(user))
    assert response.status_code == 200, response.text
    ids = {c["id"] for c in response.json()}
    assert str(pinned_recurring.id) in ids
    assert str(upcoming_one_off.id) in ids


async def test_my_conferences_excludes_others_and_ended(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    """Чужие конференции и завершённые разовые в выдачу владельца не попадают."""
    owner = await _make_user(db_session)
    stranger = await _make_user(db_session)
    mine_pinned = await _make_conference(
        db_session, owner_id=owner.id, status="scheduled", is_pinned=True
    )
    mine_upcoming = await _make_conference(
        db_session, owner_id=owner.id, status="scheduled", scheduled_at=FUTURE
    )
    mine_ended_one_off = await _make_conference(
        db_session,
        owner_id=owner.id,
        status="ended",
        scheduled_at=datetime.now(UTC) - timedelta(days=1),
        ended_at=datetime.now(UTC),
    )
    strangers_pinned = await _make_conference(
        db_session, owner_id=stranger.id, status="scheduled", is_pinned=True
    )
    await db_session.commit()

    response = await client.get("/api/v1/conferences/my", headers=_auth_headers(owner))
    assert response.status_code == 200, response.text
    ids = {c["id"] for c in response.json()}
    assert str(mine_pinned.id) in ids
    assert str(mine_upcoming.id) in ids
    assert str(mine_ended_one_off.id) not in ids
    assert str(strangers_pinned.id) not in ids


# --- Видимость приглашённого в /my и /calendar (решение 2026-07-20 поверх ADR-003) ---------


async def test_invitee_by_user_id_sees_pinned_and_upcoming_in_my_conferences(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    owner = await _make_user(db_session)
    invitee = await _make_user(db_session)
    await db_session.commit()

    pinned = await _make_conference(
        db_session, owner_id=owner.id, status="scheduled", is_pinned=True
    )
    upcoming = await _make_conference(
        db_session, owner_id=owner.id, status="scheduled", scheduled_at=FUTURE
    )
    not_invited = await _make_conference(
        db_session, owner_id=owner.id, status="scheduled", is_pinned=True
    )
    db_session.add(ConferenceInvitee(conference_id=pinned.id, user_id=invitee.id))
    db_session.add(ConferenceInvitee(conference_id=upcoming.id, user_id=invitee.id))
    await db_session.commit()

    response = await client.get("/api/v1/conferences/my", headers=_auth_headers(invitee))
    assert response.status_code == 200, response.text
    items = {c["id"]: c for c in response.json()}
    assert str(pinned.id) in items
    assert str(upcoming.id) in items
    assert str(not_invited.id) not in items
    assert items[str(pinned.id)]["is_owner"] is False
    assert items[str(pinned.id)]["organizer_name"] == owner.name_user


async def test_invitee_by_email_case_insensitive_sees_conference_in_my_conferences(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    """Внешнее приглашение на email, под которым человек зарегистрирован (иной регистр)."""
    owner = await _make_user(db_session)
    invitee = await _make_user(db_session)
    await db_session.commit()

    pinned = await _make_conference(
        db_session, owner_id=owner.id, status="scheduled", is_pinned=True
    )
    db_session.add(ConferenceInvitee(conference_id=pinned.id, email=invitee.email.upper()))
    await db_session.commit()

    response = await client.get("/api/v1/conferences/my", headers=_auth_headers(invitee))
    assert response.status_code == 200, response.text
    ids = {c["id"] for c in response.json()}
    assert str(pinned.id) in ids


async def test_invitee_sees_occurrences_in_calendar(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    owner = await _make_user(db_session)
    invitee = await _make_user(db_session)
    await db_session.commit()

    anchor = date(2026, 8, 3)  # понедельник
    recurrence = {
        "type": "weekly",
        "weekdays": [0],
        "anchor_date": anchor.isoformat(),
        "time_local": "09:00",
        "timezone": "UTC",
        "duration_minutes": 30,
    }
    conference = await _make_conference(
        db_session, owner_id=owner.id, status="scheduled", is_pinned=True, recurrence=recurrence
    )
    db_session.add(ConferenceInvitee(conference_id=conference.id, user_id=invitee.id))
    await db_session.commit()

    t_from = datetime(2026, 8, 1, tzinfo=UTC)
    t_to = datetime(2026, 8, 15, tzinfo=UTC)
    response = await client.get(
        "/api/v1/conferences/calendar",
        params={"from": _iso(t_from), "to": _iso(t_to)},
        headers=_auth_headers(invitee),
    )
    assert response.status_code == 200, response.text
    occurrences = response.json()
    assert len(occurrences) == 2
    assert all(o["conference_id"] == str(conference.id) for o in occurrences)


async def test_invitee_by_user_id_gets_detail_with_participants(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    owner = await _make_user(db_session)
    invitee = await _make_user(db_session)
    conference = await _make_conference(db_session, owner_id=owner.id, status="scheduled")
    db_session.add(ConferenceInvitee(conference_id=conference.id, user_id=invitee.id))
    await db_session.commit()

    response = await client.get(
        f"/api/v1/conferences/{conference.id}", headers=_auth_headers(invitee)
    )
    assert response.status_code == 200, response.text
    body = response.json()
    assert body["is_owner"] is False
    assert body["organizer_name"] == owner.name_user
    user_ids = {p["user_id"] for p in body["participants"] if p["user_id"] is not None}
    assert str(invitee.id) in user_ids


async def test_invitee_by_user_id_patch_returns_403(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    """Видимость в списках/detail не расширяет права на правку/удаление (403 как раньше)."""
    owner = await _make_user(db_session)
    invitee = await _make_user(db_session)
    conference = await _make_conference(db_session, owner_id=owner.id, status="scheduled")
    db_session.add(ConferenceInvitee(conference_id=conference.id, user_id=invitee.id))
    await db_session.commit()

    patch_response = await client.patch(
        f"/api/v1/conferences/{conference.id}",
        json={"title": "Hijack"},
        headers=_auth_headers(invitee),
    )
    assert patch_response.status_code == 403
    assert patch_response.json()["detail"] == "not_owner"

    delete_response = await client.delete(
        f"/api/v1/conferences/{conference.id}", headers=_auth_headers(invitee)
    )
    assert delete_response.status_code == 403
    assert delete_response.json()["detail"] == "not_owner"


# --- Календарь ------------------------------------------------------------------


async def test_calendar_expands_recurrence_occurrences(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    user = await _make_user(db_session)
    await db_session.commit()

    anchor = date(2026, 8, 3)  # понедельник
    recurrence = {
        "type": "weekly",
        "weekdays": [0],
        "anchor_date": anchor.isoformat(),
        "time_local": "09:00",
        "timezone": "UTC",
        "duration_minutes": 30,
    }
    created = await client.post(
        "/api/v1/conferences",
        json={"title": "Weekly", "is_pinned": True, "recurrence": recurrence},
        headers=_auth_headers(user),
    )
    assert created.status_code == 201

    t_from = datetime(2026, 8, 1, tzinfo=UTC)
    t_to = datetime(2026, 8, 15, tzinfo=UTC)
    response = await client.get(
        "/api/v1/conferences/calendar",
        params={"from": _iso(t_from), "to": _iso(t_to)},
        headers=_auth_headers(user),
    )
    assert response.status_code == 200
    occurrences = response.json()
    assert len(occurrences) == 2  # 3 и 10 августа 2026


async def test_calendar_range_too_wide_returns_422(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    user = await _make_user(db_session)
    await db_session.commit()

    t_from = datetime.now(UTC)
    t_to = t_from + timedelta(days=100)
    response = await client.get(
        "/api/v1/conferences/calendar",
        params={"from": _iso(t_from), "to": _iso(t_to)},
        headers=_auth_headers(user),
    )
    assert response.status_code == 422


# --- Резолв (slug/номер) ---------------------------------------------------------


async def test_resolve_by_slug_returns_200(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    user = await _make_user(db_session)
    await db_session.commit()

    created = await client.post(
        "/api/v1/conferences", json={"title": "Findable"}, headers=_auth_headers(user)
    )
    slug = created.json()["slug"]

    response = await client.get("/api/v1/conferences/resolve", params={"q": slug})
    assert response.status_code == 200
    body = response.json()
    assert body["title"] == "Findable"
    assert body["requires_password"] is False


async def test_resolve_by_number_ignores_spaces(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    user = await _make_user(db_session)
    await db_session.commit()

    created = await client.post(
        "/api/v1/conferences", json={"title": "Numbered"}, headers=_auth_headers(user)
    )
    number = created.json()["number"]
    grouped = f"{number[:3]} {number[3:6]} {number[6:]}"

    response = await client.get("/api/v1/conferences/resolve", params={"q": grouped})
    assert response.status_code == 200
    assert response.json()["title"] == "Numbered"


async def test_resolve_ended_conference_returns_minimal_response(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    """ADR-001, п.4 (уточнение резолва): для ended — только id/title/status, без пароля."""
    conference = await _make_conference(
        db_session, status="ended", is_closed=True, password="secret1", ended_at=datetime.now(UTC)
    )
    await db_session.commit()

    response = await client.get("/api/v1/conferences/resolve", params={"q": conference.slug})
    assert response.status_code == 200
    body = response.json()
    assert body["id"] == str(conference.id)
    assert body["status"] == "ended"
    assert body.get("is_closed") is None
    assert body.get("requires_password") is None


async def test_resolve_unknown_returns_uniform_404(client: httpx.AsyncClient) -> None:
    response = await client.get(
        "/api/v1/conferences/resolve", params={"q": "does-not-exist-at-all"}
    )
    assert response.status_code == 404
    assert response.json()["detail"] == "not_found"


def _ip_headers() -> dict[str, str]:
    """Уникальный `X-Real-IP` на каждый тест.

    Счётчики rate limit живут в Redis 60 секунд и общие для всего инстанса,
    поэтому без изоляции тесты влияли бы друг на друга через остаточные ключи.
    Заодно это проверяет, что заголовок вообще читается: раньше ключ строился
    по `request.client.host`, то есть по адресу nginx, одинаковому для всех.
    """
    return {"X-Real-IP": f"198.51.100.{uuid.uuid4().int % 250 + 1}-{uuid.uuid4().hex[:8]}"}


async def test_resolve_misses_are_rate_limited(client: httpx.AsyncClient) -> None:
    """Перебор номера конференции упирается в жёсткий лимит промахов (ADR-001, п.4)."""
    headers = _ip_headers()
    for _ in range(10):
        response = await client.get(
            "/api/v1/conferences/resolve", params={"q": "irrelevant-query"}, headers=headers
        )
        assert response.status_code == 404

    limited = await client.get(
        "/api/v1/conferences/resolve", params={"q": "irrelevant-query"}, headers=headers
    )
    assert limited.status_code == 429


async def test_successful_resolves_are_not_limited_by_miss_counter(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    """Вся конференция может открыть ссылку одновременно (регресс теста 31.07.2026).

    Прежняя схема считала любые запросы с лимитом 10/мин, и одиннадцатый
    участник получал 429 — фронтенд показывал «Не удалось найти конференцию»
    для существующей и активной конференции.
    """
    conference = await _make_conference(db_session)
    await db_session.commit()
    headers = _ip_headers()

    for _ in range(50):
        response = await client.get(
            "/api/v1/conferences/resolve", params={"q": conference.slug}, headers=headers
        )
        assert response.status_code == 200, response.text


async def test_rate_limit_is_per_client_ip(client: httpx.AsyncClient) -> None:
    """Счётчик привязан к адресу клиента, а не к адресу nginx.

    Исчерпав лимит промахов с одного адреса, с другого по-прежнему можно
    работать. До исправления ключ был общим на весь инстанс.
    """
    first, second = _ip_headers(), _ip_headers()
    for _ in range(11):
        await client.get(
            "/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=first
        )

    exhausted = await client.get(
        "/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=first
    )
    assert exhausted.status_code == 429

    other = await client.get(
        "/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=second
    )
    assert other.status_code == 404, "лимит одного клиента не должен задевать другого"


# --- Вход зарегистрированным пользователем ---------------------------------------


async def test_join_ended_conference_returns_410(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    user = await _make_user(db_session)
    conference = await _make_conference(db_session, status="ended", ended_at=datetime.now(UTC))
    await db_session.commit()

    response = await client.post(
        f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(user)
    )
    assert response.status_code == 410
    assert response.json()["detail"] == "conference_ended"


async def test_join_closed_conference_without_password_returns_403(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    owner = await _make_user(db_session)
    stranger = await _make_user(db_session)
    conference = await _make_conference(
        db_session, owner_id=owner.id, is_closed=True, password="secret1"
    )
    await db_session.commit()

    response = await client.post(
        f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(stranger)
    )
    assert response.status_code == 403
    assert response.json()["detail"] == "password_required"


async def test_join_closed_conference_wrong_password_returns_403(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    owner = await _make_user(db_session)
    stranger = await _make_user(db_session)
    conference = await _make_conference(
        db_session, owner_id=owner.id, is_closed=True, password="secret1"
    )
    await db_session.commit()

    response = await client.post(
        f"/api/v1/conferences/{conference.id}/join",
        json={"password": "wrong-password"},
        headers=_auth_headers(stranger),
    )
    assert response.status_code == 403
    assert response.json()["detail"] == "invalid_password"


async def test_join_closed_conference_correct_password_returns_200(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    owner = await _make_user(db_session)
    stranger = await _make_user(db_session)
    conference = await _make_conference(
        db_session, owner_id=owner.id, is_closed=True, password="secret1"
    )
    await db_session.commit()

    response = await client.post(
        f"/api/v1/conferences/{conference.id}/join",
        json={"password": "secret1"},
        headers=_auth_headers(stranger),
    )
    assert response.status_code == 200
    assert response.json()["room_name"] == conference.slug


@pytest.mark.parametrize("enabled", [True, False])
async def test_join_reports_virtual_background_toggle(
    client: httpx.AsyncClient, db_session: AsyncSession, enabled: bool
) -> None:
    """Тумблер модуля «замена фона» доезжает до участника в ответе join.

    Значение задаётся ЯВНО, а не берётся из общей dev-БД: клиенту оно нужно до
    первого рендера комнаты, и подмена тумблера обязана менять ответ join, а не
    только `/admin/settings` (тот доступен лишь администратору).
    """
    owner = await _make_user(db_session)
    conference = await _make_conference(db_session, owner_id=owner.id)
    await InstanceSettingsService(db_session).update(
        SettingsUpdateIn(virtual_background_enabled=enabled)
    )
    await db_session.commit()

    response = await client.post(
        f"/api/v1/conferences/{conference.id}/join", json={}, headers=_auth_headers(owner)
    )
    assert response.status_code == 200, response.text
    assert response.json()["virtual_background_enabled"] is enabled


# --- Аватар в метаданных LiveKit-токена -----------------------------------------


@pytest.fixture
def media_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[Path, None, None]:
    """Изолированный `MEDIA_ROOT` на время теста (см. тот же приём в `test_users_api.py`)."""
    monkeypatch.setenv("MEDIA_ROOT", str(tmp_path))
    get_settings.cache_clear()
    yield tmp_path
    get_settings.cache_clear()


def _decode_livekit_token(token: str) -> dict[str, object]:
    """Декодировать JWT LiveKit-токена (тот же секрет, что использует сервис выдачи)."""
    return jwt.decode(token, get_settings().livekit_api_secret, algorithms=["HS256"])


async def test_join_metadata_contains_avatar_url_for_user_with_avatar(
    client: httpx.AsyncClient, db_session: AsyncSession, media_root: Path
) -> None:
    user = await _make_user(db_session)
    avatar_path = f"avatars/{user.id}.png"
    (media_root / "avatars").mkdir(parents=True, exist_ok=True)
    (media_root / avatar_path).write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16)
    user.avatar_path = avatar_path
    conference = await _make_conference(db_session)
    await db_session.commit()

    response = await client.post(
        f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(user)
    )
    assert response.status_code == 200, response.text
    payload = _decode_livekit_token(response.json()["token"])
    metadata = json.loads(str(payload["metadata"]))
    assert metadata["avatar_url"].startswith(f"/media/avatars/{user.id}.png?v=")


async def test_join_metadata_absent_for_user_without_avatar(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    user = await _make_user(db_session)
    conference = await _make_conference(db_session)
    await db_session.commit()

    response = await client.post(
        f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(user)
    )
    assert response.status_code == 200, response.text
    payload = _decode_livekit_token(response.json()["token"])
    assert "metadata" not in payload


async def test_join_metadata_contains_is_organizer_for_owner(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    owner = await _make_user(db_session)
    conference = await _make_conference(db_session, owner_id=owner.id)
    await db_session.commit()

    response = await client.post(
        f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(owner)
    )
    assert response.status_code == 200, response.text
    payload = _decode_livekit_token(response.json()["token"])
    metadata = json.loads(str(payload["metadata"]))
    assert metadata["is_organizer"] is True


async def test_join_metadata_absent_is_organizer_for_non_owner(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    owner = await _make_user(db_session)
    other = await _make_user(db_session)
    conference = await _make_conference(db_session, owner_id=owner.id)
    await db_session.commit()

    response = await client.post(
        f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(other)
    )
    assert response.status_code == 200, response.text
    payload = _decode_livekit_token(response.json()["token"])
    assert "metadata" not in payload


async def test_guest_join_metadata_is_absent(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    conference = await _make_conference(db_session)
    await db_session.commit()

    response = await client.post(
        f"/api/v1/conferences/{conference.id}/guest-join",
        json={"display_name": "Guest Bob"},
    )
    assert response.status_code == 200, response.text
    payload = _decode_livekit_token(response.json()["token"])
    assert "metadata" not in payload


# --- Гостевой вход --------------------------------------------------------------


async def test_guest_join_creates_guest_access_and_returns_join(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    conference = await _make_conference(db_session)
    await db_session.commit()

    response = await client.post(
        f"/api/v1/conferences/{conference.id}/guest-join",
        json={"display_name": "Guest Alice", "email": "alice-guest@example.com"},
    )
    assert response.status_code == 200, response.text
    body = response.json()
    assert body["room_name"] == conference.slug
    assert body["token"]

    guests = (
        await db_session.scalars(
            select(GuestAccess).where(GuestAccess.conference_id == conference.id)
        )
    ).all()
    assert len(guests) == 1
    assert guests[0].display_name == "Guest Alice"
    assert guests[0].email == "alice-guest@example.com"


async def test_guest_join_reflects_admin_configured_media_limits(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    """Настройки, сохранённые администратором в `PUT /admin/settings`, доезжают
    до гостя в join-ответе ДО входа в комнату — публичный путь доставки
    (см. `services/conference_access.py::build_join`), гость админку не видит."""
    admin = await _make_user(db_session, role="admin")
    conference = await _make_conference(db_session)
    await db_session.commit()

    settings_response = await client.put(
        "/api/v1/admin/settings",
        json={"publish_quality_cap": "180p", "stage_max_tiles": 4},
        headers=_auth_headers(admin),
    )
    assert settings_response.status_code == 200, settings_response.text

    response = await client.post(
        f"/api/v1/conferences/{conference.id}/guest-join",
        json={"display_name": "Guest Bob"},
    )
    assert response.status_code == 200, response.text
    body = response.json()
    assert body["publish_quality_cap"] == "180p"
    assert body["stage_max_tiles"] == 4


async def test_guest_join_without_email_is_allowed(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    conference = await _make_conference(db_session)
    await db_session.commit()

    response = await client.post(
        f"/api/v1/conferences/{conference.id}/guest-join",
        json={"display_name": "Guest No-Email"},
    )
    assert response.status_code == 200


async def test_guest_join_closed_conference_without_password_returns_403(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    conference = await _make_conference(db_session, is_closed=True, password="secret1")
    await db_session.commit()

    response = await client.post(
        f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Guest Bob"}
    )
    assert response.status_code == 403
    assert response.json()["detail"] == "password_required"


async def test_guest_join_closed_conference_with_correct_password_returns_200(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    conference = await _make_conference(db_session, is_closed=True, password="secret1")
    await db_session.commit()

    response = await client.post(
        f"/api/v1/conferences/{conference.id}/guest-join",
        json={"display_name": "Guest Bob", "password": "secret1"},
    )
    assert response.status_code == 200


async def test_guest_join_ended_conference_returns_410(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    conference = await _make_conference(db_session, status="ended", ended_at=datetime.now(UTC))
    await db_session.commit()

    response = await client.post(
        f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Guest Late"}
    )
    assert response.status_code == 410
    assert response.json()["detail"] == "conference_ended"


async def test_guest_join_allows_a_whole_conference_to_enter(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    """Успешные гостевые входы не упираются в лимит промахов.

    На нагрузочном тесте 31.07.2026 конференцию из семи десятков человек не
    пускало внутрь именно это ограничение — счётчик не различал легитимный
    массовый вход и перебор.
    """
    conference = await _make_conference(db_session)
    await db_session.commit()
    headers = _ip_headers()

    for i in range(30):
        response = await client.post(
            f"/api/v1/conferences/{conference.id}/guest-join",
            json={"display_name": f"Guest {i}"},
            headers=headers,
        )
        assert response.status_code == 200, response.text


async def test_guest_join_misses_are_rate_limited(client: httpx.AsyncClient) -> None:
    """Перебор идентификатора конференции по-прежнему упирается в лимит."""
    headers = _ip_headers()
    missing_id = uuid.uuid4()

    for _ in range(10):
        response = await client.post(
            f"/api/v1/conferences/{missing_id}/guest-join",
            json={"display_name": "Bruteforce"},
            headers=headers,
        )
        assert response.status_code == 404

    limited = await client.post(
        f"/api/v1/conferences/{missing_id}/guest-join",
        json={"display_name": "Bruteforce"},
        headers=headers,
    )
    assert limited.status_code == 429


async def test_guest_join_wrong_password_is_rate_limited(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    """Подбор пароля закрытой конференции считается тем же жёстким счётчиком."""
    conference = await _make_conference(db_session, is_closed=True, password="right-password")
    await db_session.commit()
    headers = _ip_headers()

    for _ in range(10):
        response = await client.post(
            f"/api/v1/conferences/{conference.id}/guest-join",
            json={"display_name": "Guesser", "password": "wrong"},
            headers=headers,
        )
        assert response.status_code == 403

    limited = await client.post(
        f"/api/v1/conferences/{conference.id}/guest-join",
        json={"display_name": "Guesser", "password": "wrong"},
        headers=headers,
    )
    assert limited.status_code == 429


# --- Правки/удаление -------------------------------------------------------------


async def test_patch_conference_by_owner_returns_200(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    owner = await _make_user(db_session)
    await db_session.commit()

    created = await client.post(
        "/api/v1/conferences",
        json={"title": "Original", "is_pinned": True},
        headers=_auth_headers(owner),
    )
    conference_id = created.json()["id"]

    response = await client.patch(
        f"/api/v1/conferences/{conference_id}",
        json={"title": "Renamed"},
        headers=_auth_headers(owner),
    )
    assert response.status_code == 200
    assert response.json()["title"] == "Renamed"


async def test_patch_conference_by_stranger_returns_403(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    owner = await _make_user(db_session)
    stranger = await _make_user(db_session)
    await db_session.commit()

    created = await client.post(
        "/api/v1/conferences",
        json={"title": "Mine", "is_pinned": True},
        headers=_auth_headers(owner),
    )
    conference_id = created.json()["id"]

    response = await client.patch(
        f"/api/v1/conferences/{conference_id}",
        json={"title": "Hijack"},
        headers=_auth_headers(stranger),
    )
    assert response.status_code == 403
    assert response.json()["detail"] == "not_owner"


async def test_patch_unpin_conference_with_recurrence_clears_it(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    """Регрессия: открепление не должно требовать явного `recurrence: null`."""
    owner = await _make_user(db_session)
    await db_session.commit()

    recurrence = {
        "type": "weekly",
        "weekdays": [0],
        "anchor_date": "2026-08-03",
        "time_local": "09:00",
        "timezone": "UTC",
        "duration_minutes": 30,
    }
    created = await client.post(
        "/api/v1/conferences",
        json={"title": "Recurring", "is_pinned": True, "recurrence": recurrence},
        headers=_auth_headers(owner),
    )
    assert created.status_code == 201
    assert created.json()["recurrence"] is not None
    conference_id = created.json()["id"]

    response = await client.patch(
        f"/api/v1/conferences/{conference_id}",
        json={"is_pinned": False},
        headers=_auth_headers(owner),
    )
    assert response.status_code == 200, response.text
    body = response.json()
    assert body["is_pinned"] is False
    assert body["recurrence"] is None

    # Проверяем, что в БД именно SQL NULL (а не JSON `null`), иначе следующий
    # PATCH/повторное чтение сломается на CHECK-constraint.
    stored = await db_session.scalar(
        select(Conference.recurrence).where(Conference.id == uuid.UUID(conference_id))
    )
    assert stored is None


async def test_patch_explicit_recurrence_null_clears_it_and_keeps_pinned(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    """Явный `recurrence: null` при `is_pinned=true` очищает правило, но не открепляет."""
    owner = await _make_user(db_session)
    await db_session.commit()

    recurrence = {
        "type": "weekly",
        "weekdays": [0],
        "anchor_date": "2026-08-03",
        "time_local": "09:00",
        "timezone": "UTC",
        "duration_minutes": 30,
    }
    created = await client.post(
        "/api/v1/conferences",
        json={"title": "Recurring", "is_pinned": True, "recurrence": recurrence},
        headers=_auth_headers(owner),
    )
    conference_id = created.json()["id"]

    response = await client.patch(
        f"/api/v1/conferences/{conference_id}",
        json={"recurrence": None},
        headers=_auth_headers(owner),
    )
    assert response.status_code == 200, response.text
    body = response.json()
    assert body["is_pinned"] is True
    assert body["recurrence"] is None

    stored = await db_session.scalar(
        select(Conference.recurrence).where(Conference.id == uuid.UUID(conference_id))
    )
    assert stored is None


async def test_delete_scheduled_conference_by_owner_returns_204(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    owner = await _make_user(db_session)
    await db_session.commit()

    created = await client.post(
        "/api/v1/conferences",
        json={"title": "To delete", "scheduled_at": _iso(FUTURE)},
        headers=_auth_headers(owner),
    )
    conference_id = created.json()["id"]

    response = await client.delete(
        f"/api/v1/conferences/{conference_id}", headers=_auth_headers(owner)
    )
    assert response.status_code == 204


async def test_delete_conference_by_stranger_returns_403(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    """Матрица прав: удалить/отменить конференцию может только организатор."""
    owner = await _make_user(db_session)
    stranger = await _make_user(db_session)
    conference = await _make_conference(db_session, owner_id=owner.id, status="scheduled")
    await db_session.commit()

    response = await client.delete(
        f"/api/v1/conferences/{conference.id}", headers=_auth_headers(stranger)
    )
    assert response.status_code == 403
    assert response.json()["detail"] == "not_owner"


async def test_delete_active_conference_returns_409(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    owner = await _make_user(db_session)
    await db_session.commit()

    created = await client.post(
        "/api/v1/conferences", json={"title": "Active"}, headers=_auth_headers(owner)
    )
    conference_id = created.json()["id"]

    response = await client.delete(
        f"/api/v1/conferences/{conference_id}", headers=_auth_headers(owner)
    )
    assert response.status_code == 409
    assert response.json()["detail"] == "conference_active"


async def test_delete_conference_by_admin_returns_204(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    owner = await _make_user(db_session)
    admin = await _make_user(db_session, role="admin")
    conference = await _make_conference(db_session, owner_id=owner.id, status="scheduled")
    await db_session.commit()

    response = await client.delete(
        f"/api/v1/conferences/{conference.id}", headers=_auth_headers(admin)
    )
    assert response.status_code == 204


async def test_patch_conference_without_token_returns_401(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    conference = await _make_conference(db_session, status="scheduled")
    await db_session.commit()

    response = await client.patch(f"/api/v1/conferences/{conference.id}", json={"title": "x"})
    assert response.status_code == 401


async def test_delete_conference_without_token_returns_401(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    conference = await _make_conference(db_session, status="scheduled")
    await db_session.commit()

    response = await client.delete(f"/api/v1/conferences/{conference.id}")
    assert response.status_code == 401


# --- Детальная карточка (GET /{id}) и участники (ADR-003) -----------


async def test_get_conference_detail_by_owner_returns_participants(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    owner = await _make_user(db_session)
    invitee = await _make_user(db_session)
    await db_session.commit()

    created = await client.post(
        "/api/v1/conferences",
        json={
            "title": "With participants",
            "scheduled_at": _iso(FUTURE),
            "participants": [{"user_id": str(invitee.id)}, {"email": "external@example.com"}],
        },
        headers=_auth_headers(owner),
    )
    assert created.status_code == 201, created.text
    conference_id = created.json()["id"]

    response = await client.get(
        f"/api/v1/conferences/{conference_id}", headers=_auth_headers(owner)
    )
    assert response.status_code == 200, response.text
    body = response.json()
    assert body["owner_id"] == str(owner.id)
    assert body["is_owner"] is True
    assert body["organizer_name"] == owner.name_user
    user_ids = {p["user_id"] for p in body["participants"] if p["user_id"] is not None}
    emails = {p["email"] for p in body["participants"] if p["email"] is not None}
    assert str(owner.id) in user_ids
    assert str(invitee.id) in user_ids
    assert "external@example.com" in emails
    organizer_entries = [p for p in body["participants"] if p["is_organizer"]]
    assert len(organizer_entries) == 1
    assert organizer_entries[0]["user_id"] == str(owner.id)


async def test_get_conference_detail_by_stranger_returns_403(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    owner = await _make_user(db_session)
    stranger = await _make_user(db_session)
    conference = await _make_conference(db_session, owner_id=owner.id, status="scheduled")
    await db_session.commit()

    response = await client.get(
        f"/api/v1/conferences/{conference.id}", headers=_auth_headers(stranger)
    )
    assert response.status_code == 403
    assert response.json()["detail"] == "not_owner"


async def test_get_conference_detail_unknown_id_returns_404(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    owner = await _make_user(db_session)
    await db_session.commit()

    response = await client.get(f"/api/v1/conferences/{uuid.uuid4()}", headers=_auth_headers(owner))
    assert response.status_code == 404


async def test_get_conference_detail_without_token_returns_401(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    conference = await _make_conference(db_session, status="scheduled")
    await db_session.commit()

    response = await client.get(f"/api/v1/conferences/{conference.id}")
    assert response.status_code == 401


async def test_create_with_unknown_participant_user_id_returns_422(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    owner = await _make_user(db_session)
    await db_session.commit()

    response = await client.post(
        "/api/v1/conferences",
        json={
            "title": "Bad invitee",
            "scheduled_at": _iso(FUTURE),
            "participants": [{"user_id": str(uuid.uuid4())}],
        },
        headers=_auth_headers(owner),
    )
    assert response.status_code == 422
    assert response.json()["detail"] == "invitee_user_not_found"


async def test_invitee_both_user_id_and_email_returns_422(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    owner = await _make_user(db_session)
    await db_session.commit()

    response = await client.post(
        "/api/v1/conferences",
        json={
            "title": "Ambiguous invitee",
            "participants": [{"user_id": str(owner.id), "email": "x@example.com"}],
        },
        headers=_auth_headers(owner),
    )
    assert response.status_code == 422


async def test_patch_replaces_participant_set_and_returns_it(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    owner = await _make_user(db_session)
    first_invitee = await _make_user(db_session)
    second_invitee = await _make_user(db_session)
    await db_session.commit()

    created = await client.post(
        "/api/v1/conferences",
        json={
            "title": "Roster",
            "scheduled_at": _iso(FUTURE),
            "participants": [{"user_id": str(first_invitee.id)}],
        },
        headers=_auth_headers(owner),
    )
    conference_id = created.json()["id"]

    response = await client.patch(
        f"/api/v1/conferences/{conference_id}",
        json={"participants": [{"user_id": str(second_invitee.id)}]},
        headers=_auth_headers(owner),
    )
    assert response.status_code == 200, response.text
    user_ids = {p["user_id"] for p in response.json()["participants"] if p["user_id"] is not None}
    assert user_ids == {str(owner.id), str(second_invitee.id)}


async def test_my_conferences_list_does_not_include_participants(
    client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
    owner = await _make_user(db_session)
    invitee = await _make_user(db_session)
    await db_session.commit()

    created = await client.post(
        "/api/v1/conferences",
        json={
            "title": "Pinned with roster",
            "is_pinned": True,
            "participants": [{"user_id": str(invitee.id)}],
        },
        headers=_auth_headers(owner),
    )
    conference_id = created.json()["id"]

    my_response = await client.get("/api/v1/conferences/my", headers=_auth_headers(owner))
    item = next(c for c in my_response.json() if c["id"] == conference_id)
    assert item["participants"] == []
    assert item["is_owner"] is True
    assert item["organizer_name"] == owner.name_user
