"""Step definitions for the output rendering framework feature tests."""

from __future__ import annotations

import contextlib
import json
import re
import threading
import time
from typing import Any, cast

import yaml
from behave import given, then, use_step_matcher, when
from behave.runner import Context

from cleveragents.cli.formatting import (
    format_output,
    format_output_session,
)
from cleveragents.cli.output.handles import (
    ActionHint,
    CodeBlock,
    ColumnDef,
    DiffBlock,
    DiffLine,
    ElementClosedError,
    ElementHandle,
    ElementUpdated,
    Panel,
    PanelEntry,
    ProgressIndicator,
    StatusMessage,
    Table,
    TextBlock,
    Tree,
)
from cleveragents.cli.output.materializers import (
    ColorMaterializer,
    JsonMaterializer,
    PlainMaterializer,
    RichMaterializer,
    TableMaterializer,
    YamlMaterializer,
    strip_terminal_escapes,
)
from cleveragents.cli.output.selection import (
    TerminalCapabilities,
    detect_terminal_capabilities,
    select_materializer,
)
from cleveragents.cli.output.session import OutputSession

# ---- ANSI detection helper ----
ANSI_RE = re.compile(r"\033\[")


# ===========================================================================
# OutputSession lifecycle
# ===========================================================================


@given('an OutputSession with format "{fmt}"')
def step_given_output_session(context: Context, fmt: str) -> None:
    strategy = PlainMaterializer()
    context.session = OutputSession(format=fmt, command="test", strategy=strategy)
    context.strategy = strategy


@when("the session is closed")
def step_session_close(context: Context) -> None:
    context.session_result = context.session.close()


@then('the session state is "{state}"')
def step_session_state(context: Context, state: str) -> None:
    assert context.session._state == state


@when('I use an OutputSession context manager with format "{fmt}"')
def step_session_context_manager(context: Context, fmt: str) -> None:
    strategy = PlainMaterializer()
    context.strategy = strategy
    with OutputSession(format=fmt, command="test-ctx", strategy=strategy) as sess:
        context.session = sess
        context.cm_session_open = sess._state == "open"


@then("the session was closed automatically")
def step_session_auto_closed(context: Context) -> None:
    assert context.cm_session_open is True
    assert context.session._state == "closed"


@when("I use an OutputSession context manager that raises an exception")
def step_session_ctx_exception(context: Context) -> None:
    strategy = PlainMaterializer()
    context.strategy = strategy
    try:
        with OutputSession(
            format="plain", command="test-exc", strategy=strategy
        ) as sess:
            context.session = sess
            raise RuntimeError("Intentional test error")
    except RuntimeError:
        pass
    # The session should have been closed with exit_code 1 by __exit__.


@then("the session exit code is {code:d}")
def step_session_exit_code(context: Context, code: int) -> None:
    assert context.session._state == "closed"
    # L8 fix (Luis review): use public snapshot() to read exit_code
    # instead of accessing private _exit_code attribute.
    snap = context.session.snapshot()
    assert snap.exit_code == code, f"Expected exit_code={code}, got {snap.exit_code}"


# ===========================================================================
# PanelHandle
# ===========================================================================


@when('I create a panel handle with title "{title}"')
def step_create_panel(context: Context, title: str) -> None:
    context.panel_handle = context.session.panel(title)


@when('I set entry "{key}" to "{value}"')
def step_set_entry(context: Context, key: str, value: str) -> None:
    context.panel_handle.set_entry(key, value)


@when("I close the panel handle")
def step_close_panel(context: Context) -> None:
    context.panel_handle.close()


@then("the panel has {count:d} entries")
def step_panel_entry_count(context: Context, count: int) -> None:
    panel = cast(Panel, context.panel_handle.element)
    assert len(panel.entries) == count, f"Expected {count}, got {len(panel.entries)}"


@then('the panel entry "{key}" has value "{value}"')
def step_panel_entry_value(context: Context, key: str, value: str) -> None:
    panel = cast(Panel, context.panel_handle.element)
    for entry in panel.entries:
        if entry.key == key:
            assert entry.value == value, f"Expected {value!r}, got {entry.value!r}"
            return
    raise AssertionError(f"Entry {key!r} not found in panel")


@when('I batch set entries with keys "{keys}" and values "{values}"')
def step_batch_set_entries(context: Context, keys: str, values: str) -> None:
    key_list = keys.split(",")
    val_list = values.split(",")
    entries = dict(zip(key_list, val_list, strict=True))
    context.panel_handle.set_entries(entries)


@when('I remove entry "{key}"')
def step_remove_entry(context: Context, key: str) -> None:
    context.panel_handle.remove_entry(key)


@when('I add line "{key}" with value "{val}"')
def step_add_line(context: Context, key: str, val: str) -> None:
    context.panel_handle.add_line(key, val)


@then('setting entry "{key}" to "{value}" raises ElementClosedError')
def step_set_entry_closed_raises(context: Context, key: str, value: str) -> None:
    try:
        context.panel_handle.set_entry(key, value)
        raise AssertionError("Expected ElementClosedError")
    except ElementClosedError:
        pass


# ===========================================================================
# TableHandle
# ===========================================================================


@when('I create a table handle with columns "{columns}"')
def step_create_table(context: Context, columns: str) -> None:
    col_list = columns.split(",")
    context.table_handle = context.session.table(columns=col_list)


@when('I add a dict row with Name "{name}" and Status "{status}"')
def step_add_dict_row(context: Context, name: str, status: str) -> None:
    tbl = cast(Table, context.table_handle.element)
    col_names = [c.name for c in tbl.columns]
    row: dict[str, str] = {}
    if len(col_names) >= 1:
        row[col_names[0]] = name
    if len(col_names) >= 2:
        row[col_names[1]] = status
    context.table_handle.add_row(row)


@when('I add a list row with values "{values}"')
def step_add_list_row(context: Context, values: str) -> None:
    vals = values.split(",")
    context.table_handle.add_row(vals)


@when("I add {count:d} batch rows")
def step_add_batch_rows(context: Context, count: int) -> None:
    tbl = cast(Table, context.table_handle.element)
    col_names = [c.name for c in tbl.columns]
    rows: list[dict[str, str]] = []
    for i in range(count):
        row: dict[str, str] = {}
        for col in col_names:
            row[col] = f"val-{i}"
        rows.append(row)
    context.table_handle.add_rows(rows)


@then("the table has {count:d} rows")
def step_table_row_count(context: Context, count: int) -> None:
    tbl = cast(Table, context.table_handle.element)
    assert len(tbl.rows) == count, f"Expected {count}, got {len(tbl.rows)}"


@when('I set summary with total "{total}"')
def step_set_summary(context: Context, total: str) -> None:
    context.table_handle.set_summary({"total": total})


@then("the table summary is set")
def step_table_summary_set(context: Context) -> None:
    tbl = cast(Table, context.table_handle.element)
    assert tbl.summary is not None


@when('I set sort key to "{column}"')
def step_set_sort_key(context: Context, column: str) -> None:
    context.table_handle.set_sort_key(column)


@then('the table sort key is "{column}"')
def step_table_sort_key(context: Context, column: str) -> None:
    tbl = cast(Table, context.table_handle.element)
    assert tbl.sort_key == column


@when("I close the table handle")
def step_close_table(context: Context) -> None:
    context.table_handle.close()


@then("adding a row raises ElementClosedError")
def step_add_row_closed_raises(context: Context) -> None:
    try:
        context.table_handle.add_row({"Name": "x"})
        raise AssertionError("Expected ElementClosedError")
    except ElementClosedError:
        pass


# ===========================================================================
# StatusHandle
# ===========================================================================


@when('I create a status handle with message "{message}"')
def step_create_status(context: Context, message: str) -> None:
    context.status_handle = context.session.status(message)


@when('I update the status message to "{message}"')
def step_update_status_message(context: Context, message: str) -> None:
    context.status_handle.set_message(message)


@when('I update the status level to "{level}"')
def step_update_status_level(context: Context, level: str) -> None:
    context.status_handle.set_level(level)


@when('I set the status detail to "{detail}"')
def step_set_status_detail(context: Context, detail: str) -> None:
    context.status_handle.set_detail(detail)


@when("I close the status handle")
def step_close_status(context: Context) -> None:
    context.status_handle.close()


@then('the status message is "{message}"')
def step_status_message_check(context: Context, message: str) -> None:
    sm = cast(StatusMessage, context.status_handle.element)
    assert sm.message == message


@then('the status level is "{level}"')
def step_status_level_check(context: Context, level: str) -> None:
    sm = cast(StatusMessage, context.status_handle.element)
    assert sm.level == level


@then('the status detail is "{detail}"')
def step_status_detail_check(context: Context, detail: str) -> None:
    sm = cast(StatusMessage, context.status_handle.element)
    assert sm.detail == detail


@then('setting status level to "{level}" raises ValueError')
def step_invalid_status_level(context: Context, level: str) -> None:
    try:
        context.status_handle.set_level(level)
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


# ===========================================================================
# ProgressHandle
# ===========================================================================


use_step_matcher("re")


@when(
    r'I create a progress handle labelled "(?P<label>[^"]+)" with total (?P<total>\d+)'
)
def step_create_progress_total(context: Context, label: str, total: str) -> None:
    context.progress_handle = context.session.progress(label, total=int(total))


@when(
    r'I create a progress handle labelled "(?P<label>[^"]+)" with steps "(?P<steps>[^"]+)"'
)
def step_create_progress_steps(context: Context, label: str, steps: str) -> None:
    step_list = steps.split(",")
    context.progress_handle = context.session.progress(label, steps=step_list)


@when(r'I create a progress handle labelled "(?P<label>[^"]+)"')
def step_create_progress(context: Context, label: str) -> None:
    context.progress_handle = context.session.progress(label)


use_step_matcher("parse")


@when("I set progress to {current:d} of {total:d}")
def step_set_progress(context: Context, current: int, total: int) -> None:
    context.progress_handle.set_progress(current, total)


@when("I tick the progress {count:d} times")
def step_tick_progress(context: Context, count: int) -> None:
    for _ in range(count):
        context.progress_handle.tick()


@when("I increment progress by {delta:d}")
def step_increment_progress(context: Context, delta: int) -> None:
    context.progress_handle.increment(delta)


@when('I set progress label to "{label}"')
def step_set_progress_label(context: Context, label: str) -> None:
    context.progress_handle.set_label(label)


@when("I set progress to indeterminate mode")
def step_set_indeterminate(context: Context) -> None:
    context.progress_handle.set_indeterminate()


@when('I set step "{step}" status to "{status}"')
def step_set_step_status(context: Context, step: str, status: str) -> None:
    context.progress_handle.set_step_status(step, status)


@when("I close the progress handle")
def step_close_progress(context: Context) -> None:
    context.progress_handle.close()


@then("the progress current is {val:d}")
def step_progress_current(context: Context, val: int) -> None:
    pi = cast(ProgressIndicator, context.progress_handle.element)
    assert pi.current == val, f"Expected {val}, got {pi.current}"


@then("the progress total is {val:d}")
def step_progress_total(context: Context, val: int) -> None:
    pi = cast(ProgressIndicator, context.progress_handle.element)
    assert pi.total == val


@then('the progress label is "{label}"')
def step_progress_label(context: Context, label: str) -> None:
    pi = cast(ProgressIndicator, context.progress_handle.element)
    assert pi.label == label


@then("the progress is indeterminate")
def step_progress_indeterminate(context: Context) -> None:
    pi = cast(ProgressIndicator, context.progress_handle.element)
    assert pi.indeterminate is True


@then('step "{step}" has status "{status}"')
def step_check_step_status(context: Context, step: str, status: str) -> None:
    pi = cast(ProgressIndicator, context.progress_handle.element)
    assert pi.steps is not None
    for s in pi.steps:
        if s.label == step:
            assert s.status == status, f"Expected {status}, got {s.status}"
            return
    raise AssertionError(f"Step {step!r} not found")


@then("setting progress to {val:d} raises ValueError")
def step_negative_progress(context: Context, val: int) -> None:
    try:
        context.progress_handle.set_progress(val)
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then('setting step "{step}" to status "{status}" raises ValueError')
def step_invalid_step_status(context: Context, step: str, status: str) -> None:
    try:
        context.progress_handle.set_step_status(step, status)
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


# ===========================================================================
# Materializer output tests
# ===========================================================================


@given('an OutputSession with format "{fmt}" using PlainMaterializer')
def step_session_plain(context: Context, fmt: str) -> None:
    context.strategy = PlainMaterializer()
    context.session = OutputSession(
        format=fmt, command="test", strategy=context.strategy
    )


@given('an OutputSession with format "{fmt}" using ColorMaterializer')
def step_session_color(context: Context, fmt: str) -> None:
    context.strategy = ColorMaterializer()
    context.session = OutputSession(
        format=fmt, command="test", strategy=context.strategy
    )


@given('an OutputSession with format "{fmt}" using TableMaterializer')
def step_session_table(context: Context, fmt: str) -> None:
    context.strategy = TableMaterializer()
    context.session = OutputSession(
        format=fmt, command="test", strategy=context.strategy
    )


@given('an OutputSession with format "{fmt}" using RichMaterializer')
def step_session_rich(context: Context, fmt: str) -> None:
    context.strategy = RichMaterializer()
    context.session = OutputSession(
        format=fmt, command="test", strategy=context.strategy
    )


@given('an OutputSession with format "{fmt}" using JsonMaterializer')
def step_session_json(context: Context, fmt: str) -> None:
    context.strategy = JsonMaterializer()
    context.session = OutputSession(
        format=fmt, command="test", strategy=context.strategy
    )


@given('an OutputSession with format "{fmt}" using YamlMaterializer')
def step_session_yaml(context: Context, fmt: str) -> None:
    context.strategy = YamlMaterializer()
    context.session = OutputSession(
        format=fmt, command="test", strategy=context.strategy
    )


# Plain output assertions
@then('the plain output contains "{text}"')
def step_plain_output_contains(context: Context, text: str) -> None:
    output = context.strategy.get_output()
    assert text in output, f"Expected {text!r} in output:\n{output}"


@then("the plain output does not contain ANSI codes")
def step_plain_no_ansi(context: Context) -> None:
    output = context.strategy.get_output()
    assert not ANSI_RE.search(output), f"Found ANSI codes in plain output:\n{output}"


# Color output assertions
@then("the color output contains ANSI codes")
def step_color_has_ansi(context: Context) -> None:
    output = context.strategy.get_output()
    assert ANSI_RE.search(output), f"No ANSI codes found in color output:\n{output}"


@then('the color output contains "{text}"')
def step_color_output_contains(context: Context, text: str) -> None:
    output = context.strategy.get_output()
    # Strip ANSI for text check
    clean = ANSI_RE.sub("", output).replace("\033[0m", "").replace("\033", "")
    assert text in output or text in clean, f"Expected {text!r} in output:\n{output}"


# Table output assertions
@then("the table output contains box-drawing characters")
def step_table_box_chars(context: Context) -> None:
    output = context.strategy.get_output()
    # P2-5: upgraded to Unicode box-drawing characters.
    assert "│" in output or "╭" in output, f"No box chars in:\n{output}"


@then('the table output contains "{text}"')
def step_table_output_contains(context: Context, text: str) -> None:
    output = context.strategy.get_output()
    assert text in output, f"Expected {text!r} in:\n{output}"


# Rich output assertions
@then('the rich output contains "{text}"')
def step_rich_output_contains(context: Context, text: str) -> None:
    output = context.strategy.get_output()
    clean = re.sub(r"\033\[[0-9;]*m", "", output)
    assert text in output or text in clean, f"Expected {text!r} in:\n{output}"


# JSON output assertions
@then("the json output is valid JSON")
def step_json_valid(context: Context) -> None:
    output = context.strategy.get_output()
    context.json_data = json.loads(output)


@then('the json output contains element type "{etype}"')
def step_json_element_type(context: Context, etype: str) -> None:
    data = context.json_data
    elements = data.get("data", [])
    types = [e.get("type") for e in elements]
    assert etype in types, f"Expected element type {etype!r} in {types}"


@then('the json output contains "{text}"')
def step_json_output_contains(context: Context, text: str) -> None:
    output = context.strategy.get_output()
    assert text in output, f"Expected {text!r} in JSON output"


# YAML output assertions
@then("the yaml output is valid YAML")
def step_yaml_valid(context: Context) -> None:
    output = context.strategy.get_output()
    context.yaml_data = yaml.safe_load(output)


@then('the yaml output contains "{text}"')
def step_yaml_output_contains(context: Context, text: str) -> None:
    output = context.strategy.get_output()
    assert text in output, f"Expected {text!r} in YAML output"


# ===========================================================================
# Error envelope
# ===========================================================================


@given("a JsonMaterializer")
def step_json_materializer(context: Context) -> None:
    context.json_mat = JsonMaterializer()


@when('I generate an error envelope with code "{code}" and message "{msg}"')
def step_json_error_envelope(context: Context, code: str, msg: str) -> None:
    context.json_error_output = context.json_mat.get_error_output(code, msg)


@then('the json error output contains "{text}"')
def step_json_error_contains(context: Context, text: str) -> None:
    assert text in context.json_error_output, (
        f"Expected {text!r} in:\n{context.json_error_output}"
    )


@given("a YamlMaterializer")
def step_yaml_materializer(context: Context) -> None:
    context.yaml_mat = YamlMaterializer()


@when('I generate a yaml error envelope with code "{code}" and message "{msg}"')
def step_yaml_error_envelope(context: Context, code: str, msg: str) -> None:
    context.yaml_error_output = context.yaml_mat.get_error_output(code, msg)


@then('the yaml error output contains "{text}"')
def step_yaml_error_contains(context: Context, text: str) -> None:
    assert text in context.yaml_error_output, (
        f"Expected {text!r} in:\n{context.yaml_error_output}"
    )


# ===========================================================================
# Concurrent producers
# ===========================================================================


@when("I create two panels concurrently and close them out of order")
def step_concurrent_panels(context: Context) -> None:
    panel_a = context.session.panel("Panel A")
    panel_b = context.session.panel("Panel B")

    # Simulate concurrent writes with threads
    def write_a() -> None:
        panel_a.set_entry("A-Key", "A-Val")

    def write_b() -> None:
        panel_b.set_entry("B-Key", "B-Val")

    t1 = threading.Thread(target=write_a)
    t2 = threading.Thread(target=write_b)
    t1.start()
    t2.start()
    t1.join()
    t2.join()

    # Close out of declaration order (B before A)
    panel_b.close()
    panel_a.close()


@then("the plain output has panel A before panel B")
def step_output_order(context: Context) -> None:
    output = context.strategy.get_output()
    pos_a = output.find("Panel A")
    pos_b = output.find("Panel B")
    assert pos_a >= 0, f"Panel A not found in output:\n{output}"
    assert pos_b >= 0, f"Panel B not found in output:\n{output}"
    assert pos_a < pos_b, (
        f"Panel A (pos={pos_a}) should appear before Panel B (pos={pos_b})"
    )


# ===========================================================================
# Format fallback / materializer selection
# ===========================================================================


@given("a non-TTY terminal environment")
def step_non_tty(context: Context) -> None:
    context.terminal_caps = TerminalCapabilities(
        is_tty=False, supports_ansi=False, supports_cursor=False, term=""
    )


@given("a terminal without ANSI support")
def step_no_ansi(context: Context) -> None:
    context.terminal_caps = TerminalCapabilities(
        is_tty=True, supports_ansi=False, supports_cursor=False, term="dumb"
    )


@when('I select a materializer for format "{fmt}"')
def step_select_materializer(context: Context, fmt: str) -> None:
    caps = getattr(context, "terminal_caps", None)
    context.selected_mat = select_materializer(fmt, capabilities=caps)


@when('I select a materializer for format "{fmt}" with explicit flag')
def step_select_materializer_explicit(context: Context, fmt: str) -> None:
    caps = getattr(context, "terminal_caps", None)
    context.selected_mat = select_materializer(fmt, capabilities=caps, explicit=True)


@then("the selected materializer is PlainMaterializer")
def step_mat_is_plain(context: Context) -> None:
    assert isinstance(context.selected_mat, PlainMaterializer)


@then("the selected materializer is JsonMaterializer")
def step_mat_is_json(context: Context) -> None:
    assert isinstance(context.selected_mat, JsonMaterializer)


@then("the selected materializer is RichMaterializer")
def step_mat_is_rich(context: Context) -> None:
    assert isinstance(context.selected_mat, RichMaterializer)


@when('I attempt to select a materializer for format "{fmt}"')
def step_select_invalid_format(context: Context, fmt: str) -> None:
    context.selection_error = None
    try:
        select_materializer(fmt)
    except ValueError as exc:
        context.selection_error = exc


@when("I attempt to select a materializer with empty format")
def step_select_empty_format(context: Context) -> None:
    context.selection_error = None
    try:
        select_materializer("")
    except ValueError as exc:
        context.selection_error = exc


@then("a ValueError is raised")
def step_value_error_raised(context: Context) -> None:
    assert context.selection_error is not None


# ===========================================================================
# Backward compatibility
# ===========================================================================


@when('I call format_output with a dict and format "{fmt}"')
def step_call_format_output(context: Context, fmt: str) -> None:
    from contextlib import redirect_stdout
    from io import StringIO

    data = {"Name": "test", "Status": "active"}
    buf = StringIO()
    with redirect_stdout(buf):
        result = format_output(data, fmt)
    context.format_result = result or buf.getvalue().rstrip("\n")


@then("the output contains key-value pairs")
def step_output_kv(context: Context) -> None:
    assert "Name" in context.format_result
    assert "test" in context.format_result


@then("the output is valid JSON")
def step_output_valid_json(context: Context) -> None:
    json.loads(context.format_result)


@when('I call format_output_session with a dict and format "{fmt}"')
def step_call_format_output_session(context: Context, fmt: str) -> None:
    data = {"Alpha": "one", "Beta": "two"}
    context.session_format_result = format_output_session(data, fmt)


@then("the session output contains the dict keys")
def step_session_output_keys(context: Context) -> None:
    assert "Alpha" in context.session_format_result
    assert "Beta" in context.session_format_result


# ===========================================================================
# Handle context manager
# ===========================================================================


@when("I use a panel handle as a context manager")
def step_panel_ctx_manager(context: Context) -> None:
    with context.session.panel("CTX Panel") as p:
        p.set_entry("X", "Y")
        context.panel_handle = p


@then("the handle is closed after the context exits")
def step_handle_closed(context: Context) -> None:
    assert context.panel_handle.is_open is False


# ===========================================================================
# Snapshot
# ===========================================================================


@then("the session snapshot has {count:d} elements")
def step_snapshot_count(context: Context, count: int) -> None:
    snap = context.session.snapshot()
    assert len(snap.elements) == count, (
        f"Expected {count} elements, got {len(snap.elements)}"
    )


# ===========================================================================
# Coverage boost: handle validation guards
# ===========================================================================


@then("setting entry with empty key raises ValueError")
def step_set_entry_empty_key(context: Context) -> None:
    try:
        context.panel_handle.set_entry("", "value")
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("batch setting None entries raises ValueError")
def step_batch_set_none(context: Context) -> None:
    try:
        none_val: Any = None
        context.panel_handle.set_entries(none_val)
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("removing entry with empty key raises ValueError")
def step_remove_empty_key(context: Context) -> None:
    try:
        context.panel_handle.remove_entry("")
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("adding a None row raises ValueError")
def step_add_none_row(context: Context) -> None:
    try:
        none_val: Any = None
        context.table_handle.add_row(none_val)
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("adding None rows raises ValueError")
def step_add_none_rows(context: Context) -> None:
    try:
        none_val: Any = None
        context.table_handle.add_rows(none_val)
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("setting None summary raises ValueError")
def step_set_none_summary(context: Context) -> None:
    try:
        none_val: Any = None
        context.table_handle.set_summary(none_val)
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("setting empty sort key raises ValueError")
def step_set_empty_sort_key(context: Context) -> None:
    try:
        context.table_handle.set_sort_key("")
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("setting empty status message raises ValueError")
def step_set_empty_status_msg(context: Context) -> None:
    try:
        context.status_handle.set_message("")
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("setting empty status level raises ValueError")
def step_set_empty_status_level(context: Context) -> None:
    try:
        context.status_handle.set_level("")
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("setting empty progress label raises ValueError")
def step_set_empty_progress_label(context: Context) -> None:
    try:
        context.progress_handle.set_label("")
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("incrementing by negative delta raises ValueError")
def step_incr_negative(context: Context) -> None:
    try:
        context.progress_handle.increment(-1)
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("setting step status with empty label raises ValueError")
def step_set_step_empty_label(context: Context) -> None:
    try:
        context.progress_handle.set_step_status("", "complete")
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


# ===========================================================================
# Coverage boost: handle data paths
# ===========================================================================


@when('I batch add list rows with values "{r1}" and "{r2}"')
def step_batch_list_rows(context: Context, r1: str, r2: str) -> None:
    rows = [r1.split(","), r2.split(",")]
    context.table_handle.add_rows(rows)


@when("I set progress current to {val:d}")
def step_set_progress_current(context: Context, val: int) -> None:
    context.progress_handle.set_progress(val)


# ===========================================================================
# Coverage boost: materializer step helpers
# ===========================================================================


@when("I create a table with no columns")
def step_create_empty_table(context: Context) -> None:
    context.table_handle = context.session.table(columns=[])


@when('I set table title to "{title}"')
def step_set_table_title(context: Context, title: str) -> None:
    tbl = cast(Table, context.table_handle.element)
    tbl.title = title


@when('I set entry with key "{key}" value "{val}" and style hint "{hint}"')
def step_set_entry_style_hint(context: Context, key: str, val: str, hint: str) -> None:
    panel = cast(Panel, context.panel_handle.element)
    panel.entries.append(PanelEntry(key=key, value=val, style_hint=hint))


@when('I set entry with icon key "{key}" value "{val}" and icon "{icon}"')
def step_set_entry_icon(context: Context, key: str, val: str, icon: str) -> None:
    panel = cast(Panel, context.panel_handle.element)
    panel.entries.append(PanelEntry(key=key, value=val, icon=icon))


@when(
    'I generate an error envelope with code "{code}" message "{msg}" and details "{det}"'
)
def step_json_error_with_details(
    context: Context, code: str, msg: str, det: str
) -> None:
    context.json_error_output = context.json_mat.get_error_output(
        code, msg, details={"info": det}
    )


@then('the strategy output contains "{text}"')
def step_strategy_output_contains(context: Context, text: str) -> None:
    output = context.strategy.get_output()
    assert text in output, f"Expected {text!r} in:\n{output}"


# ===========================================================================
# Coverage boost: format selection
# ===========================================================================


@given("a terminal with ANSI but no cursor support")
def step_ansi_no_cursor(context: Context) -> None:
    context.terminal_caps = TerminalCapabilities(
        is_tty=True, supports_ansi=True, supports_cursor=False, term="xterm"
    )


@given("a terminal with ANSI support")
def step_ansi_support(context: Context) -> None:
    context.terminal_caps = TerminalCapabilities(
        is_tty=True, supports_ansi=True, supports_cursor=False, term="xterm"
    )


@given("a TTY terminal with ANSI support")
def step_tty_ansi(context: Context) -> None:
    context.terminal_caps = TerminalCapabilities(
        is_tty=True, supports_ansi=True, supports_cursor=True, term="xterm-256color"
    )


@then("the selected materializer is ColorMaterializer")
def step_mat_is_color(context: Context) -> None:
    assert isinstance(context.selected_mat, ColorMaterializer)


@then("the selected materializer is YamlMaterializer")
def step_mat_is_yaml(context: Context) -> None:
    assert isinstance(context.selected_mat, YamlMaterializer)


@then("the selected materializer is TableMaterializer")
def step_mat_is_table(context: Context) -> None:
    assert isinstance(context.selected_mat, TableMaterializer)


# ===========================================================================
# Coverage boost: session edge cases
# ===========================================================================


@then("creating a panel with empty title raises ValueError")
def step_panel_empty_title(context: Context) -> None:
    try:
        context.session.panel("")
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("creating a status with empty message raises ValueError")
def step_status_empty_msg(context: Context) -> None:
    try:
        context.session.status("")
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("creating a progress with empty label raises ValueError")
def step_progress_empty_label(context: Context) -> None:
    try:
        context.session.progress("")
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("creating a panel on closed session raises RuntimeError")
def step_closed_session_panel(context: Context) -> None:
    try:
        context.session.panel("Should fail")
        raise AssertionError("Expected RuntimeError")
    except RuntimeError:
        pass


# ===========================================================================
# ElementHandle validation guards
# ===========================================================================


@then("constructing a handle with empty handle_id raises ValueError")
def step_handle_empty_id(context: Context) -> None:
    try:
        ElementHandle(
            handle_id="",
            element_type="panel",
            declaration_index=0,
            session=context.session,
            element=Panel(title="T"),
        )
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("constructing a handle with empty element_type raises ValueError")
def step_handle_empty_type(context: Context) -> None:
    try:
        ElementHandle(
            handle_id="h1",
            element_type="",
            declaration_index=0,
            session=context.session,
            element=Panel(title="T"),
        )
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("constructing a handle with negative index raises ValueError")
def step_handle_negative_index(context: Context) -> None:
    try:
        ElementHandle(
            handle_id="h1",
            element_type="panel",
            declaration_index=-1,
            session=context.session,
            element=Panel(title="T"),
        )
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("constructing a handle with None session raises ValueError")
def step_handle_none_session(context: Context) -> None:
    try:
        ElementHandle(
            handle_id="h1",
            element_type="panel",
            declaration_index=0,
            session=cast(Any, None),
            element=Panel(title="T"),
        )
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("constructing a handle with None element raises ValueError")
def step_handle_none_element(context: Context) -> None:
    try:
        ElementHandle(
            handle_id="h1",
            element_type="panel",
            declaration_index=0,
            session=context.session,
            element=cast(Any, None),
        )
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


# ===========================================================================
# Handle close / __exit__ edge cases
# ===========================================================================


@when("I close the panel handle again")
def step_close_panel_again(context: Context) -> None:
    context.panel_handle.close()


@then('the handle state is "{state}"')
def step_handle_state(context: Context, state: str) -> None:
    handle = getattr(context, "panel_handle", None)
    assert handle is not None
    assert handle._state == state, f"Expected {state}, got {handle._state}"


@when('I use a panel handle as context manager with title "{title}"')
def step_use_context_manager(context: Context, title: str) -> None:
    with context.session.panel(title) as handle:
        handle.set_entry("key", "value")
    context.panel_handle = handle


# ===========================================================================
# Table list rows
# ===========================================================================


# ===========================================================================
# Table with ColumnDef objects
# ===========================================================================


@when('I create a table with ColumnDef objects "{cols}"')
def step_table_columndef(context: Context, cols: str) -> None:
    col_defs = [ColumnDef(name=c.strip()) for c in cols.split(",")]
    context.table_handle = context.session.table(columns=col_defs)


# ===========================================================================
# Detect capabilities
# ===========================================================================


@when("I detect terminal capabilities")
def step_detect_caps(context: Context) -> None:
    context.detected_caps = detect_terminal_capabilities()


# ===========================================================================
# Second panel handle for out-of-order flush
# ===========================================================================


@when('I create a second panel handle with title "{title}"')
def step_create_second_panel(context: Context, title: str) -> None:
    context.panel_handle_2 = context.session.panel(title)


@when('I set entry "{key}" to "{val}" on handle 2')
def step_set_entry_handle2(context: Context, key: str, val: str) -> None:
    context.panel_handle_2.set_entry(key, val)


@when("I close handle 2")
def step_close_handle2(context: Context) -> None:
    context.panel_handle_2.close()


# ===========================================================================
# JSON serializer with no session
# ===========================================================================


@given("a JsonMaterializer with no session")
def step_json_no_session(context: Context) -> None:
    context.strategy = JsonMaterializer()


@then("the serializer get_output returns empty string")
def step_serializer_empty(context: Context) -> None:
    output = context.strategy.get_output()
    assert output == "", f"Expected empty string, got: {output!r}"


# ===========================================================================
# TreeHandle
# ===========================================================================


use_step_matcher("re")


@when(
    r'I create a tree handle with root label "(?P<label>[^"]+)" and show_guides false'
)
def step_create_tree_no_guides(context: Context, label: str) -> None:
    context.tree_handle = context.session.tree(label, show_guides=False)


@when(
    r'I create a tree handle with root label "(?P<label>[^"]+)" and max_depth_hint (?P<depth>\d+)'
)
def step_create_tree_max_depth(context: Context, label: str, depth: str) -> None:
    context.tree_handle = context.session.tree(label, max_depth_hint=int(depth))


@when(r'I create a tree handle with root label "(?P<label>[^"]+)"')
def step_create_tree(context: Context, label: str) -> None:
    context.tree_handle = context.session.tree(label)


use_step_matcher("parse")


@when('I add a tree child "{label}" under "{path}"')
def step_add_tree_child(context: Context, label: str, path: str) -> None:
    context.tree_handle.add_child(path, label)


@when("I close the tree handle")
def step_close_tree(context: Context) -> None:
    context.tree_handle.close()


@when('I set tree node style "{path}" to "{style}"')
def step_set_tree_node_style(context: Context, path: str, style: str) -> None:
    context.tree_handle.set_node_style(path, style)


@then('the tree root label is "{label}"')
def step_tree_root_label(context: Context, label: str) -> None:
    tree = cast(Tree, context.tree_handle.element)
    assert tree.root.label == label, f"Expected {label!r}, got {tree.root.label!r}"


@then("the tree root has {count:d} children")
def step_tree_root_children(context: Context, count: int) -> None:
    tree = cast(Tree, context.tree_handle.element)
    actual = len(tree.root.children)
    assert actual == count, f"Expected {count} children, got {actual}"


@then('the tree node "{path}" has {count:d} children')
def step_tree_node_children(context: Context, path: str, count: int) -> None:
    node = context.tree_handle._find_node(path)
    assert node is not None, f"Node {path!r} not found"
    actual = len(node.children)
    assert actual == count, f"Expected {count} children, got {actual}"


@then('the tree node "{path}" has style "{style}"')
def step_tree_node_style(context: Context, path: str, style: str) -> None:
    node = context.tree_handle._find_node(path)
    assert node is not None, f"Node {path!r} not found"
    assert node.style_hint == style, f"Expected {style!r}, got {node.style_hint!r}"


@then("adding a tree child with empty label raises ValueError")
def step_tree_child_empty_label(context: Context) -> None:
    try:
        context.tree_handle.add_child("Root", "")
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("adding a tree child under invalid path raises ValueError")
def step_tree_child_invalid_path(context: Context) -> None:
    try:
        context.tree_handle.add_child("NonExistent/Path", "child")
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("setting tree node style with empty path raises ValueError")
def step_tree_style_empty_path(context: Context) -> None:
    try:
        context.tree_handle.set_node_style("", "bold")
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("adding a tree child on closed handle raises ElementClosedError")
def step_tree_child_closed(context: Context) -> None:
    try:
        context.tree_handle.add_child("Root", "x")
        raise AssertionError("Expected ElementClosedError")
    except ElementClosedError:
        pass


@then("creating a tree with empty root label raises ValueError")
def step_tree_empty_root(context: Context) -> None:
    try:
        context.session.tree("")
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


# ===========================================================================
# TextHandle
# ===========================================================================


use_step_matcher("re")


@when(
    r'I create a text handle with content "(?P<content>[^"]+)" and indent (?P<indent>\d+)'
)
def step_create_text_indent(context: Context, content: str, indent: str) -> None:
    context.text_handle = context.session.text(content, indent=int(indent))


@when(r'I create a text handle with content "(?P<content>[^"]*)"')
def step_create_text(context: Context, content: str) -> None:
    context.text_handle = context.session.text(content)


use_step_matcher("parse")


@when('I append text "{text}"')
def step_append_text(context: Context, text: str) -> None:
    context.text_handle.append(text)


@when('I set text content to "{content}"')
def step_set_text_content(context: Context, content: str) -> None:
    context.text_handle.set_content(content)


@when("I close the text handle")
def step_close_text(context: Context) -> None:
    context.text_handle.close()


@then('the text content is "{content}"')
def step_text_content(context: Context, content: str) -> None:
    tb = cast(TextBlock, context.text_handle.element)
    assert tb.content == content, f"Expected {content!r}, got {tb.content!r}"


@then("appending None text raises ValueError")
def step_append_none_text(context: Context) -> None:
    try:
        none_val: Any = None
        context.text_handle.append(none_val)
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("setting None text content raises ValueError")
def step_set_none_text(context: Context) -> None:
    try:
        none_val: Any = None
        context.text_handle.set_content(none_val)
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("appending to closed text handle raises ElementClosedError")
def step_append_text_closed(context: Context) -> None:
    try:
        context.text_handle.append("x")
        raise AssertionError("Expected ElementClosedError")
    except ElementClosedError:
        pass


# ===========================================================================
# CodeHandle
# ===========================================================================


use_step_matcher("re")


@when(
    r'I create a code handle with content "(?P<content>[^"]+)" and language "(?P<lang>[^"]+)"'
)
def step_create_code_lang(context: Context, content: str, lang: str) -> None:
    context.code_handle = context.session.code(content, language=lang)


@when(r'I create a code handle with content "(?P<content>[^"]*)" and line_numbers true')
def step_create_code_lineno(context: Context, content: str) -> None:
    # Behave escapes \n in feature files, so convert literal \n to newlines
    content = content.replace("\\n", "\n")
    context.code_handle = context.session.code(content, line_numbers=True)


@when(r'I create a code handle with content "(?P<content>[^"]*)"')
def step_create_code(context: Context, content: str) -> None:
    content = content.replace("\\n", "\n")
    context.code_handle = context.session.code(content)


use_step_matcher("parse")


@when('I set code content to "{content}"')
def step_set_code_content(context: Context, content: str) -> None:
    context.code_handle.set_content(content)


@when('I set code language to "{lang}"')
def step_set_code_language(context: Context, lang: str) -> None:
    context.code_handle.set_language(lang)


@when('I set code highlight lines to "{lines}"')
def step_set_code_highlight(context: Context, lines: str) -> None:
    line_list = [int(x.strip()) for x in lines.split(",")]
    context.code_handle.set_highlight_lines(line_list)


@when("I close the code handle")
def step_close_code(context: Context) -> None:
    context.code_handle.close()


@then('the code content is "{content}"')
def step_code_content(context: Context, content: str) -> None:
    cb = cast(CodeBlock, context.code_handle.element)
    assert cb.content == content, f"Expected {content!r}, got {cb.content!r}"


@then('the code language is "{lang}"')
def step_code_language(context: Context, lang: str) -> None:
    cb = cast(CodeBlock, context.code_handle.element)
    assert cb.language == lang, f"Expected {lang!r}, got {cb.language!r}"


@then('the code highlight lines are "{lines}"')
def step_code_highlight_lines(context: Context, lines: str) -> None:
    expected = [int(x.strip()) for x in lines.split(",")]
    cb = cast(CodeBlock, context.code_handle.element)
    assert cb.highlight_lines == expected, (
        f"Expected {expected}, got {cb.highlight_lines}"
    )


@then("setting None code content raises ValueError")
def step_set_none_code_content(context: Context) -> None:
    try:
        none_val: Any = None
        context.code_handle.set_content(none_val)
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("setting empty code language raises ValueError")
def step_set_empty_code_lang(context: Context) -> None:
    try:
        context.code_handle.set_language("")
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("setting None code highlight lines raises ValueError")
def step_set_none_code_highlight(context: Context) -> None:
    try:
        none_val: Any = None
        context.code_handle.set_highlight_lines(none_val)
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("setting code content on closed handle raises ElementClosedError")
def step_set_code_closed(context: Context) -> None:
    try:
        context.code_handle.set_content("x")
        raise AssertionError("Expected ElementClosedError")
    except ElementClosedError:
        pass


# ===========================================================================
# DiffHandle
# ===========================================================================


@when('I create a diff handle with file_a "{fa}" and file_b "{fb}"')
def step_create_diff(context: Context, fa: str, fb: str) -> None:
    context.diff_handle = context.session.diff(file_a=fa, file_b=fb)


@when("I create a diff handle with no files")
def step_create_diff_no_files(context: Context) -> None:
    context.diff_handle = context.session.diff()


use_step_matcher("re")


@when(
    r'I add a diff hunk with header "(?P<header>[^"]+)" and context line "(?P<ctx>[^"]+)" and add line "(?P<add>[^"]+)"'
)
def step_add_diff_hunk(context: Context, header: str, ctx: str, add: str) -> None:
    lines = [
        DiffLine(type="context", content=ctx),
        DiffLine(type="add", content=add),
    ]
    context.diff_handle.add_hunk(header, lines)


@when(
    r'I add a diff hunk with header "(?P<header>[^"]+)" and remove line "(?P<rem>[^"]+)"'
)
def step_add_diff_hunk_remove(context: Context, header: str, rem: str) -> None:
    lines = [DiffLine(type="remove", content=rem)]
    context.diff_handle.add_hunk(header, lines)


use_step_matcher("parse")


@when("I set diff stats with {ins:d} insertions and {dels:d} deletions")
def step_set_diff_stats(context: Context, ins: int, dels: int) -> None:
    context.diff_handle.set_stats(ins, dels)


@when("I close the diff handle")
def step_close_diff(context: Context) -> None:
    context.diff_handle.close()


@then('the diff file_a is "{fa}"')
def step_diff_file_a(context: Context, fa: str) -> None:
    db = cast(DiffBlock, context.diff_handle.element)
    assert db.file_a == fa, f"Expected {fa!r}, got {db.file_a!r}"


@then('the diff file_b is "{fb}"')
def step_diff_file_b(context: Context, fb: str) -> None:
    db = cast(DiffBlock, context.diff_handle.element)
    assert db.file_b == fb, f"Expected {fb!r}, got {db.file_b!r}"


@then("the diff has {count:d} hunks")
def step_diff_hunk_count(context: Context, count: int) -> None:
    db = cast(DiffBlock, context.diff_handle.element)
    actual = len(db.hunks)
    assert actual == count, f"Expected {count} hunks, got {actual}"


@then("the diff stats insertions is {val:d}")
def step_diff_stats_ins(context: Context, val: int) -> None:
    db = cast(DiffBlock, context.diff_handle.element)
    assert db.stats is not None
    assert db.stats["insertions"] == val


@then("the diff stats deletions is {val:d}")
def step_diff_stats_dels(context: Context, val: int) -> None:
    db = cast(DiffBlock, context.diff_handle.element)
    assert db.stats is not None
    assert db.stats["deletions"] == val


@then("adding a diff hunk with empty header raises ValueError")
def step_diff_empty_header(context: Context) -> None:
    try:
        context.diff_handle.add_hunk("", [])
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("adding a diff hunk with None lines raises ValueError")
def step_diff_none_lines(context: Context) -> None:
    try:
        none_val: Any = None
        context.diff_handle.add_hunk("@@ header", none_val)
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("setting diff stats with negative insertions raises ValueError")
def step_diff_negative_ins(context: Context) -> None:
    try:
        context.diff_handle.set_stats(-1, 0)
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("setting diff stats with negative deletions raises ValueError")
def step_diff_negative_dels(context: Context) -> None:
    try:
        context.diff_handle.set_stats(0, -1)
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("adding a diff hunk on closed handle raises ElementClosedError")
def step_diff_hunk_closed(context: Context) -> None:
    try:
        context.diff_handle.add_hunk("@@ header", [])
        raise AssertionError("Expected ElementClosedError")
    except ElementClosedError:
        pass


# ===========================================================================
# SeparatorHandle
# ===========================================================================


@when('I create a separator with style "{style}"')
def step_create_separator(context: Context, style: str) -> None:
    context.separator_handle = context.session.separator(style)


@then("the separator handle is closed")
def step_separator_closed(context: Context) -> None:
    assert context.separator_handle.is_open is False


@then("creating a separator with invalid style raises ValueError")
def step_separator_invalid_style(context: Context) -> None:
    try:
        context.session.separator("invalid_style")
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


# ===========================================================================
# ActionHintHandle
# ===========================================================================


use_step_matcher("re")


@when(
    r'I create an action hint with commands "(?P<cmds>[^"]+)" and description "(?P<desc>[^"]+)"'
)
def step_create_action_hint_desc(context: Context, cmds: str, desc: str) -> None:
    cmd_list = cmds.split(",")
    context.action_hint_handle = context.session.action_hint(cmd_list, desc)


@when(r'I create an action hint with commands "(?P<cmds>[^"]+)"')
def step_create_action_hint(context: Context, cmds: str) -> None:
    cmd_list = cmds.split(",")
    context.action_hint_handle = context.session.action_hint(cmd_list)


use_step_matcher("parse")


@then("the action hint handle is closed")
def step_action_hint_closed(context: Context) -> None:
    assert context.action_hint_handle.is_open is False


@then("the action hint has {count:d} commands")
def step_action_hint_count(context: Context, count: int) -> None:
    ah = cast(ActionHint, context.action_hint_handle.element)
    actual = len(ah.commands)
    assert actual == count, f"Expected {count} commands, got {actual}"


@then('the action hint description is "{desc}"')
def step_action_hint_desc(context: Context, desc: str) -> None:
    ah = cast(ActionHint, context.action_hint_handle.element)
    assert ah.description == desc, f"Expected {desc!r}, got {ah.description!r}"


@then("creating an action hint with empty commands raises ValueError")
def step_action_hint_empty_cmds(context: Context) -> None:
    try:
        context.session.action_hint([])
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


# ===========================================================================
# Session completed without error (for blank separator etc.)
# ===========================================================================


@when('I add a tree child "{label}" under the root via empty path')
def step_add_tree_child_empty_path(context: Context, label: str) -> None:
    context.tree_handle.add_child("", label)


@then("the session completed without error")
def step_session_no_error(context: Context) -> None:
    # If we got here, no exception was raised
    assert context.session._state == "closed"


# ===========================================================================
# N4: strip_terminal_escapes tests
# ===========================================================================


use_step_matcher("re")


@when(r'I strip terminal escapes from "(?P<raw>[^"]*)"')
def step_strip_escapes(context: Context, raw: str) -> None:
    # Interpret Python escape sequences (e.g. \x1b) in the feature string
    text = raw.encode("utf-8").decode("unicode_escape")
    context.stripped_result = strip_terminal_escapes(text)


use_step_matcher("parse")


@when("I strip terminal escapes from text with 8-bit CSI")
def step_strip_8bit_csi(context: Context) -> None:
    # 8-bit CSI is \x9b followed by parameters and a final byte
    text = "before\x9b31mcoloured\x9b0m after"
    context.stripped_result = strip_terminal_escapes(text)


@when("I strip terminal escapes from text with OSC title set")
def step_strip_osc(context: Context) -> None:
    # OSC = \x1b] ... ST(\x1b\\) — e.g. setting terminal title
    text = "before\x1b]0;My Title\x1b\\after"
    context.stripped_result = strip_terminal_escapes(text)


@when("I strip terminal escapes from text with C1 control chars")
def step_strip_c1(context: Context) -> None:
    # C1 control characters range from 0x80-0x9f
    text = "hello\x80\x85\x8d\x8e\x8f\x90\x9fworld"
    context.stripped_result = strip_terminal_escapes(text)


use_step_matcher("re")


@then(r'the stripped result is "(?P<expected>[^"]*)"')
def step_stripped_result_is(context: Context, expected: str) -> None:
    exp = expected.encode("utf-8").decode("unicode_escape")
    assert context.stripped_result == exp, (
        f"Expected {exp!r}, got {context.stripped_result!r}"
    )


use_step_matcher("parse")


@then("the stripped result does not contain bytes 0x9b")
def step_stripped_no_9b(context: Context) -> None:
    assert "\x9b" not in context.stripped_result, (
        f"Found 0x9b in: {context.stripped_result!r}"
    )


@then("the stripped result does not contain OSC payload")
def step_stripped_no_osc(context: Context) -> None:
    assert "My Title" not in context.stripped_result, (
        f"Found OSC payload in: {context.stripped_result!r}"
    )
    assert "\x1b]" not in context.stripped_result, (
        f"Found OSC escape in: {context.stripped_result!r}"
    )


@then("the stripped result does not contain bytes 0x80 through 0x9f")
def step_stripped_no_c1(context: Context) -> None:
    for byte_val in range(0x80, 0xA0):
        ch = chr(byte_val)
        assert ch not in context.stripped_result, (
            f"Found C1 byte 0x{byte_val:02x} in: {context.stripped_result!r}"
        )


@when("I strip terminal escapes from text with BEL-terminated OSC")
def step_strip_bel_osc(context: Context) -> None:
    # OSC terminated by BEL (0x07) — e.g. setting terminal title
    text = "\x1b]0;Evil Title\x07safetext"
    context.stripped_result = strip_terminal_escapes(text)


@when("I strip terminal escapes from text with private-mode CSI")
def step_strip_private_csi(context: Context) -> None:
    # Private-mode CSI — e.g. \x1b[?1049h (alternate screen)
    text = "\x1b[?1049hcontent\x1b[?1049l"
    context.stripped_result = strip_terminal_escapes(text)


@when("I strip terminal escapes from text with hyperlink OSC")
def step_strip_hyperlink_osc(context: Context) -> None:
    # Hyperlink OSC injection: ESC]8;;url BEL text ESC]8;; BEL
    text = "\x1b]8;;http://evil.com\x07click here\x1b]8;;\x07"
    context.stripped_result = strip_terminal_escapes(text)


@when("I strip terminal escapes from text with mixed escapes")
def step_strip_mixed_escapes(context: Context) -> None:
    # Multiple types of escapes mixed together
    text = "\x1b[1mhello\x1b[0m \x1b]0;title\x07\x9b31mworld\x9b0m"
    context.stripped_result = strip_terminal_escapes(text)


# ===========================================================================
# N13: Concurrency tests
# ===========================================================================


@when(
    "I concurrently append {count:d} lines to the same text handle from {threads:d} threads"
)
def step_concurrent_append(context: Context, count: int, threads: int) -> None:
    per_thread = count // threads
    barrier = threading.Barrier(threads)

    def worker(start: int, n: int) -> None:
        barrier.wait()
        for i in range(n):
            context.text_handle.append(f"line-{start + i}\n")

    thread_list = []
    for t in range(threads):
        th = threading.Thread(target=worker, args=(t * per_thread, per_thread))
        thread_list.append(th)
        th.start()
    for th in thread_list:
        th.join()


@then("the text handle has exactly {count:d} appended segments")
def step_text_append_count(context: Context, count: int) -> None:
    tb = cast(TextBlock, context.text_handle.element)
    # Each append added "line-N\n", so count occurrences
    segments = tb.content.count("\n")
    assert segments == count, f"Expected {count} segments, got {segments}"


@when("I concurrently create {count:d} panels from {threads:d} threads")
def step_concurrent_create_panels(context: Context, count: int, threads: int) -> None:
    per_thread = count // threads
    barrier = threading.Barrier(threads)
    handles: list[Any] = []
    lock = threading.Lock()

    def worker(n: int) -> None:
        barrier.wait()
        for i in range(n):
            h = context.session.panel(f"Panel-{threading.current_thread().name}-{i}")
            h.set_entry("k", "v")
            h.close()
            with lock:
                handles.append(h)

    thread_list = []
    for t in range(threads):
        th = threading.Thread(target=worker, args=(per_thread,), name=f"t{t}")
        thread_list.append(th)
        th.start()
    for th in thread_list:
        th.join()
    context.concurrent_handles = handles


@when("I race session close against handle writes")
def step_race_close_vs_write(context: Context) -> None:
    context.race_exception = None
    barrier = threading.Barrier(2)

    def writer() -> None:
        barrier.wait()
        try:
            for i in range(50):
                try:
                    context.text_handle.append(f"race-{i}\n")
                except (ElementClosedError, RuntimeError):
                    break
        except Exception as exc:
            context.race_exception = exc

    def closer() -> None:
        barrier.wait()
        with contextlib.suppress(RuntimeError, ElementClosedError):
            context.session.close()

    t1 = threading.Thread(target=writer)
    t2 = threading.Thread(target=closer)
    t1.start()
    t2.start()
    t1.join(timeout=5)
    t2.join(timeout=5)


@then("no unhandled exception was raised")
def step_no_exception(context: Context) -> None:
    assert context.race_exception is None, (
        f"Unhandled exception: {context.race_exception!r}"
    )


# ===========================================================================
# N14: Progress throttling
# ===========================================================================


@when("I rapidly tick the progress {count:d} times within {ms:d}ms")
def step_rapid_tick(context: Context, count: int, ms: int) -> None:
    """H2 fix (Luis review): previously this test was vacuous because
    ``supports_incremental_updates = False`` meant ``_emit_update()``
    returned immediately, so events never fired.  We now temporarily
    enable incremental updates on the strategy so events actually
    reach the counting hook.  Also fixes ``_handle_id`` → ``handle_id``
    (the public attribute).
    """
    context.progress_update_count = 0
    original_on_updated = context.strategy.on_element_updated
    # Temporarily enable incremental updates so events fire.
    context.strategy.supports_incremental_updates = True

    def counting_on_updated(event: Any) -> None:
        if event.handle_id == context.progress_handle.handle_id:
            context.progress_update_count += 1
        original_on_updated(event)

    context.strategy.on_element_updated = counting_on_updated

    for _ in range(count):
        context.progress_handle.tick()
    # Brief sleep to let any final throttled emit fire
    time.sleep(0.15)
    # Restore original flag.
    context.strategy.supports_incremental_updates = False


@then(
    "fewer than {count:d} element-updated events were emitted for the progress handle"
)
def step_fewer_events(context: Context, count: int) -> None:
    actual = context.progress_update_count
    assert actual < count, f"Expected fewer than {count} events, got {actual}"


# ===========================================================================
# Luis review fixes — M2: TreeHandle label with slash
# ===========================================================================


@then("adding a tree child with slash in label raises ValueError")
def step_tree_child_slash_label(context: Context) -> None:
    try:
        context.tree_handle.add_child("Root", "src/main")
        raise AssertionError("Expected ValueError for slash in label")
    except ValueError:
        pass


# ===========================================================================
# Luis review fixes — M3: MAX_ELEMENTS_PER_SESSION enforcement test
# ===========================================================================


@when("I create elements up to the session limit")
def step_create_to_limit(context: Context) -> None:
    from cleveragents.cli.output.handles import MAX_ELEMENTS_PER_SESSION

    # Create elements up to the limit — each separator auto-closes
    for _i in range(MAX_ELEMENTS_PER_SESSION):
        context.session.separator("line")
    context.element_limit = MAX_ELEMENTS_PER_SESSION


@then("creating one more element raises ValueError")
def step_one_more_raises(context: Context) -> None:
    try:
        context.session.separator("line")
        raise AssertionError("Expected ValueError for exceeding session element limit")
    except ValueError:
        pass


# ===========================================================================
# R2-C2 fix #15: MAX_TABLE_ROWS enforcement
# ===========================================================================


@when("I fill the table to its MAX_TABLE_ROWS limit")
def step_fill_table_to_limit(context: Context) -> None:
    # Use a small limit override for test speed — we monkey-patch the
    # constant on the module where enforcement happens.
    import cleveragents.cli.output.handles._models as models_mod
    import cleveragents.cli.output.handles._panel_table as pt_mod

    original_models = models_mod.MAX_TABLE_ROWS
    original_pt = pt_mod.MAX_TABLE_ROWS
    models_mod.MAX_TABLE_ROWS = 5  # Small limit for test speed
    pt_mod.MAX_TABLE_ROWS = 5
    context._original_max_table_rows_models = original_models
    context._original_max_table_rows_pt = original_pt
    context._models_mod = models_mod
    context._pt_mod = pt_mod
    for i in range(5):
        context.table_handle.add_row({"Name": f"row-{i}"})


@then("adding one more row raises ValueError")
def step_one_more_row_raises(context: Context) -> None:
    try:
        context.table_handle.add_row({"Name": "overflow"})
        raise AssertionError("Expected ValueError for exceeding MAX_TABLE_ROWS")
    except ValueError:
        pass
    finally:
        # Restore original limits
        context._models_mod.MAX_TABLE_ROWS = context._original_max_table_rows_models
        context._pt_mod.MAX_TABLE_ROWS = context._original_max_table_rows_pt


# ===========================================================================
# Luis review fixes — M4: sort_descending rendering test
# ===========================================================================


@when('I set sort key to "{column}" descending')
def step_set_sort_key_desc(context: Context, column: str) -> None:
    context.table_handle.set_sort_key(column, descending=True)


@then('the plain output has "{first}" before "{second}"')
def step_plain_output_ordering(context: Context, first: str, second: str) -> None:
    output = context.strategy.get_output()
    pos_first = output.find(first)
    pos_second = output.find(second)
    assert pos_first >= 0, f"{first!r} not found in output:\n{output}"
    assert pos_second >= 0, f"{second!r} not found in output:\n{output}"
    assert pos_first < pos_second, (
        f"Expected {first!r} (pos={pos_first}) before "
        f"{second!r} (pos={pos_second}) in output:\n{output}"
    )


# ===========================================================================
# Luis review fixes — L4: TextBlock negative indent
# ===========================================================================


@then("creating a text handle with negative indent raises ValueError")
def step_text_negative_indent(context: Context) -> None:
    try:
        context.session.text("x", indent=-1)
        raise AssertionError("Expected ValueError for negative indent")
    except ValueError:
        pass


# ===========================================================================
# Luis review fixes — L9: TextBlock with wrap=False
# ===========================================================================


use_step_matcher("re")


@when(
    r'I create a text handle with content "(?P<content>[^"]*)" and wrap false and indent (?P<indent>\d+)'
)
def step_create_text_nowrap(context: Context, content: str, indent: str) -> None:
    context.text_handle = context.session.text(content, wrap=False, indent=int(indent))


use_step_matcher("parse")


# ===========================================================================
# Review fix — collapsed tree child
# ===========================================================================


@when('I add a collapsed tree child "{label}" under "{path}"')
def step_add_collapsed_tree_child(context: Context, label: str, path: str) -> None:
    context.tree_handle.add_child(path, label, collapsed=True)


# ===========================================================================
# Review fix — detect_terminal_capabilities actual assertion
# ===========================================================================


@then("the capabilities is_tty is a boolean")
def step_caps_tty_bool(context: Context) -> None:
    assert isinstance(context.detected_caps.is_tty, bool)


@then("the capabilities supports_ansi is a boolean")
def step_caps_ansi_bool(context: Context) -> None:
    assert isinstance(context.detected_caps.supports_ansi, bool)


@then("the capabilities supports_cursor is a boolean")
def step_caps_cursor_bool(context: Context) -> None:
    assert isinstance(context.detected_caps.supports_cursor, bool)


@then("the capabilities term is a string")
def step_caps_term_str(context: Context) -> None:
    assert isinstance(context.detected_caps.term, str)


# ===========================================================================
# Review fix — session double-close
# ===========================================================================


@when("the session is closed again")
def step_session_close_again(context: Context) -> None:
    context.session_result_2 = context.session.close()


@then("no deadlock occurred and both closes returned snapshots")
def step_double_close_ok(context: Context) -> None:
    assert context.session_result is not None
    assert context.session_result_2 is not None
    assert context.session._state == "closed"


# ===========================================================================
# Review fix — ColumnDef non-default field serialization
# ===========================================================================


@when("I create a table with non-default ColumnDef fields")
def step_create_table_non_default_coldef(context: Context) -> None:
    col_defs = [
        ColumnDef(
            name="Name",
            col_type="string",
            alignment="center",
            width_hint=20,
            sortable=True,
            style_hint="bold",
        ),
        ColumnDef(name="Age", col_type="integer"),
    ]
    context.table_handle = context.session.table(columns=col_defs)


# ===========================================================================
# P1-1: ProgressHandle total validation
# ===========================================================================


@then("setting progress total to negative raises ValueError")
def step_progress_negative_total(context: Context) -> None:
    try:
        context.progress_handle.set_progress(0, total=-5)
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


@then("creating a progress with negative total raises ValueError")
def step_session_progress_negative_total(context: Context) -> None:
    try:
        context.session.progress("Bad", total=-10)
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


# ===========================================================================
# P1-2: JSON/YAML timing field
# ===========================================================================


# (no step needed — reuses existing "json output contains" steps)


# ===========================================================================
# P1-4: NO_COLOR env var
# ===========================================================================


@given("the NO_COLOR environment variable is set")
def step_set_no_color(context: Context) -> None:
    import os

    context.original_no_color = os.environ.get("NO_COLOR")
    os.environ["NO_COLOR"] = "1"

    def cleanup() -> None:
        if context.original_no_color is None:
            os.environ.pop("NO_COLOR", None)
        else:
            os.environ["NO_COLOR"] = context.original_no_color

    context.add_cleanup(cleanup)


# ===========================================================================
# P2-1: DiffLine.line_type
# ===========================================================================


@when('I create a DiffLine with line_type "{lt}" and content "{content}"')
def step_create_diffline_line_type(context: Context, lt: str, content: str) -> None:
    from cleveragents.cli.output.handles import DiffLine

    # Construct via model_validate to use field name (not alias).
    context.diff_line = DiffLine.model_validate({"line_type": lt, "content": content})


@when('I create a DiffLine with type alias "{lt}" and content "{content}"')
def step_create_diffline_type_alias(context: Context, lt: str, content: str) -> None:
    from cleveragents.cli.output.handles import DiffLine

    context.diff_line = DiffLine(type=lt, content=content)


@then('the DiffLine line_type is "{expected}"')
def step_diffline_line_type(context: Context, expected: str) -> None:
    assert context.diff_line.line_type == expected


# ===========================================================================
# P2-3: Numeric column sorting
# ===========================================================================


@when('I create a table with a numeric column "{name}"')
def step_create_table_numeric_col(context: Context, name: str) -> None:
    context.table_handle = context.session.table(
        columns=[ColumnDef(name=name, col_type="number")]
    )


@when("I add rows with scores {scores}")
def step_add_score_rows(context: Context, scores: str) -> None:
    for score in scores.split(", "):
        context.table_handle.add_row({"Score": score.strip()})


@when('I set sort key to "{column}" ascending')
def step_set_sort_ascending(context: Context, column: str) -> None:
    context.table_handle.set_sort_key(column, descending=False)


# ===========================================================================
# P2-4: ColumnDef default fields always serialised
# ===========================================================================


@when("I create a table with default ColumnDef fields")
def step_create_table_default_coldef(context: Context) -> None:
    col_defs = [ColumnDef(name="Name")]
    context.table_handle = context.session.table(columns=col_defs)


# ===========================================================================
# P2-5: Unicode box-drawing
# ===========================================================================


@then("the strategy output contains Unicode box-drawing chars")
def step_unicode_box_chars(context: Context) -> None:
    output = context.strategy.get_output()
    assert "╭" in output or "│" in output, f"No Unicode box-drawing chars in:\n{output}"


# ===========================================================================
# P3-3: MAX_TABLE_ROWS in add_rows batch
# ===========================================================================


@when("I fill the table close to its limit")
def step_fill_table_close_to_limit(context: Context) -> None:
    from cleveragents.cli.output.handles import MAX_TABLE_ROWS

    # Fill to MAX_TABLE_ROWS - 5
    batch = [{"Name": f"row-{i}"} for i in range(MAX_TABLE_ROWS - 5)]
    context.table_handle.add_rows(batch)


@then("adding a batch that exceeds the limit raises ValueError")
def step_add_batch_exceeds_limit(context: Context) -> None:
    try:
        context.table_handle.add_rows([{"Name": f"overflow-{i}"} for i in range(10)])
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


# ===========================================================================
# P3-5: Stress test with 10 concurrent producers
# ===========================================================================


@when("I concurrently create {n:d} panels from {t:d} threads")
def step_concurrent_panels_stress(context: Context, n: int, t: int) -> None:
    import threading

    errors: list[Exception] = []
    per_thread = n // t

    def create_panels(start_idx: int, count: int) -> None:
        try:
            for i in range(count):
                p = context.session.panel(f"Panel-{start_idx + i}")
                p.set_entry("k", "v")
                p.close()
        except Exception as exc:
            errors.append(exc)

    threads: list[threading.Thread] = []
    for i in range(t):
        th = threading.Thread(target=create_panels, args=(i * per_thread, per_thread))
        threads.append(th)
        th.start()
    for th in threads:
        th.join(timeout=30)
    assert not errors, f"Thread errors: {errors}"


# ===========================================================================
# P3-6: Boxdraw summary truncation
# ===========================================================================


@when('I create a table with a 2-char column "{col}"')
def step_create_narrow_table(context: Context, col: str) -> None:
    context.table_handle = context.session.table(columns=[col])
    context.table_handle.add_row({col: "XY"})


@when("I set a very long summary string")
def step_set_long_summary(context: Context) -> None:
    context.table_handle.set_summary({"result": "A" * 200})


@then("the strategy output does not have summary exceeding table width")
def step_summary_within_border(context: Context) -> None:
    output = context.strategy.get_output()
    lines = output.split("\n")
    # All lines should have roughly the same width (border-bound).
    border_line = lines[1] if len(lines) > 1 else lines[0]
    max_width = len(border_line) + 2  # Allow small tolerance
    for line in lines:
        assert len(line) <= max_width, (
            f"Line exceeds border: {len(line)} > {max_width}: {line!r}"
        )


# ===========================================================================
# P3-7: Explicit format flag for color and table
# (no additional step needed — reuses existing step_select_materializer_explicit)
# ===========================================================================


# ===========================================================================
# P3-8: ProgressHandle zero delta
# ===========================================================================


@then("incrementing by zero delta raises ValueError")
def step_incr_zero_delta(context: Context) -> None:
    try:
        context.progress_handle.increment(0)
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


# ===========================================================================
# Coverage boost: color table with summary
# ===========================================================================

# (reuses existing steps — no new step needed)


# ===========================================================================
# Coverage boost: color text wrap=False and multiline with empty lines
# ===========================================================================


@when("I create a text handle with multiline content including empty lines")
def step_create_text_multiline_empty(context: Context) -> None:
    context.text_handle = context.session.text("line1\n\nline3", wrap=True)


# ===========================================================================
# Coverage boost: _reset_counters
# ===========================================================================


@when("I reset the ID counters")
def step_reset_id_counters(context: Context) -> None:
    from cleveragents.cli.output._ids import _reset_counters

    _reset_counters()


@then("the next session ID starts from 1")
def step_session_id_from_1(context: Context) -> None:
    from cleveragents.cli.output._ids import generate_session_id

    sid = generate_session_id()
    assert sid == "ses-000001", f"Expected ses-000001, got {sid}"


@then("the next handle ID starts from 1")
def step_handle_id_from_1(context: Context) -> None:
    from cleveragents.cli.output._ids import generate_handle_id

    hid = generate_handle_id()
    assert hid == "hdl-000001", f"Expected hdl-000001, got {hid}"


# ===========================================================================
# Coverage boost: JSON diff with line numbers
# ===========================================================================


@when("I add a diff hunk with line numbers")
def step_add_diff_hunk_line_numbers(context: Context) -> None:
    from cleveragents.cli.output.handles import DiffLine

    lines = [
        DiffLine(
            type="context", content="existing", line_number_old=1, line_number_new=1
        ),
        DiffLine(type="add", content="new line", line_number_new=2),
    ]
    context.diff_handle.add_hunk("@@ -1,1 +1,2 @@", lines)


# ===========================================================================
# Coverage boost: JSON tree with style_hint and metadata
# ===========================================================================


@when('I add a tree child "{label}" under "{path}" with style "{style}" and metadata')
def step_add_tree_child_style_metadata(
    context: Context, label: str, path: str, style: str
) -> None:
    context.tree_handle.add_child(
        path, label, style_hint=style, metadata={"key": "value"}
    )


# ===========================================================================
# Coverage boost: flat tree depth guard
# ===========================================================================


@when(
    'I create a tree handle with root label "{root}" and show_guides false and max_depth_hint {depth:d}'
)
def step_create_flat_tree_max_depth(context: Context, root: str, depth: int) -> None:
    context.tree_handle = context.session.tree(
        root, show_guides=False, max_depth_hint=depth
    )


# ===========================================================================
# Coverage boost: _find_node cache miss traversal
# ===========================================================================


@when("I build a 3-level deep tree and query a deep path")
def step_build_deep_tree_cache_miss(context: Context) -> None:
    # Build 3 levels
    context.tree_handle.add_child("Root", "A")
    context.tree_handle.add_child("Root/A", "B")
    context.tree_handle.add_child("Root/A/B", "C")
    # Clear the path cache to force fallback traversal
    context.tree_handle._path_cache.clear()
    context.tree_handle._path_cache[""] = context.tree_handle._element.root
    context.tree_handle._path_cache[context.tree_handle._element.root.label] = (
        context.tree_handle._element.root
    )
    # Now query a deep path — forces traversal
    node = context.tree_handle._find_node("Root/A/B/C")
    context.deep_node = node


@then("the deep tree node was found correctly")
def step_deep_node_found(context: Context) -> None:
    assert context.deep_node is not None
    assert context.deep_node.label == "C"


# ===========================================================================
# Coverage boost: TextHandle with incremental strategy
# ===========================================================================


@given("an OutputSession with a mock incremental strategy")
def step_session_incremental_strategy(context: Context) -> None:
    from cleveragents.cli.output.materializers import PlainMaterializer
    from cleveragents.cli.output.session import OutputSession

    strategy = PlainMaterializer()
    strategy.supports_incremental_updates = True
    context.incremental_events = []
    original_on_updated = strategy.on_element_updated

    def tracking_on_updated(event: ElementUpdated) -> None:
        context.incremental_events.append(event)
        original_on_updated(event)

    strategy.on_element_updated = tracking_on_updated  # type: ignore[method-assign]
    context.session = OutputSession(format="plain", command="test", strategy=strategy)
    context.strategy = strategy


@then("the incremental strategy received an update event")
def step_incremental_event_received(context: Context) -> None:
    assert len(context.incremental_events) > 0, "No incremental events received"


# ===========================================================================
# Coverage boost: table → color fallback on non-TTY with ANSI
# ===========================================================================


@given("a terminal with ANSI but non-TTY")
def step_terminal_ansi_non_tty(context: Context) -> None:
    context.terminal_caps = TerminalCapabilities(
        is_tty=False, supports_ansi=True, supports_cursor=False, term="xterm-256color"
    )


# ===========================================================================
# Coverage boost: set_node_style on non-existent path
# ===========================================================================


@then("setting tree node style on non-existent path raises ValueError")
def step_set_node_style_nonexistent(context: Context) -> None:
    try:
        context.tree_handle.set_node_style("Root/nonexistent", "bold")
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


# ===========================================================================
# Coverage boost: session status factory rejects invalid level
# ===========================================================================


@then("creating a status with invalid level raises ValueError")
def step_session_status_invalid_level(context: Context) -> None:
    try:
        context.session.status("msg", level="bogus")
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


# ===========================================================================
# Coverage boost: deep tree at MAX_TREE_DEPTH
# ===========================================================================


@when("I create a tree at maximum depth")
def step_create_max_depth_tree(context: Context) -> None:
    from cleveragents.cli.output._renderers import render_element_plain
    from cleveragents.cli.output.handles import MAX_TREE_DEPTH, Tree, TreeNode

    # Build a tree model deeper than MAX_TREE_DEPTH by constructing
    # TreeNode objects directly (bypassing the handle's depth guard).
    # This tests the render-layer depth guard (defence-in-depth).
    node = TreeNode(label="leaf", children=[TreeNode(label="overflow-child")])
    for i in range(MAX_TREE_DEPTH):
        node = TreeNode(label=f"d{MAX_TREE_DEPTH - 1 - i}", children=[node])
    tree_model = Tree(root=node, show_guides=True)
    # Render directly — don't go through the session/handle
    context.deep_tree_output = render_element_plain(tree_model)
    # Create a dummy handle so the session close doesn't fail
    context.tree_handle = context.session.tree("Dummy")


# ===========================================================================
# Coverage boost: JSON tree node truncation at depth limit
# ===========================================================================


@when("I serialize a tree node beyond MAX_TREE_DEPTH")
def step_serialize_deep_tree_node(context: Context) -> None:
    from cleveragents.cli.output.handles import TreeNode
    from cleveragents.cli.output.materializers import _tree_node_to_dict

    # Build a simple node and call with depth at limit
    node = TreeNode(label="deep", children=[TreeNode(label="child")])
    context.tree_dict = _tree_node_to_dict(node, _depth=64)


@then('the deep tree output contains "{text}"')
def step_deep_tree_output_contains(context: Context, text: str) -> None:
    assert text in context.deep_tree_output, f"Expected {text!r} in deep tree output"


@then("the serialized node contains truncated flag")
def step_tree_truncated(context: Context) -> None:
    assert context.tree_dict.get("truncated") is True, (
        f"Expected truncated=True, got {context.tree_dict}"
    )


# ===========================================================================
# Coverage boost: selection.py — rich with cursor / ANSI-only
# ===========================================================================


@given("a terminal with cursor support")
def step_terminal_cursor(context: Context) -> None:
    context.terminal_caps = TerminalCapabilities(
        is_tty=True, supports_ansi=True, supports_cursor=True, term="xterm-256color"
    )


@given("a terminal with ANSI only no TTY no cursor")
def step_terminal_ansi_only(context: Context) -> None:
    context.terminal_caps = TerminalCapabilities(
        is_tty=False, supports_ansi=True, supports_cursor=False, term="ansi"
    )


# ===========================================================================
# Coverage boost: TreeHandle add_child at MAX_TREE_DEPTH
# ===========================================================================


@when("I create a tree at exactly max depth via handle")
def step_create_tree_max_depth_handle(context: Context) -> None:
    from cleveragents.cli.output.handles import MAX_TREE_DEPTH

    context.tree_handle = context.session.tree("R")
    path = "R"
    for i in range(MAX_TREE_DEPTH - 1):
        path = context.tree_handle.add_child(path, f"n{i}")
    context.max_depth_path = path


@then("adding one more child level raises ValueError")
def step_add_child_exceeds_depth(context: Context) -> None:
    try:
        context.tree_handle.add_child(context.max_depth_path, "overflow")
        raise AssertionError("Expected ValueError")
    except ValueError:
        pass


# ===========================================================================
# Coverage boost: detect_terminal_capabilities with specific TERM
# ===========================================================================


@when('I detect capabilities with TERM "{term}" on a TTY')
def step_detect_caps_specific_term(context: Context, term: str) -> None:
    import os
    import unittest.mock

    with (
        unittest.mock.patch.dict(os.environ, {"TERM": term}),
        unittest.mock.patch("sys.stdout") as mock_stdout,
    ):
        mock_stdout.isatty.return_value = True
        from cleveragents.cli.output.selection import detect_terminal_capabilities

        context.detected_caps = detect_terminal_capabilities()


@then("the capabilities supports_cursor is true")
def step_caps_cursor_true(context: Context) -> None:
    assert context.detected_caps.supports_cursor is True, (
        f"Expected supports_cursor=True, got {context.detected_caps.supports_cursor}"
    )


# ===========================================================================
# Coverage boost: _find_node with wrong root
# ===========================================================================


@then("querying a path with wrong root returns None")
def step_find_node_wrong_root(context: Context) -> None:
    # Clear cache to force traversal path
    context.tree_handle._path_cache.clear()
    context.tree_handle._path_cache[""] = context.tree_handle._element.root
    result = context.tree_handle._find_node("WrongRoot/child")
    assert result is None, f"Expected None, got {result}"


# ===========================================================================
# LiveMaterializationStrategy
# ===========================================================================


@given("a RichMaterializer instance")
def step_given_rich_materializer_instance(context: Context) -> None:
    context.rich_mat = RichMaterializer()


@then("the rich materializer supports incremental updates")
def step_rich_supports_incremental(context: Context) -> None:
    assert context.rich_mat.supports_incremental_updates is True, (
        "Expected supports_incremental_updates=True"
    )


@then('the rich materializer strategy name is "{name}"')
def step_rich_strategy_name(context: Context, name: str) -> None:
    assert context.rich_mat.strategy_name == name, (
        f"Expected strategy_name={name!r}, got {context.rich_mat.strategy_name!r}"
    )


@then("the live strategy frame rate is {rate}")
def step_live_frame_rate(context: Context, rate: str) -> None:
    assert float(rate) == context.rich_mat._FRAME_RATE, (
        f"Expected frame rate {rate}, got {context.rich_mat._FRAME_RATE}"
    )
