"""Step definitions for behave_parallel_log_filtering.feature.

Tests the conditional log replay and worker exception handling in
``scripts/run_behave_parallel.py``, as well as the PassSuppressFormatter
per-scenario output buffering behaviour.
"""

from __future__ import annotations

import importlib.util
import io
import sys
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from types import ModuleType
from typing import Any

from behave import given, then, when  # type: ignore[import-untyped]
from behave.configuration import Configuration  # type: ignore[import-untyped]
from behave.formatter.base import StreamOpener  # type: ignore[import-untyped]
from behave.runner import Context  # type: ignore[import-untyped]


def _load_runner_module() -> ModuleType:
    """Load scripts/run_behave_parallel.py as a module via importlib.

    **CWD requirement**: This function resolves the script path relative to
    the current working directory (``Path("scripts") / "run_behave_parallel.py"``).
    It must be called from the repository root (i.e. the directory that
    contains the ``scripts/`` folder).  Behave sets the working directory to
    the project root when running feature tests, so this is satisfied
    automatically in the normal test invocation.  If you invoke step
    definitions from an unexpected directory, either adjust your working
    directory or anchor the path relative to ``__file__``.
    """
    script_path = Path("scripts") / "run_behave_parallel.py"
    spec = importlib.util.spec_from_file_location(
        "run_behave_parallel", str(script_path)
    )
    if spec is None or spec.loader is None:
        msg = f"Cannot load {script_path}"
        raise ImportError(msg)
    mod = importlib.util.module_from_spec(spec)
    sys.modules[spec.name] = mod  # Prevent re-execution on subsequent calls
    spec.loader.exec_module(mod)
    return mod


_runner_mod = _load_runner_module()

# Bind module-level references for convenience.
Summary = _runner_mod.Summary
_chunk_has_failures = _runner_mod._chunk_has_failures
_chunk_no_scenarios_ran = _runner_mod._chunk_no_scenarios_ran
_empty_summary = _runner_mod._empty_summary
_worker_run_features = _runner_mod._worker_run_features
_aggregate_worker_results = _runner_mod._aggregate_worker_results
_has_failures = _runner_mod._has_failures
PassSuppressFormatter = _runner_mod.PassSuppressFormatter


def _passing_summary(features: int = 1, scenarios: int = 1) -> Summary:
    """Build a summary with all-passing counts."""
    return {
        "features": {
            "passed": features,
            "failed": 0,
            "errors": 0,
            "skipped": 0,
        },
        "scenarios": {
            "passed": scenarios,
            "failed": 0,
            "errors": 0,
            "skipped": 0,
        },
        "steps": {"passed": scenarios * 3, "failed": 0, "errors": 0, "skipped": 0},
        "duration": 1.0,
    }


def _failed_feature_summary() -> Summary:
    s = _empty_summary()
    s["features"]["failed"] = 1
    return s


def _errored_feature_summary() -> Summary:
    s = _empty_summary()
    s["features"]["errors"] = 1
    return s


def _failed_scenario_summary() -> Summary:
    s = _empty_summary()
    s["scenarios"]["failed"] = 1
    return s


def _errored_scenario_summary() -> Summary:
    s = _empty_summary()
    s["scenarios"]["errors"] = 1
    return s


def _all_skipped_summary() -> Summary:
    """Build a summary where all scenarios are skipped (but some ran)."""
    s = _empty_summary()
    s["features"]["skipped"] = 1
    s["scenarios"]["skipped"] = 3
    return s


def _crash_summary() -> Summary:
    """Build a summary representing a worker crash (features.errors = 1)."""
    s = _empty_summary()
    s["features"]["errors"] = 1
    return s


# ---- _chunk_has_failures helper steps ----


@given(
    "behave_parallel a summary with {n:d} passed features and {m:d} passed scenarios"
)
def step_passing_summary(context: Context, n: int, m: int) -> None:
    context.bp_summary = _passing_summary(features=n, scenarios=m)


@given("behave_parallel a summary with 1 failed feature")
def step_failed_feature_summary(context: Context) -> None:
    context.bp_summary = _failed_feature_summary()


@given("behave_parallel a summary with 1 errored feature")
def step_errored_feature_summary(context: Context) -> None:
    context.bp_summary = _errored_feature_summary()


@given("behave_parallel a summary with 1 failed scenario")
def step_failed_scenario_summary(context: Context) -> None:
    context.bp_summary = _failed_scenario_summary()


@given("behave_parallel a summary with 1 errored scenario")
def step_errored_scenario_summary(context: Context) -> None:
    context.bp_summary = _errored_scenario_summary()


@given("behave_parallel an empty summary")
def step_empty_summary(context: Context) -> None:
    context.bp_summary = _empty_summary()


@given("behave_parallel a summary with all scenarios skipped")
def step_all_skipped_summary(context: Context) -> None:
    context.bp_summary = _all_skipped_summary()


@when("behave_parallel I check chunk_has_failures")
def step_check_chunk_has_failures(context: Context) -> None:
    context.bp_result = _chunk_has_failures(context.bp_summary)


@when("behave_parallel I check chunk_no_scenarios_ran")
def step_check_chunk_no_scenarios_ran(context: Context) -> None:
    context.bp_result = _chunk_no_scenarios_ran(context.bp_summary)


@then("behave_parallel chunk_has_failures should return false")
def step_chunk_has_failures_false(context: Context) -> None:
    assert context.bp_result is False, f"Expected False, got {context.bp_result}"


@then("behave_parallel chunk_has_failures should return true")
def step_chunk_has_failures_true(context: Context) -> None:
    assert context.bp_result is True, f"Expected True, got {context.bp_result}"


@then("behave_parallel chunk_no_scenarios_ran should return true")
def step_chunk_no_scenarios_ran_true(context: Context) -> None:
    assert context.bp_result is True, f"Expected True, got {context.bp_result}"


@then("behave_parallel chunk_no_scenarios_ran should return false")
def step_chunk_no_scenarios_ran_false(context: Context) -> None:
    assert context.bp_result is False, f"Expected False, got {context.bp_result}"


# ---- Conditional log replay integration steps ----


@given(
    'behave_parallel a worker result with passing summary and captured stdout "{text}"'
)
def step_passing_worker_result(context: Context, text: str) -> None:
    context.bp_worker_results = [
        (False, text, "", _passing_summary()),
    ]


@given(
    'behave_parallel a worker result with failed summary and captured stdout "{text}"'
)
def step_failed_worker_result(context: Context, text: str) -> None:
    context.bp_worker_results = [
        (True, text, "", _failed_scenario_summary()),
    ]


@given(
    'behave_parallel a worker result with crashed summary and captured stderr "{text}"'
)
def step_crashed_worker_result(context: Context, text: str) -> None:
    context.bp_worker_results = [
        (True, "", text, _crash_summary()),
    ]


@given(
    'behave_parallel a worker result with empty summary and captured stderr "{text}"'
)
def step_empty_summary_worker_result(context: Context, text: str) -> None:
    # Pure all-zeros summary: _chunk_has_failures() returns False but
    # _chunk_no_scenarios_ran() returns True, exercising the no-scenarios-ran
    # path exclusively.
    context.bp_worker_results = [
        (False, "", text, _empty_summary()),
    ]


@given(
    'behave_parallel a worker result with failed summary and captured stderr "{text}"'
)
def step_failed_worker_result_stderr(context: Context, text: str) -> None:
    context.bp_worker_results = [
        (True, "", text, _failed_scenario_summary()),
    ]


@given("behave_parallel worker results with one passing and one failing chunk")
def step_mixed_worker_results(context: Context) -> None:
    context.bp_worker_results = [
        (False, "PASSING CHUNK", "", _passing_summary()),
        (True, "FAILING CHUNK", "", _failed_scenario_summary()),
    ]


@given(
    "behave_parallel a worker result where worker_failed is true but summary is passing"
    ' with stdout "{text}"'
)
def step_tdd_inverted_worker_result(context: Context, text: str) -> None:
    # TDD-inverted chunk: runner.run() returned True (raw worker_failed=True)
    # but the after_scenario hook corrected the scenario status to passing.
    # The summary therefore shows no failures.  Output must be suppressed.
    context.bp_worker_results = [
        (True, text, "", _passing_summary()),
    ]


@given("behave_parallel worker results with one crashed chunk and one passing chunk")
def step_crash_and_pass_worker_results(context: Context) -> None:
    # Partial crash: one worker sets features.errors=1, the other passes.
    # The merged total must have _has_failures() == True.
    context.bp_worker_results = [
        (True, "", "CRASH TRACEBACK", _crash_summary()),
        (False, "PASS OUT", "", _passing_summary()),
    ]


@when("behave_parallel I aggregate the worker results")
def step_aggregate_worker_results(context: Context) -> None:
    """Invoke _aggregate_worker_results() and capture its stdout/stderr output."""
    results: list[tuple[bool, str, str, Summary]] = context.bp_worker_results

    stdout_capture = io.StringIO()
    stderr_capture = io.StringIO()

    with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture):
        context.bp_aggregated_summary = _aggregate_worker_results(results)

    context.bp_aggregated_stdout = stdout_capture.getvalue()
    context.bp_aggregated_stderr = stderr_capture.getvalue()


@then('behave_parallel the aggregated stdout should not contain "{text}"')
def step_aggregated_stdout_not_contains(context: Context, text: str) -> None:
    assert text not in context.bp_aggregated_stdout, (
        f"Expected '{text}' NOT in stdout, but found it: "
        f"{context.bp_aggregated_stdout!r}"
    )


@then('behave_parallel the aggregated stdout should contain "{text}"')
def step_aggregated_stdout_contains(context: Context, text: str) -> None:
    assert text in context.bp_aggregated_stdout, (
        f"Expected '{text}' in stdout, but not found: {context.bp_aggregated_stdout!r}"
    )


@then('behave_parallel the aggregated stderr should contain "{text}"')
def step_aggregated_stderr_contains(context: Context, text: str) -> None:
    assert text in context.bp_aggregated_stderr, (
        f"Expected '{text}' in stderr, but not found: {context.bp_aggregated_stderr!r}"
    )


@then("behave_parallel the aggregated summary should have failures")
def step_aggregated_summary_has_failures(context: Context) -> None:
    summary = context.bp_aggregated_summary
    assert _has_failures(summary), (
        f"Expected _has_failures() to return True for summary: {summary}"
    )


# ---- Worker exception handling steps ----


@given("behave_parallel a worker payload that will raise an exception")
def step_worker_payload_raises(context: Context) -> None:
    # This step relies on behave raising a real exception when it cannot
    # locate the feature file — specifically, behave's feature loader raises
    # an exception (e.g. ConfigError or IOError) for paths that do not exist
    # on disk.  This is verified, documented behave behavior: passing a
    # non-existent path to behave.configuration.Configuration causes behave
    # to fail during feature loading rather than silently returning zero
    # scenarios.  If this test starts failing, check whether behave changed
    # its file-not-found handling (look at behave.runner.Runner.load_step_modules
    # and behave.runner_util.collect_feature_locations).
    context.bp_worker_payload = (
        ["__nonexistent_feature_path_that_will_crash__.feature"],
        [],
    )


@when("behave_parallel I invoke the worker")
def step_invoke_worker(context: Context) -> None:
    """Invoke _worker_run_features with a real crashing payload."""
    result = _worker_run_features(context.bp_worker_payload)
    context.bp_worker_result = result


@then("behave_parallel the worker result should indicate failure")
def step_worker_result_failure(context: Context) -> None:
    failed, _stdout, _stderr, _summary = context.bp_worker_result
    assert failed is True, f"Expected failed=True, got {failed}"


@then('behave_parallel the worker stderr should contain "{text}"')
def step_worker_stderr_contains(context: Context, text: str) -> None:
    _failed, _stdout, stderr, _summary = context.bp_worker_result
    assert text in stderr, f"Expected '{text}' in stderr, got: {stderr!r}"


@then("behave_parallel the worker summary should have features errors equal to 1")
def step_worker_summary_features_errors_1(context: Context) -> None:
    _failed, _stdout, _stderr, summary = context.bp_worker_result
    errors = summary["features"]["errors"]
    assert errors == 1, (
        f"Expected features.errors=1 so _has_failures() detects partial crash, "
        f"got: {errors}"
    )


# ---- PassSuppressFormatter helpers ----


class _MockStatus:
    """Minimal status object mirroring behave's Status enum interface."""

    def __init__(self, name: str) -> None:
        self.name = name


class _MockScenario:
    """Minimal scenario object for PassSuppressFormatter unit tests."""

    def __init__(self, name: str, status_name: str) -> None:
        self.keyword = "Scenario"
        self.name = name
        self.status = _MockStatus(status_name)


class _MockStep:
    """Minimal step object for PassSuppressFormatter unit tests."""

    def __init__(
        self,
        keyword: str,
        name: str,
        status_name: str,
        error_message: str | None,
    ) -> None:
        self.keyword = keyword
        self.name = name
        self.status = _MockStatus(status_name)
        self.error_message = error_message


def _make_pass_suppress_formatter() -> tuple[Any, io.StringIO]:
    """Create a PassSuppressFormatter writing to a fresh StringIO buffer.

    Returns ``(formatter, buffer)`` so callers can inspect what was written
    to the formatter's real output stream after simulating scenario events.
    """
    buf: io.StringIO = io.StringIO()
    opener = StreamOpener(stream=buf)
    config = Configuration(command_args=["-q"])
    formatter: Any = PassSuppressFormatter(opener, config)
    return formatter, buf


# ---- PassSuppressFormatter: Given ----


@given("behave_parallel a PassSuppressFormatter backed by a captured stream")
def step_create_pass_suppress_formatter(context: Context) -> None:
    formatter, buf = _make_pass_suppress_formatter()
    context.bp_formatter = formatter
    context.bp_formatter_stream = buf


# ---- PassSuppressFormatter: When ----


@when("behave_parallel I simulate a passing scenario through the formatter")
def step_simulate_passing_scenario(context: Context) -> None:
    formatter: Any = context.bp_formatter
    scenario = _MockScenario("Passing scenario", "passed")
    step = _MockStep("Given", "I pass", "passed", None)
    formatter.scenario(scenario)
    formatter.result(step)
    formatter.eof()


@when("behave_parallel I simulate a failing scenario through the formatter")
def step_simulate_failing_scenario(context: Context) -> None:
    formatter: Any = context.bp_formatter
    scenario = _MockScenario("Failing scenario", "failed")
    step = _MockStep(
        "When",
        "I fail",
        "failed",
        "AssertionError: expected True got False",
    )
    formatter.scenario(scenario)
    formatter.result(step)
    formatter.eof()


@when(
    "behave_parallel I simulate one passing then one failing scenario"
    " through the formatter"
)
def step_simulate_mixed_scenarios(context: Context) -> None:
    formatter: Any = context.bp_formatter
    # First: a passing scenario.
    passing_scenario = _MockScenario("Passing scenario", "passed")
    step1 = _MockStep("Given", "I pass", "passed", None)
    formatter.scenario(passing_scenario)
    formatter.result(step1)
    # Second: a failing scenario.  Calling formatter.scenario() here finalises
    # the previous (passing) scenario, which should be discarded.
    failing_scenario = _MockScenario("Failing scenario", "failed")
    step2 = _MockStep("When", "I fail", "failed", "AssertionError: it broke")
    formatter.scenario(failing_scenario)
    formatter.result(step2)
    formatter.eof()


# ---- PassSuppressFormatter: Then ----


@then("behave_parallel the formatter real stream output should be empty")
def step_formatter_output_empty(context: Context) -> None:
    output: str = context.bp_formatter_stream.getvalue()
    assert output == "", f"Expected empty output, got: {output!r}"


@then('behave_parallel the formatter real stream output should contain "{text}"')
def step_formatter_output_contains(context: Context, text: str) -> None:
    output: str = context.bp_formatter_stream.getvalue()
    assert text in output, f"Expected {text!r} in formatter output, got: {output!r}"


@then('behave_parallel the formatter real stream output should not contain "{text}"')
def step_formatter_output_not_contains(context: Context, text: str) -> None:
    output: str = context.bp_formatter_stream.getvalue()
    assert text not in output, (
        f"Expected {text!r} NOT in formatter output, got: {output!r}"
    )
