"""Step definitions for v3_plans table schema alignment (issue #921).

Tests the effective_profile_snapshot column, root_plan_id self-referencing,
automation_profile NOT NULL default, bare-string deserialization, and
NULL root_plan_id constraint enforcement.
"""

from __future__ import annotations

from datetime import UTC, datetime

from behave import given, then, when
from behave.runner import Context
from sqlalchemy import create_engine, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker

from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import (
    AutomationProfileProvenance,
    AutomationProfileRef,
    NamespacedName,
    Plan,
    PlanIdentity,
    PlanPhase,
    PlanTimestamps,
    ProcessingState,
)
from cleveragents.infrastructure.database.models import (
    Base,
    LifecyclePlanModel,
)
from cleveragents.infrastructure.database.repositories import (
    ActionRepository,
    LifecyclePlanRepository,
)

_NOW = datetime(2026, 3, 19, tzinfo=UTC)


def _make_plan(
    plan_id: str,
    *,
    parent_plan_id: str | None = None,
    root_plan_id: str | None = None,
    effective_profile_snapshot: str = "{}",
    automation_profile: AutomationProfileRef | None = None,
    action_name: str = "local/schema-test",
) -> Plan:
    """Build a Plan domain object for schema alignment tests."""
    return Plan(
        identity=PlanIdentity(
            plan_id=plan_id,
            parent_plan_id=parent_plan_id,
            root_plan_id=root_plan_id,
            attempt=1,
        ),
        namespaced_name=NamespacedName(namespace="local", name="schema-test"),
        action_name=action_name,
        description="Schema alignment test plan",
        definition_of_done="All assertions pass",
        phase=PlanPhase.ACTION,
        processing_state=ProcessingState.QUEUED,
        automation_profile=automation_profile,
        effective_profile_snapshot=effective_profile_snapshot,
        strategy_actor="local/s",
        execution_actor="local/e",
        timestamps=PlanTimestamps(created_at=_NOW, updated_at=_NOW),
        created_by="test-schema",
        tags=[],
        reusable=True,
        read_only=False,
    )


def _setup_db(context: Context) -> None:
    """Create a fresh in-memory SQLite database and attach to context."""
    engine = create_engine("sqlite:///:memory:", echo=False)
    Base.metadata.create_all(engine)
    sm = sessionmaker(bind=engine)
    session = sm()
    context._sa_engine = engine
    context._sa_session = session
    context._sa_session_factory = lambda: session
    context._sa_plan_repo = LifecyclePlanRepository(
        session_factory=context._sa_session_factory,
    )
    context._sa_action_repo = ActionRepository(
        session_factory=context._sa_session_factory,
    )


@given("a schema-aligned fresh in-memory database")
def step_setup_db(context: Context) -> None:
    _setup_db(context)


@given('a schema-aligned prerequisite action "{action_name}" exists')
def step_create_action(context: Context, action_name: str) -> None:
    ns = NamespacedName.parse(action_name)
    action = Action(
        namespaced_name=ns,
        description="Schema alignment test action",
        definition_of_done="Done",
        strategy_actor="local/s",
        execution_actor="local/e",
        state=ActionState.AVAILABLE,
        created_at=_NOW,
        updated_at=_NOW,
    )
    context._sa_action_repo.create(action)
    context._sa_session.commit()


@given('a schema-aligned plan with ID "{plan_id}"')
def step_create_plan(context: Context, plan_id: str) -> None:
    context._sa_plan = _make_plan(plan_id)


@given("the plan effective_profile_snapshot is '{snapshot}'")
def step_set_snapshot(context: Context, snapshot: str) -> None:
    context._sa_plan = context._sa_plan.model_copy(
        update={"effective_profile_snapshot": snapshot}
    )


@given('a schema-aligned root plan with ID "{plan_id}"')
def step_create_root_plan(context: Context, plan_id: str) -> None:
    # root_plan_id is None here; from_domain will set it to plan_id
    context._sa_plan = _make_plan(plan_id, root_plan_id=None)


@given(
    'a schema-aligned child plan with ID "{child_id}" parent "{parent_id}" root "{root_id}"'
)
def step_create_child_plan(
    context: Context, child_id: str, parent_id: str, root_id: str
) -> None:
    context._sa_child_plan = _make_plan(
        child_id, parent_plan_id=parent_id, root_plan_id=root_id
    )


@given(
    'a schema-aligned grandchild plan with ID "{gc_id}" parent "{parent_id}" root "{root_id}"'
)
def step_create_grandchild_plan(
    context: Context, gc_id: str, parent_id: str, root_id: str
) -> None:
    context._sa_grandchild_plan = _make_plan(
        gc_id, parent_plan_id=parent_id, root_plan_id=root_id
    )


@given('a schema-aligned plan with ID "{plan_id}" and no automation profile')
def step_create_plan_no_profile(context: Context, plan_id: str) -> None:
    context._sa_plan = _make_plan(plan_id, automation_profile=None)


@when("I persist the schema-aligned plan")
def step_persist_plan(context: Context) -> None:
    context._sa_plan_repo.create(context._sa_plan)
    context._sa_session.commit()
    plan_id = context._sa_plan.identity.plan_id
    context._sa_retrieved = context._sa_plan_repo.get(plan_id)


@when("I persist both schema-aligned plans")
def step_persist_both(context: Context) -> None:
    context._sa_plan_repo.create(context._sa_plan)
    context._sa_session.commit()
    context._sa_plan_repo.create(context._sa_child_plan)
    context._sa_session.commit()
    child_id = context._sa_child_plan.identity.plan_id
    context._sa_retrieved_child = context._sa_plan_repo.get(child_id)


@when("I persist root, child, and grandchild schema-aligned plans")
def step_persist_three_levels(context: Context) -> None:
    context._sa_plan_repo.create(context._sa_plan)
    context._sa_session.commit()
    context._sa_plan_repo.create(context._sa_child_plan)
    context._sa_session.commit()
    context._sa_plan_repo.create(context._sa_grandchild_plan)
    context._sa_session.commit()
    gc_id = context._sa_grandchild_plan.identity.plan_id
    context._sa_retrieved_grandchild = context._sa_plan_repo.get(gc_id)


@when("I update the plan effective_profile_snapshot to '{new_snapshot}'")
def step_update_snapshot(context: Context, new_snapshot: str) -> None:
    retrieved = context._sa_retrieved
    assert retrieved is not None, "Plan was not retrieved before update"
    updated = retrieved.model_copy(update={"effective_profile_snapshot": new_snapshot})
    context._sa_plan_repo.update(updated)
    context._sa_session.commit()
    plan_id = updated.identity.plan_id
    context._sa_retrieved = context._sa_plan_repo.get(plan_id)


@then("the retrieved plan effective_profile_snapshot should be '{expected}'")
def step_check_snapshot(context: Context, expected: str) -> None:
    retrieved = context._sa_retrieved
    assert retrieved is not None, "Plan was not retrieved"
    assert retrieved.effective_profile_snapshot == expected, (
        f"Expected snapshot '{expected}', got '{retrieved.effective_profile_snapshot}'"
    )


@then('the plan root_plan_id should equal the plan_id "{plan_id}"')
def step_check_root_self_ref(context: Context, plan_id: str) -> None:
    retrieved = context._sa_retrieved
    assert retrieved is not None, "Plan was not retrieved"
    assert retrieved.identity.root_plan_id == plan_id, (
        f"Expected root_plan_id '{plan_id}', got '{retrieved.identity.root_plan_id}'"
    )


@then('the child plan root_plan_id should be "{expected}"')
def step_check_child_root(context: Context, expected: str) -> None:
    child = context._sa_retrieved_child
    assert child is not None, "Child plan was not retrieved"
    assert child.identity.root_plan_id == expected, (
        f"Expected root_plan_id '{expected}', got '{child.identity.root_plan_id}'"
    )


@then('the grandchild plan root_plan_id should be "{expected}"')
def step_check_grandchild_root(context: Context, expected: str) -> None:
    gc = context._sa_retrieved_grandchild
    assert gc is not None, "Grandchild plan was not retrieved"
    assert gc.identity.root_plan_id == expected, (
        f"Expected root_plan_id '{expected}', got '{gc.identity.root_plan_id}'"
    )


@then('the grandchild plan parent_plan_id should be "{expected}"')
def step_check_grandchild_parent(context: Context, expected: str) -> None:
    gc = context._sa_retrieved_grandchild
    assert gc is not None, "Grandchild plan was not retrieved"
    assert gc.identity.parent_plan_id == expected, (
        f"Expected parent_plan_id '{expected}', got '{gc.identity.parent_plan_id}'"
    )


@then('the child plan parent_plan_id should be "{expected}"')
def step_check_child_parent(context: Context, expected: str) -> None:
    child = context._sa_retrieved_child
    assert child is not None, "Child plan was not retrieved"
    assert child.identity.parent_plan_id == expected, (
        f"Expected parent_plan_id '{expected}', got '{child.identity.parent_plan_id}'"
    )


@then('the persisted automation_profile column should be "{expected}"')
def step_check_automation_profile_col(context: Context, expected: str) -> None:
    plan_id = context._sa_plan.identity.plan_id
    row = context._sa_session.execute(
        text("SELECT automation_profile FROM v3_plans WHERE plan_id = :pid"),
        {"pid": plan_id},
    ).fetchone()
    assert row is not None, "Row not found in v3_plans"
    assert row[0] == expected, (
        f"Expected automation_profile='{expected}', got '{row[0]}'"
    )


# --- M3: Bare-string / invalid-provenance automation_profile handling ---


@given('the plan automation_profile column is set to bare string "{value}"')
def step_set_bare_string_profile(context: Context, value: str) -> None:
    """Persist the plan, then overwrite the column with a bare string."""
    context._sa_plan_repo.create(context._sa_plan)
    context._sa_session.commit()
    plan_id = context._sa_plan.identity.plan_id
    context._sa_session.execute(
        text("UPDATE v3_plans SET automation_profile = :val WHERE plan_id = :pid"),
        {"val": value, "pid": plan_id},
    )
    context._sa_session.commit()
    context._sa_target_plan_id = plan_id


@given("the plan automation_profile column is set to '{value}'")
def step_set_invalid_json_profile(context: Context, value: str) -> None:
    """Persist the plan, then overwrite the column with arbitrary JSON."""
    context._sa_plan_repo.create(context._sa_plan)
    context._sa_session.commit()
    plan_id = context._sa_plan.identity.plan_id
    context._sa_session.execute(
        text("UPDATE v3_plans SET automation_profile = :val WHERE plan_id = :pid"),
        {"val": value, "pid": plan_id},
    )
    context._sa_session.commit()
    context._sa_target_plan_id = plan_id


@when("I load the plan from the database")
def step_load_plan(context: Context) -> None:
    plan_id = context._sa_target_plan_id
    context._sa_retrieved = context._sa_plan_repo.get(plan_id)


@then("the plan automation_profile ref should be None")
def step_check_profile_is_none(context: Context) -> None:
    retrieved = context._sa_retrieved
    assert retrieved is not None, "Plan was not retrieved"
    assert retrieved.automation_profile is None, (
        f"Expected automation_profile=None, got {retrieved.automation_profile!r}"
    )


# --- L3: NULL root_plan_id constraint enforcement ---


@given('a schema-aligned plan with ID "{plan_id}" forced NULL root_plan_id')
def step_create_plan_null_root(context: Context, plan_id: str) -> None:
    context._sa_null_root_plan_id = plan_id


@when("I attempt to persist the plan with NULL root_plan_id")
def step_persist_null_root(context: Context) -> None:
    plan_id = context._sa_null_root_plan_id
    now_iso = _NOW.isoformat()
    try:
        context._sa_session.execute(
            text(
                "INSERT INTO v3_plans "
                "(plan_id, root_plan_id, action_name, namespaced_name, "
                "namespace, phase, processing_state, attempt, description, "
                "tags_json, effective_profile_snapshot, automation_profile, "
                "reusable, read_only, created_at, updated_at) "
                "VALUES (:pid, NULL, :act, :ns, 'local', 'action', 'queued', "
                "1, 'test', '[]', '{}', 'balanced', 1, 0, :now, :now)"
            ),
            {
                "pid": plan_id,
                "act": "local/schema-test",
                "ns": "local/schema-test",
                "now": now_iso,
            },
        )
        context._sa_session.commit()
        context._sa_integrity_error = None
    except IntegrityError as exc:
        context._sa_session.rollback()
        context._sa_integrity_error = exc


@then("the database should reject the insert with an integrity error")
def step_check_integrity_error(context: Context) -> None:
    assert context._sa_integrity_error is not None, (
        "Expected IntegrityError for NULL root_plan_id, but insert succeeded"
    )


# --- T2: AutomationProfileRef round-trip ---


@given(
    'a schema-aligned plan "{plan_id}" with profile "{profile}" and provenance "{provenance}"'
)
def step_create_plan_with_profile(
    context: Context, plan_id: str, profile: str, provenance: str
) -> None:
    ref = AutomationProfileRef(
        profile_name=profile,
        provenance=AutomationProfileProvenance(provenance),
    )
    context._sa_plan = _make_plan(plan_id, automation_profile=ref)


@then('the plan automation_profile name should be "{expected}"')
def step_check_profile_name(context: Context, expected: str) -> None:
    retrieved = context._sa_retrieved
    assert retrieved is not None, "Plan was not retrieved"
    assert retrieved.automation_profile is not None, (
        "Expected automation_profile to be set, got None"
    )
    assert retrieved.automation_profile.profile_name == expected, (
        f"Expected profile_name='{expected}', "
        f"got '{retrieved.automation_profile.profile_name}'"
    )


@then('the plan automation_profile provenance should be "{expected}"')
def step_check_profile_provenance(context: Context, expected: str) -> None:
    retrieved = context._sa_retrieved
    assert retrieved is not None, "Plan was not retrieved"
    assert retrieved.automation_profile is not None, (
        "Expected automation_profile to be set, got None"
    )
    assert retrieved.automation_profile.provenance.value == expected, (
        f"Expected provenance='{expected}', "
        f"got '{retrieved.automation_profile.provenance.value}'"
    )


# --- TC5: NULL effective_profile_snapshot constraint enforcement ---


@given(
    'a schema-aligned plan with ID "{plan_id}" forced NULL effective_profile_snapshot'
)
def step_create_plan_null_snapshot(context: Context, plan_id: str) -> None:
    context._sa_null_snapshot_plan_id = plan_id


@when("I attempt to persist the plan with NULL effective_profile_snapshot")
def step_persist_null_snapshot(context: Context) -> None:
    plan_id = context._sa_null_snapshot_plan_id
    now_iso = _NOW.isoformat()
    try:
        context._sa_session.execute(
            text(
                "INSERT INTO v3_plans "
                "(plan_id, root_plan_id, action_name, namespaced_name, "
                "namespace, phase, processing_state, attempt, description, "
                "tags_json, effective_profile_snapshot, automation_profile, "
                "reusable, read_only, created_at, updated_at) "
                "VALUES (:pid, :pid, :act, :ns, 'local', 'action', 'queued', "
                "1, 'test', '[]', NULL, 'balanced', 1, 0, :now, :now)"
            ),
            {
                "pid": plan_id,
                "act": "local/schema-test",
                "ns": "local/schema-test",
                "now": now_iso,
            },
        )
        context._sa_session.commit()
        context._sa_snapshot_integrity_error = None
    except IntegrityError as exc:
        context._sa_session.rollback()
        context._sa_snapshot_integrity_error = exc


@then("the database should reject the snapshot insert with an integrity error")
def step_check_snapshot_integrity_error(context: Context) -> None:
    assert context._sa_snapshot_integrity_error is not None, (
        "Expected IntegrityError for NULL effective_profile_snapshot, "
        "but insert succeeded"
    )


# --- TC4: PlanIdentity model_validator self-reference resolution ---


@given('a schema-aligned plan with ID "{plan_id}" and None root_plan_id')
def step_create_plan_none_root(context: Context, plan_id: str) -> None:
    context._sa_plan = _make_plan(plan_id, root_plan_id=None)


@then('the domain plan root_plan_id should already equal "{expected}"')
def step_check_domain_root_resolved(context: Context, expected: str) -> None:
    plan = context._sa_plan
    assert plan is not None, "Plan was not created"
    assert plan.identity.root_plan_id == expected, (
        f"Expected PlanIdentity.root_plan_id='{expected}' after model_validator, "
        f"got '{plan.identity.root_plan_id}'"
    )


@when("I convert the plan via from_domain")
def step_convert_from_domain(context: Context) -> None:
    context._sa_orm_model = LifecyclePlanModel.from_domain(context._sa_plan)


@then('the ORM model root_plan_id should equal "{expected}"')
def step_check_orm_root(context: Context, expected: str) -> None:
    model = context._sa_orm_model
    assert model is not None, "ORM model was not created"
    assert model.root_plan_id == expected, (
        f"Expected ORM root_plan_id='{expected}', got '{model.root_plan_id}'"
    )


# --- TC6: Invalid JSON in effective_profile_snapshot ---


@when('the effective_profile_snapshot column is corrupted to "{bad_json}"')
def step_corrupt_snapshot_column(context: Context, bad_json: str) -> None:
    """Overwrite the effective_profile_snapshot column with invalid JSON."""
    plan_id = context._sa_plan.identity.plan_id
    context._sa_session.execute(
        text(
            "UPDATE v3_plans SET effective_profile_snapshot = :val WHERE plan_id = :pid"
        ),
        {"val": bad_json, "pid": plan_id},
    )
    context._sa_session.commit()


@when("I reload the plan from the database")
def step_reload_plan(context: Context) -> None:
    """Expire cached ORM state and reload from DB to pick up raw SQL changes."""
    context._sa_session.expire_all()
    plan_id = context._sa_plan.identity.plan_id
    context._sa_retrieved = context._sa_plan_repo.get(plan_id)


@when('I attempt to construct a Plan with effective_profile_snapshot "{bad_json}"')
def step_construct_plan_bad_snapshot(context: Context, bad_json: str) -> None:
    from pydantic import ValidationError as PydanticValidationError

    try:
        _make_plan(
            "01HV000000000000000000SA21",
            effective_profile_snapshot=bad_json,
        )
        context._sa_validation_error = None
    except PydanticValidationError as exc:
        context._sa_validation_error = exc


@when("I attempt to construct a Plan with an empty effective_profile_snapshot")
def step_construct_plan_empty_snapshot(context: Context) -> None:
    from pydantic import ValidationError as PydanticValidationError

    try:
        _make_plan(
            "01HV000000000000000000SA22",
            effective_profile_snapshot="",
        )
        context._sa_validation_error = None
    except PydanticValidationError as exc:
        context._sa_validation_error = exc


@then(
    "the Plan construction should raise a validation error "
    "for effective_profile_snapshot"
)
def step_check_validation_error(context: Context) -> None:
    assert context._sa_validation_error is not None, (
        "Expected a ValidationError for invalid JSON in "
        "effective_profile_snapshot, but Plan was created successfully"
    )
