"""dynamic conferences (ADR-001)

Реализует раздел «Модель данных» ADR-001 (`docs/architecture/adr/001-dynamic-conferences-pivot.md`):
- переименование `conferences` (сеанс) в `conference_sessions`;
- новая сущность `conferences` (конференция: номер, ссылка, владелец, статус,
  закрепление/закрытость, расписание, recurrence);
- backfill новых `conferences` из существующих `rooms` (по одной на комнату,
  на которую ссылается хотя бы один сеанс) и перевязка `conference_sessions`;
- переименование `conference_id` -> `session_id` в `phrases`/`chat_messages`/
  `conference_participants`;
- `conference_participants`: `user_id` NULLABLE + `guest_id` + CHECK «ровно
  одно из двух заполнено»;
- новая таблица `guest_access`;
- удаление `booking_participants`, `room_bookings` (вместе с ней уходит
  EXCLUDE-constraint — см. ADR-001, п.5) и `rooms`.

Продакшен-данных нет (см. контекст ADR-001) — backfill рассчитан на
непустую тестовую/дев БД, но не падает и на пустой (см. `_backfill_conferences_from_rooms`).

Revision ID: f418dd65e7b1
Revises: 299053c6f7b8
Create Date: 2026-07-16 12:00:00.000000

"""
import secrets
from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
from sqlalchemy.engine import Connection

# revision identifiers, used by Alembic.
revision: str = 'f418dd65e7b1'
down_revision: Union[str, Sequence[str], None] = '299053c6f7b8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def _generate_conference_number(used: set[str]) -> str:
    """Сгенерировать 9-значный номер конференции, уникальный в рамках backfill.

    Логика идентична `services/conference_ids.py::generate_number` — миграции
    не импортируют прикладной код (он может измениться со временем и
    сломать применение старых ревизий), поэтому продублирована здесь.
    """
    while True:
        first_digit = str(secrets.randbelow(9) + 1)
        rest_digits = "".join(str(secrets.randbelow(10)) for _ in range(8))
        number = first_digit + rest_digits
        if number not in used:
            used.add(number)
            return number


def _backfill_conferences_from_rooms(connection: Connection) -> None:
    """Создать по одной `conferences`-записи на каждую `room`, встречающуюся в сеансах.

    `slug` = `permanent_link` комнаты (сохраняет действующие постоянные
    ссылки), `status='ended'` (это уже прожитая история, а не активная
    конференция), `owner_id` не определён (комнаты были общими). На пустой
    БД (нет строк `rooms`) цикл просто не выполняется.
    """
    rooms = connection.execute(
        sa.text(
            """
            SELECT DISTINCT r.id, r.name, r.permanent_link, r.is_pinned, r.created_at
            FROM rooms r
            WHERE EXISTS (SELECT 1 FROM conference_sessions cs WHERE cs.room_id = r.id)
            """
        )
    ).mappings().all()

    used_numbers: set[str] = set()
    for room in rooms:
        connection.execute(
            sa.text(
                """
                INSERT INTO conferences
                    (id, number, slug, title, status, is_pinned, created_at)
                VALUES
                    (gen_random_uuid(), :number, :slug, :title, 'ended', :is_pinned, :created_at)
                """
            ),
            {
                "number": _generate_conference_number(used_numbers),
                "slug": room["permanent_link"],
                "title": room["name"],
                "is_pinned": room["is_pinned"],
                "created_at": room["created_at"],
            },
        )


def upgrade() -> None:
    """Upgrade schema."""
    bind = op.get_bind()

    # 1. conferences (сеанс) -> conference_sessions. FK-constraints и данные
    # сохраняются автоматически (Postgres переносит их вместе с таблицей,
    # constraint-имена, унаследованные от старого имени таблицы, не переименовываются —
    # это косметика, на работу не влияет). Индексы на удаляемой ниже колонке
    # `room_id` пересоздаём под новую модель.
    op.rename_table('conferences', 'conference_sessions')
    op.drop_index('ix_conferences_room_id_t_start', table_name='conference_sessions')
    op.drop_index('ix_conferences_pipeline_status', table_name='conference_sessions')
    op.create_index(
        'ix_conference_sessions_pipeline_status', 'conference_sessions', ['pipeline_status']
    )

    # 2. Новая сущность Conference + её ENUM жизненного цикла (ADR-001, п.2).
    # Тип создаётся автоматически вместе с таблицей (create_type=True по
    # умолчанию) — отдельный `.create()` здесь не нужен и приводит к
    # DuplicateObjectError при повторном создании тем же вызовом create_table.
    conference_status = postgresql.ENUM('scheduled', 'active', 'ended', name='conference_status')
    op.create_table(
        'conferences',
        sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
        sa.Column('number', sa.String(length=9), nullable=False),
        sa.Column('slug', sa.String(length=22), nullable=False),
        sa.Column('title', sa.String(length=255), nullable=True),
        sa.Column('owner_id', sa.UUID(), nullable=True),
        sa.Column('status', conference_status, server_default='scheduled', nullable=False),
        sa.Column('is_pinned', sa.Boolean(), server_default='false', nullable=False),
        sa.Column('is_closed', sa.Boolean(), server_default='false', nullable=False),
        sa.Column('password_hash', sa.Text(), nullable=True),
        sa.Column('scheduled_at', sa.DateTime(timezone=True), nullable=True),
        sa.Column('duration_minutes', sa.Integer(), nullable=True),
        sa.Column('recurrence', postgresql.JSONB(), nullable=True),
        sa.Column('ended_at', sa.DateTime(timezone=True), nullable=True),
        sa.Column(
            'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'),
            nullable=False,
        ),
        sa.CheckConstraint(
            'is_closed = false OR password_hash IS NOT NULL',
            name='ck_conferences_closed_requires_password',
        ),
        sa.CheckConstraint(
            'recurrence IS NULL OR is_pinned = true',
            name='ck_conferences_recurrence_requires_pinned',
        ),
        sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ondelete='SET NULL'),
        sa.PrimaryKeyConstraint('id'),
        sa.UniqueConstraint('number'),
        sa.UniqueConstraint('slug'),
    )

    # 3. Backfill: одна конференция на каждую room, на которую ссылались сеансы.
    _backfill_conferences_from_rooms(bind)

    # 4. Перевязка: conference_sessions получает conference_id, найденный через
    # исходный room_id (join по slug == permanent_link, который backfill
    # сохранил равным исходной ссылке комнаты), после чего room_id/booking_id уходят.
    op.add_column('conference_sessions', sa.Column('conference_id', sa.UUID(), nullable=True))
    op.execute(
        """
        UPDATE conference_sessions cs
        SET conference_id = c.id
        FROM rooms r
        JOIN conferences c ON c.slug = r.permanent_link
        WHERE cs.room_id = r.id
        """
    )
    op.alter_column('conference_sessions', 'conference_id', nullable=False)
    op.create_foreign_key(
        'conference_sessions_conference_id_fkey',
        'conference_sessions', 'conferences',
        ['conference_id'], ['id'], ondelete='CASCADE',
    )
    op.create_index(
        'ix_conference_sessions_conference_id_t_start',
        'conference_sessions', ['conference_id', 't_start'],
    )
    op.drop_constraint('conferences_room_id_fkey', 'conference_sessions', type_='foreignkey')
    op.drop_constraint('conferences_booking_id_fkey', 'conference_sessions', type_='foreignkey')
    op.drop_column('conference_sessions', 'room_id')
    op.drop_column('conference_sessions', 'booking_id')

    # 5. conference_id -> session_id в phrases/chat_messages/conference_participants.
    # FK на conference_sessions(id) сохраняется автоматически (см. п.1); индексы
    # переименовываются вслед за колонкой.
    op.alter_column('phrases', 'conference_id', new_column_name='session_id')
    op.execute('ALTER INDEX ix_phrases_conference_id_t_start RENAME TO ix_phrases_session_id_t_start')

    op.alter_column('chat_messages', 'conference_id', new_column_name='session_id')
    op.execute(
        'ALTER INDEX ix_chat_messages_conference_id_created_at '
        'RENAME TO ix_chat_messages_session_id_created_at'
    )

    op.alter_column('conference_participants', 'conference_id', new_column_name='session_id')
    op.execute(
        'ALTER INDEX ix_conference_participants_conference_id '
        'RENAME TO ix_conference_participants_session_id'
    )

    # 6. guest_access — создаётся до правки conference_participants, т.к. её
    # новый guest_id ссылается на эту таблицу (порядок из-за FK-зависимости
    # отличается от порядка перечисления в ADR-001, итоговая схема та же).
    op.create_table(
        'guest_access',
        sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
        sa.Column('conference_id', sa.UUID(), nullable=False),
        sa.Column('display_name', sa.String(length=255), nullable=False),
        sa.Column('email', sa.String(length=320), nullable=True),
        sa.Column(
            'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'),
            nullable=False,
        ),
        sa.ForeignKeyConstraint(['conference_id'], ['conferences.id'], ondelete='CASCADE'),
        sa.PrimaryKeyConstraint('id'),
    )

    # 7. conference_participants: user_id nullable, guest_id, CHECK «ровно одно из двух».
    op.alter_column('conference_participants', 'user_id', nullable=True)
    op.add_column('conference_participants', sa.Column('guest_id', sa.UUID(), nullable=True))
    op.create_foreign_key(
        'conference_participants_guest_id_fkey',
        'conference_participants', 'guest_access',
        ['guest_id'], ['id'],
    )
    op.create_check_constraint(
        'ck_conference_participants_exactly_one_identity',
        'conference_participants',
        '(user_id IS NOT NULL)::int + (guest_id IS NOT NULL)::int = 1',
    )

    # 8. Комнаты и бронирование уходят вместе с EXCLUDE-constraint'ом
    # (ADR-001, п.5); список допущенных участников брони не переносится —
    # закрытая конференция теперь защищена только паролем.
    op.drop_table('booking_participants')
    op.drop_table('room_bookings')
    op.drop_table('rooms')


def downgrade() -> None:
    """Downgrade schema.

    Разрушительная миграция (ADR-001: «продакшен-данных нет — допустима
    структурная миграция»): downgrade восстанавливает схему, но не исходные
    данные комнат/броней/номеров — они не хранились обратимо после backfill.
    """
    op.create_table(
        'rooms',
        sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
        sa.Column('name', sa.String(length=255), nullable=False),
        sa.Column('is_pinned', sa.Boolean(), server_default='false', nullable=False),
        sa.Column('permanent_link', sa.String(length=64), nullable=False),
        sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
        sa.Column(
            'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'),
            nullable=False,
        ),
        sa.PrimaryKeyConstraint('id'),
        sa.UniqueConstraint('permanent_link'),
    )
    op.create_table(
        'room_bookings',
        sa.Column('id', sa.UUID(), server_default=sa.text('gen_random_uuid()'), nullable=False),
        sa.Column('room_id', sa.UUID(), nullable=False),
        sa.Column('organizer_id', sa.UUID(), nullable=False),
        sa.Column('title', sa.String(length=255), nullable=True),
        sa.Column('period', postgresql.TSTZRANGE(), nullable=False),
        sa.Column('is_closed', sa.Boolean(), server_default='false', nullable=False),
        sa.Column('password_hash', sa.Text(), nullable=True),
        sa.Column('access_link', sa.String(length=43), nullable=False),
        sa.Column(
            'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'),
            nullable=False,
        ),
        postgresql.ExcludeConstraint(
            (sa.column('room_id'), '='), (sa.column('period'), '&&'),
            using='gist', name='excl_room_bookings_overlap',
        ),
        sa.CheckConstraint('NOT isempty(period)', name='ck_room_bookings_period_not_empty'),
        sa.ForeignKeyConstraint(['organizer_id'], ['users.id'], ondelete='RESTRICT'),
        sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ondelete='CASCADE'),
        sa.PrimaryKeyConstraint('id'),
        sa.UniqueConstraint('access_link'),
    )
    op.create_table(
        'booking_participants',
        sa.Column('booking_id', sa.UUID(), nullable=False),
        sa.Column('user_id', sa.UUID(), nullable=False),
        sa.ForeignKeyConstraint(['booking_id'], ['room_bookings.id'], ondelete='CASCADE'),
        sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
        sa.PrimaryKeyConstraint('booking_id', 'user_id'),
    )

    op.drop_constraint(
        'ck_conference_participants_exactly_one_identity', 'conference_participants',
        type_='check',
    )
    op.drop_constraint(
        'conference_participants_guest_id_fkey', 'conference_participants', type_='foreignkey'
    )
    op.drop_column('conference_participants', 'guest_id')
    op.alter_column('conference_participants', 'user_id', nullable=False)

    op.drop_table('guest_access')

    op.execute(
        'ALTER INDEX ix_conference_participants_session_id '
        'RENAME TO ix_conference_participants_conference_id'
    )
    op.alter_column('conference_participants', 'session_id', new_column_name='conference_id')

    op.execute(
        'ALTER INDEX ix_chat_messages_session_id_created_at '
        'RENAME TO ix_chat_messages_conference_id_created_at'
    )
    op.alter_column('chat_messages', 'session_id', new_column_name='conference_id')

    op.execute('ALTER INDEX ix_phrases_session_id_t_start RENAME TO ix_phrases_conference_id_t_start')
    op.alter_column('phrases', 'session_id', new_column_name='conference_id')

    op.add_column('conference_sessions', sa.Column('room_id', sa.UUID(), nullable=True))
    op.add_column('conference_sessions', sa.Column('booking_id', sa.UUID(), nullable=True))

    # Реконструируем rooms из conferences, на которые ссылаются сеансы —
    # операция, обратная backfill'у в upgrade(). `is_active` не хранилось
    # раздельно от новой модели — восстанавливаем как `true`.
    op.execute(
        """
        INSERT INTO rooms (id, name, permanent_link, is_pinned, is_active, created_at)
        SELECT gen_random_uuid(), COALESCE(c.title, c.slug), c.slug, c.is_pinned, true, c.created_at
        FROM conferences c
        WHERE EXISTS (SELECT 1 FROM conference_sessions cs WHERE cs.conference_id = c.id)
        """
    )
    op.execute(
        """
        UPDATE conference_sessions cs
        SET room_id = r.id
        FROM conferences c
        JOIN rooms r ON r.permanent_link = c.slug
        WHERE cs.conference_id = c.id
        """
    )
    op.create_foreign_key(
        'conferences_room_id_fkey', 'conference_sessions', 'rooms', ['room_id'], ['id'],
        ondelete='CASCADE',
    )
    op.create_foreign_key(
        'conferences_booking_id_fkey', 'conference_sessions', 'room_bookings', ['booking_id'],
        ['id'], ondelete='SET NULL',
    )
    op.drop_index('ix_conference_sessions_conference_id_t_start', table_name='conference_sessions')
    op.drop_constraint(
        'conference_sessions_conference_id_fkey', 'conference_sessions', type_='foreignkey'
    )
    op.drop_column('conference_sessions', 'conference_id')
    op.alter_column('conference_sessions', 'room_id', nullable=False)

    op.drop_table('conferences')
    postgresql.ENUM(name='conference_status').drop(op.get_bind(), checkfirst=True)

    op.drop_index('ix_conference_sessions_pipeline_status', table_name='conference_sessions')
    op.create_index(
        'ix_conferences_room_id_t_start', 'conference_sessions', ['room_id', 't_start']
    )
    op.create_index(
        'ix_conferences_pipeline_status', 'conference_sessions', ['pipeline_status']
    )
    op.rename_table('conference_sessions', 'conferences')
