"""Юнит-тесты `services/conferences.py`: retry при коллизии номера/slug, резолв,
постановка рассылки .ics-приглашений и инкремент `ics_sequence`.
"""

import uuid
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock

import pytest
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

import services.conferences as conferences_module
from core.security import hash_password
from models.conference import Conference
from models.user import User
from schemas.conferences import ConferenceCreateIn, ConferenceUpdateIn, InviteeIn
from services.conference_ids import generate_number, generate_slug
from services.conferences import ConferenceService, InviteeUserNotFoundError

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


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


async def test_create_retries_on_number_collision_and_succeeds(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Первая попытка сталкивается с занятым номером, вторая — успевает с новым."""
    owner = await _make_user(db_session)
    existing = Conference(number="123456789", slug=generate_slug(), status="active")
    db_session.add(existing)
    await db_session.commit()

    fresh_number = generate_number()
    while fresh_number == "123456789":
        fresh_number = generate_number()
    attempts = iter(["123456789", fresh_number])
    monkeypatch.setattr(conferences_module, "generate_number", lambda: next(attempts))

    service = ConferenceService(db_session)
    conference, join = await service.create(owner=owner, data=ConferenceCreateIn())

    assert conference.number == fresh_number
    assert join is not None


async def test_create_gives_up_after_max_attempts(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    """При постоянной коллизии номера и slug после 5 попыток исключение всплывает."""
    owner = await _make_user(db_session)
    existing = Conference(number="223456789", slug="always-colliding-slug", status="active")
    db_session.add(existing)
    await db_session.commit()

    attempt_count = 0

    def _always_colliding_number() -> str:
        nonlocal attempt_count
        attempt_count += 1
        return "223456789"

    monkeypatch.setattr(conferences_module, "generate_number", _always_colliding_number)
    monkeypatch.setattr(conferences_module, "generate_slug", lambda: "always-colliding-slug")

    service = ConferenceService(db_session)
    with pytest.raises(IntegrityError):
        await service.create(owner=owner, data=ConferenceCreateIn())

    assert attempt_count == conferences_module.MAX_ID_GENERATION_ATTEMPTS


async def test_resolve_returns_none_for_unknown_query(db_session: AsyncSession) -> None:
    service = ConferenceService(db_session)
    assert await service.resolve("nothing-like-this-exists") is None


async def test_resolve_by_number_strips_internal_spaces(db_session: AsyncSession) -> None:
    conference = Conference(number=generate_number(), slug=generate_slug(), status="active")
    db_session.add(conference)
    await db_session.commit()

    service = ConferenceService(db_session)
    number = conference.number
    grouped = f"{number[:3]} {number[3:6]} {number[6:]}"

    resolved = await service.resolve(grouped)
    assert resolved is not None
    assert resolved.id == conference.id


# --- Постановка `send_invitations` + `ics_sequence` ----------


async def test_create_instant_conference_does_not_enqueue_invitations(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    owner = await _make_user(db_session)
    await db_session.commit()
    mock_enqueue = MagicMock()
    monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)

    service = ConferenceService(db_session)
    await service.create(owner=owner, data=ConferenceCreateIn())

    mock_enqueue.assert_not_called()


async def test_create_scheduled_conference_enqueues_invitations(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    owner = await _make_user(db_session)
    await db_session.commit()
    mock_enqueue = MagicMock()
    monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)

    service = ConferenceService(db_session)
    conference, _ = await service.create(owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE))

    mock_enqueue.assert_called_once_with(conference.id)


async def test_create_with_summary_recipients_override_is_persisted(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    owner = await _make_user(db_session)
    await db_session.commit()
    monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())

    service = ConferenceService(db_session)
    conference, _ = await service.create(
        owner=owner,
        data=ConferenceCreateIn(scheduled_at=FUTURE, summary_recipients="owner"),
    )

    assert conference.summary_recipients == "owner"
    assert service.to_out(conference).summary_recipients == "owner"


async def test_update_schedule_change_increments_sequence_and_enqueues(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    owner = await _make_user(db_session)
    await db_session.commit()
    monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())

    service = ConferenceService(db_session)
    conference, _ = await service.create(
        owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE, duration_minutes=30)
    )
    assert conference.ics_sequence == 0

    mock_enqueue = MagicMock()
    monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
    new_time = FUTURE + timedelta(hours=1)
    updated = await service.update(
        conference.id, actor=owner, data=ConferenceUpdateIn(scheduled_at=new_time)
    )

    assert updated.ics_sequence == 1
    mock_enqueue.assert_called_once_with(conference.id)


async def test_update_unrelated_field_does_not_touch_sequence(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    owner = await _make_user(db_session)
    await db_session.commit()
    monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())

    service = ConferenceService(db_session)
    conference, _ = await service.create(
        owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE, password=None)
    )

    mock_enqueue = MagicMock()
    monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
    updated = await service.update(
        conference.id, actor=owner, data=ConferenceUpdateIn(is_closed=True, password="secret1")
    )

    assert updated.ics_sequence == 0
    mock_enqueue.assert_not_called()


async def test_update_summary_recipients_explicit_null_resets_override(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    owner = await _make_user(db_session)
    await db_session.commit()
    monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())

    service = ConferenceService(db_session)
    conference, _ = await service.create(
        owner=owner,
        data=ConferenceCreateIn(scheduled_at=FUTURE, summary_recipients="owner"),
    )
    assert conference.summary_recipients == "owner"

    updated = await service.update(
        conference.id, actor=owner, data=ConferenceUpdateIn(summary_recipients=None)
    )

    assert updated.summary_recipients is None


# --- Постановка рассылки не должна ронять запрос при недоступности брокера ------------


async def test_create_survives_enqueue_invitations_failure(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Конференция уже закоммичена — сбой постановки .ics-рассылки (Redis недоступен)
    не должен ронять `create` 500-й; компенсация — ручная рассылка из админки."""
    owner = await _make_user(db_session)
    await db_session.commit()
    monkeypatch.setattr(
        conferences_module,
        "enqueue_invitations",
        MagicMock(side_effect=ConnectionError("брокер недоступен")),
    )

    service = ConferenceService(db_session)
    conference, join = await service.create(
        owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE)
    )

    assert join is None
    assert conference.id is not None
    reloaded = await ConferenceService(db_session).resolve(conference.number)
    assert reloaded is not None
    assert reloaded.id == conference.id


async def test_update_survives_enqueue_invitations_failure(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Аналогично для `update`: правка расписания сохраняется и `ics_sequence`
    растёт, даже если постановку рассылки не удалось поставить в очередь."""
    owner = await _make_user(db_session)
    await db_session.commit()
    monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())

    service = ConferenceService(db_session)
    conference, _ = await service.create(
        owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE, duration_minutes=30)
    )

    monkeypatch.setattr(
        conferences_module,
        "enqueue_invitations",
        MagicMock(side_effect=ConnectionError("брокер недоступен")),
    )
    new_time = FUTURE + timedelta(hours=1)
    updated = await service.update(
        conference.id, actor=owner, data=ConferenceUpdateIn(scheduled_at=new_time)
    )

    assert updated.ics_sequence == 1
    assert updated.scheduled_at == new_time


# --- Участники (ADR-003) ---------------------------------------


async def test_create_participants_dedupes_owner_and_lists_organizer_first(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Организатор всегда первый в `participants` и неудаляем — передача его же
    (по `user_id` и по email) в списке молча дедуплицируется (ADR-003, п.2)."""
    owner = await _make_user(db_session, name="Owner Tester")
    external_invitee = await _make_user(db_session, name="Invited Colleague")
    await db_session.commit()
    monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())

    service = ConferenceService(db_session)
    conference, _ = await service.create(
        owner=owner,
        data=ConferenceCreateIn(
            scheduled_at=FUTURE,
            participants=[
                InviteeIn(user_id=owner.id),  # дубль владельца — дедуп
                InviteeIn(email=owner.email.upper()),  # email владельца — тоже дедуп
                InviteeIn(user_id=external_invitee.id),
            ],
        ),
    )

    out = await service.to_detail_out(conference, viewer=owner)
    assert [p.user_id for p in out.participants] == [owner.id, external_invitee.id]
    assert out.participants[0].is_organizer is True
    assert out.participants[1].is_organizer is False
    assert out.participants[1].name == "Invited Colleague"


async def test_create_with_unknown_participant_user_id_raises(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    owner = await _make_user(db_session)
    await db_session.commit()
    monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())
    unknown_user_id = uuid.uuid4()

    service = ConferenceService(db_session)
    with pytest.raises(InviteeUserNotFoundError):
        await service.create(
            owner=owner,
            data=ConferenceCreateIn(
                scheduled_at=FUTURE, participants=[InviteeIn(user_id=unknown_user_id)]
            ),
        )


async def test_update_replaces_participant_set(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    owner = await _make_user(db_session)
    first_invitee = await _make_user(db_session, name="First Invitee")
    second_invitee = await _make_user(db_session, name="Second Invitee")
    await db_session.commit()
    monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())

    service = ConferenceService(db_session)
    conference, _ = await service.create(
        owner=owner,
        data=ConferenceCreateIn(
            scheduled_at=FUTURE, participants=[InviteeIn(user_id=first_invitee.id)]
        ),
    )

    updated = await service.update(
        conference.id,
        actor=owner,
        data=ConferenceUpdateIn(participants=[InviteeIn(user_id=second_invitee.id)]),
    )

    out = await service.to_detail_out(updated, viewer=owner)
    participant_ids = {p.user_id for p in out.participants}
    assert participant_ids == {owner.id, second_invitee.id}
    assert first_invitee.id not in participant_ids


async def test_update_participants_none_does_not_change_existing_set(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    """`participants=None` в PATCH — «не менять» (ADR-003, п.3)."""
    owner = await _make_user(db_session)
    invitee = await _make_user(db_session, name="Untouched Invitee")
    await db_session.commit()
    monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())

    service = ConferenceService(db_session)
    conference, _ = await service.create(
        owner=owner,
        data=ConferenceCreateIn(scheduled_at=FUTURE, participants=[InviteeIn(user_id=invitee.id)]),
    )

    updated = await service.update(
        conference.id, actor=owner, data=ConferenceUpdateIn(title="Renamed")
    )

    out = await service.to_detail_out(updated, viewer=owner)
    assert invitee.id in {p.user_id for p in out.participants}


async def test_update_participants_only_change_enqueues_without_incrementing_sequence(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Изменение ТОЛЬКО состава: рассылка ставится, `ics_sequence` не растёт (анти-спам)."""
    owner = await _make_user(db_session)
    invitee = await _make_user(db_session, name="New Invitee")
    await db_session.commit()
    monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())

    service = ConferenceService(db_session)
    conference, _ = await service.create(
        owner=owner, data=ConferenceCreateIn(scheduled_at=FUTURE, duration_minutes=30)
    )
    assert conference.ics_sequence == 0

    mock_enqueue = MagicMock()
    monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
    updated = await service.update(
        conference.id,
        actor=owner,
        data=ConferenceUpdateIn(participants=[InviteeIn(user_id=invitee.id)]),
    )

    assert updated.ics_sequence == 0
    mock_enqueue.assert_called_once_with(conference.id)


async def test_update_same_participant_set_does_not_enqueue(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Повторная передача ТОГО ЖЕ состава — фактических изменений нет, рассылка не ставится."""
    owner = await _make_user(db_session)
    invitee = await _make_user(db_session, name="Same Invitee")
    await db_session.commit()
    monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())

    service = ConferenceService(db_session)
    conference, _ = await service.create(
        owner=owner,
        data=ConferenceCreateIn(scheduled_at=FUTURE, participants=[InviteeIn(user_id=invitee.id)]),
    )

    mock_enqueue = MagicMock()
    monkeypatch.setattr(conferences_module, "enqueue_invitations", mock_enqueue)
    await service.update(
        conference.id,
        actor=owner,
        data=ConferenceUpdateIn(participants=[InviteeIn(user_id=invitee.id)]),
    )

    mock_enqueue.assert_not_called()


async def test_list_my_does_not_populate_participants(
    db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
    """`/my` не раздувает состав — `participants` пуст (ADR-003, п.5)."""
    owner = await _make_user(db_session)
    invitee = await _make_user(db_session, name="Should Not Appear")
    await db_session.commit()
    monkeypatch.setattr(conferences_module, "enqueue_invitations", MagicMock())

    service = ConferenceService(db_session)
    conference, _ = await service.create(
        owner=owner,
        data=ConferenceCreateIn(is_pinned=True, participants=[InviteeIn(user_id=invitee.id)]),
    )

    my_list = await service.list_my(owner=owner)
    item = next(c for c in my_list if c.id == conference.id)
    assert item.participants == []
    assert item.owner_id == owner.id
    assert item.is_owner is True
    assert item.organizer_name == owner.name_user
