//! Comment-toggle acceptance (Arc 2, docs/comment-toggle-framing.md).
//!
//! Dispatch-driven: `M-;` through `dispatch_key`, `M-x` through the
//! real minibuffer. Buffers are file-backed (language detection needs
//! a path); each editor gets a private tempdir `StateDir` and an
//! emptied `pmacs.lsp.config` so opening `.rs`/`.py` fixtures never
//! spawns a real language server.

use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
use pmacs::editor::EditorState;
use pmacs::lua_bindings::StateDir;
use pmacs::protocol::FrontendId;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};

fn fresh_state_dir() -> PathBuf {
    static SEQ: AtomicUsize = AtomicUsize::new(0);
    let dir = std::env::temp_dir().join(format!(
        "pmacs-comment-{}-{}",
        std::process::id(),
        SEQ.fetch_add(1, Ordering::Relaxed)
    ));
    std::fs::create_dir_all(&dir).unwrap();
    dir
}

fn editor(state_dir: &std::path::Path) -> EditorState {
    let s = EditorState::new();
    s.lua_host.lua().remove_app_data::<StateDir>();
    s.lua_host
        .lua()
        .set_app_data(StateDir(state_dir.to_path_buf()));
    // Language DETECTION must work (filetypes/grammars); server
    // SPAWNING must not (rust/python have default configs).
    exec(&s, "pmacs.lsp.config = {}");
    s
}

fn write_file(dir: &std::path::Path, name: &str, body: &str) -> String {
    let p = dir.join(name);
    std::fs::write(&p, body).unwrap();
    p.display().to_string()
}

fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
    KeyEvent {
        code,
        modifiers: mods,
        kind: KeyEventKind::Press,
        state: KeyEventState::NONE,
    }
}

fn ctrl(s: &mut EditorState, c: char) {
    s.dispatch_key(
        FrontendId::LOCAL,
        key(KeyCode::Char(c), KeyModifiers::CONTROL),
    );
}

fn alt(s: &mut EditorState, c: char) {
    s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::ALT));
}

fn press(s: &mut EditorState, code: KeyCode) {
    s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE));
}

fn type_str(s: &mut EditorState, text: &str) {
    for ch in text.chars() {
        s.dispatch_key(
            FrontendId::LOCAL,
            key(KeyCode::Char(ch), KeyModifiers::NONE),
        );
    }
}

fn m_x(s: &mut EditorState, name: &str) {
    alt(s, 'x');
    type_str(s, name);
    press(s, KeyCode::Enter);
}

fn exec(s: &EditorState, src: &str) {
    s.lua_host.lua().load(src.to_string()).exec().unwrap();
}

fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
    s.lua_host.lua().load(src.to_string()).eval().unwrap()
}

fn buffer_text(s: &EditorState) -> String {
    let b: mlua::String = eval(
        s,
        "local b = pmacs.window.buffer(); return b:slice(0, b:len())",
    );
    String::from_utf8_lossy(&b.as_bytes()).into_owned()
}

fn cursor(s: &EditorState) -> i64 {
    eval(s, "return pmacs.editor.cursor()")
}

fn status(s: &EditorState) -> String {
    s.core.borrow().status.clone()
}

/// Fresh editor visiting `name` (created in the state tempdir) with
/// `body` on disk, cursor at 0.
fn editor_visiting(name: &str, body: &str) -> EditorState {
    let dir = fresh_state_dir();
    let s = editor(&dir);
    let f = write_file(&dir, name, body);
    exec(&s, &format!("pmacs.buffer.find_or_open({f:?})"));
    exec(&s, "pmacs.editor.goto_byte(0)");
    s
}

// ---------------------------------------------------------------------------
// Single-line toggle (comment-line behavior, Q#CT2)
// ---------------------------------------------------------------------------

#[test]
fn rust_line_toggle_round_trips_and_cursor_walks_to_the_next_line() {
    let mut s = editor_visiting("t.rs", "fn main() {\n    let x = 1;\n}\n");
    exec(&s, "pmacs.editor.goto_byte(16)"); // inside "    let x = 1;"
    alt(&mut s, ';');
    assert_eq!(buffer_text(&s), "fn main() {\n    // let x = 1;\n}\n");
    assert_eq!(cursor(&s), 30, "cursor moved to the next line's start");
    // Toggle back from anywhere in the commented line: exact round
    // trip, including the padding space.
    exec(&s, "pmacs.editor.goto_byte(14)");
    alt(&mut s, ';');
    assert_eq!(buffer_text(&s), "fn main() {\n    let x = 1;\n}\n");
    assert_eq!(cursor(&s), 27);
}

#[test]
fn lua_buffer_gets_the_dash_dash_prefix() {
    let mut s = editor_visiting("t.lua", "local x = 1\nreturn x\n");
    alt(&mut s, ';');
    assert_eq!(buffer_text(&s), "-- local x = 1\nreturn x\n");
    assert_eq!(cursor(&s), 15);
}

#[test]
fn last_line_without_newline_toggles_and_clamps_the_cursor() {
    let mut s = editor_visiting("e.py", "x = 1");
    alt(&mut s, ';');
    assert_eq!(buffer_text(&s), "# x = 1");
    assert_eq!(cursor(&s), 7, "no next line: cursor clamps to buffer end");
    alt(&mut s, ';');
    assert_eq!(buffer_text(&s), "x = 1");
}

#[test]
fn a_blank_line_is_a_noop_with_a_status() {
    let mut s = editor_visiting("b.py", "\n  \n");
    alt(&mut s, ';');
    assert!(
        status(&s).contains("nothing to comment"),
        "got: {:?}",
        status(&s)
    );
    assert_eq!(buffer_text(&s), "\n  \n");
}

// ---------------------------------------------------------------------------
// Region toggles (Q#CT4)
// ---------------------------------------------------------------------------

#[test]
fn region_comments_at_min_indent_skips_blanks_and_clears_the_selection() {
    let mut s = editor_visiting("t.py", "  two\nzero\n\n    four\n");
    exec(
        &s,
        "pmacs.editor.begin_selection(0); pmacs.editor.goto_byte(20)",
    );
    alt(&mut s, ';');
    // Min indent across non-blank lines is 0 (line "zero"), so every
    // prefix lands at column 0; the blank line is untouched.
    assert_eq!(buffer_text(&s), "#   two\n# zero\n\n#     four\n");
    let region_active: bool = eval(&s, "return pmacs.editor.region() ~= nil");
    assert!(!region_active, "selection clears after a region toggle");
    assert_eq!(cursor(&s), 0, "cursor lands at the span start");
}

#[test]
fn mixed_region_comments_preserving_inner_prefixes_and_round_trips() {
    let mut s = editor_visiting("t2.py", "# a\nb\n");
    exec(
        &s,
        "pmacs.editor.begin_selection(0); pmacs.editor.goto_byte(5)",
    );
    alt(&mut s, ';');
    // Mixed span COMMENTS (Q#CT4): the already-commented line gets a
    // second prefix, preserving the inner commented-out code.
    assert_eq!(buffer_text(&s), "# # a\n# b\n");
    exec(
        &s,
        "pmacs.editor.begin_selection(0); pmacs.editor.goto_byte(9)",
    );
    alt(&mut s, ';');
    // Now every line is commented → uncomment strips the outer layer.
    assert_eq!(buffer_text(&s), "# a\nb\n", "double-prefix round-trips");
}

#[test]
fn region_ending_at_column_zero_excludes_that_line() {
    let mut s = editor_visiting("c.py", "one\ntwo\n");
    exec(
        &s,
        "pmacs.editor.begin_selection(0); pmacs.editor.goto_byte(4)",
    );
    alt(&mut s, ';');
    assert_eq!(
        buffer_text(&s),
        "# one\ntwo\n",
        "a region stopping at a line's column 0 does not touch that line"
    );
}

// ---------------------------------------------------------------------------
// Unknown language (Q#CT3)
// ---------------------------------------------------------------------------

#[test]
fn unknown_language_reports_and_edits_nothing() {
    let mut s = editor_visiting("t.txt", "hello\n");
    alt(&mut s, ';');
    assert!(
        status(&s).contains("no comment syntax known"),
        "got: {:?}",
        status(&s)
    );
    assert_eq!(buffer_text(&s), "hello\n");
}

#[test]
fn pathless_scratch_buffer_reports_and_edits_nothing() {
    let dir = fresh_state_dir();
    let mut s = editor(&dir);
    type_str(&mut s, "hello");
    exec(&s, "pmacs.editor.goto_byte(0)");
    alt(&mut s, ';');
    assert!(
        status(&s).contains("no comment syntax known"),
        "got: {:?}",
        status(&s)
    );
    assert_eq!(buffer_text(&s), "hello");
}

// ---------------------------------------------------------------------------
// One edit, one undo step (Q#CT5)
// ---------------------------------------------------------------------------

#[test]
fn a_multi_line_toggle_is_one_undo_step() {
    let mut s = editor_visiting("u.py", "a = 1\nb = 2\n");
    exec(
        &s,
        "pmacs.editor.begin_selection(0); pmacs.editor.goto_byte(11)",
    );
    alt(&mut s, ';');
    assert_eq!(buffer_text(&s), "# a = 1\n# b = 2\n");
    ctrl(&mut s, '/'); // buffer.undo, exactly once
    assert_eq!(
        buffer_text(&s),
        "a = 1\nb = 2\n",
        "one undo restores the whole multi-line toggle"
    );
}

// ---------------------------------------------------------------------------
// Intercept discipline (Q#CT5)
// ---------------------------------------------------------------------------

#[test]
fn rejecting_intercept_reports_without_throwing() {
    let mut s = editor_visiting("i.py", "a\nb\n");
    exec(
        &s,
        r#"
        _G.reject_once = true
        pmacs.buffer.add_intercept(pmacs.window.buffer(), function(_op)
          if _G.reject_once then
            _G.reject_once = false
            error("rejected by test intercept")
          end
          return nil
        end)
        "#,
    );
    alt(&mut s, ';'); // rejected: reported, nothing changed, no throw
    assert!(status(&s).contains("rejected"), "got: {:?}", status(&s));
    assert_eq!(buffer_text(&s), "a\nb\n");
    assert_eq!(cursor(&s), 0, "no cursor fix-up on a rejected toggle");
    alt(&mut s, ';'); // allowed again: the command still works
    assert_eq!(buffer_text(&s), "# a\nb\n");
}

#[test]
fn transforming_intercept_is_reported_and_skips_the_cursor_fixup() {
    let mut s = editor_visiting("j.py", "ab\ncd\n");
    // Enlarges every replace's end by one byte — the effective edit
    // deviates from the request, so the toggle must report and leave
    // the cursor alone (the span it would fix up toward moved).
    exec(
        &s,
        r#"
        pmacs.buffer.add_intercept(pmacs.window.buffer(), function(op)
          if op.kind == "replace" then
            return {
              kind = "replace",
              start = op.start,
              ["end"] = op["end"] + 1,
              bytes = op.bytes,
            }
          end
          return nil
        end)
        "#,
    );
    alt(&mut s, ';');
    assert!(status(&s).contains("altered"), "got: {:?}", status(&s));
    // The interceptor's result stands (accepted post-hoc semantics):
    // it swallowed the newline after "ab".
    assert_eq!(buffer_text(&s), "# abcd\n");
    assert_eq!(cursor(&s), 0, "cursor fix-up skipped");
}

// ---------------------------------------------------------------------------
// Substrate plumbing (Q#CT6)
// ---------------------------------------------------------------------------

#[test]
fn after_edit_fires_exactly_once_per_toggle_keybound_and_m_x() {
    let mut s = editor_visiting("h.py", "a\nb\n");
    exec(
        &s,
        "_G.ae = 0; pmacs.hook.add('buffer.after-edit', function() _G.ae = _G.ae + 1 end)",
    );
    alt(&mut s, ';'); // keybound path
    let n: i64 = eval(&s, "return _G.ae");
    assert_eq!(n, 1, "keybound toggle fires after-edit once");
    // Cursor walked to line 2; M-x path must fire it too (via
    // invoke_interactive + with_after_edit_check), and the minibuffer
    // typing itself must not inflate the count.
    m_x(&mut s, "edit.toggle-comment");
    assert_eq!(buffer_text(&s), "# a\n# b\n");
    let n: i64 = eval(&s, "return _G.ae");
    assert_eq!(n, 2, "M-x toggle fires after-edit exactly once more");
}

#[test]
fn toggle_between_kills_breaks_the_kill_chain() {
    let mut s = editor_visiting("k.rs", "one\ntwo\nthree\n");
    ctrl(&mut s, 'k'); // kills "one"; line now blank, cursor 0
    alt(&mut s, ';'); // no-op on the blank line, but the command ROTATES
    ctrl(&mut s, 'k'); // kills "\n" — must push fresh, not append
    let ring: Vec<String> = eval(&s, "return pmacs.killring.list()");
    assert_eq!(
        ring,
        vec!["\n", "one"],
        "C-k, M-;, C-k yields two ring entries (chain broken)"
    );
}
