"""Step definitions for invariant_reconciliation_actor.feature.

Tests the InvariantReconciliationActor: multi-scope collection,
conflict detection, specificity-based resolution with non_overridable
support, invariant_enforced decision recording, and reconciled
InvariantSet production.
"""

from __future__ import annotations

from behave import given, then, when  # type: ignore[import-untyped]
from ulid import ULID

from cleveragents.actor.reconciliation import (
    InvariantReconciliationActor,
    ReconciliationResult,
)
from cleveragents.application.services.decision_service import DecisionService
from cleveragents.application.services.invariant_service import InvariantService
from cleveragents.domain.models.core.decision import DecisionType
from cleveragents.domain.models.core.invariant import (
    Invariant,
    InvariantScope,
    InvariantSet,
)

# ================================================================
# Background / Setup
# ================================================================


@given("a fresh InvariantService for reconciliation")
def step_fresh_invariant_service(context):
    """Create a fresh InvariantService instance."""
    context.invariant_service = InvariantService()


@given("a fresh DecisionService for reconciliation")
def step_fresh_decision_service(context):
    """Create a fresh DecisionService instance."""
    context.decision_service = DecisionService()


# ================================================================
# Adding Invariants
# ================================================================


@given('a global invariant "{text}" from source "{source}"')
def step_add_global_invariant(context, text, source):
    """Add a global-scope invariant."""
    context.invariant_service.add_invariant(
        text=text, scope=InvariantScope.GLOBAL, source_name=source
    )


@given('a non_overridable global invariant "{text}" from source "{source}"')
def step_add_non_overridable_global(context, text, source):
    """Add a non_overridable global-scope invariant."""
    # add_invariant does not expose non_overridable; create directly and store
    inv = Invariant(
        id=str(ULID()),
        text=text,
        scope=InvariantScope.GLOBAL,
        source_name=source,
        non_overridable=True,
    )
    context.invariant_service._invariants[inv.id] = inv


@given('a project invariant "{text}" from source "{source}" for project "{project}"')
def step_add_project_invariant(context, text, source, project):
    """Add a project-scope invariant."""
    context.invariant_service.add_invariant(
        text=text, scope=InvariantScope.PROJECT, source_name=source
    )


@given('an action invariant "{text}" from source "{source}" for action "{action}"')
def step_add_action_invariant(context, text, source, action):
    """Add an action-scope invariant."""
    context.invariant_service.add_invariant(
        text=text, scope=InvariantScope.ACTION, source_name=source
    )


@given('a plan invariant "{text}" from source "{source}"')
def step_add_plan_invariant(context, text, source):
    """Add a plan-scope invariant."""
    context.invariant_service.add_invariant(
        text=text, scope=InvariantScope.PLAN, source_name=source
    )


@given('an inactive global invariant "{text}" from source "{source}"')
def step_add_inactive_global(context, text, source):
    """Add a global invariant then soft-delete it."""
    inv = context.invariant_service.add_invariant(
        text=text, scope=InvariantScope.GLOBAL, source_name=source
    )
    context.invariant_service.remove_invariant(inv.id)


# ================================================================
# Running the actor
# ================================================================


def _build_actor(context):
    """Build the reconciliation actor from context services."""
    return InvariantReconciliationActor(
        invariant_service=context.invariant_service,
        decision_service=context.decision_service,
    )


@when('I reconcile plan-only for "{plan_id}"')
def step_run_reconciliation(context, plan_id):
    """Run the reconciliation actor with plan-only scope."""
    actor = _build_actor(context)
    context.reconciliation_result = actor.run(plan_id=plan_id)


@when('I reconcile with-project "{project}" for plan "{plan_id}"')
def step_run_reconciliation_with_project(context, project, plan_id):
    """Run the reconciliation actor with plan and project scope."""
    actor = _build_actor(context)
    context.reconciliation_result = actor.run(plan_id=plan_id, project_name=project)


@when('I reconcile with-project-action "{project}" and "{action}" for plan "{plan_id}"')
def step_run_reconciliation_with_project_and_action(context, project, action, plan_id):
    """Run the reconciliation actor with plan, project, and action scope."""
    actor = _build_actor(context)
    context.reconciliation_result = actor.run(
        plan_id=plan_id, project_name=project, action_name=action
    )


@when('I reconcile with-action "{action}" for plan "{plan_id}"')
def step_run_reconciliation_with_action(context, action, plan_id):
    """Run the reconciliation actor with plan and action scope."""
    actor = _build_actor(context)
    context.reconciliation_result = actor.run(plan_id=plan_id, action_name=action)


@when('I reconcile with-parent "{parent_id}" for plan "{plan_id}"')
def step_run_reconciliation_with_parent(context, parent_id, plan_id):
    """Run the reconciliation actor with a parent decision ID."""
    actor = _build_actor(context)
    context.reconciliation_result = actor.run(
        plan_id=plan_id, parent_decision_id=parent_id
    )


@when(
    'I collect invariants for plan "{plan_id}" '
    'with project "{project}" and action "{action}"'
)
def step_collect_invariants(context, plan_id, project, action):
    """Collect invariants without full reconciliation."""
    actor = _build_actor(context)
    context.collected_scope = actor.collect_invariants(
        plan_id=plan_id, project_name=project, action_name=action
    )


# ================================================================
# Reconciled set assertions
# ================================================================


@then("the reconciled set should contain {count:d} invariant")
@then("the reconciled set should contain {count:d} invariants")
def step_reconciled_count(context, count):
    """Assert the reconciled set has the expected number of invariants."""
    result: ReconciliationResult = context.reconciliation_result
    actual = len(result.reconciled_set.invariants)
    assert actual == count, f"Expected {count} invariants, got {actual}"


@then('the reconciled set should contain "{text}"')
def step_reconciled_contains_text(context, text):
    """Assert the reconciled set contains an invariant with the given text."""
    result: ReconciliationResult = context.reconciliation_result
    texts = [inv.text for inv in result.reconciled_set.invariants]
    assert text in texts, f"'{text}' not found in {texts}"


@then('the reconciled set should not contain "{text}"')
def step_reconciled_not_contains_text(context, text):
    """Assert the reconciled set does not contain the given text."""
    result: ReconciliationResult = context.reconciliation_result
    texts = [inv.text for inv in result.reconciled_set.invariants]
    assert text not in texts, f"'{text}' unexpectedly found in {texts}"


# ================================================================
# Conflict assertions
# ================================================================


@then("{count:d} conflict should be detected")
@then("{count:d} conflicts should be detected")
def step_conflict_count(context, count):
    """Assert the expected number of conflicts."""
    result: ReconciliationResult = context.reconciliation_result
    actual = len(result.conflicts)
    assert actual == count, f"Expected {count} conflicts, got {actual}"


@then('the winning invariant for "{key}" should be from "{scope}" scope')
def step_winning_scope(context, key, scope):
    """Assert the winner for a given normalised key is from the expected scope."""
    result: ReconciliationResult = context.reconciliation_result
    for inv in result.reconciled_set.invariants:
        if inv.text.strip().lower() == key.strip().lower():
            assert inv.scope.value == scope, (
                f"Expected winner scope '{scope}', got '{inv.scope.value}'"
            )
            return
    raise AssertionError(f"No invariant found with key '{key}'")


@then('the conflict reason should mention "{text}"')
def step_conflict_reason_mentions(context, text):
    """Assert at least one conflict reason contains the given text."""
    result: ReconciliationResult = context.reconciliation_result
    reasons = [c.reason for c in result.conflicts]
    assert any(text in r for r in reasons), (
        f"'{text}' not found in conflict reasons: {reasons}"
    )


# ================================================================
# Decision assertions
# ================================================================


@then("{count:d} invariant_enforced decision should be recorded")
@then("{count:d} invariant_enforced decisions should be recorded")
def step_decision_count(context, count):
    """Assert the expected number of enforced decisions."""
    result: ReconciliationResult = context.reconciliation_result
    actual = len(result.enforced_decision_ids)
    assert actual == count, f"Expected {count} decisions, got {actual}"


@then('all recorded decisions should be of type "{dtype}"')
def step_all_decisions_type(context, dtype):
    """Assert all recorded decisions are of the specified type."""
    result: ReconciliationResult = context.reconciliation_result
    for did in result.enforced_decision_ids:
        decision = context.decision_service.get_decision(did)
        assert decision.decision_type == DecisionType(dtype), (
            f"Decision {did} type is {decision.decision_type}, expected {dtype}"
        )


@then('each decision should reference the plan "{plan_id}"')
def step_decisions_reference_plan(context, plan_id):
    """Assert each decision references the given plan."""
    result: ReconciliationResult = context.reconciliation_result
    for did in result.enforced_decision_ids:
        decision = context.decision_service.get_decision(did)
        assert decision.plan_id == plan_id, (
            f"Decision {did} plan_id is {decision.plan_id}, expected {plan_id}"
        )


@then('the decision rationale should mention "{text}"')
def step_decision_rationale_mentions(context, text):
    """Assert at least one decision rationale mentions the text."""
    result: ReconciliationResult = context.reconciliation_result
    for did in result.enforced_decision_ids:
        decision = context.decision_service.get_decision(did)
        if text.lower() in decision.rationale.lower():
            return
    raise AssertionError(f"No decision rationale mentions '{text}'")


@then('the decision question should contain "{text}"')
def step_decision_question_contains(context, text):
    """Assert at least one decision question contains the text."""
    result: ReconciliationResult = context.reconciliation_result
    for did in result.enforced_decision_ids:
        decision = context.decision_service.get_decision(did)
        if text in decision.question:
            return
    raise AssertionError(f"No decision question contains '{text}'")


@then('the recorded decisions should have parent "{parent_id}"')
def step_decisions_have_parent(context, parent_id):
    """Assert all recorded decisions have the given parent decision ID."""
    result: ReconciliationResult = context.reconciliation_result
    for did in result.enforced_decision_ids:
        decision = context.decision_service.get_decision(did)
        assert decision.parent_decision_id == parent_id, (
            f"Decision {did} parent is {decision.parent_decision_id}, "
            f"expected {parent_id}"
        )


# ================================================================
# InvariantSet assertions
# ================================================================


@then("the result should contain a reconciled InvariantSet")
def step_result_has_invariant_set(context):
    """Assert the result contains a reconciled InvariantSet."""
    result: ReconciliationResult = context.reconciliation_result
    assert result.reconciled_set is not None
    assert isinstance(result.reconciled_set, InvariantSet)


@then("the InvariantSet should have {count:d} invariants")
def step_invariant_set_count(context, count):
    """Assert the InvariantSet has the expected count."""
    result: ReconciliationResult = context.reconciliation_result
    actual = len(result.reconciled_set.invariants)
    assert actual == count, f"Expected {count} invariants in set, got {actual}"


@then("the InvariantSet invariants should be Invariant model instances")
def step_invariant_set_types(context):
    """Assert each element in the InvariantSet is an Invariant instance."""
    result: ReconciliationResult = context.reconciliation_result
    for inv in result.reconciled_set.invariants:
        assert isinstance(inv, Invariant), (
            f"Expected Invariant, got {type(inv).__name__}"
        )


# ================================================================
# Validation / error assertions
# ================================================================


@then("running reconciliation with empty plan_id should raise ValueError")
def step_empty_plan_id_raises(context: object) -> None:
    """Assert ValueError is raised for empty plan_id."""
    actor = _build_actor(context)
    try:
        actor.run(plan_id="")
        raise AssertionError("Expected ValueError for empty plan_id")
    except ValueError:
        pass


@then("creating an actor with None invariant_service should raise ValueError")
def step_none_invariant_service_raises(context: object) -> None:
    """Assert ValueError when invariant_service is None."""
    try:
        InvariantReconciliationActor(
            invariant_service=None,  # type: ignore[arg-type]
            decision_service=context.decision_service,
        )
        raise AssertionError("Expected ValueError for None invariant_service")
    except ValueError:
        pass


@then("creating an actor with None decision_service should raise ValueError")
def step_none_decision_service_raises(context: object) -> None:
    """Assert ValueError when decision_service is None."""
    try:
        InvariantReconciliationActor(
            invariant_service=context.invariant_service,
            decision_service=None,  # type: ignore[arg-type]
        )
        raise AssertionError("Expected ValueError for None decision_service")
    except ValueError:
        pass


@when('I reconcile without-plan-id using project "{project}"')
def step_reconcile_without_plan_id(context: object, project: str) -> None:
    """Collect invariants without specifying a plan_id."""
    actor = _build_actor(context)
    context.collected_scope = actor.collect_invariants(
        project_name=project,
    )


@then("the collected plan invariants should be empty")
def step_collected_plan_empty(context: object) -> None:
    """Assert the collected plan invariants list is empty."""
    actual = len(context.collected_scope.plan_invariants)
    assert actual == 0, f"Expected 0 plan invariants, got {actual}"


# ================================================================
# Collection assertions
# ================================================================


@then("the collected scope invariants should have {count:d} global invariant")
@then("the collected scope invariants should have {count:d} global invariants")
def step_collected_global_count(context, count):
    """Assert the collected scope has the expected global count."""
    actual = len(context.collected_scope.global_invariants)
    assert actual == count, f"Expected {count} global invariants, got {actual}"


@then("the collected scope invariants should have {count:d} project invariant")
@then("the collected scope invariants should have {count:d} project invariants")
def step_collected_project_count(context, count):
    """Assert the collected scope has the expected project count."""
    actual = len(context.collected_scope.project_invariants)
    assert actual == count, f"Expected {count} project invariants, got {actual}"


@then("the collected scope invariants should have {count:d} action invariant")
@then("the collected scope invariants should have {count:d} action invariants")
def step_collected_action_count(context, count):
    """Assert the collected scope has the expected action count."""
    actual = len(context.collected_scope.action_invariants)
    assert actual == count, f"Expected {count} action invariants, got {actual}"


@then("the collected scope invariants should have {count:d} plan invariant")
@then("the collected scope invariants should have {count:d} plan invariants")
def step_collected_plan_count(context, count):
    """Assert the collected scope has the expected plan count."""
    actual = len(context.collected_scope.plan_invariants)
    assert actual == count, f"Expected {count} plan invariants, got {actual}"
