"""design systems + tokens, and the project pointer

Revision ID: 0072
Revises: 0071
Create Date: 2026-07-30

Makes the design system a first-class record instead of prose in a rulebook: a
named set of tokens with an optional parent, so a family system holds the house
style and an app system holds only what it changes.

`design_systems.parent_id` is the whole model. It replaces both an `always_on`
flag (a family system is one with no parent) and a subscription join table (a
project points at ONE system, and the chain supplies the rest), which is less
schema than the rulebook shape it mirrors.

Two deliberate choices worth stating here rather than leaving to be re-derived:

  - **`design_tokens.value_by_mode` is JSONB keyed by mode**, not `value_light` +
    `value_dark` columns. In a child system an unset mode means "inherit"; in a
    root it would mean "not mode-dependent", and as columns both are NULL and
    indistinguishable. As a map, resolution is a dict merge at every level with
    no special case for roots — and a third mode (high-contrast, print) is data
    rather than a schema change. The cost is that a typo'd mode key is not
    rejected by the database. Nothing filters tokens by value in SQL, so the
    queryability the columns would have bought is for a query no caller makes.
    (Named `value_by_mode` rather than `values`, which is reserved in SQL.)
  - **`group_name` is free text, not a CHECK enum.** Groupings are each design
    system's own vocabulary; a whitelist would bake one install's kit into the
    schema. No CHECK is introduced anywhere in this migration.

`parent_id` and `projects.design_system_id` are both ON DELETE SET NULL. Deleting
a family system must orphan its children into roots that still hold their own
overrides, not cascade away every app system that inherited from it; deleting a
system a project points at must unstyle that project, not delete it.

Downgrade drops both tables and the column. Any design system defined this way
is lost — this is the migration that introduces the concept, so there is no
earlier representation to fall back to.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB


revision = "0072"
down_revision = "0071"
branch_labels = None
depends_on = None


def upgrade() -> None:
    op.create_table(
        "design_systems",
        sa.Column("id", sa.BigInteger(), primary_key=True),
        sa.Column(
            "owner_user_id", sa.BigInteger(),
            sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
        ),
        sa.Column("title", sa.Text(), nullable=False),
        sa.Column("description", sa.Text(), nullable=True),
        sa.Column(
            "parent_id", sa.BigInteger(),
            sa.ForeignKey("design_systems.id", ondelete="SET NULL"), nullable=True,
        ),
        sa.Column(
            "created_at", sa.DateTime(timezone=True), nullable=False,
            server_default=sa.text("now()"),
        ),
        sa.Column(
            "updated_at", sa.DateTime(timezone=True), nullable=False,
            server_default=sa.text("now()"),
        ),
        sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("deleted_batch_id", sa.Text(), nullable=True),
    )
    op.create_index(
        "ix_design_systems_owner_user_id", "design_systems", ["owner_user_id"]
    )
    op.create_index("ix_design_systems_parent_id", "design_systems", ["parent_id"])

    op.create_table(
        "design_tokens",
        sa.Column("id", sa.BigInteger(), primary_key=True),
        sa.Column(
            "design_system_id", sa.BigInteger(),
            sa.ForeignKey("design_systems.id", ondelete="CASCADE"), nullable=False,
        ),
        sa.Column("name", sa.Text(), nullable=False),
        # NOT NULL with a '{}' default: a nullable JSONB column has two empty
        # states (SQL NULL and JSON null) and every reader has to test for both.
        sa.Column(
            "value_by_mode", JSONB,
            nullable=False, server_default=sa.text("'{}'::jsonb"),
        ),
        sa.Column("group_name", sa.Text(), nullable=True),
        sa.Column("purpose", sa.Text(), nullable=True),
        sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
        sa.Column(
            "created_at", sa.DateTime(timezone=True), nullable=False,
            server_default=sa.text("now()"),
        ),
        sa.Column(
            "updated_at", sa.DateTime(timezone=True), nullable=False,
            server_default=sa.text("now()"),
        ),
        sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("deleted_batch_id", sa.Text(), nullable=True),
    )
    op.create_index(
        "ix_design_tokens_design_system_id", "design_tokens", ["design_system_id"]
    )
    # Partial unique: a name is unique among LIVE tokens in a system. Two live
    # rows with the same name are a duplicate definition and the cascade would
    # pick between them arbitrarily; a trashed row must not block reusing its
    # name.
    op.create_index(
        "uq_token_per_design_system", "design_tokens", ["design_system_id", "name"],
        unique=True, postgresql_where=sa.text("deleted_at IS NULL"),
    )

    op.add_column(
        "projects", sa.Column("design_system_id", sa.BigInteger(), nullable=True)
    )
    op.create_foreign_key(
        "fk_projects_design_system_id", "projects", "design_systems",
        ["design_system_id"], ["id"], ondelete="SET NULL",
    )


def downgrade() -> None:
    op.drop_constraint("fk_projects_design_system_id", "projects", type_="foreignkey")
    op.drop_column("projects", "design_system_id")
    op.drop_index("uq_token_per_design_system", table_name="design_tokens")
    op.drop_index("ix_design_tokens_design_system_id", table_name="design_tokens")
    op.drop_table("design_tokens")
    op.drop_index("ix_design_systems_parent_id", table_name="design_systems")
    op.drop_index("ix_design_systems_owner_user_id", table_name="design_systems")
    op.drop_table("design_systems")
