"""Step definitions for repository coverage boost.

Targets uncovered lines in ``repositories.py``:
- ActionRepository.update: row-is-None branch, arguments loop with default_value,
  invariants loop
- LifecyclePlanRepository: get_by_name, update PlanNotFoundError,
  update with project_links/arguments/invariants, list_plans by phase
- ResourceTypeRepository: create, get, duplicate handling
- ResourceRepository: create, get, get_by_name, list_resources by type
"""

from __future__ import annotations

from datetime import datetime

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

from cleveragents.core.exceptions import DatabaseError
from cleveragents.domain.models.core.action import (
    Action,
    ActionArgument,
    ActionState,
    ArgumentRequirement,
    ArgumentType,
)
from cleveragents.domain.models.core.plan import (
    InvariantSource,
    NamespacedName,
    Plan,
    PlanIdentity,
    PlanInvariant,
    PlanPhase,
    PlanTimestamps,
    ProcessingState,
    ProjectLink,
)
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
    ActionRepository,
    LifecyclePlanRepository,
    PlanNotFoundError,
)

# Crockford base32 alphabet for ULID generation
_CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
_ULID_COUNTER = 100  # start high to avoid collisions with other step files


def _next_ulid() -> str:
    """Return a unique, valid ULID string for each call."""
    from ulid import ULID

    return str(ULID())


def _make_action(
    name: str = "local/test-action",
    state: str = "available",
    arguments: list[ActionArgument] | None = None,
    invariants: list[str] | None = None,
) -> Action:
    """Create a minimal valid Action domain object."""
    parts = name.split("/", 1)
    namespace = parts[0] if len(parts) == 2 else "local"
    short_name = parts[1] if len(parts) == 2 else parts[0]

    return Action(
        namespaced_name=NamespacedName(
            namespace=namespace,
            name=short_name,
        ),
        description=f"Test action {short_name}",
        long_description=None,
        definition_of_done=f"Verify {short_name} completes",
        strategy_actor="local/strategist",
        execution_actor="local/executor",
        estimation_actor=None,
        review_actor=None,
        arguments=arguments or [],
        invariants=invariants or [],
        reusable=True,
        read_only=False,
        state=ActionState(state),
        created_at=datetime.now(),
        updated_at=datetime.now(),
        created_by=None,
        tags=[],
    )


def _make_plan(
    action_name: str,
    plan_id: str | None = None,
    phase: PlanPhase = PlanPhase.STRATEGIZE,
    ns_name: str = "local/test-plan",
) -> Plan:
    """Create a minimal valid Plan domain object."""
    pid = plan_id or _next_ulid()
    parts = ns_name.split("/", 1)
    namespace = parts[0] if len(parts) == 2 else "local"
    short_name = parts[1] if len(parts) == 2 else parts[0]

    return Plan(
        identity=PlanIdentity(
            plan_id=pid,
            parent_plan_id=None,
            root_plan_id=None,
            attempt=1,
        ),
        namespaced_name=NamespacedName(
            namespace=namespace,
            name=short_name,
        ),
        action_name=action_name,
        description=f"Test plan for {action_name}",
        definition_of_done="Tests pass",
        phase=phase,
        processing_state=ProcessingState.QUEUED,
        strategy_actor="local/strategist",
        execution_actor="local/executor",
        timestamps=PlanTimestamps(
            created_at=datetime.now(),
            updated_at=datetime.now(),
        ),
        created_by=None,
        tags=[],
        reusable=True,
        read_only=False,
    )


# ========================================================================
# Background
# ========================================================================


@given("a fresh in-memory database with full lifecycle schema")
def step_fresh_db(context: Context) -> None:
    engine = create_engine("sqlite:///:memory:")
    Base.metadata.create_all(engine)
    context.db_engine = engine
    session = sessionmaker(bind=engine)()
    context.db_session = session
    context.db_session_factory = lambda: session
    context.error = None


@given("an action repository using the session factory")
def step_action_repo(context: Context) -> None:
    context.action_repo = ActionRepository(
        session_factory=context.db_session_factory,
    )
    context.error = None


@given("a lifecycle plan repository using the session factory")
def step_plan_repo(context: Context) -> None:
    context.plan_repo = LifecyclePlanRepository(
        session_factory=context.db_session_factory,
    )
    context.error = None


# ========================================================================
# ActionRepository.update - non-existent action (line 921)
# ========================================================================


@given('a valid action object named "{name}"')
def step_make_action_obj(context: Context, name: str) -> None:
    context.action = _make_action(name=name)


@when("the action is updated without being persisted first")
def step_update_non_existent_action(context: Context) -> None:
    try:
        context.action_repo.update(context.action)
    except Exception as exc:
        context.error = exc


@then('a DatabaseError mentioning "{text}" should be raised')
def step_verify_database_error_text(context: Context, text: str) -> None:
    assert context.error is not None, "Expected DatabaseError, got no error"
    assert isinstance(context.error, DatabaseError), (
        f"Expected DatabaseError, got {type(context.error).__name__}: {context.error}"
    )
    assert text in str(context.error), (
        f"Expected '{text}' in error message: {context.error}"
    )


# ========================================================================
# ActionRepository.update - with arguments and invariants (lines 940-965)
# ========================================================================


@given("the action has been persisted in the database")
def step_persist_action(context: Context) -> None:
    context.action_repo.create(context.action)
    context.db_session.commit()


@when("the action arguments are replaced with new arguments including defaults")
def step_replace_arguments(context: Context) -> None:
    new_args = [
        ActionArgument(
            name="target_coverage",
            arg_type=ArgumentType.INTEGER,
            requirement=ArgumentRequirement.REQUIRED,
            description="Target coverage percentage",
            default_value=80,
            min_value=0.0,
            max_value=100.0,
        ),
        ActionArgument(
            name="framework",
            arg_type=ArgumentType.STRING,
            requirement=ArgumentRequirement.OPTIONAL,
            description="Test framework to use",
            default_value="pytest",
        ),
        ActionArgument(
            name="verbose",
            arg_type=ArgumentType.BOOLEAN,
            requirement=ArgumentRequirement.OPTIONAL,
            description="Enable verbose output",
            default_value=None,
        ),
    ]
    context.action = context.action.model_copy(update={"arguments": new_args})


@when("the action invariants are set to new invariant texts")
def step_set_invariants(context: Context) -> None:
    context.action = context.action.model_copy(
        update={
            "invariants": [
                "All tests must pass before deployment",
                "Code coverage must not decrease",
            ],
        }
    )


@when("the action is updated via the repository")
def step_update_action(context: Context) -> None:
    try:
        context.action = context.action.model_copy(
            update={"updated_at": datetime.now()}
        )
        context.result_action = context.action_repo.update(context.action)
        context.db_session.commit()
    except Exception as exc:
        context.error = exc


@then("the update should succeed without error")
def step_verify_no_update_error(context: Context) -> None:
    assert context.error is None, f"Unexpected error: {context.error}"


@then("retrieving the action should show the new arguments")
def step_verify_action_arguments(context: Context) -> None:
    fetched = context.action_repo.get_by_name(str(context.action.namespaced_name))
    assert fetched is not None, "Action not found after update"
    assert len(fetched.arguments) == 3, (
        f"Expected 3 arguments, got {len(fetched.arguments)}"
    )
    arg_names = [a.name for a in fetched.arguments]
    assert "target_coverage" in arg_names
    assert "framework" in arg_names
    assert "verbose" in arg_names

    # Verify default_value round-trip for the argument that has one
    framework_arg = next(a for a in fetched.arguments if a.name == "framework")
    assert framework_arg.default_value == "pytest", (
        f"Expected default_value 'pytest', got {framework_arg.default_value!r}"
    )

    coverage_arg = next(a for a in fetched.arguments if a.name == "target_coverage")
    assert coverage_arg.default_value == 80, (
        f"Expected default_value 80, got {coverage_arg.default_value!r}"
    )


@then("retrieving the action should show the new invariants")
def step_verify_action_invariants(context: Context) -> None:
    fetched = context.action_repo.get_by_name(str(context.action.namespaced_name))
    assert fetched is not None, "Action not found after update"
    assert len(fetched.invariants) == 2, (
        f"Expected 2 invariants, got {len(fetched.invariants)}"
    )
    assert "All tests must pass before deployment" in fetched.invariants
    assert "Code coverage must not decrease" in fetched.invariants


# ========================================================================
# LifecyclePlanRepository.get_by_name (lines 1162-1163)
# ========================================================================


@given('a lifecycle plan domain object linked to "{action_name}"')
def step_make_plan(context: Context, action_name: str) -> None:
    context.plan = _make_plan(
        action_name=action_name,
        ns_name=f"local/plan-{action_name.split('/')[-1]}",
    )


@given("the lifecycle plan has been persisted in the database")
def step_persist_plan(context: Context) -> None:
    context.plan_repo.create(context.plan)
    context.db_session.commit()


@when("the lifecycle plan is looked up by namespaced name")
def step_get_plan_by_name(context: Context) -> None:
    ns_name = str(context.plan.namespaced_name)
    context.result_plan = context.plan_repo.get_by_name(ns_name)


@then("the returned plan should match the original plan identity")
def step_verify_plan_identity(context: Context) -> None:
    assert context.result_plan is not None, "Expected a plan, got None"
    assert context.result_plan.identity.plan_id == context.plan.identity.plan_id, (
        f"Expected plan_id {context.plan.identity.plan_id}, "
        f"got {context.result_plan.identity.plan_id}"
    )


@when('a lifecycle plan is looked up by name "{name}"')
def step_get_plan_by_name_direct(context: Context, name: str) -> None:
    context.result_plan = context.plan_repo.get_by_name(name)


@then("no lifecycle plan should be returned")
def step_verify_no_plan(context: Context) -> None:
    assert context.result_plan is None, f"Expected None, got {context.result_plan}"


# ========================================================================
# LifecyclePlanRepository.update - PlanNotFoundError (lines 1190-1191)
# ========================================================================


@when("the lifecycle plan is updated without being persisted first")
def step_update_non_existent_plan(context: Context) -> None:
    try:
        context.plan_repo.update(context.plan)
    except Exception as exc:
        context.error = exc


@then("a PlanNotFoundError should be raised for the repository boost")
def step_verify_plan_not_found(context: Context) -> None:
    assert context.error is not None, "Expected PlanNotFoundError, got no error"
    assert isinstance(context.error, PlanNotFoundError), (
        f"Expected PlanNotFoundError, got {type(context.error).__name__}: {context.error}"
    )


# ========================================================================
# LifecyclePlanRepository.update - with project_links, arguments, invariants
# (lines 1225-1314)
# ========================================================================


@when("the plan is updated with project links, arguments, and invariants")
def step_update_plan_with_children(context: Context) -> None:
    try:
        updated = context.plan.model_copy(
            update={
                "project_links": [
                    ProjectLink(
                        project_name="local/api-service",
                        alias="api",
                        read_only=False,
                    ),
                    ProjectLink(
                        project_name="local/web-frontend",
                        alias="web",
                        read_only=True,
                    ),
                ],
                "arguments": {
                    "env": "production",
                    "replicas": 3,
                },
                "arguments_order": ["env", "replicas"],
                "invariants": [
                    PlanInvariant(
                        text="Must not break backward compatibility",
                        source=InvariantSource.PLAN,
                    ),
                    PlanInvariant(
                        text="All integration tests must pass",
                        source=InvariantSource.ACTION,
                    ),
                ],
                "timestamps": PlanTimestamps(
                    created_at=context.plan.timestamps.created_at,
                    updated_at=datetime.now(),
                ),
            }
        )
        context.plan = updated
        context.result_plan = context.plan_repo.update(updated)
        context.db_session.commit()
    except Exception as exc:
        context.error = exc


@then("the plan update should succeed without error")
def step_verify_plan_update_ok(context: Context) -> None:
    assert context.error is None, f"Unexpected error: {context.error}"


@then("retrieving the plan should show the new project links")
def step_verify_plan_project_links(context: Context) -> None:
    fetched = context.plan_repo.get(context.plan.identity.plan_id)
    assert fetched is not None, "Plan not found after update"
    assert len(fetched.project_links) == 2, (
        f"Expected 2 project links, got {len(fetched.project_links)}"
    )
    names = [pl.project_name for pl in fetched.project_links]
    assert "local/api-service" in names
    assert "local/web-frontend" in names


@then("retrieving the plan should show the new plan arguments")
def step_verify_plan_arguments(context: Context) -> None:
    fetched = context.plan_repo.get(context.plan.identity.plan_id)
    assert fetched is not None, "Plan not found after update"
    assert "env" in fetched.arguments, (
        f"Expected 'env' in arguments, got {fetched.arguments}"
    )
    assert fetched.arguments["env"] == "production"
    assert fetched.arguments["replicas"] == 3


@then("retrieving the plan should show the new plan invariants")
def step_verify_plan_invariants(context: Context) -> None:
    fetched = context.plan_repo.get(context.plan.identity.plan_id)
    assert fetched is not None, "Plan not found after update"
    assert len(fetched.invariants) == 2, (
        f"Expected 2 invariants, got {len(fetched.invariants)}"
    )
    texts = [inv.text for inv in fetched.invariants]
    assert "Must not break backward compatibility" in texts
    assert "All integration tests must pass" in texts


@given('the lifecycle plan has argument "{arg_name}" set to "{arg_value}"')
def step_set_initial_plan_argument(
    context: Context, arg_name: str, arg_value: str
) -> None:
    """Set an initial argument on the in-memory plan before persisting.

    This enables a regression scenario where update() replaces a child
    argument row with the same composite key (plan_id, name).
    """
    context.plan = context.plan.model_copy(
        update={
            "arguments": {arg_name: arg_value},
            "arguments_order": [arg_name],
            "timestamps": PlanTimestamps(
                created_at=context.plan.timestamps.created_at,
                updated_at=datetime.now(),
            ),
        }
    )


@when('the lifecycle plan argument "{arg_name}" is updated to "{new_value}"')
def step_update_plan_argument_same_name(
    context: Context, arg_name: str, new_value: str
) -> None:
    """Update an existing argument name to a new value via repository.update()."""
    updated = context.plan.model_copy(
        update={
            "arguments": {arg_name: new_value},
            "arguments_order": [arg_name],
            "timestamps": PlanTimestamps(
                created_at=context.plan.timestamps.created_at,
                updated_at=datetime.now(),
            ),
        }
    )
    context.plan = updated
    try:
        context.result_plan = context.plan_repo.update(updated)
        context.db_session.commit()
        context.error = None
    except Exception as exc:
        context.error = exc


@then('retrieving the plan should show argument "{arg_name}" value "{expected_value}"')
def step_verify_plan_argument_value(
    context: Context, arg_name: str, expected_value: str
) -> None:
    """Verify the replacement argument row persisted with new value."""
    fetched = context.plan_repo.get(context.plan.identity.plan_id)
    assert fetched is not None, "Plan not found after update"
    assert arg_name in fetched.arguments, (
        f"Expected argument '{arg_name}' in plan args, got {fetched.arguments}"
    )
    assert fetched.arguments[arg_name] == expected_value, (
        f"Expected argument '{arg_name}' value '{expected_value}', "
        f"got '{fetched.arguments[arg_name]}'"
    )


# ========================================================================
# LifecyclePlanRepository.list_plans filtered by phase (lines 1269-1271)
# ========================================================================


@given('lifecycle plans in different phases linked to "{action_name}"')
def step_create_plans_in_phases(context: Context, action_name: str) -> None:
    context.strategize_plan_ids = []
    context.execute_plan_ids = []

    for i in range(2):
        plan = _make_plan(
            action_name=action_name,
            phase=PlanPhase.STRATEGIZE,
            ns_name=f"local/strat-plan-{i}",
        )
        context.plan_repo.create(plan)
        context.strategize_plan_ids.append(plan.identity.plan_id)

    for i in range(1):
        plan = _make_plan(
            action_name=action_name,
            phase=PlanPhase.EXECUTE,
            ns_name=f"local/exec-plan-{i}",
        )
        context.plan_repo.create(plan)
        context.execute_plan_ids.append(plan.identity.plan_id)

    context.db_session.commit()


@when('plans are listed filtered by phase "{phase}"')
def step_list_by_phase(context: Context, phase: str) -> None:
    context.result_plans = context.plan_repo.list_plans(phase=phase)


@then("only the strategize-phase plans should be returned")
def step_verify_phase_filter(context: Context) -> None:
    assert len(context.result_plans) == 2, (
        f"Expected 2 strategize plans, got {len(context.result_plans)}"
    )
    for plan in context.result_plans:
        assert plan.phase == PlanPhase.STRATEGIZE, (
            f"Expected STRATEGIZE phase, got {plan.phase}"
        )


# ========================================================================
# ResourceTypeRepository (lines 1126-1135)
# ========================================================================


@given("a resource type repository using the session factory")
def step_resource_type_repo(context: Context) -> None:
    from cleveragents.infrastructure.database.repositories import (
        ResourceTypeRepository,
    )

    context.resource_type_repo = ResourceTypeRepository(
        session_factory=context.db_session_factory,
    )
    context.error = None


@given('a valid resource type domain object named "{name}"')
def step_make_resource_type(context: Context, name: str) -> None:
    from cleveragents.domain.models.core.resource_type import (
        ResourceKind,
        ResourceTypeSpec,
        SandboxStrategy,
    )

    context.resource_type = ResourceTypeSpec(
        name=name,
        description="Test resource type",
        resource_kind=ResourceKind.PHYSICAL,
        sandbox_strategy=SandboxStrategy.COPY_ON_WRITE,
        user_addable=True,
        cli_args=[],
        parent_types=[],
        child_types=[],
        auto_discovery=None,
        equivalence=None,
        handler=None,
        capabilities={
            "read": True,
            "write": True,
            "sandbox": True,
            "checkpoint": False,
        },
        built_in=False,
    )


@given("the resource type has been persisted in the database")
def step_persist_resource_type(context: Context) -> None:
    context.resource_type_repo.create(context.resource_type)
    context.db_session.commit()


@when("the resource type is created through the repository")
def step_create_resource_type(context: Context) -> None:
    try:
        context.resource_type_repo.create(context.resource_type)
        context.db_session.commit()
    except Exception as exc:
        context.error = exc


@then('the resource type should be retrievable by name "{name}"')
def step_verify_resource_type_by_name(context: Context, name: str) -> None:
    fetched = context.resource_type_repo.get(name)
    assert fetched is not None, f"Resource type '{name}' not found"
    assert fetched.name == name


@when("the same resource type is created again")
def step_create_duplicate_resource_type(context: Context) -> None:
    try:
        context.resource_type_repo.create(context.resource_type)
        context.db_session.commit()
    except Exception as exc:
        context.error = exc


@then("a DuplicateResourceTypeError should be raised")
def step_verify_dup_resource_type(context: Context) -> None:
    from cleveragents.infrastructure.database.repositories import (
        DuplicateResourceTypeError,
    )

    assert context.error is not None, "Expected DuplicateResourceTypeError"
    assert isinstance(context.error, DuplicateResourceTypeError), (
        f"Expected DuplicateResourceTypeError, "
        f"got {type(context.error).__name__}: {context.error}"
    )


# ========================================================================
# ResourceRepository (lines 1346-1422)
# ========================================================================


@given("a resource repository using the session factory")
def step_resource_repo(context: Context) -> None:
    from cleveragents.infrastructure.database.repositories import ResourceRepository

    context.resource_repo = ResourceRepository(
        session_factory=context.db_session_factory,
    )
    context.error = None


@given('a valid resource domain object of type "{type_name}"')
def step_make_resource(context: Context, type_name: str) -> None:
    from cleveragents.domain.models.core.resource import (
        PhysVirt,
        Resource,
        ResourceCapabilities,
    )

    resource_id = _next_ulid()
    context.resource = Resource(
        resource_id=resource_id,
        name=f"local/test-resource-{resource_id[-6:].lower()}",
        resource_type_name=type_name,
        classification=PhysVirt.PHYSICAL,
        description="A test resource",
        properties={"key": "value"},
        location="/tmp/test-location",
        content_hash=None,
        sandbox_strategy=None,
        capabilities=ResourceCapabilities(
            readable=True,
            writable=True,
            sandboxable=True,
            checkpointable=False,
        ),
    )


@when("the resource is created through the repository")
def step_create_resource(context: Context) -> None:
    try:
        context.resource_repo.create(context.resource)
        context.db_session.commit()
    except Exception as exc:
        context.error = exc


@then("the resource should be retrievable by its ID")
def step_verify_resource_by_id(context: Context) -> None:
    assert context.error is None, f"Unexpected error: {context.error}"
    fetched = context.resource_repo.get(context.resource.resource_id)
    assert fetched is not None, "Resource not found by ID"
    assert fetched.resource_id == context.resource.resource_id


@then("the resource should be retrievable by its namespaced name")
def step_verify_resource_by_name(context: Context) -> None:
    fetched = context.resource_repo.get_by_name(context.resource.name)
    assert fetched is not None, "Resource not found by namespaced name"
    assert fetched.name == context.resource.name


@given('multiple resources of type "{type_name}" have been created')
def step_create_multiple_resources(context: Context, type_name: str) -> None:
    from cleveragents.domain.models.core.resource import (
        PhysVirt,
        Resource,
        ResourceCapabilities,
    )

    context.created_resource_ids = []
    for i in range(3):
        resource_id = _next_ulid()
        resource = Resource(
            resource_id=resource_id,
            name=f"local/multi-res-{resource_id[-6:].lower()}",
            resource_type_name=type_name,
            classification=PhysVirt.PHYSICAL,
            description=f"Test resource {i}",
            properties={},
            location=f"/tmp/test-{i}",
            content_hash=None,
            sandbox_strategy=None,
            capabilities=ResourceCapabilities(
                readable=True,
                writable=True,
                sandboxable=True,
                checkpointable=False,
            ),
        )
        context.resource_repo.create(resource)
        context.created_resource_ids.append(resource_id)
    context.db_session.commit()


@when('resources are listed by type "{type_name}"')
def step_list_resources_by_type(context: Context, type_name: str) -> None:
    context.result_resources = context.resource_repo.list_resources(
        type_name=type_name,
    )


@then("all resources of that type should be returned")
def step_verify_resources_listed(context: Context) -> None:
    assert len(context.result_resources) == 3, (
        f"Expected 3 resources, got {len(context.result_resources)}"
    )
    returned_ids = {r.resource_id for r in context.result_resources}
    for rid in context.created_resource_ids:
        assert rid in returned_ids, f"Resource {rid} not in result list"
