"""Step definitions for plan use action arguments integrity tests.

Covers the fix for issue #4174 — ``plan use`` crashing with
``sqlite3.IntegrityError`` on the ``action_arguments`` UNIQUE
constraint when action arguments already exist from ``action create``.
"""

from __future__ import annotations

import re

from behave import given, then, when
from behave.runner import Context
from sqlalchemy import func, select
from sqlalchemy.orm import Session, sessionmaker

from cleveragents.application.services.plan_lifecycle_service import (
    PlanLifecycleService,
)
from cleveragents.config.settings import Settings
from cleveragents.domain.models.core.action import (
    ActionArgument,
    ArgumentRequirement,
    ArgumentType,
)
from cleveragents.domain.models.core.plan import NamespacedName, Plan, PlanPhase
from cleveragents.infrastructure.database.models import (
    ActionArgumentModel,
    ActionInvariantModel,
)
from features.mocks.test_uow_factory import build_test_uow

# Crockford base32 ULID pattern (26 uppercase characters)
_ULID_RE = re.compile(r"^[0-9A-HJKMNP-TV-Z]{26}$")


@given("a persisted plan lifecycle service for action args integrity tests")
def step_create_service(context: Context) -> None:
    """Create a persisted PlanLifecycleService backed by in-memory SQLite."""
    uow, sf, engine = build_test_uow()
    settings = Settings()
    service = PlanLifecycleService(settings=settings, unit_of_work=uow)
    context.pai_service = service
    context.pai_uow = uow
    context.pai_sf = sf
    context.pai_engine = engine
    context.pai_error = None
    context.pai_pending_invariants: list[str] = []
    context.add_cleanup(engine.dispose)
    plans: list[Plan] = []
    context.pai_plans = plans


def _parse_args_table(context: Context) -> list[ActionArgument]:
    """Parse a Behave table of argument definitions."""
    arguments: list[ActionArgument] = []
    table = context.table
    if table is None:
        return arguments
    for row in table:
        default_raw = row["default"].strip() if "default" in row.headings else ""
        default_value: str | int | float | bool | None = None
        if default_raw:
            arg_type_str = row["type"].strip().lower()
            if arg_type_str == "integer":
                default_value = int(default_raw)
            elif arg_type_str == "float":
                default_value = float(default_raw)
            elif arg_type_str == "boolean":
                default_value = default_raw.lower() in ("true", "1", "yes")
            else:
                default_value = default_raw

        arguments.append(
            ActionArgument(
                name=row["name"].strip(),
                arg_type=ArgumentType(row["type"].strip().lower()),
                requirement=ArgumentRequirement(row["requirement"].strip().lower()),
                description=f"Test arg {row['name'].strip()}",
                default_value=default_value,
            )
        )
    return arguments


def _consume_pending_invariants(context: Context) -> list[str]:
    invariants: list[str] = getattr(context, "pai_pending_invariants", [])
    context.pai_pending_invariants = []
    return invariants


@given("invariants for the next action:")
def step_store_invariants(context: Context) -> None:
    """Store invariants to be applied when the next action is created."""
    table = context.table
    if table is None:
        context.pai_pending_invariants = []
        return
    context.pai_pending_invariants = [row["text"].strip() for row in table]


@given('an action "{name}" with arguments:')
def step_create_action_with_args(context: Context, name: str) -> None:
    """Create a persisted action with the specified arguments."""
    svc: PlanLifecycleService = context.pai_service
    arguments = _parse_args_table(context)
    invariants = _consume_pending_invariants(context)
    svc.create_action(
        name=name,
        description=f"Test action {name}",
        definition_of_done="All tests pass",
        strategy_actor="openai/gpt-4",
        execution_actor="openai/gpt-4",
        arguments=arguments,
        invariants=invariants,
    )


@given('an action "{name}" with no arguments')
def step_create_action_no_args(context: Context, name: str) -> None:
    """Create a persisted action with zero arguments."""
    svc: PlanLifecycleService = context.pai_service
    invariants = _consume_pending_invariants(context)
    svc.create_action(
        name=name,
        description=f"Test action {name}",
        definition_of_done="All tests pass",
        strategy_actor="openai/gpt-4",
        execution_actor="openai/gpt-4",
        arguments=[],
        invariants=invariants,
    )


@given('a non-reusable action "{name}" with arguments:')
def step_create_non_reusable_action(context: Context, name: str) -> None:
    """Create a persisted non-reusable action with the specified arguments."""
    svc: PlanLifecycleService = context.pai_service
    arguments = _parse_args_table(context)
    invariants = _consume_pending_invariants(context)
    svc.create_action(
        name=name,
        description=f"Test action {name}",
        definition_of_done="All tests pass",
        strategy_actor="openai/gpt-4",
        execution_actor="openai/gpt-4",
        arguments=arguments,
        invariants=invariants,
        reusable=False,
    )


@when('I use the action "{name}" to create a plan with arguments:')
def step_use_action_with_args(context: Context, name: str) -> None:
    """Use an action to create a plan, passing argument values."""
    svc: PlanLifecycleService = context.pai_service
    arguments: dict[str, str | int | float | bool] = {}
    table = context.table
    if table is None:
        raise ValueError("Step requires a table of argument values")
    for row in table:
        val_str = row["value"].strip()
        # Attempt numeric/bool coercion
        try:
            arguments[row["name"].strip()] = int(val_str)
        except ValueError:
            try:
                arguments[row["name"].strip()] = float(val_str)
            except ValueError:
                if val_str.lower() in ("true", "false"):
                    arguments[row["name"].strip()] = val_str.lower() == "true"
                else:
                    arguments[row["name"].strip()] = val_str

    try:
        plan = svc.use_action(action_name=name, arguments=arguments)
        context.pai_plans.append(plan)
        context.pai_error = None
    except Exception as exc:
        context.pai_error = exc


@when('I use the action "{name}" to create a plan without arguments')
def step_use_action_no_args(context: Context, name: str) -> None:
    """Use an action to create a plan without explicit arguments."""
    svc: PlanLifecycleService = context.pai_service
    try:
        plan = svc.use_action(action_name=name)
        context.pai_plans.append(plan)
        context.pai_error = None
    except Exception as exc:
        context.pai_error = exc


@when('I use the action "{name}" to create a second plan without arguments')
def step_use_action_second_plan(context: Context, name: str) -> None:
    """Use the same action a second time to create another plan."""
    svc: PlanLifecycleService = context.pai_service
    try:
        plan = svc.use_action(action_name=name)
        context.pai_plans.append(plan)
        context.pai_error = None
    except Exception as exc:
        context.pai_error = exc


@when('the action "{name}" is updated through the repository')
def step_update_action_via_repo(context: Context, name: str) -> None:
    """Update an action via the repository to trigger the upsert code path."""
    svc: PlanLifecycleService = context.pai_service
    try:
        action = svc.get_action(name)
        action.description = "Updated description"
        # Persist the update through the UoW (exercises ActionRepository.update)
        uow = context.pai_uow
        with uow.transaction() as ctx:
            ctx.actions.update(action)
        context.pai_error = None
    except Exception as exc:
        context.pai_error = exc


@then("the plan should be created successfully in strategize phase")
def step_plan_created_in_strategize(context: Context) -> None:
    """Verify the most recent plan was created in Strategize phase."""
    assert context.pai_error is None, f"Expected no error, got: {context.pai_error}"
    assert len(context.pai_plans) >= 1, "No plan was created"
    plan = context.pai_plans[-1]
    assert plan.phase == PlanPhase.STRATEGIZE, (
        f"Expected STRATEGIZE phase, got {plan.phase}"
    )


@then("the plan should have a valid ULID identifier")
def step_plan_has_valid_ulid(context: Context) -> None:
    """Verify the plan has a valid 26-char Crockford base32 ULID."""
    plan = context.pai_plans[-1]
    plan_id = plan.identity.plan_id
    assert _ULID_RE.match(plan_id), f"Plan ID '{plan_id}' is not a valid ULID"


@then("both plans should be created successfully")
def step_both_plans_created(context: Context) -> None:
    """Verify exactly two plans were created without errors."""
    assert context.pai_error is None, f"Expected no error, got: {context.pai_error}"
    assert len(context.pai_plans) >= 2, (
        f"Expected at least 2 plans, got {len(context.pai_plans)}"
    )
    # Both should be in Strategize phase
    for plan in context.pai_plans[-2:]:
        assert plan.phase == PlanPhase.STRATEGIZE, (
            f"Expected STRATEGIZE phase, got {plan.phase}"
        )


@then("the repository should not raise an integrity error")
def step_no_integrity_error(context: Context) -> None:
    """Verify the update did not raise an IntegrityError."""
    assert context.pai_error is None, f"Expected no error, got: {context.pai_error}"


@then('the action "{name}" should still have {count:d} argument(s)')
def step_action_has_n_arguments(context: Context, name: str, count: int) -> None:
    """Verify the action has the expected number of arguments after update."""
    svc: PlanLifecycleService = context.pai_service
    session_factory: sessionmaker[Session] = context.pai_sf
    normalised = str(NamespacedName.parse(name))

    # The PlanLifecycleService caches actions between calls.  We clear the
    # cache entry to ensure the subsequent read reflects freshly persisted
    # rows instead of the now-stale in-memory snapshot.
    svc._actions.pop(normalised, None)

    with session_factory() as session:
        db_count = int(
            session.scalar(
                select(func.count())
                .select_from(ActionArgumentModel)
                .where(
                    ActionArgumentModel.action_name == normalised,
                )
            )
            or 0
        )

    assert db_count == count, f"Expected {count} argument(s) persisted, got {db_count}"

    action = svc.get_action(name)
    assert len(action.arguments) == count, (
        f"Expected {count} argument(s), got {len(action.arguments)}"
    )


@then('the action "{name}" should still have {count:d} invariant(s)')
def step_action_has_n_invariants(context: Context, name: str, count: int) -> None:
    """Verify the action has the expected number of invariants after update."""
    svc: PlanLifecycleService = context.pai_service
    session_factory: sessionmaker[Session] = context.pai_sf
    normalised = str(NamespacedName.parse(name))

    svc._actions.pop(normalised, None)

    with session_factory() as session:
        db_count = int(
            session.scalar(
                select(func.count())
                .select_from(ActionInvariantModel)
                .where(
                    ActionInvariantModel.action_name == normalised,
                )
            )
            or 0
        )

    assert db_count == count, f"Expected {count} invariant(s) persisted, got {db_count}"

    action = svc.get_action(name)
    assert len(action.invariants) == count, (
        f"Expected {count} invariant(s), got {len(action.invariants)}"
    )
