//! Drive [`hjkl_engine`] with an [`OracleCase`] and return the outcome.
//!
//! Two drivers are provided:
//!
//! - [`run_case`] — in-process driver (fast, no subprocess). Used by default.
//! - [`run_case_via_nvim_api`] — subprocess driver that spawns `hjkl --nvim-api`
//!   and drives it over msgpack-rpc using the same protocol as nvim-rs.
//!   Enabled by setting `HJKL_ORACLE_NVIM_API=1`.

use crate::{OracleCase, test_host::TestHost};
use hjkl_engine::{Options, VimMode, decode_macro};
use hjkl_vim::VimEditorExt;

/// State snapshot produced after replaying a case's keystrokes through the
/// hjkl engine.
pub struct HjklOutcome {
    /// Full buffer content, lines joined with `\n`. Trailing `\n` is present
    /// when the last logical line of the buffer is empty (mirrors vim's
    /// convention).
    pub buffer: String,
    /// `(row, col)` cursor, 0-based. `col` is a **char index** within the line
    /// (the engine uses char-indexed positions internally; byte-col conversion
    /// is left to callers that need it for comparison against nvim's byte-col).
    pub cursor: (usize, usize),
    /// Lowercase mode name: `"normal"`, `"insert"`, `"visual"`,
    /// `"visual_line"`, or `"visual_block"`.
    pub mode: String,
    /// Contents of the default `"` register after the keystroke sequence.
    pub default_register: String,
    /// Contents of the register the case named in
    /// [`OracleCase::expected_register`], or `None` when it named none. The
    /// unnamed register is already in [`Self::default_register`]; this is
    /// what lets a case pin `"a` and have it diffed.
    pub pinned_register: Option<String>,
}

/// Read the register `case` pins via `expected_register`, if any. An
/// unrecognised selector reads as an empty register, matching vim's
/// `getreg()`.
fn pinned_register_of<H: hjkl_engine::types::Host>(
    case: &OracleCase,
    editor: &hjkl_engine::Editor<hjkl_buffer::View, H>,
) -> Option<String> {
    let (reg, _) = case.expected_register.as_ref()?;
    Some(editor.with_registers(|regs| {
        regs.read(*reg)
            .map_or_else(String::new, |slot| slot.text.clone())
    }))
}

/// Run `case` through the hjkl engine and return the resulting state.
///
/// # Errors
///
/// Propagates any error from buffer construction. Engine `step` is
/// infallible; failures (unknown key, mode mismatch) surface as state
/// divergence rather than errors.
pub fn run_case(case: &OracleCase) -> anyhow::Result<HjklOutcome> {
    // 1. Build buffer from the initial content.
    let buffer = hjkl_buffer::View::from_str(&case.initial_buffer);

    // 2. Construct editor.
    let mut editor = hjkl_vim::vim_editor(buffer, TestHost::new(), Options::default());

    // 2a. Publish the viewport height explicitly, mirroring what a real TUI
    // render loop does every frame via `set_viewport_height`. Matches
    // `nvim --headless --embed`'s default window size (24 rows, no UI
    // attached) so `H`/`M`/`L` and scrolloff math agree between drivers.
    // (`DefaultHost::DEFAULT_VIEWPORT` already starts at 24; this call is
    // belt-and-suspenders so the oracle doesn't silently drift if that
    // default ever changes.)
    editor.set_viewport_height(24);

    // 2b. Apply per-case indent settings so `>>` / `<<` match nvim's output.
    if let Some(sw) = case.shiftwidth {
        editor.settings_mut().shiftwidth = sw;
    }
    if let Some(et) = case.expandtab {
        editor.settings_mut().expandtab = et;
    }
    if let Some(tw) = case.textwidth {
        editor.settings_mut().textwidth = tw;
    }
    if let Some(ai) = case.autoindent {
        editor.settings_mut().autoindent = ai;
    }
    if let Some(ref fdm) = case.foldmethod {
        use hjkl_engine::types::FoldMethod;
        editor.settings_mut().foldmethod = match fdm.as_str() {
            "manual" => FoldMethod::Manual,
            "marker" => FoldMethod::Marker,
            _ => FoldMethod::Expr,
        };
    }

    // 3. Set initial cursor.
    let (init_row, init_col) = case.initial_cursor;
    editor.jump_cursor(init_row, init_col);

    // 4. Parse and replay keystrokes.
    let inputs = decode_macro(&case.keys);
    for input in inputs {
        hjkl_vim::dispatch_input(&mut editor, input);
    }

    // 5. Read back state.
    let rope = editor.buffer().rope();
    let lines: Vec<String> = (0..rope.len_lines())
        .map(|i| hjkl_buffer::rope_line_str(&rope, i))
        .collect();
    // Reconstruct the buffer string: join with '\n'. If the last line is empty
    // (buffer originally ended with '\n'), the join produces a trailing '\n'.
    let buffer_str = lines.join("\n");

    let cursor = editor.cursor();

    let mode = match editor.vim_mode() {
        VimMode::Normal => "normal",
        VimMode::Insert => "insert",
        VimMode::Visual => "visual",
        VimMode::VisualLine => "visual_line",
        VimMode::VisualBlock => "visual_block",
    }
    .to_string();

    let default_register = editor.yank();
    let pinned_register = pinned_register_of(case, &editor);

    Ok(HjklOutcome {
        buffer: buffer_str,
        cursor,
        mode,
        default_register,
        pinned_register,
    })
}

// ── nvim-api subprocess driver ─────────────────────────────────────────────────

use async_trait::async_trait;
use nvim_rs::{Handler, Neovim, Value, compat::tokio::Compat, create::tokio as create};
use tokio::process::ChildStdin;

/// Noop handler — we only send requests to hjkl.
#[derive(Clone)]
struct NoopHandler;

#[async_trait]
impl Handler for NoopHandler {
    type Writer = Compat<ChildStdin>;
}

/// Run `case` by spawning `hjkl --nvim-api` and driving it over msgpack-rpc.
///
/// This driver validates that the nvim-api surface produces the same results
/// as the in-process driver. Enable via `HJKL_ORACLE_NVIM_API=1`.
///
/// The binary is located via the `HJKL_BIN` environment variable (preferred)
/// or falls back to `target/debug/hjkl` relative to the workspace root
/// (good enough for local `cargo test`; CI should set `HJKL_BIN`).
pub async fn run_case_via_nvim_api(case: &OracleCase) -> anyhow::Result<HjklOutcome> {
    use tokio::process::Command;

    let bin = std::env::var("HJKL_BIN").unwrap_or_else(|_| {
        // Derive from CARGO_MANIFEST_DIR: oracle is two levels below workspace root.
        let manifest = env!("CARGO_MANIFEST_DIR");
        let exe_name = format!("hjkl{}", std::env::consts::EXE_SUFFIX);
        let workspace_root = std::path::Path::new(manifest)
            .parent() // crates/
            .and_then(|p| p.parent())
            .map_or_else(
                || std::path::PathBuf::from(&exe_name),
                |p| p.join("target/debug").join(&exe_name),
            );
        workspace_root.to_string_lossy().into_owned()
    });

    let mut cmd = Command::new(&bin);
    cmd.arg("--nvim-api");
    // Reap the child even if this future is cancelled mid-case.
    cmd.kill_on_drop(true);
    let (nvim, _io_handle, mut child) = create::new_child_cmd(&mut cmd, NoopHandler).await?;

    let result = run_case_via_nvim_api_inner(&nvim, case).await;

    // Shut down hjkl gracefully. If it ignores `qa!` (e.g. a desynced RPC
    // stream), don't hang the test run on `wait()` — kill.
    let _ = nvim.command("qa!").await;
    if tokio::time::timeout(std::time::Duration::from_secs(5), child.wait())
        .await
        .is_err()
    {
        let _ = child.kill().await;
    }

    result
}

async fn run_case_via_nvim_api_inner(
    nvim: &Neovim<Compat<ChildStdin>>,
    case: &OracleCase,
) -> anyhow::Result<HjklOutcome> {
    // 1. Set buffer content.
    let has_trailing_newline = case.initial_buffer.ends_with('\n');
    let mut lines: Vec<String> = case.initial_buffer.split('\n').map(str::to_owned).collect();
    if has_trailing_newline && lines.last().is_some_and(String::is_empty) {
        lines.pop();
    }

    let cur_buf = nvim.get_current_buf().await?;
    cur_buf.set_lines(0, -1, false, lines).await?;

    // 2. Set initial cursor (nvim: 1-based row, 0-based byte-col).
    let (init_row, init_col) = case.initial_cursor;
    let cur_win = nvim.get_current_win().await?;
    cur_win
        .set_cursor((init_row as i64 + 1, init_col as i64))
        .await?;

    // 3. Apply keystrokes.
    //
    // Cases written as `:...<CR>` get routed through `nvim_command` so they
    // reach `ex::run` directly. hjkl's `nvim_input` runs through the engine
    // FSM, which (like the in-process oracle driver) does not dispatch `:`
    // from key replay — that's a separate cmdline-mode gap. The corpus that
    // uses this driver (`nvim_api_tier.toml`) is curated for ex-command
    // exercises; non-`:` key sequences fall through to `nvim_input` for any
    // future normal-mode cases.
    //
    // Multiple chained ex commands — e.g. `:set foo<CR>:retab<CR>` — are
    // split on `<CR>` and each segment dispatched as a separate nvim_command
    // call so that the individual ex commands reach `ex::run` independently.
    //
    // Mixed sequences — e.g. `:s/foo/bar/<CR>g&` — are dispatched per-segment:
    // ex-command segments go through `nvim_command`, normal-mode key segments
    // go through `nvim_input`. This lets oracle cases exercise normal-mode
    // commands that depend on prior ex-command state (like `g&` after `:s`).
    if !case.keys.is_empty() {
        let keys = &case.keys;
        // Split on `<CR>` / `<cr>` to support chained sequences.
        let segments: Vec<&str> = keys
            .split_inclusive("<CR>")
            .flat_map(|s| s.split_inclusive("<cr>"))
            .collect();
        let has_ex = segments.iter().any(|seg| {
            let trimmed = seg.trim_end_matches("<CR>").trim_end_matches("<cr>").trim();
            trimmed.starts_with(':')
        });
        if has_ex {
            // Dispatch each segment individually: ex-commands via nvim_command,
            // non-empty non-ex segments via nvim_input.
            for seg in &segments {
                let body = seg.trim_end_matches("<CR>").trim_end_matches("<cr>").trim();
                if body.is_empty() {
                    continue;
                }
                if let Some(cmd) = body.strip_prefix(':') {
                    let cmd = cmd.trim();
                    if !cmd.is_empty() {
                        nvim.command(cmd).await?;
                    }
                } else {
                    // Normal-mode keys (no leading `:`). Append <CR> back if
                    // the original segment had one; for bare normal-mode tails
                    // (no trailing `<CR>`) just send the keys as-is.
                    let had_cr = seg.ends_with("<CR>") || seg.ends_with("<cr>");
                    let input = if had_cr {
                        format!("{body}<CR>")
                    } else {
                        body.to_owned()
                    };
                    nvim.input(&input).await?;
                }
            }
        } else {
            nvim.input(keys).await?;
        }
    }

    // No sync barrier needed: hjkl's `nvim_input` / `nvim_command` handlers
    // process synchronously inside `dispatch`, so the awaited response above
    // already implies the editor state has settled.

    // 4. Read back buffer.
    let raw_lines = cur_buf.get_lines(0, -1, false).await?;
    let mut buf_str = raw_lines.join("\n");
    // See nvim_driver.rs's matching comment: don't fabricate a trailing `\n`
    // for a fully-emptied buffer (`raw_lines == [""]` / `buf_str == ""`).
    if has_trailing_newline && !buf_str.is_empty() {
        buf_str.push('\n');
    }

    // 5. Read back cursor (convert from 1-based row to 0-based). Clamp at 0
    //    so malformed RPC values can't wrap to huge usize garbage.
    let (nvim_row, nvim_col) = cur_win.get_cursor().await?;
    let cursor = ((nvim_row - 1).max(0) as usize, nvim_col.max(0) as usize);

    // 6. Read back mode.
    let mode_pairs = nvim.get_mode().await?;
    let mode_char = mode_pairs
        .into_iter()
        .find_map(|(k, v)| {
            if k == Value::from("mode") {
                v.as_str().map(str::to_owned)
            } else {
                None
            }
        })
        .unwrap_or_default();
    let mode = nvim_mode_to_string(&mode_char);

    // 7. Read back default register.
    let reg_val = nvim
        .call_function("getreg", vec![Value::from("\"")])
        .await?;
    let default_register = reg_val.as_str().unwrap_or("").to_owned();

    // 7a. Read back the register the case pinned, if any.
    let pinned_register = match case.expected_register.as_ref() {
        Some((reg, _)) => {
            let val = nvim
                .call_function("getreg", vec![Value::from(reg.to_string())])
                .await?;
            Some(val.as_str().unwrap_or("").to_owned())
        }
        None => None,
    };

    Ok(HjklOutcome {
        buffer: buf_str,
        cursor,
        mode,
        default_register,
        pinned_register,
    })
}

/// Map nvim's short mode codes to the canonical lowercase strings used by the
/// oracle (same mapping as nvim_driver.rs).
fn nvim_mode_to_string(code: &str) -> String {
    match code {
        "n" => "normal",
        "i" => "insert",
        "v" => "visual",
        "V" => "visual_line",
        "\u{16}" => "visual_block",
        other => return other.to_owned(),
    }
    .to_owned()
}

#[cfg(test)]
mod reconstruction_tests {
    //! Buffer-string reconstruction contract for [`run_case`].
    //!
    //! These pin down how a collapsed buffer serialises, and guard against a
    //! tempting-but-wrong "fix": unconditionally re-appending `\n` whenever the
    //! source ended in one. That would match a naive reading of the trailing-
    //! newline rule but DIVERGE from nvim, because the rope model already encodes
    //! the distinction nvim tracks via `wordcount().bytes`:
    //!
    //! - A *linewise* delete that empties the buffer (`dd`, `dG`, `V…d`) removes
    //!   the final newline too, leaving an empty rope (one empty row) → `join`
    //!   yields `""`. Real nvim reports the same: an emptied buffer is its 0-byte
    //!   "ML_EMPTY" state (`wordcount().bytes == 0`), so `nvim_driver` returns
    //!   `""` as well (verified against nvim 0.12.4).
    //! - A *charwise* delete that empties a line (`x` on a one-char line) keeps
    //!   the newline, leaving rope `"\n"` (two empty rows) → `join` yields `"\n"`,
    //!   matching nvim's 1-byte file.
    //!
    //! So the plain `join` is already correct on both sides; re-appending `\n`
    //! for the linewise case would break the emptied-buffer oracle cases that
    //! currently pass (e.g. `register_survives_noop_dd`, `r3_dG_from_row0_…`).

    use super::run_case;
    use crate::OracleCase;

    /// Build a minimal [`OracleCase`] from just the fields the buffer-string
    /// reconstruction contract depends on; all optional per-case knobs default
    /// off. Deserialised from TOML (as real corpora are) so we don't have to
    /// spell out every optional field.
    fn case(initial_buffer: &str, keys: &str) -> OracleCase {
        let toml = format!(
            "name = \"recon\"\ninitial_buffer = {initial_buffer:?}\nkeys = {keys:?}\nexpected_buffer = \"\"\n"
        );
        toml::from_str(&toml).expect("valid case toml")
    }

    #[test]
    fn linewise_empty_collapse_has_no_trailing_newline() {
        // `dd` on the one-line buffer "abc\n" empties it to an empty rope. The
        // final newline goes with the line, so the reconstruction is "" — NOT
        // "\n". This mirrors real nvim, whose emptied buffer is a 0-byte file.
        assert_eq!(run_case(&case("abc\n", "dd")).unwrap().buffer, "");
        // A multi-line linewise empty (`V`-select all + delete) behaves the same.
        assert_eq!(run_case(&case("one\ntwo\n", "VGd")).unwrap().buffer, "");
    }

    #[test]
    fn charwise_empty_line_keeps_trailing_newline() {
        // `x` on the one-char buffer "a\n" deletes the char but keeps the
        // newline, leaving one empty line → "\n", matching nvim's 1-byte file.
        assert_eq!(run_case(&case("a\n", "x")).unwrap().buffer, "\n");
    }

    #[test]
    fn genuinely_empty_source_stays_empty() {
        // A genuinely-empty source (no trailing newline) reconstructs to "" with
        // no phantom "\n" — same as nvim's 0-byte read-back.
        assert_eq!(run_case(&case("", "")).unwrap().buffer, "");
    }
}
