"""nullable post.source_id + denormalized post.artist_id; retire sidecar synthetics

Revision ID: 0030
Revises: 0029
Create Date: 2026-06-01

Operator-asked 2026-06-01 after the Dymkens orphan investigation: the
sidecar synthetic Source pattern (`sidecar:<platform>:<slug>` rows
with enabled=false) was technically correct but misled the operator
into thinking they had phantom subscriptions. The synthetics existed
solely to satisfy `Post.source_id NOT NULL` for filesystem-imported
content with no real subscription.

This migration makes the data model honest:

1. **Post gets a denormalized `artist_id` column** so artist filters
   work without traversing `Post → Source.artist_id`. Backfilled from
   the existing Source linkage, then NOT NULL'd.
2. **`Post.source_id` becomes nullable**, FK ondelete `CASCADE` → `SET
   NULL`. Deleting a Source detaches its Posts instead of destroying
   imported content (semantically: subscription ends, archive stays).
3. **`ImageProvenance.source_id` becomes nullable** with the same FK
   semantic change.
4. **Sidecar synthetic Sources are deleted** — first NULL out the
   FKs from Post + ImageProvenance pointing at them (so the implicit
   CASCADE doesn't fire), then delete. DownloadEvent FK is unchanged
   (still CASCADE'd, NOT NULL'd) — synthetics have `enabled=false`
   so no events exist for them.

Uniqueness handling: the existing `uq_post_source_external_id`
(source_id, external_post_id) keeps working for source-bound Posts
(Postgres treats NULL != NULL so NULL-source rows aren't deduped by
it). A second partial unique index covers the NULL-source case on
(artist_id, external_post_id) so filesystem-imported posts still
dedupe within an artist.
"""
from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op
from sqlalchemy import text

revision: str = "0030"
down_revision: Union[str, None] = "0029"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


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

    # Step 1: add Post.artist_id, initially nullable for backfill.
    # FK naming follows the Base.metadata naming_convention
    # (fk_<table>_<column>_<referred_table>) — alembic 0001 set this up.
    op.add_column(
        "post",
        sa.Column("artist_id", sa.Integer, nullable=True),
    )
    op.create_foreign_key(
        "fk_post_artist_id_artist", "post", "artist",
        ["artist_id"], ["id"], ondelete="CASCADE",
    )

    # Step 2: backfill from Source.artist_id (every existing Post has a
    # Source today, so every row gets populated).
    conn.execute(text("""
        UPDATE post p
        SET artist_id = s.artist_id
        FROM source s
        WHERE p.source_id = s.id AND p.artist_id IS NULL
    """))

    # Sanity: count any remaining NULLs. Should be zero pre-this-migration.
    remaining = conn.execute(text(
        "SELECT COUNT(*) FROM post WHERE artist_id IS NULL"
    )).scalar_one()
    if remaining:
        raise RuntimeError(
            f"alembic 0030: {remaining} post rows have no resolvable "
            f"artist_id after backfill. Investigate before continuing."
        )

    # Step 3: enforce NOT NULL + add index for artist-filter queries.
    op.alter_column("post", "artist_id", nullable=False)
    op.create_index("ix_post_artist_id", "post", ["artist_id"])

    # Step 4: relax post.source_id + flip FK to SET NULL. The original FK
    # name from alembic 0001 is `fk_post_source_id_source` per the
    # NAMING_CONVENTION in models/base.py.
    op.alter_column("post", "source_id", nullable=True)
    op.drop_constraint("fk_post_source_id_source", "post", type_="foreignkey")
    op.create_foreign_key(
        "fk_post_source_id_source", "post", "source",
        ["source_id"], ["id"], ondelete="SET NULL",
    )

    # Step 5: relax image_provenance.source_id + flip FK to SET NULL.
    op.alter_column("image_provenance", "source_id", nullable=True)
    op.drop_constraint(
        "fk_image_provenance_source_id_source", "image_provenance",
        type_="foreignkey",
    )
    op.create_foreign_key(
        "fk_image_provenance_source_id_source", "image_provenance", "source",
        ["source_id"], ["id"], ondelete="SET NULL",
    )

    # Step 6: partial unique index on (artist_id, external_post_id) for
    # NULL-source Posts. The existing uq_post_source_external_id keeps
    # guarding source-bound rows; NULL-source rows now dedupe within
    # an artist.
    op.execute(
        "CREATE UNIQUE INDEX uq_post_artist_external_id_null_source "
        "ON post (artist_id, external_post_id) "
        "WHERE source_id IS NULL"
    )

    # Step 7: retire sidecar synthetic Sources. NULL out the references
    # FIRST (the new FK is SET NULL so CASCADE wouldn't fire anyway, but
    # being explicit makes the intent clear). Then delete the synthetic
    # source rows. Any DownloadEvent rows under synthetics CASCADE-die
    # with the source — synthetics have enabled=false so there shouldn't
    # be any in practice.
    conn.execute(text("""
        UPDATE post
        SET source_id = NULL
        WHERE source_id IN (SELECT id FROM source WHERE url LIKE 'sidecar:%')
    """))
    conn.execute(text("""
        UPDATE image_provenance
        SET source_id = NULL
        WHERE source_id IN (SELECT id FROM source WHERE url LIKE 'sidecar:%')
    """))
    deleted = conn.execute(text(
        "DELETE FROM source WHERE url LIKE 'sidecar:%' RETURNING id"
    )).rowcount
    print(f"alembic 0030: deleted {deleted} sidecar synthetic source rows")


def downgrade() -> None:
    # Lossy migration — the deleted sidecar synthetics can't be
    # restored from the orphan post.source_id / image_provenance.source_id
    # values, and the partial unique index encodes a constraint that
    # NULL-source Posts may now exist. No safe downgrade.
    pass
