// tests/m8_3_acceptance.rs --- T M8.3 wdired (editable rename / chmod) acceptance.

//! Acceptance for the M8.3 deliverable: layering an editable
//! rename / chmod surface on top of the M8.2 dired view. Pins the
//! four spec bullets:
//!
//! 1. Renaming a file by editing its name and committing produces
//!    the matching `rename` syscall.
//! 2. Editing permissions to an invalid pattern produces a
//!    rejection at the `intercept_edit` layer, not at the syscall.
//! 3. Editing the size column (which is read-only) is rejected
//!    cleanly.
//! 4. Concurrent external file changes during edit produce a
//!    refresh prompt rather than corrupting the user's edits.
//!
//! Plus several read-only-column regressions: kind char, mtime
//! column, separators, and newline insertion.

use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::time::{Duration, Instant};

use pmacs::editor::EditorState;
use pmacs::lua_bindings::PackageInstallOverride;
use tempfile::TempDir;

fn dired_package_path() -> PathBuf {
    let here = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    here.join("tests").join("fixtures").join("pmacs-dired")
}

fn pump_until<F: Fn(&EditorState) -> bool>(state: &mut EditorState, predicate: F) {
    let deadline = Instant::now() + Duration::from_secs(5);
    while !predicate(state) {
        assert!(
            Instant::now() < deadline,
            "async pump deadline exceeded after 5s"
        );
        state.tick_async();
        std::thread::sleep(Duration::from_millis(2));
    }
}

fn editor_with_dired() -> (EditorState, TempDir, TempDir) {
    let cache = tempfile::tempdir().expect("cache tempdir");
    let user_root = tempfile::tempdir().expect("user-root tempdir");
    let mut state = EditorState::new();
    state.lua_host.reopen_init_phase_for_testing();
    state.lua_host.set_package_install_override(
        PackageInstallOverride::new()
            .with_cache_dir(cache.path().to_path_buf())
            .with_user_install_root(user_root.path().to_path_buf()),
    );
    let pkg = dired_package_path();
    let pkg_str = pkg.display().to_string();
    let install = format!(
        r#"
        pmacs.packages.install_local("{pkg_str}")
        require("pmacs-dired")
    "#
    );
    state
        .lua_host
        .eval(Some("dired-install"), &install)
        .unwrap_or_else(|e| panic!("install_local + require failed: {e}"));
    (state, cache, user_root)
}

/// Open `path` in dired and pump until M.open returns. Active buffer
/// is the dired buffer afterward; a wdired session can begin via
/// the wdired-edit command.
fn open_dired_on(state: &mut EditorState, path: &str) {
    let chunk = format!(
        r#"
        _G.M8_3_DONE = nil
        pmacs.async(function()
            require("pmacs-dired").open("{path}")
            _G.M8_3_DONE = true
        end)
    "#
    );
    state
        .lua_host
        .eval(Some("open-for-wdired"), &chunk)
        .unwrap_or_else(|e| panic!("dired open failed: {e}"));
    pump_until(state, |s| {
        s.lua_host
            .lua()
            .globals()
            .get::<bool>("M8_3_DONE")
            .unwrap_or(false)
    });
}

/// Pump until the active handle's `last_commit_outcome` is set
/// (success or failure). The wdired-commit command schedules a
/// pmacs.async whose outcome lands there; we don't observe it via
/// pcall on invoke because async errors are surfaced through
/// pmacs.error, not raised back.
fn pump_until_commit_done(state: &mut EditorState) {
    pump_until(state, |s| {
        s.lua_host
            .lua()
            .load(
                r"
                    local h = require('pmacs-dired')._test.active_handle()
                    return h ~= nil and h.last_commit_outcome ~= nil
                ",
            )
            .eval::<bool>()
            .unwrap_or(false)
    });
}

/// Read the active handle's `last_commit_outcome` ("ok" / "failed: ...").
fn read_commit_outcome(state: &mut EditorState) -> String {
    state
        .lua_host
        .lua()
        .load(
            r"
                local h = require('pmacs-dired')._test.active_handle()
                return tostring(h.last_commit_outcome)
            ",
        )
        .eval::<String>()
        .unwrap_or_default()
}

fn active_buffer_text(state: &mut EditorState) -> String {
    state
        .lua_host
        .lua()
        .load(
            r"
            local buf = pmacs.window.buffer()
            return buf:slice(0, buf:len())
        ",
        )
        .eval::<String>()
        .expect("read active buffer")
}

fn edit_wdired_names(state: &mut EditorState, pairs: &[(&str, &str)]) {
    let pairs_lua = pairs
        .iter()
        .map(|(from, to)| format!("[{from:?}] = {to:?}"))
        .collect::<Vec<_>>()
        .join(", ");
    let chunk = format!(
        r#"
            local replacements = {{ {pairs_lua} }}
            local h = require("pmacs-dired")._test.active_handle()
            local NAME_START = require("pmacs-dired")._test.NAME_START
            local function line_end(i)
                if i < #h.edit.line_start_marks then
                    return h.edit.line_start_marks[i + 1]:get() - 1
                end
                return h.buf:len()
            end
            for i, e in ipairs(h.edit.snapshot) do
                local to = replacements[e.name]
                if to then
                    local ls = h.edit.line_start_marks[i]:get()
                    h.buf:replace(ls + NAME_START, line_end(i), to)
                end
            end
        "#
    );
    state
        .lua_host
        .eval(Some("edit-wdired-names"), &chunk)
        .expect("edit wdired names");
}

fn install_second_rename_failure_hook(state: &mut EditorState) {
    state
        .lua_host
        .eval(
            Some("hook-second-rename"),
            r#"
                local real = pmacs.fs.rename
                local calls = 0
                pmacs.fs.rename = function(from, to)
                    calls = calls + 1
                    if calls == 2 then
                        return setmetatable({}, {
                            __index = {
                                await = function()
                                    error("synthetic mid-batch rename failure")
                                end,
                            },
                        })
                    end
                    return real(from, to)
                end
            "#,
        )
        .expect("install rename hook");
}

fn wdired_edit_active(state: &mut EditorState) -> bool {
    state
        .lua_host
        .lua()
        .load(
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                return h.edit ~= nil
            "#,
        )
        .eval()
        .expect("read edit state")
}

fn file_mtime_parts(path: &std::path::Path) -> (u64, u32) {
    let modified = std::fs::metadata(path)
        .expect("metadata")
        .modified()
        .expect("modified");
    let since_epoch = modified
        .duration_since(std::time::UNIX_EPOCH)
        .expect("test mtimes are post-epoch");
    (since_epoch.as_secs(), since_epoch.subsec_nanos())
}

// ---------------------------------------------------------------------------
// Bullet 1 --- rename via name edit + commit
// ---------------------------------------------------------------------------

#[test]
fn wdired_rename_via_name_edit_then_commit_produces_rename_syscall() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("old.txt"), b"contents").expect("write old");

    let (mut state, _c, _u) = editor_with_dired();
    let path_str = dir.path().display().to_string();
    open_dired_on(&mut state, &path_str);

    // Enter wdired edit mode.
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    // The line for "old.txt" has the name beginning at byte 39 of
    // the line. Replace the entire name segment ([line_start+39,
    // eol)) with "new.txt".
    state
        .lua_host
        .eval(
            Some("rename-edit"),
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                local mark = h.edit.line_start_marks[1]
                local ls = mark:get()
                local NAME_START = require("pmacs-dired")._test.NAME_START
                local eol = h.buf:len()
                local name_start = ls + NAME_START
                h.buf:replace(name_start, eol, "new.txt")
            "#,
        )
        .expect("rename edit");

    // Commit. The command schedules a pmacs.async; poll the
    // handle's last_commit_outcome to detect completion.
    state
        .lua_host
        .eval(
            Some("wd-commit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
        )
        .expect("commit eval");
    pump_until_commit_done(&mut state);

    let outcome = read_commit_outcome(&mut state);
    assert_eq!(outcome, "ok", "commit must succeed; got: {outcome}");
    // The file is now at the new path; the old path no longer exists.
    assert!(
        dir.path().join("new.txt").exists(),
        "after wdired-commit, new.txt must exist on disk"
    );
    assert!(
        !dir.path().join("old.txt").exists(),
        "after wdired-commit, old.txt must be gone from disk"
    );

    // The buffer reflects the post-commit listing.
    let text = active_buffer_text(&mut state);
    assert!(
        text.contains("new.txt") && !text.contains(" old.txt"),
        "post-commit dired buffer must list new.txt only; got: {text}"
    );
}

// ---------------------------------------------------------------------------
// Bullet 2 --- invalid perms char rejected at intercept_edit
// ---------------------------------------------------------------------------

#[test]
fn wdired_invalid_perms_char_rejected_at_intercept_layer() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("file.txt"), b"x").expect("write file");

    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());

    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    // Replace one perm char with 'z' (not in the rwx alphabet). We
    // use replace (not insert) because the perms column is fixed-
    // width: an insert would shift the column and is rejected for
    // a different reason --- the alphabet check is for length-
    // preserving same-column replaces.
    let msg: String = state
        .lua_host
        .lua()
        .load(
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                local ls = h.edit.line_start_marks[1]:get()
                -- Position 1 is the first perms char; replace it with 'z'.
                local ok, err = pcall(function()
                    h.buf:replace(ls + 1, ls + 2, "z")
                end)
                assert(not ok, "intercept must reject 'z' in perms")
                return tostring(err)
            "#,
        )
        .eval()
        .expect("intercept error eval");
    assert!(
        msg.contains("perms column") && msg.contains("'r', 'w', 'x', and '-'"),
        "rejection must name the rwx alphabet; got: {msg}"
    );

    // Buffer is unchanged: file mode unchanged on disk because no
    // chmod ran (we never reached commit).
    let mode_before = std::fs::metadata(dir.path().join("file.txt"))
        .unwrap()
        .permissions()
        .mode()
        & 0o777;
    // Run wdired-abandon to clean up, then verify nothing happened.
    state
        .lua_host
        .eval(
            Some("abandon"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-abandon")"#,
        )
        .expect("abandon");
    let mode_after = std::fs::metadata(dir.path().join("file.txt"))
        .unwrap()
        .permissions()
        .mode()
        & 0o777;
    assert_eq!(mode_before, mode_after, "no chmod should have fired");
}

// ---------------------------------------------------------------------------
// Bullet 3 --- size column is read-only and rejected
// ---------------------------------------------------------------------------

#[test]
fn wdired_size_column_edits_rejected() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("file.txt"), b"hello").expect("write file");

    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());

    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    // Try to insert into the size column. Per the layout, size starts
    // at relative offset 11 (PERMS_END + 1).
    let msg: String = state
        .lua_host
        .lua()
        .load(
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                local ls = h.edit.line_start_marks[1]:get()
                local ok, err = pcall(function() h.buf:insert(ls + 13, "9") end)
                assert(not ok, "intercept must reject size-column edits")
                return tostring(err)
            "#,
        )
        .eval()
        .expect("size-column eval");
    assert!(
        msg.contains("read-only"),
        "rejection must name the read-only column; got: {msg}"
    );
}

// ---------------------------------------------------------------------------
// Bullet 4 --- external change between edit and commit produces refresh
// ---------------------------------------------------------------------------

#[test]
fn wdired_external_change_at_commit_aborts_with_refresh_guidance() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("a.txt"), b"a").expect("write a");
    std::fs::write(dir.path().join("b.txt"), b"b").expect("write b");

    let (mut state, _c, _u) = editor_with_dired();
    let path_str = dir.path().display().to_string();
    open_dired_on(&mut state, &path_str);

    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    // Edit a.txt's name to a_new.txt.
    state
        .lua_host
        .eval(
            Some("name-edit"),
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                local NAME_START = require("pmacs-dired")._test.NAME_START
                local ls1 = h.edit.line_start_marks[1]:get()
                local ls2 = h.edit.line_start_marks[2]:get()
                -- a.txt is on line 1; name region is [ls1 + NAME_START, ls2 - 1).
                local name_start = ls1 + NAME_START
                local eol = ls2 - 1  -- position of the trailing '\n'
                h.buf:replace(name_start, eol, "a_new.txt")
            "#,
        )
        .expect("name-edit");

    // Externally rename b.txt -> b_external.txt while the user is
    // mid-edit. This is the "concurrent external file change"
    // scenario.
    std::fs::rename(dir.path().join("b.txt"), dir.path().join("b_external.txt"))
        .expect("external rename");

    // Try to commit: should refuse with refresh-prompt guidance.
    state
        .lua_host
        .eval(
            Some("commit-attempt"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
        )
        .expect("commit eval");
    pump_until_commit_done(&mut state);

    let outcome = read_commit_outcome(&mut state);
    assert!(
        outcome.starts_with("failed: "),
        "commit must fail when external changes happened; got: {outcome}"
    );
    assert!(
        outcome.contains("external"),
        "error must say 'external'; got: {outcome}"
    );
    assert!(
        outcome.contains("wdired-abandon"),
        "error must point at wdired-abandon; got: {outcome}"
    );

    // a.txt was NOT renamed (commit aborted before any syscalls).
    assert!(
        dir.path().join("a.txt").exists(),
        "a.txt must remain since commit aborted"
    );
    assert!(
        !dir.path().join("a_new.txt").exists(),
        "a_new.txt must not exist since commit aborted"
    );
}

// ---------------------------------------------------------------------------
// Regressions: read-only column edges
// ---------------------------------------------------------------------------

#[test]
fn wdired_kind_char_edit_rejected() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    let msg: String = state
        .lua_host
        .lua()
        .load(
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                local ls = h.edit.line_start_marks[1]:get()
                local ok, err = pcall(function() h.buf:replace(ls, ls + 1, "d") end)
                assert(not ok, "kind-char edit must be rejected")
                return tostring(err)
            "#,
        )
        .eval()
        .expect("kind-char eval");
    assert!(
        msg.contains("read-only"),
        "rejection must name read-only: {msg}"
    );
}

#[test]
fn wdired_mtime_column_edit_rejected() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");
    let msg: String = state
        .lua_host
        .lua()
        .load(
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                local ls = h.edit.line_start_marks[1]:get()
                -- Mtime is at relative offset 22..38; pick 25.
                local ok, err = pcall(function() h.buf:insert(ls + 25, "X") end)
                assert(not ok, "mtime edit must be rejected")
                return tostring(err)
            "#,
        )
        .eval()
        .expect("mtime eval");
    assert!(msg.contains("read-only"), "must name read-only: {msg}");
}

#[test]
fn wdired_newline_insert_rejected() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");
    let msg: String = state
        .lua_host
        .lua()
        .load(
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                local ls = h.edit.line_start_marks[1]:get()
                local NAME_START = require("pmacs-dired")._test.NAME_START
                -- Try to insert a newline in the editable name region;
                -- still rejected because newlines split lines.
                local ok, err = pcall(function() h.buf:insert(ls + NAME_START + 1, "\n") end)
                assert(not ok, "newline insert must be rejected")
                return tostring(err)
            "#,
        )
        .eval()
        .expect("newline eval");
    assert!(
        msg.contains("newline"),
        "rejection must name newline: {msg}"
    );
}

// ---------------------------------------------------------------------------
// Bullet 1 companion --- chmod via perms edit + commit produces chmod syscall
// ---------------------------------------------------------------------------

#[test]
fn wdired_chmod_via_perms_edit_then_commit_changes_mode_on_disk() {
    let dir = tempfile::tempdir().expect("tempdir");
    let file = dir.path().join("file.txt");
    std::fs::write(&file, b"x").expect("write");
    // Set a known starting mode so we can detect the change.
    std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).expect("chmod 0o644");

    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());

    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    // Replace the perms column "rw-r--r--" (0o644) with "rwxr-x---"
    // (0o750). The perms region is [line_start + 1, line_start + 10).
    state
        .lua_host
        .eval(
            Some("chmod-edit"),
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                local ls = h.edit.line_start_marks[1]:get()
                h.buf:replace(ls + 1, ls + 10, "rwxr-x---")
            "#,
        )
        .expect("chmod edit");

    state
        .lua_host
        .eval(
            Some("wd-commit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
        )
        .expect("commit eval");
    pump_until_commit_done(&mut state);

    let outcome = read_commit_outcome(&mut state);
    assert_eq!(outcome, "ok", "commit must succeed; got: {outcome}");
    let mode = std::fs::metadata(&file).unwrap().permissions().mode() & 0o777;
    assert_eq!(
        mode, 0o750,
        "chmod via wdired-commit must set mode 0o750; got 0o{mode:o}"
    );
}

// ---------------------------------------------------------------------------
// Bullet 1 companion --- empty name rejected at commit
// ---------------------------------------------------------------------------

#[test]
fn wdired_commit_rejects_empty_name() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    // Replace the name with empty.
    state
        .lua_host
        .eval(
            Some("blank-name"),
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                local NAME_START = require("pmacs-dired")._test.NAME_START
                local ls = h.edit.line_start_marks[1]:get()
                h.buf:replace(ls + NAME_START, h.buf:len(), "")
            "#,
        )
        .expect("blank-name edit");

    state
        .lua_host
        .eval(
            Some("commit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
        )
        .expect("commit eval");
    pump_until_commit_done(&mut state);
    let outcome = read_commit_outcome(&mut state);
    assert!(
        outcome.starts_with("failed: "),
        "empty name must be rejected; got: {outcome}"
    );
    assert!(
        outcome.contains("empty"),
        "error must name 'empty'; got: {outcome}"
    );
    // File on disk untouched.
    assert!(
        dir.path().join("file.txt").exists(),
        "file.txt must remain since commit was rejected"
    );
}

// Slashed-name companion --- a basename containing '/' is forbidden
// (would escape the directory). Sibling of the empty-name test.
#[test]
fn wdired_commit_rejects_slashed_name() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    state
        .lua_host
        .eval(
            Some("slashed"),
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                local NAME_START = require("pmacs-dired")._test.NAME_START
                local ls = h.edit.line_start_marks[1]:get()
                h.buf:replace(ls + NAME_START, h.buf:len(), "foo/bar")
            "#,
        )
        .expect("slashed-edit");

    state
        .lua_host
        .eval(
            Some("commit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
        )
        .expect("commit eval");
    pump_until_commit_done(&mut state);
    let outcome = read_commit_outcome(&mut state);
    assert!(
        outcome.starts_with("failed: "),
        "slashed name must be rejected; got: {outcome}"
    );
    assert!(
        outcome.contains("'/'") || outcome.contains("directory separator"),
        "error must mention slash / directory separator: {outcome}"
    );
    // Disk untouched: original file still there, no foo/ subdir created.
    assert!(dir.path().join("file.txt").exists(), "file.txt must remain");
    assert!(!dir.path().join("foo").exists(), "foo/ must not be created");
    assert!(
        !dir.path().join("foo").join("bar").exists(),
        "foo/bar must not be created"
    );
}

// ---------------------------------------------------------------------------
// Navigation rejected while editing
// ---------------------------------------------------------------------------

#[test]
fn wdired_navigation_commands_blocked_while_editing() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    for cmd in [
        "pmacs-dired.parent",
        "pmacs-dired.sort-name",
        "pmacs-dired.sort-mtime",
        "pmacs-dired.sort-size",
    ] {
        let probe = format!(
            r#"
                local ok, err = pcall(function() pmacs.command.invoke("{cmd}") end)
                assert(not ok, "{cmd} must be rejected while editing")
                return tostring(err)
            "#
        );
        let msg: String = state
            .lua_host
            .lua()
            .load(&probe)
            .eval()
            .unwrap_or_else(|e| panic!("nav-block eval failed for {cmd}: {e}"));
        assert!(
            msg.contains("wdired-edit") && msg.contains("commit") && msg.contains("abandon"),
            "rejection must point at commit/abandon; got from {cmd}: {msg}"
        );
    }
}

// ---------------------------------------------------------------------------
// wdired-abandon restores the buffer to its pre-edit text
// ---------------------------------------------------------------------------

#[test]
fn wdired_abandon_restores_pre_edit_buffer_text() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());

    let before = active_buffer_text(&mut state);

    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");
    state
        .lua_host
        .eval(
            Some("scribble"),
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                local NAME_START = require("pmacs-dired")._test.NAME_START
                local ls = h.edit.line_start_marks[1]:get()
                h.buf:replace(ls + NAME_START, h.buf:len(), "scribbled.txt")
            "#,
        )
        .expect("scribble");
    state
        .lua_host
        .eval(
            Some("abandon"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-abandon")"#,
        )
        .expect("abandon");

    let after = active_buffer_text(&mut state);
    assert_eq!(
        before, after,
        "wdired-abandon must restore the pre-edit text byte-for-byte"
    );
    // File on disk untouched.
    assert!(
        dir.path().join("file.txt").exists(),
        "file.txt must be intact after abandon"
    );
    assert!(
        !dir.path().join("scribbled.txt").exists(),
        "no rename should have happened"
    );
}

// ---------------------------------------------------------------------------
// wdired-edit twice errors instead of double-attaching
// ---------------------------------------------------------------------------

#[test]
fn wdired_edit_twice_errors() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("first wdired-edit");
    let msg: String = state
        .lua_host
        .lua()
        .load(
            r#"
                local ok, err = pcall(function()
                    pmacs.command.invoke("pmacs-dired.wdired-edit")
                end)
                assert(not ok, "second wdired-edit must error")
                return tostring(err)
            "#,
        )
        .eval()
        .expect("second-edit eval");
    assert!(
        msg.contains("already in wdired-edit mode"),
        "second wdired-edit error must say already in mode: {msg}"
    );
}

// ---------------------------------------------------------------------------
// parse_perm_string: positional validation (lib-style test through seam)
// ---------------------------------------------------------------------------

#[test]
fn wdired_parse_perm_string_positional_validation() {
    let (state, _c, _u) = editor_with_dired();
    let lua = state.lua_host.lua();
    // Valid: standard 0o755.
    let mode: i64 = lua
        .load(
            r#"
                local m, _ = require("pmacs-dired")._test.parse_perm_string("rwxr-xr-x")
                return m
            "#,
        )
        .eval()
        .expect("valid 755");
    assert_eq!(mode, 0o755);

    // Invalid: char in wrong position.
    let invalid: String = lua
        .load(
            r#"
                local m, err = require("pmacs-dired")._test.parse_perm_string("xrwr-xr-x")
                if m then return "valid" else return tostring(err) end
            "#,
        )
        .eval()
        .expect("xr in pos 0");
    assert!(
        invalid.contains("invalid perms char"),
        "wrong-position char must surface: {invalid}"
    );

    // Wrong length.
    let wrong_len: String = lua
        .load(
            r#"
                local m, err = require("pmacs-dired")._test.parse_perm_string("rwxr-xr--")
                if m == nil then return tostring(err) else return tostring(m) end
            "#,
        )
        .eval()
        .expect("9-char with - in last pos is valid actually");
    // "rwxr-xr--" is 0o744 (owner=7, group=4, other=4), valid.
    let _ = wrong_len;

    // Bad length (8 chars).
    let too_short: String = lua
        .load(
            r#"
                local m, err = require("pmacs-dired")._test.parse_perm_string("rwxr-xr-")
                if m then return "ok" else return tostring(err) end
            "#,
        )
        .eval()
        .expect("8 chars");
    assert!(
        too_short.contains("9 chars"),
        "must complain length: {too_short}"
    );
}

// ---------------------------------------------------------------------------
// Pass-2 Finding High1 --- escaped names round-trip without spurious renames
// ---------------------------------------------------------------------------
//
// A file whose real name contains a `\n` byte renders as `weird\nname.txt`
// (escape_displayable handles control chars and the backslash itself).
// Before the unescape step, commit would parse the rendered text as a NEW
// name with a literal backslash + 'n', and fire a rename to that new name.
// After the fix, a no-op commit is genuinely no-op.

#[test]
fn wdired_newline_in_filename_round_trips_without_spurious_rename() {
    let dir = tempfile::tempdir().expect("tempdir");
    let weird_name = "weird\nname.txt";
    std::fs::write(dir.path().join(weird_name), b"x").expect("write weird");

    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    // No edits. Just commit. Must succeed without renaming the file.
    state
        .lua_host
        .eval(
            Some("commit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
        )
        .expect("commit eval");
    pump_until_commit_done(&mut state);

    let outcome = read_commit_outcome(&mut state);
    assert_eq!(outcome, "ok", "no-op commit must succeed; got: {outcome}");

    // The file with a real newline in its name must still exist.
    assert!(
        dir.path().join(weird_name).exists(),
        "file with newline-in-name must still exist after no-op commit"
    );
    // The literal "weird\\nname.txt" (a name containing a backslash + 'n')
    // must NOT exist on disk --- that would mean we mistook the rendered
    // escape for the real name and fired a spurious rename.
    assert!(
        !dir.path().join("weird\\nname.txt").exists(),
        "escape-as-real-name file must not have been created"
    );
}

// Backslash-only name: tests the same round-trip path through a different
// escape (`\\`), and verifies unescape_displayable directly via the seam.
#[test]
fn wdired_unescape_displayable_round_trips_known_escapes() {
    let (state, _c, _u) = editor_with_dired();
    let lua = state.lua_host.lua();

    // Round-trips for each recognized escape.
    let result: String = lua
        .load(
            r#"
                local te = require("pmacs-dired")._test
                local cases = {
                    { input = "plain.txt",       expect = "plain.txt"        },
                    { input = "a\\nb",           expect = "a\nb"             },
                    { input = "a\\rb",           expect = "a\rb"             },
                    { input = "a\\tb",           expect = "a\tb"             },
                    { input = "a\\\\b",          expect = "a\\b"             },
                    { input = "x\\x00y",         expect = "x\0y"             },
                    { input = "z\\xFFw",         expect = "z\255w"           },
                }
                for i, c in ipairs(cases) do
                    local out, err = te.unescape_displayable(c.input)
                    if out ~= c.expect then
                        return ("case " .. i .. " input=" .. c.input ..
                                " got=" .. tostring(out) ..
                                " want=" .. tostring(c.expect) ..
                                " err=" .. tostring(err))
                    end
                end
                return "ok"
            "#,
        )
        .eval()
        .expect("unescape eval");
    assert_eq!(result, "ok", "all escape round-trips must succeed");

    // Bad escapes are rejected.
    for bad in ["bad\\q", "bad\\", "bad\\xZZ", "bad\\x1"] {
        let probe = format!(
            r#"
                local te = require("pmacs-dired")._test
                local out, err = te.unescape_displayable({bad:?})
                if out == nil then return tostring(err) end
                return "accepted unexpectedly"
            "#
        );
        let msg: String = lua
            .load(&probe)
            .eval()
            .unwrap_or_else(|e| panic!("bad-escape eval failed for {bad}: {e}"));
        assert!(
            !msg.starts_with("accepted"),
            "must reject bad escape '{bad}'; got: {msg}"
        );
    }
}

// ---------------------------------------------------------------------------
// Pass-2 Finding High2 --- duplicate final-name rejection (overwrite case)
// ---------------------------------------------------------------------------
//
// Renaming a -> b where b is unchanged in the same dired listing would have
// the kernel's rename() silently overwrite b. The commit planner must catch
// this before any syscall fires.

#[test]
fn wdired_duplicate_final_name_rejected_pre_syscall() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("a.txt"), b"A-content").expect("write a");
    std::fs::write(dir.path().join("b.txt"), b"B-content").expect("write b");

    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    // Sort is "name" by default, so a.txt is line 1 and b.txt is line 2.
    // Edit a.txt's name to "b.txt": collides with the unchanged b.txt.
    state
        .lua_host
        .eval(
            Some("collide"),
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                local NAME_START = require("pmacs-dired")._test.NAME_START
                local ls1 = h.edit.line_start_marks[1]:get()
                local ls2 = h.edit.line_start_marks[2]:get()
                local eol1 = ls2 - 1  -- '\n' between line 1 and line 2
                h.buf:replace(ls1 + NAME_START, eol1, "b.txt")
            "#,
        )
        .expect("collide-edit");

    state
        .lua_host
        .eval(
            Some("commit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
        )
        .expect("commit eval");
    pump_until_commit_done(&mut state);

    let outcome = read_commit_outcome(&mut state);
    assert!(
        outcome.starts_with("failed: "),
        "collision must fail commit; got: {outcome}"
    );
    assert!(
        outcome.contains("collide") || outcome.contains("final name"),
        "error must describe collision: {outcome}"
    );
    // No syscall ran: both files still in their original state.
    assert!(dir.path().join("a.txt").exists(), "a.txt must remain");
    assert!(dir.path().join("b.txt").exists(), "b.txt must remain");
    let a_content = std::fs::read(dir.path().join("a.txt")).unwrap();
    assert_eq!(a_content, b"A-content", "a.txt content unchanged");
    let b_content = std::fs::read(dir.path().join("b.txt")).unwrap();
    assert_eq!(b_content, b"B-content", "b.txt content unchanged");
}

// Swap case: a -> b, b -> a. Final names are unique (no duplicate-final-name
// rejection), but a direct rename(a, b) would destroy b. The two-phase
// temp-name detour makes this safe.
#[test]
fn wdired_name_swap_via_two_phase_rename_succeeds() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("a.txt"), b"A-content").expect("write a");
    std::fs::write(dir.path().join("b.txt"), b"B-content").expect("write b");

    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    // Same-length swaps. Replacing same-length keeps the gravity-right
    // line-start marks at their original byte positions.
    state
        .lua_host
        .eval(
            Some("swap"),
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                local NAME_START = require("pmacs-dired")._test.NAME_START
                local ls1 = h.edit.line_start_marks[1]:get()
                local ls2 = h.edit.line_start_marks[2]:get()
                local eol1 = ls2 - 1
                local eol2 = h.buf:len()
                -- a.txt (line 1) -> "b.txt"
                h.buf:replace(ls1 + NAME_START, eol1, "b.txt")
                -- b.txt (line 2) -> "a.txt"
                h.buf:replace(ls2 + NAME_START, eol2, "a.txt")
            "#,
        )
        .expect("swap-edit");

    state
        .lua_host
        .eval(
            Some("commit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
        )
        .expect("commit eval");
    pump_until_commit_done(&mut state);

    let outcome = read_commit_outcome(&mut state);
    assert_eq!(outcome, "ok", "swap commit must succeed; got: {outcome}");

    // Disk shows swapped names with original contents.
    let a_after = std::fs::read(dir.path().join("a.txt")).unwrap();
    let b_after = std::fs::read(dir.path().join("b.txt")).unwrap();
    assert_eq!(
        a_after, b"B-content",
        "a.txt now holds B's original content"
    );
    assert_eq!(
        b_after, b"A-content",
        "b.txt now holds A's original content"
    );

    // No leftover temp files.
    let entries: Vec<String> = std::fs::read_dir(dir.path())
        .unwrap()
        .map(|e| e.unwrap().file_name().to_string_lossy().to_string())
        .collect();
    for e in &entries {
        assert!(
            !e.contains(".pmacs-wdired-tmp-"),
            "no temp files must remain; got: {entries:?}"
        );
    }
    assert_eq!(
        entries.len(),
        2,
        "only 2 entries expected; got: {entries:?}"
    );
}

// ---------------------------------------------------------------------------
// Pass-2 Finding High3 --- wdired-abandon refreshes from disk
// ---------------------------------------------------------------------------
//
// The wdired-commit error guidance points users at wdired-abandon to "refresh
// the listing from disk". Abandon must therefore actually re-read the
// directory, not just repaint from the cached snapshot.

#[test]
fn wdired_abandon_refreshes_listing_from_disk() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("orig.txt"), b"x").expect("write");

    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    // Externally rename orig.txt -> renamed.txt while the user is in
    // wdired-edit mode --- the precise scenario abandon should resync.
    std::fs::rename(dir.path().join("orig.txt"), dir.path().join("renamed.txt"))
        .expect("external rename");

    // Abandon: schedules an async refresh. Pump until the buffer
    // shows the post-rename listing.
    state
        .lua_host
        .eval(
            Some("abandon"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-abandon")"#,
        )
        .expect("abandon");
    pump_until(&mut state, |s| {
        s.lua_host
            .lua()
            .load(
                r"
                    local buf = pmacs.window.buffer()
                    local text = buf:slice(0, buf:len())
                    return text:find('renamed.txt', 1, true) ~= nil
                ",
            )
            .eval::<bool>()
            .unwrap_or(false)
    });

    let text = active_buffer_text(&mut state);
    assert!(
        text.contains("renamed.txt"),
        "buffer must show post-disk listing after abandon; got: {text}"
    );
    assert!(
        !text.contains("orig.txt"),
        "buffer must not show pre-rename name after abandon; got: {text}"
    );
}

// ---------------------------------------------------------------------------
// Pass-2 Finding Med4 --- detect_external_changes catches mode change
// ---------------------------------------------------------------------------
//
// The previous implementation compared only count + name set. An external
// chmod, truncate, or kind switch slipped through. This test pins the mode
// case; size / mtime / kind would all surface analogously.

#[test]
fn wdired_external_mode_change_at_commit_aborts() {
    let dir = tempfile::tempdir().expect("tempdir");
    let f = dir.path().join("file.txt");
    std::fs::write(&f, b"x").expect("write");
    std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o644)).expect("chmod 0o644");

    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    // External chmod between snapshot and commit. No user edit; commit
    // is intent-empty but external-change detection must abort it.
    std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o600)).expect("external chmod");

    state
        .lua_host
        .eval(
            Some("commit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
        )
        .expect("commit eval");
    pump_until_commit_done(&mut state);

    let outcome = read_commit_outcome(&mut state);
    assert!(
        outcome.starts_with("failed: "),
        "external mode change must fail commit; got: {outcome}"
    );
    assert!(
        outcome.contains("mode change") && outcome.contains("file.txt"),
        "error must describe the external mode change on file.txt: {outcome}"
    );
    // File mode on disk reflects the external chmod, not the snapshot.
    let mode = std::fs::metadata(&f).unwrap().permissions().mode() & 0o777;
    assert_eq!(mode, 0o600, "external chmod must remain on disk");
}

#[test]
fn wdired_external_same_size_same_second_rewrite_aborts() {
    for _attempt in 0..25 {
        let dir = tempfile::tempdir().expect("tempdir");
        let f = dir.path().join("file.txt");
        std::fs::write(&f, b"aa").expect("write initial");

        let (mut state, _c, _u) = editor_with_dired();
        open_dired_on(&mut state, &dir.path().display().to_string());
        state
            .lua_host
            .eval(
                Some("wd-edit"),
                r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
            )
            .expect("wdired-edit");

        let before = file_mtime_parts(&f);
        std::fs::write(&f, b"bb").expect("same-size rewrite");
        let after = file_mtime_parts(&f);
        if before.0 != after.0 || before.1 == after.1 {
            continue;
        }

        state
            .lua_host
            .eval(
                Some("commit"),
                r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
            )
            .expect("commit eval");
        pump_until_commit_done(&mut state);

        let outcome = read_commit_outcome(&mut state);
        assert!(
            outcome.starts_with("failed: "),
            "same-second rewrite must fail commit; got: {outcome}"
        );
        assert!(
            outcome.contains("mtime change") && outcome.contains("file.txt"),
            "error must describe external mtime change on file.txt: {outcome}"
        );
        return;
    }
    panic!("could not produce same-second rewrite with distinct nanoseconds");
}

// ---------------------------------------------------------------------------
// Pass-2 Finding Med5 --- inserts in perms rejected (length-changing)
// ---------------------------------------------------------------------------
//
// Inserting a char into the perms column would shift every subsequent column
// and break the fixed-width layout the commit parser relies on. The
// intercept must reject the insert at edit time, not let it through and
// fail later at parse time.

#[test]
fn wdired_perms_column_insert_rejected_at_intercept() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    // Insert a valid alphabet char ('r') into the middle of the perms
    // column. Bytes are valid; the rejection is for the length change.
    let msg: String = state
        .lua_host
        .lua()
        .load(
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                local ls = h.edit.line_start_marks[1]:get()
                local ok, err = pcall(function()
                    h.buf:insert(ls + 3, "r")
                end)
                assert(not ok, "insert in perms must be rejected")
                return tostring(err)
            "#,
        )
        .eval()
        .expect("perms-insert eval");
    assert!(
        msg.contains("fixed-width") || msg.contains("name column"),
        "rejection must explain fixed-width / name-only inserts; got: {msg}"
    );

    // Same check for delete inside perms: must also be rejected.
    let del_msg: String = state
        .lua_host
        .lua()
        .load(
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                local ls = h.edit.line_start_marks[1]:get()
                local ok, err = pcall(function()
                    h.buf:replace(ls + 1, ls + 2, "")  -- delete one perms char
                end)
                assert(not ok, "delete in perms must be rejected")
                return tostring(err)
            "#,
        )
        .eval()
        .expect("perms-delete eval");
    assert!(
        del_msg.contains("fixed-width") || del_msg.contains("preserve"),
        "rejection must explain fixed-width / length preservation; got: {del_msg}"
    );
}

// ---------------------------------------------------------------------------
// Pass-2 Finding Med6 --- symlink target edits rejected at commit
// ---------------------------------------------------------------------------
//
// Symlink target editing is out of scope for v0.1 wdired. Previously the
// commit silently stripped the rendered " -> target" suffix and produced no
// op when the user edited only the target --- their edit was discarded with
// no signal. Now an edited target text different from the snapshot's
// rendering aborts the commit explicitly.

#[test]
fn wdired_symlink_target_edit_rejected_at_commit() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("real.txt"), b"contents").expect("write target");
    std::os::unix::fs::symlink(dir.path().join("real.txt"), dir.path().join("link"))
        .expect("create symlink");

    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    // Find the symlink line and overwrite its name region with a
    // different target text but the same basename. The commit must
    // refuse: target edits are not supported in v0.1.
    state
        .lua_host
        .eval(
            Some("target-edit"),
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                local NAME_START = require("pmacs-dired")._test.NAME_START
                local symlink_idx
                for i, e in ipairs(h.edit.snapshot) do
                    if e.kind == "symlink" then symlink_idx = i; break end
                end
                assert(symlink_idx, "symlink must be in the snapshot")
                local ls = h.edit.line_start_marks[symlink_idx]:get()
                local line_end
                if symlink_idx < #h.edit.line_start_marks then
                    line_end = h.edit.line_start_marks[symlink_idx + 1]:get() - 1
                else
                    line_end = h.buf:len()
                end
                -- Replace the entire name region. The basename "link"
                -- is unchanged but the target portion now reads
                -- "elsewhere", which doesn't match the snapshot's
                -- escape_displayable(real.txt) -- different bytes.
                h.buf:replace(ls + NAME_START, line_end, "link -> elsewhere")
            "#,
        )
        .expect("target-edit");

    state
        .lua_host
        .eval(
            Some("commit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
        )
        .expect("commit eval");
    pump_until_commit_done(&mut state);

    let outcome = read_commit_outcome(&mut state);
    assert!(
        outcome.starts_with("failed: "),
        "symlink target edit must fail commit; got: {outcome}"
    );
    assert!(
        outcome.contains("symlink target"),
        "error must say 'symlink target': {outcome}"
    );

    // Disk symlink unchanged.
    let target = std::fs::read_link(dir.path().join("link")).expect("readlink");
    assert_eq!(
        target,
        dir.path().join("real.txt"),
        "symlink target must not have been changed on disk"
    );
}

// ---------------------------------------------------------------------------
// Pass-2 review High1 --- chmod on a symlink line is rejected at intercept
// ---------------------------------------------------------------------------
//
// Per src/fs.rs:349, `chmod(2)` follows symlinks: chmodding the displayed
// link line would silently mutate the *target* file's mode, while a refresh
// would then show the link's unchanged lstat perms (commonly 0o777). That
// asymmetry is too sharp an edge to leave editable in v0.1 wdired.

#[test]
fn wdired_symlink_perms_edit_rejected_at_intercept() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("real.txt"), b"contents").expect("write target");
    std::os::unix::fs::symlink(dir.path().join("real.txt"), dir.path().join("link"))
        .expect("create symlink");

    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    // Find the symlink line and try to replace one perms char. Same-length
    // replace + valid alphabet would normally pass --- the rejection here
    // is specifically because the line is a symlink.
    let msg: String = state
        .lua_host
        .lua()
        .load(
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                local symlink_idx
                for i, e in ipairs(h.edit.snapshot) do
                    if e.kind == "symlink" then symlink_idx = i; break end
                end
                assert(symlink_idx, "symlink must be in the snapshot")
                local ls = h.edit.line_start_marks[symlink_idx]:get()
                -- Position 1 is the first perms char of this line.
                local ok, err = pcall(function()
                    h.buf:replace(ls + 1, ls + 2, "r")
                end)
                assert(not ok, "perms replace on symlink line must be rejected")
                return tostring(err)
            "#,
        )
        .eval()
        .expect("symlink-perms eval");
    assert!(
        msg.contains("symlink") && msg.contains("perms column"),
        "rejection must mention symlink + perms column: {msg}"
    );
    assert!(
        msg.contains("chmod follows symlinks"),
        "rejection must explain why (chmod follows symlinks): {msg}"
    );

    // Disk untouched: target file's mode and the link itself unchanged.
    let target_mode = std::fs::metadata(dir.path().join("real.txt"))
        .unwrap()
        .permissions()
        .mode()
        & 0o777;
    let link_target =
        std::fs::read_link(dir.path().join("link")).expect("readlink after rejection");
    assert_eq!(
        link_target,
        dir.path().join("real.txt"),
        "symlink unchanged"
    );
    // We didn't set an explicit mode on real.txt, so we don't assert a
    // specific number; we just confirm the rejection path didn't fire a
    // chmod (the commit was never reached).
    let _ = target_mode;
}

// ---------------------------------------------------------------------------
// Pass-2 review High2 --- decoded names containing NUL are rejected
// ---------------------------------------------------------------------------
//
// unescape_displayable accepts \x00, but POSIX filenames cannot contain NUL.
// Without this check, a chmod might fire on the source path before the
// rename syscall fails at the kernel boundary --- a partial commit. Reject
// at the planner.

#[test]
fn wdired_commit_rejects_nul_in_decoded_name() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    // Replace name with literal "bad\x00name" --- escape_displayable would
    // *render* such a real name as "bad\\x00name", and the user typing
    // exactly that produces "bad<NUL>name" via unescape. Reject pre-syscall.
    state
        .lua_host
        .eval(
            Some("nul-name"),
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                local NAME_START = require("pmacs-dired")._test.NAME_START
                local ls = h.edit.line_start_marks[1]:get()
                h.buf:replace(ls + NAME_START, h.buf:len(), "bad\\x00name")
            "#,
        )
        .expect("nul-name edit");

    state
        .lua_host
        .eval(
            Some("commit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
        )
        .expect("commit eval");
    pump_until_commit_done(&mut state);

    let outcome = read_commit_outcome(&mut state);
    assert!(
        outcome.starts_with("failed: "),
        "NUL in decoded name must fail commit; got: {outcome}"
    );
    assert!(outcome.contains("NUL"), "error must mention NUL: {outcome}");
    // Original file unchanged.
    assert!(
        dir.path().join("file.txt").exists(),
        "file.txt must remain since commit was rejected"
    );
}

// ---------------------------------------------------------------------------
// Pass-2 review Med3 --- symlink names containing literal " -> " round-trip
// ---------------------------------------------------------------------------
//
// A symlink whose basename literally contains " -> " renders as
// `<name with arrow> -> <target>`. The naive "split at first ' -> '" decode
// would mistake everything after the first arrow for the target text and
// flag a no-op commit as a target edit. Suffix-stripping fixes this.

#[test]
fn wdired_symlink_basename_with_arrow_substring_round_trips() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("real.txt"), b"contents").expect("write target");
    // Create a symlink whose basename literally contains " -> ".
    let weird_link_name = "a -> b";
    std::os::unix::fs::symlink(
        dir.path().join("real.txt"),
        dir.path().join(weird_link_name),
    )
    .expect("create symlink with arrow in name");

    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    // No-op commit. Must succeed, no rename, link unchanged.
    state
        .lua_host
        .eval(
            Some("commit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
        )
        .expect("commit eval");
    pump_until_commit_done(&mut state);

    let outcome = read_commit_outcome(&mut state);
    assert_eq!(
        outcome, "ok",
        "no-op commit on weirdly-named symlink must succeed; got: {outcome}"
    );
    // Symlink with arrow-in-name still exists, still pointing at real.txt.
    let link_target = std::fs::read_link(dir.path().join(weird_link_name))
        .expect("link with arrow in name still readable");
    assert_eq!(
        link_target,
        dir.path().join("real.txt"),
        "symlink target unchanged"
    );
}

// ---------------------------------------------------------------------------
// Pass-2 review Med4 --- syscall success and refresh failure are distinct
// ---------------------------------------------------------------------------
//
// If all chmod / rename ops landed but the post-commit navigate_to fails
// (e.g., the parent dir vanished between the last syscall and the
// refresh), the disk changes are real --- reporting "failed:" would
// mislead the user. The new outcome shape is "applied; refresh failed: ...".
//
// We exercise the split by injecting a synthetic failure into
// `pmacs.fs.read_dir`'s *second* call within wdired-commit: the first
// is `detect_external_changes` (must pass to reach the syscall phase),
// the second is the post-commit `navigate_to` refresh (forced to fail).
// A no-op commit (nothing to chmod / rename) reaches the refresh
// without touching disk, so the test is deterministic --- no real
// syscalls race the hook.

#[test]
fn wdired_refresh_failure_after_apply_reports_applied_distinctly() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("only.txt"), b"x").expect("write only");

    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    // Hook pmacs.fs.read_dir: first call (external-change detection)
    // delegates to the real impl, second + later calls return a future
    // whose :await() raises a synthetic failure.
    state
        .lua_host
        .eval(
            Some("install-readdir-hook"),
            r#"
                local real = pmacs.fs.read_dir
                local calls = 0
                pmacs.fs.read_dir = function(path)
                    calls = calls + 1
                    if calls >= 2 then
                        return setmetatable({}, {
                            __index = {
                                await = function()
                                    error("synthetic refresh failure")
                                end,
                            },
                        })
                    end
                    return real(path)
                end
            "#,
        )
        .expect("hook install");

    // No-op commit. Validation + planning succeed (no chmod / rename
    // ops since nothing changed); detect_external_changes reads disk
    // (call #1, real). do_wdired_commit returns cleanly. The async
    // wrapper then calls navigate_to (call #2, forced failure) ---
    // outcome must be "applied; refresh failed: ...".
    state
        .lua_host
        .eval(
            Some("commit-noop"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
        )
        .expect("commit eval");
    pump_until_commit_done(&mut state);

    let outcome = read_commit_outcome(&mut state);
    assert!(
        outcome.starts_with("applied; refresh failed: "),
        "syscall success + refresh failure must use applied-prefix outcome; got: {outcome}"
    );
    assert!(
        outcome.contains("synthetic refresh failure"),
        "outcome must surface the underlying refresh error: {outcome}"
    );

    // Disk untouched (no-op commit). The original file is still there.
    assert!(
        dir.path().join("only.txt").exists(),
        "no-op commit must not have touched disk"
    );
}

#[test]
fn wdired_commit_rejects_second_invocation_while_pending() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("only.txt"), b"x").expect("write only");

    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    let msg: String = state
        .lua_host
        .lua()
        .load(
            r#"
                local ok1, err1 = pcall(function()
                    pmacs.command.invoke("pmacs-dired.wdired-commit")
                end)
                assert(ok1, "first commit should schedule cleanly: " .. tostring(err1))
                local ok2, err2 = pcall(function()
                    pmacs.command.invoke("pmacs-dired.wdired-commit")
                end)
                assert(not ok2, "second commit while first is pending must be rejected")
                return tostring(err2)
            "#,
        )
        .eval()
        .expect("double commit eval");
    assert!(
        msg.contains("already in progress"),
        "second commit error must explain in-progress commit; got: {msg}"
    );

    pump_until_commit_done(&mut state);
    let outcome = read_commit_outcome(&mut state);
    assert_eq!(
        outcome, "ok",
        "first commit should still complete; got: {outcome}"
    );
}

#[test]
fn wdired_mid_batch_rename_failure_reports_partial_and_refreshes() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("a.txt"), b"a").expect("write a");
    std::fs::write(dir.path().join("b.txt"), b"b").expect("write b");

    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());
    state
        .lua_host
        .eval(
            Some("wd-edit"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-edit")"#,
        )
        .expect("wdired-edit");

    edit_wdired_names(&mut state, &[("a.txt", "aa.txt"), ("b.txt", "bb.txt")]);
    install_second_rename_failure_hook(&mut state);

    state
        .lua_host
        .eval(
            Some("commit-partial"),
            r#"pmacs.command.invoke("pmacs-dired.wdired-commit")"#,
        )
        .expect("commit eval");
    pump_until_commit_done(&mut state);

    let outcome = read_commit_outcome(&mut state);
    assert!(
        outcome.starts_with("partially applied: "),
        "mid-batch syscall failure must report partial application; got: {outcome}"
    );
    assert!(
        outcome.contains("synthetic mid-batch rename failure") && outcome.contains("refreshed"),
        "outcome must include original failure and refresh status; got: {outcome}"
    );

    assert!(
        !wdired_edit_active(&mut state),
        "partial application must leave wdired-edit mode"
    );

    assert!(
        !dir.path().join("a.txt").exists(),
        "first rename should have moved a.txt to a temp name before the injected failure"
    );
    assert!(
        dir.path().join("b.txt").exists(),
        "second rename should not have run the real syscall"
    );
    let entries: Vec<String> = std::fs::read_dir(dir.path())
        .expect("read tempdir")
        .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
        .collect();
    assert!(
        entries.iter().any(|e| e.contains(".pmacs-wdired-tmp-")),
        "partial failure should leave the already-moved temp name visible; got: {entries:?}"
    );
}

// ---------------------------------------------------------------------------
// M8.4 audit finding 2 --- paint() must clear the painting flag on error
// ---------------------------------------------------------------------------
//
// The `painting` flag is a bypass-flag pattern (M6.9 finding 7 shape) that
// tells the package's wdired intercept to let paint()'s own writes pass
// through without re-validating each as a user edit. If `:replace` raises
// while the flag is set, the flag must still clear --- otherwise the next
// user edit would silently bypass the intercept and corrupt the buffer
// without validation. The audit fix wraps the buffer write in pcall.
//
// To exercise the failure path deterministically, attach a *second*
// intercept that always raises. The package's intercept (when present)
// honors the painting flag; a foreign intercept does not, so it fires
// during paint() and triggers the error path.

#[test]
fn dired_paint_clears_painting_flag_on_buffer_replace_error() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());

    // Sort once to confirm the baseline (no error, paint clean).
    state
        .lua_host
        .eval(
            Some("baseline-sort"),
            r#"pmacs.command.invoke("pmacs-dired.sort-name")"#,
        )
        .expect("baseline sort");

    // Attach a foreign intercept that always raises. This intercept
    // doesn't know about the package's `painting` flag, so it fires
    // even when paint() is in progress.
    state
        .lua_host
        .eval(
            Some("attach-failing-intercept"),
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                _G.M8_4_FOREIGN_INTERCEPT = pmacs.buffer.add_intercept(
                    h.buf,
                    function(op) error("synthetic intercept failure") end
                )
            "#,
        )
        .expect("attach intercept");

    // Trigger paint via a sort command. Paint flips painting=true,
    // calls :replace, the foreign intercept raises, the buffer write
    // errors. The package's pcall wrap should restore painting=false
    // before re-raising.
    let painting_state: bool = state
        .lua_host
        .lua()
        .load(
            r#"
                local h = require("pmacs-dired")._test.active_handle()
                local ok, err = pcall(function()
                    pmacs.command.invoke("pmacs-dired.sort-mtime")
                end)
                assert(not ok, "sort must propagate the synthetic failure")
                -- The flag must be cleared (false or nil --- both count).
                return h.painting == true
            "#,
        )
        .eval()
        .expect("paint-flag eval");
    assert!(
        !painting_state,
        "painting flag must clear after a paint() error; if it stays \
         set, subsequent user edits silently bypass the intercept"
    );

    // Detach the foreign intercept so the editor cleanup is clean.
    state
        .lua_host
        .eval(
            Some("detach-foreign-intercept"),
            "pmacs.buffer.remove_intercept(_G.M8_4_FOREIGN_INTERCEPT)",
        )
        .expect("detach intercept");
}

// Companion to the previous test, pinning the *eager-evaluation* case the
// initial pcall scope missed: Lua evaluates pcall's argument list before
// entering the protected scope, so `pcall(fn, a, b, buf:len())` would
// invoke `buf:len()` outside the pcall. If `:len()` raises (e.g., because
// the underlying buffer was removed via `pmacs.buffer.remove` between
// paint() entries), the painting flag would skip past its clear-step.
// The corrected fix wraps the *whole* op in a closure: `pcall(function()
// handle.buf:replace(0, handle.buf:len(), text) end)`. This test forces a
// `:len()` failure by stubbing `handle.buf` with a Lua table that errors
// on :len; the flag must still clear.
#[test]
fn dired_paint_clears_painting_flag_on_len_error_too() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());

    // Stub handle.buf so :len() raises before :replace is reached.
    // We invoke paint() directly via the test seam (not through a
    // command) to avoid `active_handle()`'s identity check, which
    // matches handles by `h.buf == active_buffer` --- swapping
    // h.buf for a stub table would break that lookup and the
    // command would short-circuit before reaching paint().
    // We restore h.buf before the assertions so editor teardown is clean.
    let painting_state: bool = state
        .lua_host
        .lua()
        .load(
            r#"
                local te = require("pmacs-dired")._test
                local h = te.active_handle()
                local original_buf = h.buf
                h.buf = setmetatable({}, {
                    __index = function(_, k)
                        if k == "len" then
                            return function() error("synthetic len failure") end
                        end
                        if k == "replace" then
                            return function() error("should not reach replace") end
                        end
                        return original_buf[k]
                    end,
                })
                local ok, err = pcall(te.paint, h)
                local stuck = (h.painting == true)
                h.buf = original_buf
                assert(not ok, "paint must propagate the synthetic len failure")
                return stuck
            "#,
        )
        .eval()
        .expect("paint-flag-on-len-error eval");
    assert!(
        !painting_state,
        "painting flag must clear even when :len() fails before :replace; \
         eager-evaluation of pcall arguments would otherwise skip the clear-step"
    );
}

#[test]
fn dired_active_handle_is_cleared_when_active_buffer_removed() {
    let dir = tempfile::tempdir().expect("tempdir");
    std::fs::write(dir.path().join("file.txt"), b"x").expect("write");
    let (mut state, _c, _u) = editor_with_dired();
    open_dired_on(&mut state, &dir.path().display().to_string());

    let active_cleared: bool = state
        .lua_host
        .lua()
        .load(
            r#"
                local te = require("pmacs-dired")._test
                local h = te.active_handle()
                assert(h ~= nil, "dired handle should exist before remove")
                pmacs.buffer.remove(h.buf)
                return te.active_handle() == nil
            "#,
        )
        .eval()
        .expect("active-handle-after-remove eval");
    assert!(
        active_cleared,
        "active_handle must not return a stale handle after its buffer is removed"
    );
}
