"""event-scoped guests, admin-created events, html card renderer

Revision ID: b7d2f8e19c34
Revises: 9a4c17be2d05
Create Date: 2026-08-13 02:40:00.000000

**This migration is one-way once a guest has actually been split.** Splitting a guest who
held invitations to several events into one record per event destroys the fact that they were
ever one person (design D11), and leaves two records sharing a phone within one wedding —
which the old unique indexes forbid, so the downgrade cannot even restore the schema. It
refuses with an explanation rather than failing on an opaque constraint error. Take a backup
first: `make backup` covers both the database and the media directory.

Order matters here. The per-wedding unique indexes on phone and email are dropped *before*
the split, because the split deliberately creates rows sharing a phone number with the row
they were copied from — under the new model that is legal, under the old one it is a
constraint violation.
"""

import logging
from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

revision: str = "b7d2f8e19c34"
down_revision: str | None = "9a4c17be2d05"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

logger = logging.getLogger("alembic.runtime.migration")

#: Every column carried onto a split copy. `id` and `event_id` are supplied per row and
#: `updated_at` is refreshed, so they are absent here. Listed explicitly rather than selected
#: with `*` so that adding a column to `guest` later fails loudly in review instead of
#: silently dropping that column from every split record.
_COPIED_COLUMNS = (
    "wedding_id",
    "full_name",
    "email",
    "phone_e164",
    "whatsapp_phone_e164",
    "side",
    "group_tag",
    "preferred_locale",
    "preferred_channel",
    "source",
    "do_not_contact",
    "email_invalid",
    "is_deleted",
    "created_at",
    "invitation_type",
)


def upgrade() -> None:
    conn = op.get_bind()

    # 1. The new renderer. Postgres 12+ permits this inside a transaction as long as the new
    #    label is not *used* in the same transaction, which it is not.
    op.execute("ALTER TYPE card_renderer ADD VALUE IF NOT EXISTS 'html'")

    # 2. Nullable to begin with: existing rows have no event until the backfill below.
    op.add_column("guest", sa.Column("event_id", sa.Uuid(), nullable=True))

    # 3. Old uniqueness must go before the split, which intentionally duplicates contacts.
    op.drop_index("uq_guest_wedding_phone", table_name="guest")
    op.drop_index("uq_guest_wedding_email", table_name="guest")

    # 4. Point every guest at the event of its earliest invitation.
    op.execute(
        """
        UPDATE guest g
        SET event_id = first.event_id
        FROM (
            SELECT DISTINCT ON (guest_id) guest_id, event_id
            FROM invitation
            WHERE guest_id IS NOT NULL
            ORDER BY guest_id, created_at, id
        ) AS first
        WHERE g.id = first.guest_id
        """
    )

    # 5. Split: every invitation beyond a guest's first gets its own copy of that guest.
    extra = conn.execute(
        sa.text(
            """
            SELECT id AS invitation_id, guest_id, event_id FROM (
                SELECT id, guest_id, event_id,
                       row_number() OVER (PARTITION BY guest_id ORDER BY created_at, id) AS rn
                FROM invitation
                WHERE guest_id IS NOT NULL
            ) ranked
            WHERE rn > 1
            """
        )
    ).all()

    columns = ", ".join(_COPIED_COLUMNS)
    for invitation_id, guest_id, event_id in extra:
        new_guest_id = conn.execute(
            sa.text(
                f"""
                INSERT INTO guest (id, event_id, updated_at, {columns})
                SELECT gen_random_uuid(), :event_id, now(), {columns}
                FROM guest WHERE id = :guest_id
                RETURNING id
                """
            ),
            {"event_id": event_id, "guest_id": guest_id},
        ).scalar_one()
        conn.execute(
            sa.text("UPDATE invitation SET guest_id = :new WHERE id = :inv"),
            {"new": new_guest_id, "inv": invitation_id},
        )

    if extra:
        logger.info("split %d guest record(s) out to their own event", len(extra))

    # 6. A guest with no invitation has no event, and the new model has nowhere to put it.
    #    Such a row is already unreachable — no token, no link, no message can reach it — so
    #    it is removed rather than blocking the NOT NULL below.
    orphans = conn.execute(sa.text("DELETE FROM guest WHERE event_id IS NULL RETURNING id")).all()
    if orphans:
        logger.warning("removed %d guest(s) that belonged to no event", len(orphans))

    op.alter_column("guest", "event_id", nullable=False)
    op.create_foreign_key(
        "fk_guest_event", "guest", "event", ["event_id"], ["id"], ondelete="CASCADE"
    )
    op.create_index("ix_guest_event_id", "guest", ["event_id"])

    # 7. Uniqueness is now per event: the same person may appear under several events, but
    #    not twice under one.
    op.create_index(
        "uq_guest_event_phone",
        "guest",
        ["event_id", "phone_e164"],
        unique=True,
        postgresql_where=sa.text("phone_e164 IS NOT NULL AND is_deleted = false"),
    )
    op.create_index(
        "uq_guest_event_email",
        "guest",
        ["event_id", sa.text("lower(email)")],
        unique=True,
        postgresql_where=sa.text("email IS NOT NULL AND is_deleted = false"),
    )

    # 8. A wedding may now hold any number of events of a type (design D12).
    op.drop_constraint("uq_event_wedding_type", "event", type_="unique")


def downgrade() -> None:
    """Only possible while no guest has actually been split.

    Once a person exists under two events they share a phone and an email within one wedding,
    which is exactly what the old per-wedding unique indexes forbid. Recreating those indexes
    then fails on a duplicate-key error that says nothing about the cause, so the check below
    turns it into an instruction. Restoring from the pre-migration backup is the only way
    back once a split has happened — the information that two records were one person does
    not exist any more, so nothing can recombine them.
    """
    conn = op.get_bind()
    duplicates = conn.execute(
        sa.text(
            """
            SELECT count(*) FROM (
                SELECT wedding_id, phone_e164 FROM guest
                WHERE phone_e164 IS NOT NULL AND is_deleted = false
                GROUP BY wedding_id, phone_e164 HAVING count(*) > 1
                UNION ALL
                SELECT wedding_id, lower(email) FROM guest
                WHERE email IS NOT NULL AND is_deleted = false
                GROUP BY wedding_id, lower(email) HAVING count(*) > 1
            ) clashes
            """
        )
    ).scalar_one()

    if duplicates:
        raise RuntimeError(
            f"Cannot downgrade: {duplicates} contact(s) appear under more than one event in "
            "the same wedding, which the per-wedding unique indexes this downgrade restores "
            "would forbid. Splitting guests per event is a one-way door (design D11) — "
            "restore the database from the backup taken before this migration instead."
        )

    op.create_unique_constraint("uq_event_wedding_type", "event", ["wedding_id", "type"])

    op.drop_index("uq_guest_event_email", table_name="guest")
    op.drop_index("uq_guest_event_phone", table_name="guest")
    op.drop_index("ix_guest_event_id", table_name="guest")
    op.drop_constraint("fk_guest_event", "guest", type_="foreignkey")
    op.drop_column("guest", "event_id")

    op.create_index(
        "uq_guest_wedding_phone",
        "guest",
        ["wedding_id", "phone_e164"],
        unique=True,
        postgresql_where=sa.text("phone_e164 IS NOT NULL AND is_deleted = false"),
    )
    op.create_index(
        "uq_guest_wedding_email",
        "guest",
        ["wedding_id", sa.text("lower(email)")],
        unique=True,
        postgresql_where=sa.text("email IS NOT NULL AND is_deleted = false"),
    )
    # `card_renderer`'s 'html' label is deliberately left in place: Postgres cannot drop an
    # enum value, and re-creating the type would mean rewriting every dependent column. An
    # unused label is harmless — the API rejects renderers it does not implement anyway.
