"""Align v3_plans table schema with specification DDL.

- Add effective_profile_snapshot column (TEXT NOT NULL DEFAULT '{}').
- Make root_plan_id NOT NULL (backfill with plan_id first).
- Make automation_profile NOT NULL (backfill with 'balanced' first).

Note on downgrade: the backfill is not reversible.  After downgrade,
root plans that previously had ``root_plan_id IS NULL`` will retain
the self-referencing value (``root_plan_id = plan_id``).

Revision ID: m8_001_align_plans_schema
Revises: m4_003_plan_env_columns
Create Date: 2026-03-19 00:00:00

"""

import logging
from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

_logger = logging.getLogger(__name__)

# revision identifiers, used by Alembic.
revision: str = "m8_001_align_plans_schema"
down_revision: str | Sequence[str] | None = "m4_003_plan_env_columns"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
    """Align v3_plans with spec DDL: new column + nullability fixes."""
    # 1. Add effective_profile_snapshot column
    op.add_column(
        "v3_plans",
        sa.Column(
            "effective_profile_snapshot",
            sa.Text(),
            nullable=False,
            server_default="{}",
        ),
    )

    # 2. Backfill root_plan_id: root plans (no parent) self-reference;
    #    child plans inherit root from their parent's root_plan_id.
    #    The child backfill propagates level-by-level: each iteration
    #    only updates children whose parent already has root_plan_id
    #    resolved.  This avoids the snapshot-semantics pitfall where a
    #    single correlated UPDATE would give grandchildren their
    #    parent's plan_id instead of the root ancestor's ID.
    op.execute(
        sa.text(
            "UPDATE v3_plans SET root_plan_id = plan_id "
            "WHERE root_plan_id IS NULL AND parent_plan_id IS NULL"
        )
    )
    bind = op.get_bind()
    _MAX_DEPTH = 100
    for _depth in range(_MAX_DEPTH):
        result = bind.execute(
            sa.text(
                "UPDATE v3_plans SET root_plan_id = ("
                "  SELECT p.root_plan_id"
                "  FROM v3_plans p WHERE p.plan_id = v3_plans.parent_plan_id"
                ") WHERE root_plan_id IS NULL"
                "  AND parent_plan_id IS NOT NULL"
                "  AND parent_plan_id IN ("
                "    SELECT p2.plan_id FROM v3_plans p2"
                "    WHERE p2.root_plan_id IS NOT NULL"
                "  )"
            )
        )
        if result.rowcount == 0:
            break
    else:
        # Log affected plan IDs before the fallback overwrites them.
        cycle_rows = bind.execute(
            sa.text("SELECT plan_id FROM v3_plans WHERE root_plan_id IS NULL")
        ).fetchall()
        cycle_ids = [r[0] for r in cycle_rows]
        _MAX_LOG_IDS = 50
        display_ids = cycle_ids[:_MAX_LOG_IDS]
        _logger.error(
            "root_plan_id backfill did not converge after %d iterations "
            "— possible cycle in parent_plan_id; affected count=%d, "
            "plan_ids (first %d)=%s",
            _MAX_DEPTH,
            len(cycle_ids),
            _MAX_LOG_IDS,
            display_ids,
        )
    # Fallback: any remaining NULLs (orphaned children) self-reference.
    fallback_result = bind.execute(
        sa.text("UPDATE v3_plans SET root_plan_id = plan_id WHERE root_plan_id IS NULL")
    )
    if fallback_result.rowcount > 0:
        _logger.warning(
            "root_plan_id fallback: %d orphaned row(s) self-referenced "
            "(parent_plan_id points to a missing plan)",
            fallback_result.rowcount,
        )

    # 3. Backfill automation_profile with 'balanced' where NULL or empty.
    op.execute(
        sa.text(
            "UPDATE v3_plans SET automation_profile = 'balanced' "
            "WHERE automation_profile IS NULL OR automation_profile = ''"
        )
    )

    # 4. Apply NOT NULL constraints in a single batch to avoid
    #    two full table copies in SQLite batch mode.
    #
    #    NOTE: The ORM model specifies ondelete="RESTRICT" for the
    #    root_plan_id FK, but SQLite batch_alter_table recreates the
    #    table from reflected metadata and does not update FK policies
    #    unless explicitly told to.  Fresh databases created via
    #    Base.metadata.create_all() get the correct RESTRICT policy
    #    from the ORM model.  Migrated databases retain the previous
    #    FK policy.  This is acceptable because SQLite disables FK
    #    enforcement by default (PRAGMA foreign_keys = OFF) and the
    #    NOT NULL constraint on root_plan_id independently prevents
    #    the SET NULL action from succeeding.
    with op.batch_alter_table("v3_plans") as batch_op:
        batch_op.alter_column(
            "root_plan_id", existing_type=sa.String(26), nullable=False
        )
        batch_op.alter_column(
            "automation_profile",
            existing_type=sa.Text(),
            nullable=False,
            server_default="balanced",
        )


def downgrade() -> None:
    """Revert v3_plans schema changes."""
    # Reverse order: make columns nullable again, drop new column.
    # Single batch to minimise table copies.
    with op.batch_alter_table("v3_plans") as batch_op:
        batch_op.alter_column(
            "automation_profile",
            existing_type=sa.Text(),
            nullable=True,
            server_default=None,
        )
        batch_op.alter_column(
            "root_plan_id", existing_type=sa.String(26), nullable=True
        )

    op.drop_column("v3_plans", "effective_profile_snapshot")
