//! Cucumber/Gherkin end-to-end test suite for the editor.
//!
//! Each scenario starts from a clean `EditorWorld`, sets up buffer state via
//! `Given` steps, drives the command registry via `When I run "..."`, and
//! asserts observable state in `Then` steps — no rendering involved.

use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

use cucumber::gherkin::Step;
use cucumber::{World, WriterExt as _, given, then, when, writer};

use oo_ide::app::builtin::register_all;
use oo_ide::app_state::{AppState, Screen};
use oo_ide::commands::{CommandId, CommandRegistry};
use oo_ide::editor::buffer::Buffer;
use oo_ide::editor::fold::FoldState;
use oo_ide::editor::history::ChangeKind;
use oo_ide::editor::position::Position;
use oo_ide::editor::selection::Selection;
use oo_ide::file_index;
use oo_ide::project::Project;
use oo_ide::settings::Settings;
use oo_ide::views::View as _;
use oo_ide::views::editor::{EditorView, SearchKind, SearchMode, SearchOptions, SearchState};
use oo_ide::views::file_selector::FileSelector;
use oo_ide::widgets::focus::FocusRing;
use oo_ide::widgets::input_field::InputField;
use std::cell::RefCell;
use portable_pty::{native_pty_system, PtySize};
use vt100::Parser;
use oo_ide::views::terminal::{TerminalView, TerminalTab};
use oo_ide::operation::Operation;
use oo_ide::operation::SearchOp;

use oo_ide::log_matcher::{
    CompileOptions, CompileResult, CompiledMatcher, LogMatcherDef, MatcherEngine, Message,
    MessageLevel, compile_matchers,
};
use oo_ide::operation::LspCompletionItem;
use oo_ide::schema::completions_from_schema;

// ---------------------------------------------------------------------------
// World
// ---------------------------------------------------------------------------

#[derive(World)]
#[world(init = Self::new)]
pub struct EditorWorld {
    pub inner: Box<EditorWorldInner>,
}

pub struct EditorWorldInner {
    pub app: AppState,
    pub registry: CommandRegistry,
    pub _dir: tempfile::TempDir,

    // Fields used by schema-driven completion tests (merged from other test files)
    pub schema_json: String,
    pub schema_lines: Vec<String>,
    pub schema_cursor: Position,
    pub schema_completions: Vec<LspCompletionItem>,
    pub schema_target: Option<PathBuf>,

    // Log matcher test state
    #[allow(dead_code)]
    pub lm_defs: Vec<LogMatcherDef>,
    #[allow(dead_code)]
    pub lm_warn_unused_captures: bool,
    #[allow(dead_code)]
    pub lm_result: Option<Result<CompileResult, Vec<Message>>>,

    // Engine execution test state
    pub lm_engine: Option<MatcherEngine>,
    pub lm_engine_matchers: Vec<CompiledMatcher>,
    pub lm_engine_issues: Vec<oo_ide::issue_registry::NewIssue>,
}

impl std::fmt::Debug for EditorWorld {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("EditorWorld").finish_non_exhaustive()
    }
}

impl EditorWorld {
    fn new() -> Self {
        let dir = tempfile::tempdir().unwrap();
        // Create the .oo directory so Project::new can find it
        std::fs::create_dir(dir.path().join(".oo")).unwrap();
        // Copy initial tasks.yaml (if present in repo) into temp .oo before AppState::new
        // so startup loads task definitions. This ensures features that expect tasks
        // in the command palette work even when the file is read at AppState::new.
        let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
        let initial_src = std::path::Path::new(&manifest_dir)
            .join("tests")
            .join("features")
            .join("initial_tasks.yaml");
        if initial_src.exists() {
            let dst = dir.path().join(".oo").join("tasks.yaml");
            std::fs::copy(&initial_src, &dst).expect("copy initial_tasks.yaml failed");
        }
        // Also allow tests to provide an initial project_state.yaml under tests/features
        let initial_psrc = std::path::Path::new(&manifest_dir)
            .join("tests").join("features").join("initial_project_state.yaml");
        if initial_psrc.exists() {
            let cache_dir = dir.path().join(".oo").join("cache");
            std::fs::create_dir_all(&cache_dir).expect("create .oo/cache dir failed");
            let dst = cache_dir.join("project_state.yaml");
            std::fs::copy(&initial_psrc, &dst).expect("copy initial_project_state.yaml failed");
        } else {
            // If no standalone fixture file exists, attempt to extract an embedded
            // project_state YAML from the command_history.feature docstring. This
            // allows inlining the fixture inside the feature file without requiring
            // a separate YAML file on disk.
            let feature_path = std::path::Path::new(&manifest_dir)
                .join("tests").join("features").join("command_history.feature");
            if feature_path.exists()
                && let Ok(feature_text) = std::fs::read_to_string(&feature_path) {
                    // First attempt: find a docstring attached to the Given step that
                    // creates project_state.yaml. This supports features that inline the YAML
                    // as a DocString.
                    let mut lines = feature_text.lines();
                    let needle = "Given after creating the file .oo/project_state.yaml with the following content:";
                    let mut found = false;
                    while let Some(line) = lines.next() {
                        if line.trim() == needle {
                            // find opening triple quotes
                            while let Some(l) = lines.next() {
                                if l.trim().starts_with("\"\"\"") {
                                    // collect YAML lines until closing triple quotes
                                    let mut yaml_lines = Vec::new();
                                    for yl in lines.by_ref() {
                                        if yl.trim().starts_with("\"\"\"") {
                                            break;
                                        }
                                        yaml_lines.push(yl);
                                    }
                                    let yaml = yaml_lines.join("\n");
                                    let cache_dir = dir.path().join(".oo").join("cache");
                                    std::fs::create_dir_all(&cache_dir).expect("create .oo/cache dir failed");
                                    let dst = cache_dir.join("project_state.yaml");
                                    std::fs::write(&dst, yaml).expect("write project_state.yaml failed");
                                    found = true;
                                    break;
                                }
                            }
                            if found { break; }
                        }
                    }

                    // Fallback: look for a commented BEGIN/END marker block so the YAML can
                    // be inlined safely without being attached to a step. This is handy when
                    // embedding the fixture in a feature but avoiding additional Given steps.
                    if !found {
                        let begin = "# BEGIN_PROJECT_STATE";
                        let end = "# END_PROJECT_STATE";
                        let mut in_block = false;
                        let mut yaml_lines = Vec::new();
                        for l in feature_text.lines() {
                            if l.trim() == begin {
                                in_block = true;
                                continue;
                            }
                            if l.trim() == end {
                                break;
                            }
                            if in_block {
                                // Preserve indentation after the leading '#' so YAML structure
                                // (list indentation) is preserved. Remove only the first '#' and
                                // a single following space if present.
                                let mut s = l;
                                if let Some(pos) = s.find('#') {
                                    s = &s[(pos + 1)..];
                                    if s.starts_with(' ') {
                                        s = &s[1..];
                                    }
                                }
                                yaml_lines.push(s);
                            }
                        }
                        if !yaml_lines.is_empty() {
                            let yaml = yaml_lines.join("\n");
                            let cache_dir = dir.path().join(".oo").join("cache");
                            std::fs::create_dir_all(&cache_dir).expect("create .oo/cache dir failed");
                            let dst = cache_dir.join("project_state.yaml");
                            std::fs::write(&dst, yaml).expect("write project_state.yaml failed");
                        }
                    }
                }
        }

        let settings = Settings::new(dir.path().join(".oo").join("config.yaml").as_path()).unwrap();
        let mut project = Project::new(dir.path().to_path_buf()).unwrap();
        // Restore any persisted small project state (project_state.yaml)
        project.restore_state();
        // Debug: report how many persisted entries were loaded (helps diagnose test fixture issues)
        eprintln!(
            "DEBUG: restored persisted command_history entries = {}",
            project.get_persisted_command_history().len()
        );
        // If a project_state.yaml was written by the test harness, print it for debugging
        let debug_ps = dir.path().join(".oo").join("cache").join("project_state.yaml");
        if let Ok(s) = std::fs::read_to_string(&debug_ps) {
            eprintln!("DEBUG: project_state.yaml contents ({}):\n{}", debug_ps.display(), s);
        } else {
            eprintln!("DEBUG: project_state.yaml not present at {}", debug_ps.display());
        }
        let buffer = Buffer::from_lines(vec![String::new()], None);
        let editor = EditorView::open(buffer, FoldState::default(), &settings);
        let file_index = file_index::spawn_indexer(dir.path().to_path_buf());
        let mut app = AppState::new(
            Screen::Editor(Box::new(editor)),
            project,
            settings,
            file_index,
        );
        app.recompute_contexts();
        let mut registry = CommandRegistry::new();
        register_all(&mut registry);
        // Also register tasks loaded from .oo/tasks.yaml so they appear in the
        // command palette during tests.
        oo_ide::app::builtin::register_tasks(&mut registry, &app);
        // Seed registry history from any persisted project state (project_state.yaml)
        let persisted = app.project.get_persisted_command_history();
        if !persisted.is_empty() {
            for entry in persisted.iter().rev() {
                if let Ok(cmd_id) = entry.0.parse::<CommandId>() {
                    let args: std::collections::HashMap<String, oo_ide::commands::ArgValue> =
                        entry
                            .1
                            .iter()
                            .map(|(k, v): (&String, &String)| (k.clone(), oo_ide::commands::ArgValue::String(v.clone())))
                            .collect();
                    registry.record_palette_selection(cmd_id, args);
                } else {
                    // ignore malformed entries
                }
            }
        }
        // Debug: dump the in-memory registry history after seeding
        eprintln!("DEBUG: registry.history() count = {}", registry.history().len());
        Self {
            inner: Box::new(EditorWorldInner {
                app,
                registry,
                _dir: dir,
                schema_json: String::new(),
                schema_lines: vec![String::new()],
                schema_cursor: Position { line: 0, column: 0 },
                schema_completions: Vec::new(),
                schema_target: None,
                lm_defs: Vec::new(),
                lm_warn_unused_captures: false,
                lm_result: None,
                lm_engine: None,
                lm_engine_matchers: Vec::new(),
                lm_engine_issues: Vec::new(),
            }),
        }
    }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

fn ed(world: &EditorWorld) -> &EditorView {
    match &world.inner.app.screen {
        Screen::Editor(e) => e,
        _ => panic!("active screen is not the editor"),
    }
}

fn ed_mut(world: &mut EditorWorld) -> &mut EditorView {
    match &mut world.inner.app.screen {
        Screen::Editor(e) => e,
        _ => panic!("active screen is not the editor"),
    }
}

fn run_command(world: &mut EditorWorld, cmd: &str) {
    let id: CommandId = cmd.parse().expect("bad command id");
    let ops = world.inner.registry.execute(&id, HashMap::new(), &world.inner.app);
    let settings = &world.inner.app.settings;
    for op in ops {
        if let Screen::Editor(ed) = &mut world.inner.app.screen {
            ed.handle_operation(&op, settings);
        }
    }
    world.inner.app.recompute_contexts();
}

// ---------------------------------------------------------------------------
// Given steps
// ---------------------------------------------------------------------------

/// Set the buffer to a single-line string, inserting char-by-char so that
/// the undo stack reflects the typing history.
#[given(expr = "the buffer contains {string}")]
fn given_buffer_contains(world: &mut EditorWorld, text: String) {
    let ed = ed_mut(world);
    ed.buffer = Buffer::from_lines(vec![String::new()], None);
    ed.buffer.insert(&text);
    world.inner.app.recompute_contexts();
}

/// Set the buffer to multi-line content from a docstring.
#[given(expr = "the buffer contains:")]
fn given_buffer_contains_multiline(world: &mut EditorWorld, step: &cucumber::gherkin::Step) {
    let raw = step.docstring().expect("expected a docstring");
    // The gherkin parser may include surrounding newlines; strip them.
    let content = raw.trim_matches('\n');
    let lines: Vec<String> = content.lines().map(|l: &str| l.to_string()).collect();
    let lines = if lines.is_empty() {
        vec![String::new()]
    } else {
        lines
    };
    ed_mut(world).buffer = Buffer::from_lines(lines, None);
    world.inner.app.recompute_contexts();
}

#[given(expr = "the cursor is at row {int}, col {int}")]
fn given_cursor(world: &mut EditorWorld, row: usize, col: usize) {
    ed_mut(world).buffer.set_cursor(Position::new(row, col));
}

#[given(expr = "there is a selection from row {int} col {int} to row {int} col {int}")]
fn given_selection(
    world: &mut EditorWorld,
    anchor_row: usize,
    anchor_col: usize,
    head_row: usize,
    head_col: usize,
) {
    let anchor = Position::new(anchor_row, anchor_col);
    let head = Position::new(head_row, head_col);
    ed_mut(world).buffer.set_selection(Some(Selection {
        anchor,
        active: head,
    }));
}

#[given(expr = "the search query is {string} and the bar is closed")]
fn given_search_query_closed(world: &mut EditorWorld, query: String) {
    let ed = ed_mut(world);
    let lines = ed.buffer.lines().to_vec();
    let matches = find_matches_simple(&lines, &query);
    let mut query_field = InputField::new("Find");
    query_field.set_text(query);
    ed.last_search = Some(SearchState {
        query: query_field,
        replacement: InputField::new("Replace"),
        kind: SearchKind::Find,
        mode: SearchMode::default(),
        focus: FocusRing::new(vec!["search_query"]),
        opts: SearchOptions::default(),
        matches,
        current: 0,
        files: Vec::new(),
        file_path_index: HashMap::new(),
        selected_file: 0,
        file_panel_scroll: 0,
        match_panel_scroll: 0,
        include_filter: InputField::new("incl").with_text("*"),
        exclude_filter: InputField::new("excl"),
        project_search_generation: 0,
        expanded_files: HashSet::new(),
        tree_cursor_path: None,
        tree_cursor_match: None,
        tree_scroll: 0,
        project_match_cursor: None,
    });
    ed.search = None;
}

/// Insert a single character into the buffer so that the undo stack has a
/// snapshot to restore (used by the undo scenario).
#[given(expr = "a character {string} has been inserted")]
fn given_char_inserted(world: &mut EditorWorld, ch: String) {
    let ed = ed_mut(world);
    ed.buffer.begin_transaction(ChangeKind::InsertText);
    ed.buffer.insert(&ch);
    ed.buffer.end_transaction();
}

#[given(expr = "after creating the file .oo/tasks.yaml with the following content:")]
fn given_create_tasks_yaml(world: &mut EditorWorld, step: &cucumber::gherkin::Step) {
    let raw = step.docstring().expect("expected a docstring");
    // Trim surrounding whitespace (handles CRLF) and take ownership to avoid borrowing
    // the gherkin parser's internal buffer.
    let mut content = raw.trim().to_string();
    let path = world.inner._dir.path().join(".oo").join("tasks.yaml");
    std::fs::write(&path, &content).expect("could not write tasks.yaml");
    // Read back from disk to ensure the parser sees the exact bytes written.
    content = std::fs::read_to_string(&path).expect("could not read tasks.yaml back");
    match oo_ide::task_config::parse_str(&content) {
        Ok(parsed) => {
            world.inner.app.task_config = Some(parsed.clone());
            oo_ide::app::builtin::register_tasks_from_tasksfile(
                &mut world.inner.registry,
                world.inner.app.task_config.as_ref().unwrap(),
            );
        }
        Err(errs) => {
            panic!("tasks.yaml parse failed: {:?}", errs);
        }
    }
    world.inner.app.recompute_contexts();
}


#[given(expr = "after creating the file .oo/project_state.yaml with the following content:")]
fn given_create_project_state(world: &mut EditorWorld, step: &cucumber::gherkin::Step) {
    let raw = step.docstring().expect("expected a docstring");
    // Trim surrounding whitespace and write to the temp project's .oo dir
    let content = raw.trim().to_string();
    let cache_dir = world.inner._dir.path().join(".oo").join("cache");
    std::fs::create_dir_all(&cache_dir).expect("create .oo/cache dir failed");
    let path = cache_dir.join("project_state.yaml");
    std::fs::write(&path, &content).expect("could not write project_state.yaml");
    // Restore project state into the in-memory Project and seed the command registry
    world.inner.app.project.restore_state();
    let persisted = world.inner.app.project.get_persisted_command_history();
    if !persisted.is_empty() {
        for entry in persisted.iter().rev() {
            if let Ok(cmd_id) = entry.0.parse::<CommandId>() {
                let args: std::collections::HashMap<String, oo_ide::commands::ArgValue> = entry
                    .1
                    .iter()
                    .map(|(k, v): (&String, &String)| (k.clone(), oo_ide::commands::ArgValue::String(v.clone())))
                    .collect();
                world.inner.registry.record_palette_selection(cmd_id, args);
            } else {
                // ignore malformed entries
            }
        }
    }
    world.inner.app.recompute_contexts();
}

// ---------------------------------------------------------------------------
// When steps
// ---------------------------------------------------------------------------

#[when(expr = "I run {string}")]
fn when_run(world: &mut EditorWorld, cmd: String) {
    run_command(world, &cmd);
}

#[when(expr = "I press enter")]
fn when_press_enter(world: &mut EditorWorld) {
    // Simulate pressing Enter by inserting text "\n" which triggers insert_newline_with_indent
    let ed = ed_mut(world);
    ed.buffer.begin_transaction(ChangeKind::InsertText);
    ed.buffer
        .insert_newline_with_indent(ed.use_space, ed.indentation_width);
    ed.buffer.end_transaction();
}

#[when(expr = "I press arrow_down")]
fn when_arrow_down(world: &mut EditorWorld) {
    let ed = ed_mut(world);
    let new = ed.buffer.offset_down(ed.buffer.cursor());
    ed.buffer.set_cursor(new);
}

#[when(expr = "I press arrow_up")]
fn when_arrow_up(world: &mut EditorWorld) {
    let ed = ed_mut(world);
    let new = ed.buffer.offset_up(ed.buffer.cursor());
    ed.buffer.set_cursor(new);
}

#[when(expr = "I press home")]
fn when_home(world: &mut EditorWorld) {
    let ed = ed_mut(world);
    let new = ed.buffer.offset_line_start(ed.buffer.cursor());
    ed.buffer.set_cursor(new);
}

#[when(expr = "I press end")]
fn when_end(world: &mut EditorWorld) {
    let ed = ed_mut(world);
    let new = ed.buffer.offset_line_end(ed.buffer.cursor());
    ed.buffer.set_cursor(new);
}

#[when(expr = "I press arrow_left")]
fn when_arrow_left(world: &mut EditorWorld) {
    let ed = ed_mut(world);
    let new = ed.buffer.offset_left(ed.buffer.cursor());
    ed.buffer.set_cursor(new);
}

#[when(expr = "I press arrow_right")]
fn when_arrow_right(world: &mut EditorWorld) {
    let ed = ed_mut(world);
    let new = ed.buffer.offset_right(ed.buffer.cursor());
    ed.buffer.set_cursor(new);
}

#[when(expr = "I press page_down")]
fn when_page_down(world: &mut EditorWorld) {
    let ed = ed_mut(world);
    let new = ed.buffer.offset_down_n(ed.buffer.cursor(), 20);
    ed.buffer.set_cursor(new);
}

#[when(expr = "I press page_up")]
fn when_page_up(world: &mut EditorWorld) {
    let ed = ed_mut(world);
    let new = ed.buffer.offset_up_n(ed.buffer.cursor(), 20);
    ed.buffer.set_cursor(new);
}

// ---------------------------------------------------------------------------
// Then steps
// ---------------------------------------------------------------------------

#[then(expr = "the cursor is at row {int}, col {int}")]
fn then_cursor(world: &mut EditorWorld, row: usize, col: usize) {
    assert_eq!(
        ed(world).buffer.cursor(),
        Position::new(row, col),
        "cursor mismatch"
    );
}

#[then(expr = "buffer line {int} is {string}")]
fn then_line(world: &mut EditorWorld, row: usize, expected: String) {
    assert_eq!(
        ed(world).buffer.line(row),
        Some(expected),
        "line {row} mismatch"
    );
}

#[then(expr = "the tasks config contains task {string}")]
fn then_tasks_contains(world: &mut EditorWorld, task_id: String) {
    assert!(
        world.inner.app.task_config.is_some(),
        "expected task_config to be loaded"
    );
    let tf = world.inner.app.task_config.as_ref().unwrap();
    assert!(
        tf.tasks.contains_key(&task_id),
        "task '{}' not found in tasks.yaml",
        task_id
    );
}

#[then(expr = "the command palette contains task {string}")]
fn then_command_palette_contains_task(world: &mut EditorWorld, task_id: String) {
    let found = world.inner.registry
        .user_commands_sorted()
        .into_iter()
        .any(|c| c.meta.id.group == "task" && c.meta.id.name == task_id);
    assert!(
        found,
        "expected task command 'task.{}' to be registered",
        task_id
    );
}

#[then(expr = "the command history contains {string}")]
fn then_command_history_contains(world: &mut EditorWorld, cmd_id: String) {
    let found = world.inner
        .registry
        .history()
        .iter()
        .any(|e| e.id.to_string() == cmd_id);
    assert!(found, "expected command history to contain {}", cmd_id);
}

#[then(expr = "the buffer has {int} lines")]
fn then_line_count(world: &mut EditorWorld, n: usize) {
    assert_eq!(ed(world).buffer.line_count(), n, "line count mismatch");
}

#[then(expr = "there is no selection")]
fn then_no_selection(world: &mut EditorWorld) {
    assert!(
        ed(world).buffer.selection().is_none(),
        "expected no selection but found one"
    );
}

#[then(expr = "there is a selection from row {int} col {int} to row {int} col {int}")]
fn then_selection(
    world: &mut EditorWorld,
    anchor_row: usize,
    anchor_col: usize,
    head_row: usize,
    head_col: usize,
) {
    let sel = ed(world)
        .buffer
        .selection()
        .expect("expected a selection but found none");
    assert_eq!(
        sel.anchor,
        Position::new(anchor_row, anchor_col),
        "selection anchor mismatch"
    );
    assert_eq!(
        sel.active,
        Position::new(head_row, head_col),
        "selection active mismatch"
    );
}

#[then(expr = "the buffer is dirty")]
fn then_dirty(world: &mut EditorWorld) {
    assert!(ed(world).buffer.is_dirty(), "buffer should be dirty");
}

#[then(expr = "the buffer is not dirty")]
fn then_not_dirty(world: &mut EditorWorld) {
    assert!(!ed(world).buffer.is_dirty(), "buffer should not be dirty");
}

#[then(expr = "there are {int} search matches")]
fn then_search_matches(world: &mut EditorWorld, n: usize) {
    let count = ed(world)
        .last_search
        .as_ref()
        .map_or(0, |s| s.matches.len());
    assert_eq!(count, n, "search match count mismatch");
}

#[then(expr = "the search bar is open with query {string}")]
fn then_search_bar_open(world: &mut EditorWorld, query: String) {
    let search = ed(world)
        .search
        .as_ref()
        .expect("expected search bar to be open");
    assert_eq!(search.query.text(), query, "search bar query mismatch");
}

#[then(expr = "line {int} is folded")]
fn then_line_folded(world: &mut EditorWorld, row: usize) {
    assert!(
        ed(world).folds.is_folded_header(row),
        "expected line {row} to be a folded header"
    );
}

#[then(expr = "line {int} is not folded")]
fn then_line_not_folded(world: &mut EditorWorld, row: usize) {
    assert!(
        !ed(world).folds.is_folded_header(row),
        "expected line {row} to not be a folded header"
    );
}

#[then(expr = "word wrap is enabled")]
fn then_word_wrap_on(world: &mut EditorWorld) {
    assert!(ed(world).word_wrap, "expected word wrap to be enabled");
}

#[then(expr = "word wrap is disabled")]
fn then_word_wrap_off(world: &mut EditorWorld) {
    assert!(!ed(world).word_wrap, "expected word wrap to be disabled");
}

#[given(expr = "word wrap is disabled")]
fn given_word_wrap_disabled(world: &mut EditorWorld) {
    ed_mut(world).word_wrap = false;
}

#[then(expr = "there is a marker at row {int}")]
fn then_marker_at(world: &mut EditorWorld, row: usize) {
    assert!(
        ed(world).buffer.markers.iter().any(|m| m.line == row),
        "expected marker at row {row}"
    );
}

#[then(expr = "there is no marker at row {int}")]
fn then_no_marker_at(world: &mut EditorWorld, row: usize) {
    assert!(
        !ed(world).buffer.markers.iter().any(|m| m.line == row),
        "expected no marker at row {row}"
    );
}

#[then(expr = "the search mode is {string}")]
fn then_search_mode(world: &mut EditorWorld, mode: String) {
    let search = ed(world).search.as_ref().expect("expected search bar to be open");
    match mode.as_str() {
        "Inline" => assert_eq!(search.mode, SearchMode::Inline, "search mode mismatch"),
        "Expanded" => assert_eq!(search.mode, SearchMode::Expanded, "search mode mismatch"),
        other => panic!("unknown search mode: {}", other),
    }
}

// ---------------------------------------------------------------------------
// Private helper: simple (non-regex, case-sensitive) match finding for
// the `given_search_query_closed` step.
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// View state preservation steps
// ---------------------------------------------------------------------------

/// Write a file with the given content to the temp project directory.
#[given(expr = "a temp file {string} with content {string}")]
fn given_temp_file_with_content(world: &mut EditorWorld, filename: String, content: String) {
    let path = world.inner._dir.path().join(&filename);
    std::fs::write(&path, &content).expect("write temp file");
}

/// Open a file into the editor and insert extra text so the buffer is dirty.
/// The extra text is appended after the existing file content on the same line.
#[given(expr = "the buffer for {string} has unsaved text {string}")]
fn given_buffer_with_unsaved_text(world: &mut EditorWorld, filename: String, extra: String) {
    let path = world.inner._dir.path().join(&filename);
    let buf = Buffer::open(&path).expect("open file for buffer");
    let ed = {
        let settings = &world.inner.app.settings;
        let mut ed = EditorView::open(buf, FoldState::default(), settings);
        // Move cursor to end of first line so insert appends (not prepends) content.
        let end_pos = ed.buffer.offset_line_end(ed.buffer.cursor());
        ed.buffer.set_cursor(end_pos);
        ed.buffer.insert(&extra);
        ed
    };
    world.inner.app.set_screen(Screen::Editor(Box::new(ed)));
}

/// Open a file into the editor and move the cursor to the given position.
#[given(expr = "the buffer for {string} is open at the cursor row {int}, col {int}")]
fn given_buffer_open_at_cursor(
    world: &mut EditorWorld,
    filename: String,
    row: usize,
    col: usize,
) {
    let path = world.inner._dir.path().join(&filename);
    let buf = Buffer::open(&path).expect("open file for buffer");
    let ed = {
        let settings = &world.inner.app.settings;
        let mut ed = EditorView::open(buf, FoldState::default(), settings);
        ed.buffer.set_cursor(Position::new(row, col));
        ed
    };
    world.inner.app.set_screen(Screen::Editor(Box::new(ed)));
}

/// Simulate opening the file selector (Primary → Modal stash).
/// The editor (or other primary view) is stashed into `stashed_primary`.
#[when("I open the file selector")]
fn when_open_file_selector(world: &mut EditorWorld) {
    let project_root = world.inner.app.project.project_path.clone();
    let file_index = world.inner.app.file_index.clone();
    let fs = FileSelector::new(project_root, &[], file_index, Default::default(), None);
    // set_screen returns None when transitioning Primary→Modal; the editor is now stashed.
    let _ = world.inner.app.set_screen(Screen::FileSelector(fs));
}

/// Simulate selecting a file in the file selector.
/// Uses `open_file_preserving_stash` which flushes any stashed primary view
/// into the project stash before opening, preserving unsaved changes and cursor.
#[when(expr = "I select file {string} via OpenFile")]
fn when_select_file_via_open_file(world: &mut EditorWorld, filename: String) {
    let path = world.inner._dir.path().join(&filename);
    let ok = world.inner.app.open_file_preserving_stash(path);
    assert!(ok, "open_file_preserving_stash failed for {filename}");
}


fn find_matches_simple(lines: &[String], query: &str) -> Vec<(usize, usize, usize)> {
    if query.is_empty() {
        return vec![];
    }
    let mut out = Vec::new();
    for (row, line) in lines.iter().enumerate() {
        let mut start = 0;
        while let Some(pos) = line[start..].find(query) {
            let byte_start = start + pos;
            let byte_end = byte_start + query.len();
            out.push((row, byte_start, byte_end));
            start = byte_end;
        }
    }
    out
}

// ---------------------------------------------------------------------------
// Schema-driven completion steps (merged from cargo_toml_completion.rs & gitlab_ci_completion.rs)
// ---------------------------------------------------------------------------

#[given("the Cargo.toml schema is loaded")]
fn given_cargo_schema_loaded(world: &mut EditorWorld) {
    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
        .expect("CARGO_MANIFEST_DIR not set — run via `cargo test`");
    let schema_path =
        Path::new(&manifest_dir).join("../../extensions/rust/manifests/cargo.schema.json");
    world.inner.schema_json = std::fs::read_to_string(&schema_path)
        .unwrap_or_else(|e| panic!("Cannot read {}: {}", schema_path.display(), e));
    world.inner.schema_target = Some(PathBuf::from("Cargo.toml"));
}

#[given("the GitLab CI schema is loaded")]
fn given_gitlab_schema_loaded(world: &mut EditorWorld) {
    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
        .expect("CARGO_MANIFEST_DIR not set — run via `cargo test`");
    let schema_path =
        Path::new(&manifest_dir).join("../../extensions/gitlab/manifests/gitlab-ci.schema.json");
    world.inner.schema_json = std::fs::read_to_string(&schema_path)
        .unwrap_or_else(|e| panic!("Cannot read {}: {}", schema_path.display(), e));
    world.inner.schema_target = Some(PathBuf::from(".gitlab-ci.yml"));
}

#[given(expr = "a Cargo.toml buffer with content {string}")]
fn given_cargo_buffer(world: &mut EditorWorld, content: String) {
    world.inner.schema_lines = if content.is_empty() {
        vec![String::new()]
    } else {
        content.lines().map(String::from).collect()
    };
}

#[given(expr = "a Cargo.toml buffer starting with {string}")]
fn given_cargo_buffer_start(world: &mut EditorWorld, first_line: String) {
    world.inner.schema_lines = vec![first_line];
}

#[given(expr = "a GitLab buffer with content {string}")]
fn given_gitlab_buffer(world: &mut EditorWorld, content: String) {
    world.inner.schema_lines = if content.is_empty() {
        vec![String::new()]
    } else {
        content.lines().map(String::from).collect()
    };
}

#[given(expr = "a GitLab buffer starting with {string}")]
fn given_gitlab_buffer_start(world: &mut EditorWorld, first_line: String) {
    world.inner.schema_lines = vec![first_line];
}

#[given(expr = "a buffer line {string}")]
fn given_buffer_line(world: &mut EditorWorld, line: String) {
    world.inner.schema_lines.push(line);
}

#[given(expr = "the cursor is at line {int} column {int}")]
fn given_schema_cursor(world: &mut EditorWorld, line: usize, column: usize) {
    world.inner.schema_cursor = Position { line, column };
}

#[when("I request completions")]
fn when_schema_completions(world: &mut EditorWorld) {
    let path_opt: Option<&Path> = world.inner.schema_target.as_deref();
    world.inner.schema_completions = completions_from_schema(
        &world.inner.schema_json,
        &world.inner.schema_lines,
        world.inner.schema_cursor,
        path_opt,
    );
}

#[then(expr = "the completions include {string}")]
fn then_includes(world: &mut EditorWorld, label: String) {
    let found = world.inner.schema_completions.iter().any(|c| c.label == label);
    assert!(
        found,
        "expected completion {:?} but got: {:?}",
        label,
        world.inner.schema_completions
            .iter()
            .map(|c| &c.label)
            .collect::<Vec<_>>()
    );
}

// ---------------------------------------------------------------------------
// Private helper: run the previous schema_completion_integration.rs test inline
// ---------------------------------------------------------------------------

fn run_schema_integration_tests() {
    // Arrange: register a schema that covers *.json files
    let mut reg = oo_ide::schema::SchemaRegistry::new();
    reg.register_from_extension(
        "test-schema".into(),
        "Test Schema".into(),
        vec!["*.json".into()],
        r#"{"type":"object","properties":{"opt":{"type":"string","enum":["x","y"]}}}"#.into(),
    );

    // Resolve and ensure the registry finds it for a matching path
    let path = Path::new("config.json");
    let content = reg.resolve(path).expect("schema should be resolved");
    assert_eq!(content.id, "test-schema");

    // Simulate editor buffer with cursor after an incomplete value for `opt`
    let lines = vec![r#"{"opt": ""#.to_string()];
    let pos = oo_ide::editor::position::Position {
        line: 0,
        column: lines[0].len(),
    };

    // Act: run schema-backed completions (fallback) using the resolved schema
    let items =
        oo_ide::schema::completions_from_schema(&content.schema_json, &lines, pos, Some(path));

    // Assert: enum values from the schema are suggested
    let labels: Vec<String> = items.into_iter().map(|i| i.label).collect();
    assert!(
        labels.contains(&"x".to_string()),
        "expected 'x' in completions"
    );
    assert!(
        labels.contains(&"y".to_string()),
        "expected 'y' in completions"
    );
}

// ---------------------------------------------------------------------------
// Log matcher Cucumber steps (merged from tests/log_matcher_compilation.rs)
// ---------------------------------------------------------------------------

// Helper: build a minimal LogMatcherDef via YAML
fn minimal_lm_def(id: &str) -> LogMatcherDef {
    let yaml = format!(
        "id: {id:?}\nsource: test\nstart:\n  match: \"^test\"\nend:\n  condition: next_start\nemit:\n  severity: error\n  message: \"test message\"\n",
        id = id
    );
    serde_saphyr::from_str(&yaml).expect("minimal_def yaml should parse")
}

#[given(expr = "a valid log matcher with id {string}")]
fn given_valid_matcher(world: &mut EditorWorld, id: String) {
    world.inner.lm_defs.push(minimal_lm_def(&id));
}

#[given(expr = "a minimal log matcher with id {string}")]
fn given_minimal_matcher(world: &mut EditorWorld, id: String) {
    world.inner.lm_defs.push(minimal_lm_def(&id));
}

#[given(expr = "a log matcher with id {string} and start pattern {string}")]
fn given_matcher_with_start(world: &mut EditorWorld, id: String, pattern: String) {
    let yaml = format!(
        "id: {id:?}\nsource: test\nstart:\n  match: {pattern:?}\nend:\n  condition: next_start\nemit:\n  severity: error\n  message: \"msg\"\n",
        id = id,
        pattern = pattern
    );
    world.inner.lm_defs
        .push(serde_saphyr::from_str(&yaml).expect("yaml"));
}

#[given(expr = "a log matcher with id {string} and empty emit message")]
fn given_matcher_empty_message(world: &mut EditorWorld, id: String) {
    let yaml = format!(
        "id: {id:?}\nsource: test\nstart:\n  match: \"^test\"\nend:\n  condition: next_start\nemit:\n  severity: error\n  message: \"\"\n",
        id = id
    );
    world.inner.lm_defs
        .push(serde_saphyr::from_str(&yaml).expect("yaml"));
}

#[given(expr = "a log matcher with id {string} and schema_version {string}")]
fn given_matcher_schema_version(world: &mut EditorWorld, id: String, version: String) {
    let yaml = format!(
        "id: {id:?}\nsource: test\nschema_version: {version}\nstart:\n  match: \"^test\"\nend:\n  condition: next_start\nemit:\n  severity: error\n  message: \"msg\"\n",
        id = id,
        version = version
    );
    world.inner.lm_defs
        .push(serde_saphyr::from_str(&yaml).expect("yaml"));
}

#[given(expr = "a log matcher with id {string} and end condition {string}")]
fn given_matcher_end_condition(world: &mut EditorWorld, id: String, condition: String) {
    let yaml = format!(
        "id: {id:?}\nsource: test\nstart:\n  match: \"^test\"\nend:\n  condition: {condition:?}\nemit:\n  severity: error\n  message: \"msg\"\n",
        id = id,
        condition = condition
    );
    world.inner.lm_defs
        .push(serde_saphyr::from_str(&yaml).expect("yaml"));
}

#[given(
    expr = "a log matcher with id {string} and start pattern {string} and emit message {string}"
)]
fn given_matcher_with_start_and_emit(
    world: &mut EditorWorld,
    id: String,
    pattern: String,
    emit_msg: String,
) {
    let yaml = format!(
        "id: {id:?}\nsource: test\nstart:\n  match: {pattern:?}\nend:\n  condition: next_start\nemit:\n  severity: error\n  message: {emit_msg:?}\n",
        id = id,
        pattern = pattern,
        emit_msg = emit_msg
    );
    world.inner.lm_defs
        .push(serde_saphyr::from_str(&yaml).expect("yaml"));
}

#[given("warn_unused_captures is enabled")]
fn given_warn_unused_captures(world: &mut EditorWorld) {
    world.inner.lm_warn_unused_captures = true;
}

#[when("I compile the matchers")]
fn when_compile(world: &mut EditorWorld) {
    let defs = world.inner.lm_defs.drain(..).collect();
    let opts = CompileOptions {
        warn_unused_captures: world.inner.lm_warn_unused_captures,
        ..Default::default()
    };
    world.inner.lm_result = Some(match compile_matchers(defs, opts) {
        Ok(r) => Ok(r),
        Err(msgs) => Err(msgs),
    });
}

#[then("compilation succeeds")]
fn then_succeeds(world: &mut EditorWorld) {
    match &world.inner.lm_result {
        Some(Ok(_)) => {}
        Some(Err(msgs)) => panic!(
            "expected compilation to succeed but got errors:\n{}",
            msgs.iter()
                .map(|m| m.to_string())
                .collect::<Vec<_>>()
                .join("\n")
        ),
        None => panic!("no compilation result"),
    }
}

#[then("compilation fails")]
fn then_fails(world: &mut EditorWorld) {
    match &world.inner.lm_result {
        Some(Err(_)) => {}
        Some(Ok(_)) => panic!("expected compilation to fail but it succeeded"),
        None => panic!("no compilation result"),
    }
}

#[then(expr = "the result contains matcher with id {string}")]
fn then_contains_matcher(world: &mut EditorWorld, id: String) {
    let matchers = match &world.inner.lm_result {
        Some(Ok(r)) => &r.matchers,
        _ => panic!("compilation did not succeed"),
    };
    assert!(
        matchers.iter().any(|m| m.id.0 == id),
        "matcher '{}' not found; have: {:?}",
        id,
        matchers.iter().map(|m| &m.id.0).collect::<Vec<_>>()
    );
}

#[then(expr = "the result contains {int} matchers")]
fn then_result_count(world: &mut EditorWorld, count: usize) {
    let matchers = match &world.inner.lm_result {
        Some(Ok(r)) => &r.matchers,
        _ => panic!("compilation did not succeed"),
    };
    assert_eq!(
        matchers.len(),
        count,
        "expected {} matchers, got {}",
        count,
        matchers.len()
    );
}

#[then(expr = "the matcher {string} has priority {int}")]
fn then_matcher_priority(world: &mut EditorWorld, id: String, priority: u32) {
    let matchers = match &world.inner.lm_result {
        Some(Ok(r)) => &r.matchers,
        _ => panic!("compilation did not succeed"),
    };
    let m = matchers
        .iter()
        .find(|m| m.id.0 == id)
        .unwrap_or_else(|| panic!("matcher '{}' not found", id));
    assert_eq!(m.priority, priority);
}

#[then(expr = "an error references field {string}")]
fn then_error_references_field(world: &mut EditorWorld, field: String) {
    let msgs = match &world.inner.lm_result {
        Some(Err(msgs)) => msgs,
        _ => panic!("compilation did not fail"),
    };
    assert!(
        msgs.iter().any(|m| m
            .reference
            .as_ref()
            .is_some_and(|r| r.filename.contains(&field))),
        "expected an error referencing '{}'; messages:\n{}",
        field,
        msgs.iter()
            .map(|m| m.to_string())
            .collect::<Vec<_>>()
            .join("\n")
    );
}

#[then(expr = "the error text includes {string}")]
fn then_error_text_includes(world: &mut EditorWorld, needle: String) {
    let msgs = match &world.inner.lm_result {
        Some(Err(msgs)) => msgs,
        _ => panic!("compilation did not fail"),
    };
    assert!(
        msgs.iter()
            .any(|m| m.level == MessageLevel::Error && m.text.contains(&needle)),
        "expected error message containing '{}'; messages:\n{}",
        needle,
        msgs.iter()
            .map(|m| m.to_string())
            .collect::<Vec<_>>()
            .join("\n")
    );
}

#[then(expr = "the error has a related reference labeled {string}")]
fn then_error_has_related(world: &mut EditorWorld, label: String) {
    let msgs = match &world.inner.lm_result {
        Some(Err(msgs)) => msgs,
        _ => panic!("compilation did not fail"),
    };
    assert!(
        msgs.iter()
            .any(|m| m.related.iter().any(|r| r.label.contains(&label))),
        "expected an error with related reference labeled '{}'; messages:\n{}",
        label,
        msgs.iter()
            .map(|m| m.to_string())
            .collect::<Vec<_>>()
            .join("\n")
    );
}

#[then(expr = "messages contain a warning mentioning {string}")]
fn then_warning_mentioning(world: &mut EditorWorld, needle: String) {
    let msgs = match &world.inner.lm_result {
        Some(Ok(r)) => &r.messages,
        _ => panic!("compilation did not succeed"),
    };
    assert!(
        msgs.iter()
            .any(|m| m.level == MessageLevel::Warning && m.text.contains(&needle)),
        "expected a warning mentioning '{}'; messages:\n{}",
        needle,
        msgs.iter()
            .map(|m| m.to_string())
            .collect::<Vec<_>>()
            .join("\n")
    );
}

// ---------------------------------------------------------------------------
// Log matcher engine execution steps
// ---------------------------------------------------------------------------

fn make_engine_matcher(
    start_pat: &str,
    body_pat: Option<&str>,
    end_cond: &str,
    emit_msg: &str,
    priority: u32,
) -> CompiledMatcher {
    use oo_ide::log_matcher::{BodyRule, EmitSeverity, EmitTemplate, EndCondition, MatcherId};
    use regex::Regex;
    use std::sync::Arc;

    let end = if end_cond == "blank_line" {
        EndCondition::BlankLine
    } else {
        EndCondition::NextStart
    };

    let body = body_pat
        .map(|p| {
            vec![BodyRule {
                pattern: Arc::new(Regex::new(p).unwrap()),
                optional: false,
                repeat: false,
            }]
        })
        .unwrap_or_default();

    CompiledMatcher {
        id: MatcherId(format!("engine.test.{start_pat}")),
        source: "test".to_string(),
        priority,
        schema_version: 1,
        start: Arc::new(Regex::new(start_pat).unwrap()),
        body,
        max_lines: None,
        end,
        emit: EmitTemplate {
            severity: EmitSeverity::Error,
            message: emit_msg.to_string(),
            file: Some("{{ file }}".to_string()),
            line: Some("{{ line }}".to_string()),
            column: None,
            code: None,
        },
    }
}

#[given(expr = "an engine matcher with start pattern {string} and end condition {string}")]
fn given_engine_matcher_simple(world: &mut EditorWorld, start: String, end: String) {
    world.inner.lm_engine_matchers
        .push(make_engine_matcher(&start, None, &end, "{{ message }}", 0));
}

#[given(
    expr = "an engine matcher with start pattern {string} and body pattern {string} and end condition {string}"
)]
fn given_engine_matcher_with_body(
    world: &mut EditorWorld,
    start: String,
    body: String,
    end: String,
) {
    world.inner.lm_engine_matchers.push(make_engine_matcher(
        &start,
        Some(&body),
        &end,
        "{{ message }}",
        0,
    ));
}

#[given(
    expr = "an engine low-priority matcher with start pattern {string} and emit prefix {string}"
)]
fn given_engine_low_priority(world: &mut EditorWorld, start: String, prefix: String) {
    let msg = format!("{prefix}: {{{{ message }}}}");
    world.inner.lm_engine_matchers
        .push(make_engine_matcher(&start, None, "next_start", &msg, 1));
}

#[given(
    expr = "an engine high-priority matcher with start pattern {string} and emit prefix {string}"
)]
fn given_engine_high_priority(world: &mut EditorWorld, start: String, prefix: String) {
    let msg = format!("{prefix}: {{{{ message }}}}");
    world.inner.lm_engine_matchers
        .push(make_engine_matcher(&start, None, "next_start", &msg, 100));
}

#[when(expr = "I process the line {string}")]
fn when_process_line(world: &mut EditorWorld, line: String) {
    if world.inner.lm_engine.is_none() {
        let matchers = std::mem::take(&mut world.inner.lm_engine_matchers);
        world.inner.lm_engine = Some(MatcherEngine::new(matchers, "task:test:t"));
    }
    let new_issues = world.inner.lm_engine.as_mut().unwrap().process_line(&line);
    world.inner.lm_engine_issues.extend(new_issues);
}

#[when("I flush the engine")]
fn when_flush_engine(world: &mut EditorWorld) {
    if let Some(ref mut eng) = world.inner.lm_engine {
        let flushed = eng.flush();
        world.inner.lm_engine_issues.extend(flushed);
    }
}

#[then(expr = "the engine emitted {int} issue")]
fn then_emitted_one(world: &mut EditorWorld, count: usize) {
    assert_eq!(
        world.inner.lm_engine_issues.len(),
        count,
        "expected {count} issue(s), got {}; issues: {:?}",
        world.inner.lm_engine_issues.len(),
        world.inner.lm_engine_issues
            .iter()
            .map(|i| &i.message)
            .collect::<Vec<_>>()
    );
}

#[then(expr = "the engine emitted {int} issues total")]
fn then_emitted_total(world: &mut EditorWorld, count: usize) {
    assert_eq!(
        world.inner.lm_engine_issues.len(),
        count,
        "expected {count} total issue(s), got {}",
        world.inner.lm_engine_issues.len()
    );
}

#[then(expr = "the engine emitted 0 issues")]
fn then_emitted_zero(world: &mut EditorWorld) {
    assert!(
        world.inner.lm_engine_issues.is_empty(),
        "expected 0 issues, got {}",
        world.inner.lm_engine_issues.len()
    );
}

#[then(expr = "the issue message is {string}")]
fn then_issue_message(world: &mut EditorWorld, expected: String) {
    let issue = world.inner.lm_engine_issues.first().expect("no issues emitted");
    assert_eq!(issue.message, expected, "issue message mismatch");
}

#[then(expr = "the issue file is {string}")]
fn then_issue_file(world: &mut EditorWorld, expected: String) {
    let issue = world.inner.lm_engine_issues.first().expect("no issues emitted");
    assert_eq!(
        issue.path.as_deref(),
        Some(std::path::Path::new(&expected)),
        "issue file path mismatch"
    );
}

#[then(expr = "issue {int} has message {string}")]
fn then_issue_n_message(world: &mut EditorWorld, n: usize, expected: String) {
    let issue = world.inner.lm_engine_issues.get(n - 1).unwrap_or_else(|| {
        panic!(
            "no issue at index {} (have {})",
            n,
            world.inner.lm_engine_issues.len()
        )
    });
    assert_eq!(issue.message, expected, "issue {n} message mismatch");
}

#[then(expr = "the issue message starts with {string}")]
fn then_issue_starts_with(world: &mut EditorWorld, prefix: String) {
    let issue = world.inner.lm_engine_issues.first().expect("no issues emitted");
    assert!(
        issue.message.starts_with(&prefix),
        "expected message starting with {:?}, got {:?}",
        prefix,
        issue.message
    );
}

#[then(expr = "the issue line is {int}")]
fn then_issue_line(world: &mut EditorWorld, expected_line: usize) {
    let issue = world.inner.lm_engine_issues.first().expect("no issues emitted");
    let actual = issue
        .range
        .map(|(p, _)| p.line + 1)
        .unwrap_or_else(|| panic!("issue has no range"));
    assert_eq!(actual, expected_line, "issue line mismatch");
}

#[then(expr = "the issue column is {int}")]
fn then_issue_column(world: &mut EditorWorld, expected_col: usize) {
    let issue = world.inner.lm_engine_issues.first().expect("no issues emitted");
    let actual = issue
        .range
        .map(|(p, _)| p.column)
        .unwrap_or_else(|| panic!("issue has no range"));
    assert_eq!(actual, expected_col, "issue column mismatch");
}

#[then("the issue has no file")]
fn then_issue_no_file(world: &mut EditorWorld) {
    let issue = world.inner.lm_engine_issues.first().expect("no issues emitted");
    assert!(
        issue.path.is_none(),
        "expected no file, got {:?}",
        issue.path
    );
}

#[then(expr = "the issue severity is {string}")]
fn then_issue_severity(world: &mut EditorWorld, expected: String) {
    use oo_ide::issue_registry::Severity;
    let issue = world.inner.lm_engine_issues.first().expect("no issues emitted");
    let expected_sev = match expected.to_lowercase().as_str() {
        "error" => Severity::Error,
        "warning" => Severity::Warning,
        "info" | "hint" => Severity::Info,
        other => panic!("unknown severity: {other}"),
    };
    assert_eq!(issue.severity, expected_sev, "issue severity mismatch");
}

// ---------------------------------------------------------------------------
// Rust cargo log matcher step
// ---------------------------------------------------------------------------

const RUST_CARGO_MATCHERS_YAML: &[&str] = &[
    r#"
id: rust.cargo.error
source: cargo
priority: 100
start:
  match: '^error(\[(?P<code>E\d+)\])?: (?P<message>.+)'
body:
  - match: '^ *--> (?P<file>.+):(?P<line>\d+):(?P<col>\d+)'
    optional: true
  - match: '^\s+\|'
    repeat: true
    optional: true
end:
  condition: next_start
emit:
  severity: error
  message: "{{ message }}"
  file: "{{ file }}"
  line: "{{ line }}"
  column: "{{ col }}"
  code: "{{ code }}"
"#,
    r#"
id: rust.cargo.warning
source: cargo
priority: 90
start:
  match: '^warning(\[(?P<code>W\d+)\])?: (?P<message>.+)'
body:
  - match: '^ *--> (?P<file>.+):(?P<line>\d+):(?P<col>\d+)'
    optional: true
end:
  condition: next_start
emit:
  severity: warning
  message: "{{ message }}"
  file: "{{ file }}"
  line: "{{ line }}"
  column: "{{ col }}"
  code: "{{ code }}"
"#,
];

#[given("I use the Rust cargo matchers")]
fn given_rust_cargo_matchers(world: &mut EditorWorld) {
    let defs: Vec<LogMatcherDef> = RUST_CARGO_MATCHERS_YAML
        .iter()
        .map(|yaml| serde_saphyr::from_str(yaml).expect("rust cargo matcher yaml should parse"))
        .collect();
    let opts = CompileOptions::default();
    let result = compile_matchers(defs, opts);
    if let Ok(ref cr) = result {
        world.inner.lm_engine_matchers.extend(cr.matchers.clone());
    }
    world.inner.lm_result = Some(result);
}

// ---------------------------------------------------------------------------
// GCC / Clang log matcher steps
// ---------------------------------------------------------------------------

const GCC_MATCHERS_YAML: &[&str] = &[
    r#"
id: cpp.gcc.error
source: gcc
priority: 100
start:
  match: '^(?P<file>[^:\s][^:]*):(?P<line>\d+):(?P<col>\d+):\s*(?:fatal\s+)?error:\s*(?P<message>.+)$'
body:
  - match: '^\s+\d*\s*\|'
    repeat: true
    optional: true
end:
  condition: next_start
emit:
  severity: error
  message: "{{ message }}"
  file: "{{ file }}"
  line: "{{ line }}"
  column: "{{ col }}"
"#,
    r#"
id: cpp.gcc.warning
source: gcc
priority: 90
start:
  match: '^(?P<file>[^:\s][^:]*):(?P<line>\d+):(?P<col>\d+):\s*warning:\s*(?P<message>.+)$'
body:
  - match: '^\s+\d*\s*\|'
    repeat: true
    optional: true
end:
  condition: next_start
emit:
  severity: warning
  message: "{{ message }}"
  file: "{{ file }}"
  line: "{{ line }}"
  column: "{{ col }}"
"#,
];

#[given("I use the GCC matchers")]
fn given_gcc_matchers(world: &mut EditorWorld) {
    let defs: Vec<LogMatcherDef> = GCC_MATCHERS_YAML
        .iter()
        .map(|yaml| serde_saphyr::from_str(yaml).expect("gcc matcher yaml should parse"))
        .collect();
    let opts = CompileOptions::default();
    let result = compile_matchers(defs, opts);
    if let Ok(ref cr) = result {
        world.inner.lm_engine_matchers.extend(cr.matchers.clone());
    }
    world.inner.lm_result = Some(result);
}

// ---------------------------------------------------------------------------
// Python log matcher steps
// ---------------------------------------------------------------------------

// `python.traceback` — loaded from the extension YAML (no standalone exception
// matcher so the exception line is correctly captured by the body rule).
const PYTHON_TRACEBACK_MATCHERS_YAML: &[&str] = &[r#"
id: python.traceback
source: python
priority: 100
start:
  match: '^Traceback \(most recent call last\):'
body:
  - match: '^\s+File "(?P<file>.+)", line (?P<line>\d+)'
    optional: true
    repeat: true
  - match: '^\s+.+'
    optional: true
    repeat: true
  - match: '^(?P<message>[A-Za-z_][A-Za-z0-9_.]*:.+)$'
    optional: true
end:
  condition: next_start
emit:
  severity: error
  message: "{{ message }}"
  file: "{{ file }}"
  line: "{{ line }}"
"#];

// Inline standalone exception matcher (not in extension.yaml — would conflict
// with python.traceback in a combined registry).
const PYTHON_EXCEPTION_MATCHERS_YAML: &[&str] = &[r#"
id: python.exception
source: python
priority: 50
start:
  match: '^(?P<message>[A-Za-z_][A-Za-z0-9_.]*:.+)$'
end:
  condition: next_start
emit:
  severity: error
  message: "{{ message }}"
"#];

#[given("I use the Python traceback matcher")]
fn given_python_traceback_matchers(world: &mut EditorWorld) {
    let defs: Vec<LogMatcherDef> = PYTHON_TRACEBACK_MATCHERS_YAML
        .iter()
        .map(|yaml| {
            serde_saphyr::from_str(yaml).expect("python traceback matcher yaml should parse")
        })
        .collect();
    let opts = CompileOptions::default();
    let result = compile_matchers(defs, opts);
    if let Ok(ref cr) = result {
        world.inner.lm_engine_matchers.extend(cr.matchers.clone());
    }
    world.inner.lm_result = Some(result);
}

#[given("I use the Python exception matcher")]
fn given_python_exception_matcher(world: &mut EditorWorld) {
    let defs: Vec<LogMatcherDef> = PYTHON_EXCEPTION_MATCHERS_YAML
        .iter()
        .map(|yaml| {
            serde_saphyr::from_str(yaml).expect("python exception matcher yaml should parse")
        })
        .collect();
    let opts = CompileOptions::default();
    let result = compile_matchers(defs, opts);
    if let Ok(ref cr) = result {
        world.inner.lm_engine_matchers.extend(cr.matchers.clone());
    }
    world.inner.lm_result = Some(result);
}

// ---------------------------------------------------------------------------
// Todo / Issue View integration steps
// ---------------------------------------------------------------------------

#[when("the app processes the following cargo output:")]
fn when_app_processes_cargo_output(world: &mut EditorWorld, step: &Step) {
    let content = step.docstring.as_deref().expect("requires docstring");
    let matchers = world.inner.lm_engine_matchers.clone();
    let mut engine = MatcherEngine::new(matchers, "task:build:test".to_string());
    for line in content.lines() {
        for issue in engine.process_line(line) {
            world.inner.app.issue_registry.add_issue(issue);
        }
    }
    for issue in engine.flush() {
        world.inner.app.issue_registry.add_issue(issue);
    }
}

#[then(expr = "the app issue registry has {int} issue(s)")]
fn then_registry_has_n_issues(world: &mut EditorWorld, n: usize) {
    let count = world.inner.app.issue_registry.len();
    assert_eq!(count, n, "expected {n} issue(s) in registry, got {count}");
}

#[then(expr = "app issue {int} message starts with {string}")]
fn then_app_issue_message_starts_with(world: &mut EditorWorld, idx: usize, prefix: String) {
    let issues = world.inner.app.issue_registry.list_all();
    let issue = issues
        .get(idx - 1)
        .unwrap_or_else(|| panic!("no issue at index {idx}"));
    assert!(
        issue.message.starts_with(&prefix),
        "expected message starting with {:?}, got {:?}",
        prefix,
        issue.message
    );
}

#[then(expr = "app issue {int} has file containing {string}")]
fn then_app_issue_has_file_containing(world: &mut EditorWorld, idx: usize, fragment: String) {
    let issues = world.inner.app.issue_registry.list_all();
    let issue = issues
        .get(idx - 1)
        .unwrap_or_else(|| panic!("no issue at index {idx}"));
    let path_str = issue
        .path
        .as_ref()
        .unwrap_or_else(|| panic!("issue {idx} has no file, expected it to contain {fragment:?}"))
        .to_string_lossy()
        .to_string();
    assert!(
        path_str.contains(&fragment),
        "expected file containing {:?}, got {:?}",
        fragment,
        path_str
    );
}

#[then(expr = "app issue {int} has line {int}")]
fn then_app_issue_has_line(world: &mut EditorWorld, idx: usize, expected_line: usize) {
    let issues = world.inner.app.issue_registry.list_all();
    let issue = issues
        .get(idx - 1)
        .unwrap_or_else(|| panic!("no issue at index {idx}"));
    let actual = issue
        .range
        .map(|(p, _)| p.line + 1)
        .unwrap_or_else(|| panic!("issue {idx} has no range"));
    assert_eq!(actual, expected_line, "issue {idx} line mismatch");
}

#[then(expr = "app issue {int} has severity {string}")]
fn then_app_issue_has_severity(world: &mut EditorWorld, idx: usize, expected: String) {
    use oo_ide::issue_registry::Severity;
    let issues = world.inner.app.issue_registry.list_all();
    let issue = issues
        .get(idx - 1)
        .unwrap_or_else(|| panic!("no issue at index {idx}"));
    let expected_sev = match expected.to_lowercase().as_str() {
        "error" => Severity::Error,
        "warning" => Severity::Warning,
        "info" | "hint" => Severity::Info,
        other => panic!("unknown severity: {other}"),
    };
    assert_eq!(
        issue.severity, expected_sev,
        "issue {idx} severity mismatch"
    );
}

// ---------------------------------------------------------------------------
// LSP diagnostics step definitions
// ---------------------------------------------------------------------------

#[given("a fresh editor session")]
fn given_fresh_editor_session(_world: &mut EditorWorld) {
    // The EditorWorld is already fresh from init; this step is a no-op.
}

/// Simulate what `lsp::handle_publish_diagnostics` would emit for a single
/// error diagnostic at line 5 of the given file path.
fn simulate_lsp_diagnostics(world: &mut EditorWorld, filename: &str) {
    use oo_ide::editor::position::Position;
    use oo_ide::issue_registry::{NewIssue, Severity};

    let uri = format!("file:///project/{filename}");
    let marker = format!("lsp:{uri}");
    let path = std::path::PathBuf::from(filename);

    // Replicate the clear-then-add pipeline of handle_publish_diagnostics.
    world.inner.app.issue_registry.clear_by_marker(&marker);
    world.inner.app.issue_registry.add_issue(NewIssue {
        marker: Some(marker),
        source: "lsp".into(),
        path: Some(path),
        range: Some((Position::new(5, 4), Position::new(5, 10))),
        message: "cannot find value `foo`".to_string(),
        severity: Severity::Error,
    });
}

#[given(expr = "the LSP server published a prior error for {string}")]
fn given_lsp_published_prior_error(world: &mut EditorWorld, filename: String) {
    simulate_lsp_diagnostics(world, &filename);
}

#[when(expr = "the LSP server publishes diagnostics for {string}")]
fn when_lsp_publishes_diagnostics(world: &mut EditorWorld, filename: String) {
    simulate_lsp_diagnostics(world, &filename);
}

#[then("the issue registry contains an error issue at line 5")]
fn then_registry_has_error_at_line_5(world: &mut EditorWorld) {
    use oo_ide::issue_registry::Severity;
    let issues = world.inner.app.issue_registry.list_all();
    assert!(
        !issues.is_empty(),
        "expected at least one issue in the registry"
    );
    let found = issues.iter().any(|i| {
        i.severity == Severity::Error
            && i.range.is_some_and(|(p, _)| p.line == 5)
    });
    assert!(found, "expected an Error issue at line 5, got: {issues:#?}");
}

#[then("the issue marker starts with \"lsp:\"")]
fn then_issue_marker_starts_with_lsp(world: &mut EditorWorld) {
    let issues = world.inner.app.issue_registry.list_all();
    assert!(
        !issues.is_empty(),
        "expected at least one issue"
    );
    let all_lsp = issues.iter().all(|i| {
        i.marker.as_deref().is_some_and(|m| m.starts_with("lsp:"))
    });
    assert!(all_lsp, "expected all issues to have marker starting with 'lsp:'");
}

// ---------------------------------------------------------------------------
// LSP go-to-definition step defs
// ---------------------------------------------------------------------------

#[given(expr = "the editor has a file with path {string} and contents:")]
fn given_editor_has_file(world: &mut EditorWorld, path: String, step: &cucumber::gherkin::Step) {
    // Write the provided contents into the temporary project directory so the
    // IDE can open it via Project::take_buffer when simulating a go-to-definition.
    let doc = step
        .docstring()
        .expect("expected a triple-quoted docstring with file contents");
    let dir = world.inner._dir.path().to_path_buf();
    let file_path = dir.join(&path);
    if let Some(parent) = file_path.parent() {
        std::fs::create_dir_all(parent).expect("create parent dirs");
    }
    std::fs::write(&file_path, doc).expect("write test file");
}

#[when(expr = "the test triggers a go-to-definition for the symbol at {string} line {int} column {int}")]
fn when_trigger_goto(world: &mut EditorWorld, filename: String, _line: usize, _column: usize) {
    // Simulate the LSP "go to definition" result by directly opening the
    // target file in the editor and moving the cursor to the definition.
    // For this simple test the definition is at the start of the file.
    let dir = world.inner._dir.path().to_path_buf();
    let target = dir.join(&filename);
    let opened = world
        .inner
        .app
        .open_file_preserving_stash(target.clone());
    assert!(opened, "failed to open file {:?}", target);
    match &mut world.inner.app.screen {
        Screen::Editor(ed) => {
            ed.buffer.set_cursor(Position::new(0, 0));
            // Ensure any derived contexts are refreshed.
            world.inner.app.recompute_contexts();
        }
        other => panic!("expected editor screen after opening file, got: {:?}", other),
    }
}

#[then(expr = "the app opens file {string} at line {int}")]
fn then_app_opens_file_at_line(world: &mut EditorWorld, filename: String, line: usize) {
    let expected = world.inner._dir.path().join(&filename);
    match &world.inner.app.screen {
        Screen::Editor(ed) => {
            let path = ed
                .buffer
                .path
                .as_ref()
                .expect("editor buffer has no associated path");
            assert_eq!(path, &expected, "opened file path mismatch");
            assert_eq!(ed.buffer.cursor().line + 1, line, "cursor line mismatch");
        }
        other => panic!("expected editor screen, got {:?}", other),
    }
}

#[then(expr = "the app cursor is at line {int}")]
fn then_app_cursor_at_line(world: &mut EditorWorld, line: usize) {
    match &world.inner.app.screen {
        Screen::Editor(ed) => {
            assert_eq!(ed.buffer.cursor().line + 1, line, "cursor line mismatch");
        }
        other => panic!("expected editor screen, got {:?}", other),
    }
}

// ---------------------------------------------------------------------------
// LSP completion step definitions
// ---------------------------------------------------------------------------

#[then("the completion dropdown is visible")]
fn then_completion_visible(world: &mut EditorWorld) {
    match &world.inner.app.screen {
        Screen::Editor(ed) => {
            assert!(ed.completion.is_some(), "expected completion dropdown to be visible");
        }
        other => panic!("expected editor screen, got {:?}", other),
    }
}

#[then("the completion dropdown is not visible")]
fn then_completion_not_visible(world: &mut EditorWorld) {
    match &world.inner.app.screen {
        Screen::Editor(ed) => {
            assert!(ed.completion.is_none(), "expected completion dropdown to not be visible");
        }
        other => panic!("expected editor screen, got {:?}", other),
    }
}

#[then("the command executes without error")]
fn then_command_executes_without_error(_world: &mut EditorWorld) {
    // No-op: if we reach here, the command didn't panic
    // This is useful for testing that commands run without errors in test environments
    // where actual LSP might not be available
}

#[when("the test simulates completion response with items:")]
fn when_simulate_completion_response(world: &mut EditorWorld, step: &cucumber::gherkin::Step) {
    let table = step.table().expect("expected a table");
    let mut items = Vec::new();
    for row in table.rows.iter().skip(1) {
        let label = row.first().cloned().unwrap_or_default();
        let detail = row.get(1).cloned();
        let insert_text = row.get(2).cloned();
        items.push(oo_ide::operation::LspCompletionItem {
            label,
            kind: None,
            detail,
            insert_text,
        });
    }

    let trigger = {
        let ed = ed_mut(world);
        ed.buffer.cursor()
    };

    // Execute operation using the same pattern as run_command
    let op = Operation::LspLocal(oo_ide::operation::LspOp::CompletionResponse {
        items,
        trigger: Some(trigger),
        version: None,
    });

    let settings = &world.inner.app.settings;
    if let Screen::Editor(ed) = &mut world.inner.app.screen {
        ed.handle_operation(&op, settings);
    }
    world.inner.app.recompute_contexts();
}

#[then(expr = "the completion has {int} items")]
fn then_completion_count(world: &mut EditorWorld, count: usize) {
    match &world.inner.app.screen {
        Screen::Editor(ed) => {
            let c = ed.completion.as_ref().expect("completion should be active");
            assert_eq!(c.items.len(), count, "completion item count mismatch");
        }
        other => panic!("expected editor screen, got {:?}", other),
    }
}

#[then(expr = "completion item {int} label is {string}")]
fn then_completion_item_label(world: &mut EditorWorld, idx: usize, label: String) {
    match &world.inner.app.screen {
        Screen::Editor(ed) => {
            let c = ed.completion.as_ref().expect("completion should be active");
            assert!(idx < c.items.len(), "index out of bounds");
            assert_eq!(c.items[idx].label, label, "completion item label mismatch");
        }
        other => panic!("expected editor screen, got {:?}", other),
    }
}

#[given(expr = "completion is active with items:")]
fn given_completion_active(world: &mut EditorWorld, step: &cucumber::gherkin::Step) {
    let table = step.table().expect("expected a table");
    let mut items = Vec::new();
    for row in table.rows.iter().skip(1) {
        let label = row.first().cloned().unwrap_or_default();
        let insert_text = row.get(1).cloned();
        items.push(oo_ide::operation::LspCompletionItem {
            label,
            kind: None,
            detail: None,
            insert_text,
        });
    }

    let trigger = {
        let ed = ed_mut(world);
        ed.buffer.cursor()
    };

    let op = Operation::LspLocal(oo_ide::operation::LspOp::CompletionResponse {
        items,
        trigger: Some(trigger),
        version: None,
    });

    let settings = &world.inner.app.settings;
    if let Screen::Editor(ed) = &mut world.inner.app.screen {
        ed.handle_operation(&op, settings);
    }
    world.inner.app.recompute_contexts();
}

#[when("I press the down arrow")]
fn when_press_down_arrow(world: &mut EditorWorld) {
    let op = Operation::LspLocal(oo_ide::operation::LspOp::CompletionMoveDown);
    if let Screen::Editor(ed) = &mut world.inner.app.screen {
        ed.handle_operation(&op, &world.inner.app.settings);
    }
    world.inner.app.recompute_contexts();
}

#[when("I press the up arrow")]
fn when_press_up_arrow(world: &mut EditorWorld) {
    let op = Operation::LspLocal(oo_ide::operation::LspOp::CompletionMoveUp);
    if let Screen::Editor(ed) = &mut world.inner.app.screen {
        ed.handle_operation(&op, &world.inner.app.settings);
    }
    world.inner.app.recompute_contexts();
}

#[then(expr = "the completion selected index is {int}")]
fn then_completion_index(world: &mut EditorWorld, idx: usize) {
    match &world.inner.app.screen {
        Screen::Editor(ed) => {
            let c = ed.completion.as_ref().expect("completion should be active");
            assert_eq!(c.cursor, idx, "completion selected index mismatch");
        }
        other => panic!("expected editor screen, got {:?}", other),
    }
}

#[when("I press Enter")]
fn when_press_enter_in_completion(world: &mut EditorWorld) {
    let op = Operation::LspLocal(oo_ide::operation::LspOp::CompletionConfirm);
    if let Screen::Editor(ed) = &mut world.inner.app.screen {
        ed.handle_operation(&op, &world.inner.app.settings);
        // Handle deferred ops (ReplaceRange) that completion confirm generates
        let settings = &world.inner.app.settings;
        for deferred_op in ed.take_deferred_ops() {
            ed.handle_operation(&deferred_op, settings);
        }
    }
    world.inner.app.recompute_contexts();
}

#[when("I press Escape")]
fn when_press_escape_in_completion(world: &mut EditorWorld) {
    let op = Operation::LspLocal(oo_ide::operation::LspOp::CompletionDismiss);
    if let Screen::Editor(ed) = &mut world.inner.app.screen {
        ed.handle_operation(&op, &world.inner.app.settings);
    }
    world.inner.app.recompute_contexts();
}


#[given(expr = "the terminal contains:")]
fn given_terminal_contains(world: &mut EditorWorld, step: &cucumber::gherkin::Step) {
    let raw = step.docstring().expect("expected docstring");
    let content = raw.trim_matches('\n');
    let lines: Vec<oo_ide::vt_parser::StyledLine> = content
        .lines()
        .map(|l| oo_ide::vt_parser::StyledLine { text: l.to_string(), spans: Vec::new() })
        .collect();
    let mut tv = TerminalView::new();
    let dir = world.inner._dir.path().to_path_buf();
    let pty = native_pty_system();
    let pair = pty.openpty(PtySize { rows: 10, cols: 80, pixel_width: 0, pixel_height: 0 }).expect("openpty failed");
    let writer = pair.master.take_writer().unwrap();
    let parser = RefCell::new(Parser::new(10, 80, 100));
    let tab = TerminalTab {
        id: 1,
        title: "t".into(),
        command: "cmd".into(),
        cwd: dir,
        master: pair.master,
        writer,
        parser,
        links: Vec::new(),
        scroll_offset: 0,
        scrollback_len: lines.len(),
        exited: false,
        scrollback_lines: lines,
    };
    tv.tabs.push(tab);
    tv.active = 0;
    world.inner.app.screen = Screen::Terminal(Box::new(tv));
    world.inner.app.recompute_contexts();
}

#[when(expr = "I open the search bar")]
fn when_open_search_bar(world: &mut EditorWorld) {
    if let Screen::Terminal(tv) = &mut world.inner.app.screen {
        let op = Operation::SearchLocal(SearchOp::Open { replace: false });
        tv.handle_operation(&op, &world.inner.app.settings);
    } else {
        panic!("active screen is not terminal");
    }
    world.inner.app.recompute_contexts();
}

#[when(expr = "I type {string}")]
fn when_type(world: &mut EditorWorld, text: String) {
    if let Screen::Terminal(tv) = &mut world.inner.app.screen {
        let op = Operation::SearchLocal(SearchOp::QueryInput(oo_ide::widgets::input_field::InputFieldOp::SetText(text)));
        tv.handle_operation(&op, &world.inner.app.settings);
    } else { panic!("active screen is not terminal"); }
    world.inner.app.recompute_contexts();
}

#[when(expr = "I press F3")]
fn when_press_f3(world: &mut EditorWorld) {
    if let Screen::Terminal(tv) = &mut world.inner.app.screen {
        let op = Operation::SearchLocal(SearchOp::NextMatch);
        tv.handle_operation(&op, &world.inner.app.settings);
    } else { panic!("active screen is not terminal"); }
    world.inner.app.recompute_contexts();
}

#[then(expr = "the search matches count should be {int}")]
fn then_matches_count(world: &mut EditorWorld, count: usize) {
    if let Screen::Terminal(tv) = &mut world.inner.app.screen {
        let s = tv.search.as_ref().or(tv.last_search.as_ref()).expect("no search state");
        assert_eq!(s.matches.len(), count);
    } else { panic!("active screen is not terminal"); }
}

#[then(expr = "the current match index should be {int}")]
fn then_current_index(world: &mut EditorWorld, idx: usize) {
    if let Screen::Terminal(tv) = &mut world.inner.app.screen {
        let s = tv.search.as_ref().or(tv.last_search.as_ref()).expect("no search state");
        let cur = s.current.map(|i| i + 1).unwrap_or(0);
        assert_eq!(cur, idx);
    } else { panic!("active screen is not terminal"); }
}

// ---------------------------------------------------------------------------
// Project search steps
// ---------------------------------------------------------------------------

/// Put the editor into Expanded search mode with an empty query so that
/// subsequent AddProjectResult / ClearProjectResults ops can be fed in.
#[given(expr = "the editor is in Expanded search mode")]
fn given_editor_expanded_mode(world: &mut EditorWorld) {
    let ed = ed_mut(world);
    let mut query_field = InputField::new("Find");
    query_field.set_text(String::new());
    ed.search = Some(SearchState {
        query: query_field,
        replacement: InputField::new("Replace"),
        kind: SearchKind::Find,
        mode: SearchMode::Expanded,
        focus: FocusRing::new(vec!["search_query"]),
        opts: SearchOptions::default(),
        matches: Vec::new(),
        current: 0,
        files: Vec::new(),
        file_path_index: HashMap::new(),
        selected_file: 0,
        file_panel_scroll: 0,
        match_panel_scroll: 0,
        include_filter: InputField::new("incl").with_text("*"),
        exclude_filter: InputField::new("excl"),
        project_search_generation: 0,
        expanded_files: HashSet::new(),
        tree_cursor_path: None,
        tree_cursor_match: None,
        tree_scroll: 0,
        project_match_cursor: None,
    });
}

/// Put the editor into Expanded search mode with a specific query and
/// generation so that project search results can be fed in immediately.
#[given(expr = "the editor is in Expanded search mode with query {string} and generation {int}")]
fn given_editor_expanded_with_query(world: &mut EditorWorld, query: String, generation: u64) {
    let ed = ed_mut(world);
    let mut query_field = InputField::new("Find");
    query_field.set_text(query);
    ed.search = Some(SearchState {
        query: query_field,
        replacement: InputField::new("Replace"),
        kind: SearchKind::Find,
        mode: SearchMode::Expanded,
        focus: FocusRing::new(vec!["search_query"]),
        opts: SearchOptions::default(),
        matches: Vec::new(),
        current: 0,
        files: Vec::new(),
        file_path_index: HashMap::new(),
        selected_file: 0,
        file_panel_scroll: 0,
        match_panel_scroll: 0,
        include_filter: InputField::new("incl").with_text("*"),
        exclude_filter: InputField::new("excl"),
        project_search_generation: generation,
        expanded_files: HashSet::new(),
        tree_cursor_path: None,
        tree_cursor_match: None,
        tree_scroll: 0,
        project_match_cursor: None,
    });
    // Also set AppState generation so the clear/restart logic is coherent.
    world.inner.app.project_search_generation = generation;
}

/// Write a file into the test project root (NOT the .oo subdir) for use by
/// project-search end-to-end scenarios.
#[given(expr = "a project file {string} contains {string}")]
fn given_project_file_contains(world: &mut EditorWorld, filename: String, content: String) {
    let path = world.inner._dir.path().join(&filename);
    // Interpret literal "\n" sequences in the feature step as real newlines.
    let content = content.replace("\\n", "\n");
    std::fs::write(&path, &content).expect("write project file");
}

/// Feed a single AddProjectResult operation for `filename` at the given line.
#[when(expr = "a search result arrives for {string} at line {int} with generation {int}")]
fn when_search_result_arrives(world: &mut EditorWorld, filename: String, line: usize, generation: u64) {
    let file = std::path::PathBuf::from(&filename);
    let op = Operation::SearchLocal(SearchOp::AddProjectResult {
        file,
        result: oo_ide::operation::MatchSpan {
            line,
            byte_start: 0,
            byte_end: 1,
            line_text: String::from("dummy"),
        },
        generation,
    });
    let settings = &world.inner.app.settings;
    if let Screen::Editor(ed) = &mut world.inner.app.screen {
        ed.handle_operation(&op, settings);
    }
}

/// Feed a ClearProjectResults operation with the given generation.
#[when(expr = "the search results are cleared for generation {int}")]
fn when_search_results_cleared(world: &mut EditorWorld, generation: u64) {
    let op = Operation::SearchLocal(SearchOp::ClearProjectResults { generation });
    let settings = &world.inner.app.settings;
    if let Screen::Editor(ed) = &mut world.inner.app.screen {
        ed.handle_operation(&op, settings);
    }
    // Keep AppState in sync so subsequent run-synchronously steps pick up
    // the right generation.
    world.inner.app.project_search_generation = generation;
}

/// Update the search query text in the existing Expanded search state.
#[when(expr = "the editor search query is updated to {string}")]
fn when_editor_query_updated(world: &mut EditorWorld, query: String) {
    if let Screen::Editor(ed) = &mut world.inner.app.screen
        && let Some(s) = &mut ed.search {
            s.query.set_text(query);
        }
}

/// Run `run_project_search` synchronously against the project root using the
/// current search state (query + opts) and generation 1.
#[when(expr = "the project search runs synchronously")]
fn when_project_search_runs(world: &mut EditorWorld) {
    let generation = 1u64;
    let (query, opts, root) = {
        let ed = ed(world);
        let s = ed.search.as_ref().expect("search bar not open");
        (s.query.text().to_owned(), s.opts.clone(), world.inner._dir.path().to_path_buf())
    };
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Vec<Operation>>();
    let gen_shared = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(generation));
    oo_ide::views::project_search::run_project_search(
        &root,
        &query,
        &opts,
        generation,
        tokio_util::sync::CancellationToken::new(),
        &tx,
        &gen_shared,
    );
    // Drain all results and feed them through handle_operation.
    let mut all_ops: Vec<Operation> = Vec::new();
    while let Ok(batch) = rx.try_recv() {
        all_ops.extend(batch);
    }
    let settings = &world.inner.app.settings;
    if let Screen::Editor(ed) = &mut world.inner.app.screen {
        for op in all_ops {
            ed.handle_operation(&op, settings);
        }
    }
}

/// Same as above but uses a specific generation (for the "after query change" scenario).
#[when(expr = "the project search runs synchronously for generation {int}")]
fn when_project_search_runs_for_gen(world: &mut EditorWorld, generation: u64) {
    let (query, opts, root) = {
        let ed = ed(world);
        let s = ed.search.as_ref().expect("search bar not open");
        (s.query.text().to_owned(), s.opts.clone(), world.inner._dir.path().to_path_buf())
    };
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Vec<Operation>>();
    let gen_shared = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(generation));
    oo_ide::views::project_search::run_project_search(
        &root,
        &query,
        &opts,
        generation,
        tokio_util::sync::CancellationToken::new(),
        &tx,
        &gen_shared,
    );
    let mut all_ops: Vec<Operation> = Vec::new();
    while let Ok(batch) = rx.try_recv() {
        all_ops.extend(batch);
    }
    let settings = &world.inner.app.settings;
    if let Screen::Editor(ed) = &mut world.inner.app.screen {
        for op in all_ops {
            ed.handle_operation(&op, settings);
        }
    }
}

#[then(expr = "the project search shows {int} matching file(s)")]
fn then_project_search_file_count(world: &mut EditorWorld, count: usize) {
    let search = ed(world).search.as_ref().expect("search bar not open");
    assert_eq!(
        search.files.len(),
        count,
        "expected {} matching file(s), got {} (files: {:?})",
        count,
        search.files.len(),
        search.files.iter().map(|f| &f.path).collect::<Vec<_>>()
    );
}

#[then(expr = "the project search result for {string} has {int} match(es)")]
fn then_project_search_match_count(world: &mut EditorWorld, filename: String, count: usize) {
    let search = ed(world).search.as_ref().expect("search bar not open");
    let file_match = search
        .files
        .iter()
        .find(|fm| fm.path.ends_with(&filename))
        .unwrap_or_else(|| panic!("no results for file {:?}", filename));
    assert_eq!(
        file_match.matches.len(),
        count,
        "expected {} match(es) for {:?}, got {}",
        count,
        filename,
        file_match.matches.len()
    );
}

// ---------------------------------------------------------------------------
// Runner
// ---------------------------------------------------------------------------

#[tokio::main]
async fn main() {
    // Run the standalone schema integration test first so failures are visible
    run_schema_integration_tests();

    let file = std::fs::File::create("oo-features-junit.xml").unwrap();

    EditorWorld::new();

    let cucumber = EditorWorld::cucumber().with_writer(
        writer::Basic::stdout()
            .summarized()
            .tee::<EditorWorld, _>(writer::JUnit::for_tee(file, 0))
            .normalized(),
    );
    cucumber.run_and_exit("tests/features").await;
}