"""Add denial_expires_at and denial_scope for Sprint 8 deny-semantics.

Revision ID: 0016
Revises: 0015
Create Date: 2026-06-05

Sprint 8 (user policy decision: "Hard filter. If it is denied this week twice,
it should be considered denied for good."):

- meal_plan_item.denial_expires_at TIMESTAMPTZ NULL
    - NULL on existing rows and on "Never again" denials (no decay; permanent).
    - now() + 90 days on "Deny this week" denials.
    - The planner's _load_soft_denied_recipes() filters
      `denial_expires_at > now()` to find still-active soft denials.
    - When a "Deny this week" finds a prior active soft denial for the
      same (family, recipe), the API promotes the denial to permanent:
      denial_expires_at -> NULL + a NeverSuggest row is inserted.

- meal_plan_vote.denial_scope VARCHAR(16) NULL
    - NULL on approve votes.
    - "this_week" or "never_again" on deny votes. Captures the user's
      intent at vote time (per-voter audit trail).

No data migration needed for existing rows:
- Existing 1 denied item (2026-05-15 day-2 Roasted Sweet Potato and
  Chickpea Bowl) keeps denial_expires_at = NULL, which means it is NOT
  in the "soft denied" pool (filter requires > now()). Effectively
  forgotten after this migration. If the user wants it remembered,
  they can re-trigger the soft-deny cycle.
- Existing meal_plan_vote rows keep denial_scope = NULL, which is
  interpreted as "approve" (vote column is the source of truth).

Deploy via Docker (the db runs inside a container; no host psql required):

    docker compose exec backend alembic upgrade head
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = "0016"
down_revision: Union[str, None] = "0015"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
    op.add_column(
        "meal_plan_item",
        sa.Column("denial_expires_at", sa.DateTime(timezone=True), nullable=True),
    )
    # Partial index: only rows with a non-NULL denial_expires_at are
    # queried by the planner. Reduces index size and lookup cost.
    op.create_index(
        "ix_meal_plan_item_denial_expires_at",
        "meal_plan_item",
        ["denial_expires_at"],
        postgresql_where=sa.text("denial_expires_at IS NOT NULL"),
    )
    op.add_column(
        "meal_plan_vote",
        sa.Column("denial_scope", sa.String(length=16), nullable=True),
    )


def downgrade() -> None:
    op.drop_column("meal_plan_vote", "denial_scope")
    op.drop_index(
        "ix_meal_plan_item_denial_expires_at",
        table_name="meal_plan_item",
    )
    op.drop_column("meal_plan_item", "denial_expires_at")
