"""Step definitions for M5 ACMS pipeline and large-project context smoke tests.

All step names are prefixed with ``m5 smoke`` to avoid ``AmbiguousStep``
conflicts with existing steps.
"""

from __future__ import annotations

import json
from pathlib import Path, PurePosixPath
from unittest.mock import MagicMock, patch

from behave import given, then, when
from behave.runner import Context
from pydantic import ValidationError
from typer.testing import CliRunner
from ulid import ULID

from cleveragents.application.services.acms_service import ACMSPipeline
from cleveragents.cli.commands.context import app as context_app
from cleveragents.domain.models.core.context import Context as ContextModel
from cleveragents.domain.models.core.context_fragment import (
    ContextBudget,
    ContextFragment,
    FragmentProvenance,
)
from cleveragents.domain.models.core.context_policy import (
    BudgetEnforcementResult,
    ContextView,
    ProjectContextPolicy,
    enforce_size_budget,
)

_FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "m5"


def _mock_container(
    context_service: MagicMock,
    project_service: MagicMock | None = None,
) -> MagicMock:
    """Build a mock DI container for context CLI tests."""
    container = MagicMock()
    container.context_service.return_value = context_service
    if project_service is None:
        project_service = MagicMock()
        project_service.get_current_project.return_value = MagicMock()
    container.project_service.return_value = project_service
    return container


# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------


@given("a m5 smoke test runner")
def step_m5_smoke_runner(context: Context) -> None:
    """Set up the CLI runner for M5 smoke tests."""
    context.runner = CliRunner()


@given("a m5 smoke mocked context service")
def step_m5_smoke_mocked_service(context: Context) -> None:
    """Set up mocked context service for M5 smoke tests."""
    context.mock_context_service = MagicMock()
    context.m5_error = None


# ---------------------------------------------------------------------------
# Fixture loading
# ---------------------------------------------------------------------------


@when("I m5 smoke load the ACMS context policy fixture")
def step_m5_load_policy_fixture(context: Context) -> None:
    fixture_path = _FIXTURES_DIR / "acms_context_policy.json"
    context.m5_policy_fixture = json.loads(fixture_path.read_text(encoding="utf-8"))


@then("the m5 smoke policy fixture should have a default view")
def step_m5_policy_has_default(context: Context) -> None:
    assert "default_view" in context.m5_policy_fixture


@then("the m5 smoke policy fixture should have a strategize view")
def step_m5_policy_has_strategize(context: Context) -> None:
    assert "strategize_view" in context.m5_policy_fixture


@then("the m5 smoke policy fixture should have an execute view")
def step_m5_policy_has_execute(context: Context) -> None:
    assert "execute_view" in context.m5_policy_fixture


@when("I m5 smoke load the large project context fixture")
def step_m5_load_large_project(context: Context) -> None:
    fixture_path = _FIXTURES_DIR / "large_project_context.json"
    context.m5_large_project = json.loads(fixture_path.read_text(encoding="utf-8"))


@then("the m5 smoke large project fixture should have file entries")
def step_m5_large_project_has_files(context: Context) -> None:
    assert "file_entries" in context.m5_large_project
    assert len(context.m5_large_project["file_entries"]) > 0


@then("the m5 smoke large project fixture should have context tiers")
def step_m5_large_project_has_tiers(context: Context) -> None:
    tiers = context.m5_large_project.get("context_tiers", {})
    assert "hot" in tiers
    assert "warm" in tiers
    assert "cold" in tiers


@when("I m5 smoke load the context analysis results fixture")
def step_m5_load_analysis(context: Context) -> None:
    fixture_path = _FIXTURES_DIR / "context_analysis_results.json"
    context.m5_analysis_fixture = json.loads(fixture_path.read_text(encoding="utf-8"))


@then("the m5 smoke analysis fixture should have a summary")
def step_m5_analysis_has_summary(context: Context) -> None:
    assert context.m5_analysis_fixture.get("summary")


@then("the m5 smoke analysis fixture should have dependencies")
def step_m5_analysis_has_deps(context: Context) -> None:
    assert "dependencies" in context.m5_analysis_fixture
    assert len(context.m5_analysis_fixture["dependencies"]) > 0


@then("the m5 smoke analysis fixture should have relevance scores")
def step_m5_analysis_has_scores(context: Context) -> None:
    assert "relevance_scores" in context.m5_analysis_fixture
    assert len(context.m5_analysis_fixture["relevance_scores"]) > 0


# ---------------------------------------------------------------------------
# Context policy resolution
# ---------------------------------------------------------------------------


@given("a m5 smoke empty context policy")
def step_m5_empty_policy(context: Context) -> None:
    context.m5_policy = ProjectContextPolicy()


@given("a m5 smoke policy with only default view")
def step_m5_default_only_policy(context: Context) -> None:
    context.m5_policy = ProjectContextPolicy(
        default_view=ContextView(
            include_paths=["src/**/*.py"],
            exclude_paths=["**/__pycache__/**"],
            max_file_size=262144,
        ),
    )


@given("a m5 smoke policy with strategize override")
def step_m5_strategize_override(context: Context) -> None:
    context.m5_policy = ProjectContextPolicy(
        default_view=ContextView(
            include_paths=["src/**/*.py"],
        ),
        strategize_view=ContextView(
            include_paths=["src/**/*.py", "docs/**/*.md"],
            max_file_size=131072,
        ),
    )


@when('I m5 smoke resolve the view for phase "{phase}"')
def step_m5_resolve_view(context: Context, phase: str) -> None:
    context.m5_resolved_view = context.m5_policy.resolve_view(phase)


@when('I m5 smoke resolve the view for invalid phase "{phase}"')
def step_m5_resolve_invalid_phase(context: Context, phase: str) -> None:
    try:
        context.m5_policy.resolve_view(phase)
        context.m5_error = None
    except ValueError as exc:
        context.m5_error = exc


@then("the m5 smoke resolved view should include all paths")
def step_m5_view_includes_all(context: Context) -> None:
    assert context.m5_resolved_view.include_paths == []


@then("the m5 smoke resolved view should match the default view")
def step_m5_view_matches_default(context: Context) -> None:
    default_view = context.m5_policy.default_view
    assert context.m5_resolved_view == default_view


@then("the m5 smoke resolved view should use the strategize override")
def step_m5_view_uses_strategize(context: Context) -> None:
    assert context.m5_resolved_view == context.m5_policy.strategize_view


@then("a m5 smoke ValueError should be raised")
def step_m5_value_error_raised(context: Context) -> None:
    assert context.m5_error is not None
    assert isinstance(context.m5_error, ValueError)


# ---------------------------------------------------------------------------
# Budget enforcement
# ---------------------------------------------------------------------------


@given("a m5 smoke context view with max_file_size {size:d}")
def step_m5_view_max_file_size(context: Context, size: int) -> None:
    context.m5_view = ContextView(max_file_size=size)


@given("a m5 smoke context view with max_total_size {size:d}")
def step_m5_view_max_total_size(context: Context, size: int) -> None:
    context.m5_view = ContextView(max_total_size=size)


@given("a m5 smoke context view with no size limits")
def step_m5_view_no_limits(context: Context) -> None:
    context.m5_view = ContextView()


@then("a m5 smoke file of size {size:d} should exceed the budget")
def step_m5_file_exceeds(context: Context, size: int) -> None:
    assert context.m5_view.max_file_size is not None
    assert size > context.m5_view.max_file_size


@then("a m5 smoke file of size {size:d} should be within the budget")
def step_m5_file_within(context: Context, size: int) -> None:
    if context.m5_view.max_file_size is None:
        return  # No limit means always within budget
    assert size <= context.m5_view.max_file_size


@then("a m5 smoke aggregate of size {size:d} should exceed the budget")
def step_m5_aggregate_exceeds(context: Context, size: int) -> None:
    assert context.m5_view.max_total_size is not None
    assert size > context.m5_view.max_total_size


@then("a m5 smoke aggregate of size {size:d} should be within the budget")
def step_m5_aggregate_within(context: Context, size: int) -> None:
    if context.m5_view.max_total_size is None:
        return  # No limit means always within budget
    assert size <= context.m5_view.max_total_size


@when("I m5 smoke create a context view with max_file_size {size:d}")
def step_m5_create_view_file_size(context: Context, size: int) -> None:
    try:
        ContextView(max_file_size=size)
        context.m5_error = None
    except ValidationError as exc:
        context.m5_error = exc


@when("I m5 smoke create a context view with max_total_size {size:d}")
def step_m5_create_view_total_size(context: Context, size: int) -> None:
    try:
        ContextView(max_total_size=size)
        context.m5_error = None
    except ValidationError as exc:
        context.m5_error = exc


@then("a m5 smoke validation error should be raised")
def step_m5_validation_error(context: Context) -> None:
    assert context.m5_error is not None


# ---------------------------------------------------------------------------
# Context assembly via CLI
# ---------------------------------------------------------------------------


@given("a m5 smoke project with no context")
def step_m5_project_no_context(context: Context) -> None:
    context.mock_context_service.list_context.return_value = []


@given("a m5 smoke project with mocked context service")
def step_m5_project_mocked_service(context: Context) -> None:
    context.mock_context_service.add_to_context.return_value = (
        [Path("src/main.py")],
        [],
    )


@given("a m5 smoke project with context entries")
def step_m5_project_with_entries(context: Context) -> None:
    mock_entry = MagicMock(spec=ContextModel)
    mock_entry.path = "src/main.py"
    mock_entry.size = 1024
    mock_entry.type = "FILE"
    context.mock_context_service.list_context.return_value = [mock_entry]
    context.mock_context_service.show_context_content.return_value = {
        "src/main.py": "print('hello')"
    }
    context.mock_context_service.get_context_content.return_value = "print('hello')"
    context.mock_context_service.clear_context.return_value = 1


@when("I m5 smoke invoke context list")
def step_m5_invoke_context_list(context: Context) -> None:
    container = _mock_container(context.mock_context_service)
    with patch(
        "cleveragents.application.container.get_container",
        return_value=container,
    ):
        result = context.runner.invoke(context_app, ["list"])
        context.m5_result = result


@then("the m5 smoke context list should succeed")
def step_m5_context_list_success(context: Context) -> None:
    assert context.m5_result.exit_code == 0, (
        f"Exit code: {context.m5_result.exit_code}\n{context.m5_result.output}"
    )


@then("the m5 smoke context list output should be empty")
def step_m5_context_list_empty(context: Context) -> None:
    # Empty context list may show "[]", "No files", or "No context" message
    output = context.m5_result.output.strip().lower()
    assert (
        output == "[]" or "no files" in output or "no context" in output or output == ""
    )


@when('I m5 smoke invoke context add with path "{path}"')
def step_m5_invoke_context_add(context: Context, path: str) -> None:
    container = _mock_container(context.mock_context_service)
    with (
        patch(
            "cleveragents.application.container.get_container",
            return_value=container,
        ),
        patch.object(Path, "exists", return_value=True),
    ):
        result = context.runner.invoke(context_app, ["add", path])
        context.m5_result = result


@when('I m5 smoke invoke context add with missing path "{path}"')
def step_m5_invoke_context_add_missing_path(context: Context, path: str) -> None:
    container = _mock_container(context.mock_context_service)
    with (
        patch(
            "cleveragents.application.container.get_container",
            return_value=container,
        ),
        patch.object(Path, "exists", return_value=False),
    ):
        result = context.runner.invoke(context_app, ["add", path])
        context.m5_result = result


@then("the m5 smoke context add should succeed")
def step_m5_context_add_success(context: Context) -> None:
    assert context.m5_result.exit_code == 0, (
        f"Exit code: {context.m5_result.exit_code}\n{context.m5_result.output}"
    )


@then("the m5 smoke context add output should report the missing path")
def step_m5_context_add_missing_path_reported(context: Context) -> None:
    output = context.m5_result.output
    assert "Path does not exist:" in output, f"Missing-path error not shown: {output}"
    assert "No files were added to context." in output, (
        f"Expected add command to report no-op after missing path: {output}"
    )


@then("the m5 smoke context service should not add any files")
def step_m5_context_service_not_called(context: Context) -> None:
    context.mock_context_service.add_to_context.assert_not_called()


@when('I m5 smoke invoke context show for path "{path}"')
def step_m5_invoke_context_show(context: Context, path: str) -> None:
    container = _mock_container(context.mock_context_service)
    with patch(
        "cleveragents.application.container.get_container",
        return_value=container,
    ):
        result = context.runner.invoke(context_app, ["show", path])
        context.m5_result = result


@then("the m5 smoke context show should succeed")
def step_m5_context_show_success(context: Context) -> None:
    assert context.m5_result.exit_code == 0, (
        f"Exit code: {context.m5_result.exit_code}\n{context.m5_result.output}"
    )


@then("the m5 smoke context show output should contain content")
def step_m5_context_show_has_content(context: Context) -> None:
    assert len(context.m5_result.output.strip()) > 0


@when("I m5 smoke invoke context clear")
def step_m5_invoke_context_clear(context: Context) -> None:
    container = _mock_container(context.mock_context_service)
    with patch(
        "cleveragents.application.container.get_container",
        return_value=container,
    ):
        result = context.runner.invoke(context_app, ["clear", "--yes"])
        context.m5_result = result


@then("the m5 smoke context clear should succeed")
def step_m5_context_clear_success(context: Context) -> None:
    assert context.m5_result.exit_code == 0, (
        f"Exit code: {context.m5_result.exit_code}\n{context.m5_result.output}"
    )


# ---------------------------------------------------------------------------
# Project context policy CLI
# ---------------------------------------------------------------------------


@given("a m5 smoke project with a saved context policy")
def step_m5_project_with_policy(context: Context) -> None:
    context.m5_saved_policy = ProjectContextPolicy(
        default_view=ContextView(
            include_paths=["src/**/*.py"],
            max_file_size=262144,
        ),
    )


@when("I m5 smoke invoke project context show")
def step_m5_invoke_project_context_show(context: Context) -> None:
    from cleveragents.cli.commands.project_context import _default_acms_config

    mock_repo = MagicMock()
    mock_repo.get.return_value = MagicMock()  # project exists

    mock_container = MagicMock()
    mock_container.session_factory.return_value = MagicMock()

    with (
        patch(
            "cleveragents.cli.commands.project_context._get_namespaced_project_repo",
            return_value=mock_repo,
        ),
        patch(
            "cleveragents.application.container.get_container",
            return_value=mock_container,
        ),
        patch(
            "cleveragents.cli.commands.project_context._read_policy",
            return_value=context.m5_saved_policy,
        ),
        patch(
            "cleveragents.cli.commands.project_context._read_acms_config",
            return_value=_default_acms_config(),
        ),
    ):
        from cleveragents.cli.commands.project_context import app as pc_app

        result = context.runner.invoke(pc_app, ["show", "local/m5-proj"])
        context.m5_result = result


@then("the m5 smoke project context show should succeed")
def step_m5_project_context_show_success(context: Context) -> None:
    assert context.m5_result.exit_code == 0, (
        f"Exit code: {context.m5_result.exit_code}\n{context.m5_result.output}"
    )


@then("the m5 smoke project context output should contain phase views")
def step_m5_project_context_has_phases(context: Context) -> None:
    output = context.m5_result.output
    assert "default" in output.lower() or "view" in output.lower()


@when("I m5 smoke invoke project context inspect")
def step_m5_invoke_context_inspect(context: Context) -> None:
    from cleveragents.application.services.context_tiers import ContextTierService
    from cleveragents.cli.commands.project_context import (
        _default_acms_config,
    )
    from cleveragents.cli.commands.project_context import (
        app as pc_app,
    )

    mock_repo = MagicMock()
    mock_repo.get.return_value = MagicMock()

    mock_container = MagicMock()
    mock_container.session_factory.return_value = MagicMock()
    mock_container.namespaced_project_repo.return_value = mock_repo
    mock_container.context_tier_service.return_value = ContextTierService()

    with (
        patch(
            "cleveragents.application.container.get_container",
            return_value=mock_container,
        ),
        patch(
            "cleveragents.cli.commands.project_context._read_policy",
            return_value=ProjectContextPolicy(),
        ),
        patch(
            "cleveragents.cli.commands.project_context._read_acms_config",
            return_value=_default_acms_config(),
        ),
    ):
        result = context.runner.invoke(pc_app, ["inspect", "local/m5-proj"])
        context.m5_result = result


@when("I m5 smoke invoke project context simulate")
def step_m5_invoke_context_simulate(context: Context) -> None:
    from cleveragents.application.services.context_tiers import ContextTierService
    from cleveragents.cli.commands.project_context import (
        _default_acms_config,
    )
    from cleveragents.cli.commands.project_context import (
        app as pc_app,
    )

    mock_repo = MagicMock()
    mock_repo.get.return_value = MagicMock()

    mock_container = MagicMock()
    mock_container.session_factory.return_value = MagicMock()
    mock_container.namespaced_project_repo.return_value = mock_repo
    mock_container.context_tier_service.return_value = ContextTierService()

    with (
        patch(
            "cleveragents.application.container.get_container",
            return_value=mock_container,
        ),
        patch(
            "cleveragents.cli.commands.project_context._read_policy",
            return_value=ProjectContextPolicy(),
        ),
        patch(
            "cleveragents.cli.commands.project_context._read_acms_config",
            return_value=_default_acms_config(),
        ),
    ):
        result = context.runner.invoke(pc_app, ["simulate", "local/m5-proj"])
        context.m5_result = result


@then("the m5 smoke project context inspect should succeed")
def step_m5_project_context_inspect_success(context: Context) -> None:
    assert context.m5_result.exit_code == 0, (
        f"Exit code: {context.m5_result.exit_code}\n{context.m5_result.output}"
    )


@then("the m5 smoke project context simulate should succeed")
def step_m5_project_context_simulate_success(context: Context) -> None:
    assert context.m5_result.exit_code == 0, (
        f"Exit code: {context.m5_result.exit_code}\n{context.m5_result.output}"
    )


# ---------------------------------------------------------------------------
# Context analysis agent
# ---------------------------------------------------------------------------


@given("a m5 smoke mocked context analysis agent")
def step_m5_mocked_analysis_agent(context: Context) -> None:
    fixture_path = _FIXTURES_DIR / "context_analysis_results.json"
    fixture_data = json.loads(fixture_path.read_text(encoding="utf-8"))
    context.m5_mock_analysis = {
        "file_paths": list(fixture_data["dependencies"].keys()),
        "documents": [],
        "dependencies": fixture_data["dependencies"],
        "summary": fixture_data["summary"],
        "relevance_scores": fixture_data["relevance_scores"],
        "chunks": [],
        "error": None,
    }


@when("I m5 smoke invoke context analysis")
def step_m5_invoke_analysis(context: Context) -> None:
    context.m5_analysis_result = context.m5_mock_analysis


@then("the m5 smoke analysis result should have a non-empty summary")
def step_m5_analysis_has_nonempty_summary(context: Context) -> None:
    assert context.m5_analysis_result["summary"]


@then("the m5 smoke analysis result should have dependency entries")
def step_m5_analysis_has_dep_entries(context: Context) -> None:
    assert len(context.m5_analysis_result["dependencies"]) > 0


@then("the m5 smoke analysis result should have relevance scores")
def step_m5_analysis_has_rel_scores(context: Context) -> None:
    assert len(context.m5_analysis_result["relevance_scores"]) > 0


@then("all m5 smoke relevance scores should be between 0 and 1")
def step_m5_scores_bounded(context: Context) -> None:
    for path, score in context.m5_analysis_result["relevance_scores"].items():
        assert 0.0 <= score <= 1.0, f"Score for {path} out of range: {score}"


# ---------------------------------------------------------------------------
# Context CLI: multiple resources
# ---------------------------------------------------------------------------


@given("a m5 smoke project with multiple context entries")
def step_m5_project_with_multiple_entries(context: Context) -> None:
    """Set up a project with multiple context files for testing."""
    entries = []
    for i, name in enumerate(["src/a.py", "src/b.py", "src/c.py"]):
        mock_entry = MagicMock(spec=ContextModel)
        mock_entry.path = name
        mock_entry.size = 1024 * (i + 1)
        mock_entry.type = "FILE"
        mock_entry.added_at = "2026-03-01T00:00:00Z"
        mock_entry.content = f"# content of {name}"
        entries.append(mock_entry)
    context.mock_context_service.list_context.return_value = entries
    context.mock_context_service.clear_context.return_value = len(entries)


@then("the m5 smoke context list output should contain multiple files")
def step_m5_context_list_multiple(context: Context) -> None:
    output = context.m5_result.output
    assert "a.py" in output or "3 total" in output, (
        f"Expected multiple files in output, got: {output}"
    )


@given("a m5 smoke project with mocked multi-add context service")
def step_m5_project_mocked_multi_add(context: Context) -> None:
    context.mock_context_service.add_to_context.return_value = (
        [Path("src/a.py"), Path("src/b.py")],
        [],
    )


@when('I m5 smoke invoke context add with paths "{path1}" and "{path2}"')
def step_m5_invoke_context_add_multi(context: Context, path1: str, path2: str) -> None:
    container = _mock_container(context.mock_context_service)
    with (
        patch(
            "cleveragents.application.container.get_container",
            return_value=container,
        ),
        patch.object(Path, "exists", return_value=True),
    ):
        result = context.runner.invoke(context_app, ["add", path1, path2])
        context.m5_result = result


@when("I m5 smoke invoke context show without path")
def step_m5_invoke_context_show_no_path(context: Context) -> None:
    container = _mock_container(context.mock_context_service)
    with patch(
        "cleveragents.application.container.get_container",
        return_value=container,
    ):
        result = context.runner.invoke(context_app, ["show"])
        context.m5_result = result


@then("the m5 smoke context show output should contain an exact summary")
def step_m5_context_show_summary(context: Context) -> None:
    output = context.m5_result.output
    assert "Context Summary:" in output, f"Expected summary header in output: {output}"
    assert "Total files: 3" in output, f"Expected file count in output: {output}"
    assert "Total size: 6,144 bytes" in output, (
        f"Expected aggregate size in output: {output}"
    )
    assert (
        "Use 'agents context show <file>'" in output
        or "Use 'agents actor context show <file>'" in output
    ), f"Expected usage hint in output: {output}"


# ---------------------------------------------------------------------------
# Context CLI: clear and re-add
# ---------------------------------------------------------------------------


@when('I m5 smoke re-add context entry "{path}" after clear')
def step_m5_readd_after_clear(context: Context, path: str) -> None:
    """Re-add a context entry after clearing, simulating clear+re-add flow."""
    context.mock_context_service.add_to_context.return_value = (
        [Path(path)],
        [],
    )
    container = _mock_container(context.mock_context_service)
    with (
        patch(
            "cleveragents.application.container.get_container",
            return_value=container,
        ),
        patch.object(Path, "exists", return_value=True),
    ):
        result = context.runner.invoke(context_app, ["add", path])
        context.m5_result = result


# ---------------------------------------------------------------------------
# Multi-project context
# ---------------------------------------------------------------------------


@given('m5 smoke project "{name}" with {count:d} context files')
def step_m5_project_with_files(context: Context, name: str, count: int) -> None:
    if not hasattr(context, "m5_projects"):
        context.m5_projects = {}
    context.m5_projects[name] = [
        {"path": f"src/file_{i}.py", "size": 1024} for i in range(count)
    ]


@then('m5 smoke project "{name}" should have {count:d} context entries')
def step_m5_project_entry_count(context: Context, name: str, count: int) -> None:
    assert len(context.m5_projects[name]) == count, (
        f"Expected {count} entries for {name}, got {len(context.m5_projects[name])}"
    )


# ---------------------------------------------------------------------------
# Context exclusion patterns
# ---------------------------------------------------------------------------


@given('a m5 smoke context view excluding "{pattern}"')
def step_m5_view_excluding(context: Context, pattern: str) -> None:
    context.m5_view = ContextView(exclude_paths=[pattern])
    context.m5_exclude_pattern = pattern


@then('the m5 smoke path "{path}" should be excluded')
def step_m5_path_excluded(context: Context, path: str) -> None:
    assert PurePosixPath(path).match(context.m5_exclude_pattern), (
        f"Expected '{path}' to match exclude pattern '{context.m5_exclude_pattern}'"
    )


@then('the m5 smoke path "{path}" should not be excluded')
def step_m5_path_not_excluded(context: Context, path: str) -> None:
    assert not PurePosixPath(path).match(context.m5_exclude_pattern), (
        f"Expected '{path}' NOT to match exclude pattern"
    )


# ---------------------------------------------------------------------------
# Budget enforcement (enforce_size_budget)
# ---------------------------------------------------------------------------


def _make_fragment(size: int, index: int) -> ContextFragment:
    """Create a ContextFragment with content of exactly *size* bytes."""
    # ASCII 'x' is 1 byte in UTF-8, so a string of length=size gives size bytes.
    return ContextFragment(
        content="x" * size,
        token_count=max(1, size // 4),
        uko_node=f"uko://test/frag-{index}",
        provenance=FragmentProvenance(
            resource_uri=f"test://res-{index}",
            location=f"file_{index}.py",
        ),
    )


@given("m5 smoke fragments of sizes {sizes}")
def step_m5_fragments_of_sizes(context: Context, sizes: str) -> None:
    size_list = [int(s.strip()) for s in sizes.split()]
    context.m5_fragments = [_make_fragment(s, i) for i, s in enumerate(size_list)]


@given("m5 smoke fragments of sizes")
def step_m5_fragments_empty(context: Context) -> None:
    context.m5_fragments = []


@given("m5 smoke fragments with unicode content")
def step_m5_fragments_unicode(context: Context) -> None:
    """Create two fragments with multi-byte UTF-8 content.

    Fragment 0: 5 x e-acute = 10 bytes (each is 2 bytes in UTF-8) -- fits in 10.
    Fragment 1: 6 x e-acute = 12 bytes -- exceeds max_file_size of 10.
    """
    context.m5_fragments = [
        ContextFragment(
            content="é" * 5,
            token_count=5,
            uko_node="uko://test/frag-0",
            provenance=FragmentProvenance(
                resource_uri="test://res-0",
                location="file_0.py",
            ),
        ),
        ContextFragment(
            content="é" * 6,
            token_count=6,
            uko_node="uko://test/frag-1",
            provenance=FragmentProvenance(
                resource_uri="test://res-1",
                location="file_1.py",
            ),
        ),
    ]


@given("a m5 smoke context view with max_file_size {mfs:d} and max_total_size {mts:d}")
def step_m5_view_mixed_limits(context: Context, mfs: int, mts: int) -> None:
    context.m5_view = ContextView(max_file_size=mfs, max_total_size=mts)


@when("I m5 smoke enforce the size budget")
def step_m5_enforce_budget(context: Context) -> None:
    context.m5_enforcement = enforce_size_budget(context.m5_fragments, context.m5_view)


@then("{count:d} m5 smoke fragments should be accepted")
def step_m5_accepted_count(context: Context, count: int) -> None:
    result: BudgetEnforcementResult = context.m5_enforcement
    assert len(result.accepted) == count, (
        f"Expected {count} accepted, got {len(result.accepted)}"
    )


@then("{count:d} m5 smoke fragments should be violated")
def step_m5_violation_count(context: Context, count: int) -> None:
    result: BudgetEnforcementResult = context.m5_enforcement
    assert len(result.violations) == count, (
        f"Expected {count} violations, got {len(result.violations)}"
    )


@then('the m5 smoke violation type should be "{vtype}"')
def step_m5_violation_type(context: Context, vtype: str) -> None:
    result: BudgetEnforcementResult = context.m5_enforcement
    assert any(v.violation_type == vtype for v in result.violations), (
        f"No violation with type {vtype!r} found"
    )


@then('the m5 smoke violation reason should contain "{text}"')
def step_m5_violation_reason_contains(context: Context, text: str) -> None:
    result: BudgetEnforcementResult = context.m5_enforcement
    assert any(text in v.reason for v in result.violations), (
        f"No violation reason contains {text!r}"
    )


@then("the m5 smoke violation content size should be {size:d}")
def step_m5_violation_content_size(context: Context, size: int) -> None:
    result: BudgetEnforcementResult = context.m5_enforcement
    assert any(v.content_size == size for v in result.violations), (
        f"No violation with content_size={size}"
    )


@then("the m5 smoke total accepted size should be {size:d}")
def step_m5_total_size(context: Context, size: int) -> None:
    result: BudgetEnforcementResult = context.m5_enforcement
    assert result.total_size == size, (
        f"Expected total_size={size}, got {result.total_size}"
    )


@then('the m5 smoke violations should include type "{vtype}"')
def step_m5_violations_include_type(context: Context, vtype: str) -> None:
    result: BudgetEnforcementResult = context.m5_enforcement
    types = {v.violation_type for v in result.violations}
    assert vtype in types, f"Expected violation type {vtype!r} in {types}"


@when("I m5 smoke assemble via pipeline with the context view")
def step_m5_assemble_pipeline(context: Context) -> None:
    pipeline = ACMSPipeline()
    plan_id = str(ULID())
    budget = ContextBudget(max_tokens=4096)
    payload = pipeline.assemble(
        plan_id=plan_id,
        fragments=context.m5_fragments,
        budget=budget,
        context_view=context.m5_view,
    )
    context.m5_pipeline_payload = payload
    context.m5_pipeline = pipeline


@then("the m5 smoke pipeline should return {count:d} fragments")
def step_m5_pipeline_fragment_count(context: Context, count: int) -> None:
    assert len(context.m5_pipeline_payload.fragments) == count, (
        f"Expected {count}, got {len(context.m5_pipeline_payload.fragments)}"
    )


@then("the m5 smoke pipeline enforcement result should have {count:d} violation(s)")
def step_m5_pipeline_enforcement_violations(context: Context, count: int) -> None:
    result = context.m5_pipeline.last_enforcement_result
    assert result is not None, "No enforcement result on pipeline"
    assert len(result.violations) == count, (
        f"Expected {count} violations, got {len(result.violations)}"
    )
