//! End-to-end tests that drive the real `shuire` TUI through the
//! [`tuistory`](https://github.com/) terminal harness.
//!
//! Each test spawns a PTY with `tuistory launch`, sends keystrokes, waits for
//! expected UI text, asserts on the terminal snapshot, then closes the session.
//!
//! These tests are `#[ignore]`d by default because they depend on an external
//! binary (`tuistory`) and a built `shuire`. Run them explicitly:
//!
//! ```text
//! cargo build --release --bin shuire   # any profile works; tests use the bin path
//! cargo test --test e2e -- --ignored --test-threads=1
//! ```
//!
//! `--test-threads=1` keeps the shared comment-storage file from colliding
//! between sessions. `--clean` is also passed on every launch so tests do not
//! inherit stale state from a previous run.

use std::path::PathBuf;
use std::process::{Command, Output};

const MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR");
const SHUIRE_BIN: &str = env!("CARGO_BIN_EXE_shuire");

/// Quote a CLI argument so that tuistory's shell-style parser preserves it
/// verbatim — critical for passing JSON blobs through `--comment`.
fn shell_quote(arg: &str) -> String {
    let safe = arg
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || "-_./=:@".contains(c));
    if safe {
        arg.to_string()
    } else {
        format!("'{}'", arg.replace('\'', r"'\''"))
    }
}

fn tuistory_raw(args: &[&str]) -> Option<Output> {
    Command::new("tuistory").args(args).output().ok()
}

fn tuistory_ok(args: &[&str]) -> String {
    let out = tuistory_raw(args).expect("tuistory must be installed and on PATH");
    assert!(
        out.status.success(),
        "`tuistory {}` failed\nstdout: {}\nstderr: {}",
        args.join(" "),
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr),
    );
    String::from_utf8_lossy(&out.stdout).into_owned()
}

fn tuistory_available() -> bool {
    Command::new("tuistory")
        .arg("--version")
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

struct Session {
    name: String,
}

impl Session {
    fn launch(name: &str, extra_args: &[&str]) -> Self {
        let sample = format!("{MANIFEST_DIR}/testdata/sample.diff");
        let mut cmd = format!("{SHUIRE_BIN} --from-file {sample} --theme dark --no-emoji --clean");
        for a in extra_args {
            cmd.push(' ');
            cmd.push_str(&shell_quote(a));
        }
        Self::launch_raw(name, &cmd)
    }

    fn launch_raw(name: &str, cmd: &str) -> Self {
        let _ = tuistory_raw(&["close", "-s", name]);
        tuistory_ok(&["launch", cmd, "-s", name, "--cols", "140", "--rows", "40"]);
        Self { name: name.into() }
    }

    fn wait(&self, pat: &str) {
        tuistory_ok(&["wait", pat, "-s", &self.name, "--timeout", "10000"]);
    }

    fn wait_idle(&self) {
        let _ = tuistory_raw(&["wait-idle", "-s", &self.name]);
    }

    fn press(&self, keys: &[&str]) {
        let mut args: Vec<&str> = vec!["press"];
        args.extend_from_slice(keys);
        args.extend_from_slice(&["-s", &self.name]);
        tuistory_ok(&args);
    }

    fn type_text(&self, text: &str) {
        tuistory_ok(&["type", text, "-s", &self.name]);
    }

    fn snapshot(&self) -> String {
        tuistory_ok(&["snapshot", "-s", &self.name, "--trim"])
    }
}

impl Drop for Session {
    fn drop(&mut self) {
        let _ = tuistory_raw(&["close", "-s", &self.name]);
    }
}

macro_rules! require_tuistory {
    () => {
        if !tuistory_available() {
            eprintln!("skipping: `tuistory` is not installed");
            return;
        }
    };
}

#[test]
#[ignore]
fn scenario_launch_renders_file_list() {
    require_tuistory!();
    let s = Session::launch("shuire-e2e-launch", &[]);
    s.wait("FILES");
    s.wait("README.md");

    let snap = s.snapshot();
    for expected in ["README.md", "Cargo.toml", "src"] {
        assert!(
            snap.contains(expected),
            "expected `{expected}` in snapshot:\n{snap}"
        );
    }
}

#[test]
#[ignore]
fn scenario_tab_moves_to_next_file() {
    require_tuistory!();
    let s = Session::launch("shuire-e2e-nav", &[]);
    s.wait("README.md");

    s.press(&["tab"]);
    s.wait("Cargo.toml");

    s.press(&["tab"]);
    s.wait("main.rs");

    s.press(&["shift", "tab"]);
    s.wait_idle();
    let snap = s.snapshot();
    assert!(
        snap.contains("Cargo.toml"),
        "expected Cargo.toml after Shift-Tab:\n{snap}"
    );
}

#[test]
#[ignore]
fn scenario_toggle_split_view() {
    require_tuistory!();
    let s = Session::launch("shuire-e2e-split", &["--mode", "unified"]);
    s.wait("README.md");
    s.wait_idle();
    let unified = s.snapshot();

    s.press(&["s"]);
    s.wait_idle();
    let split = s.snapshot();

    assert_ne!(
        unified, split,
        "toggling split view should change the rendered layout"
    );

    s.press(&["s"]);
    s.wait_idle();
    let unified_again = s.snapshot();
    assert_ne!(split, unified_again);
}

#[test]
#[ignore]
fn scenario_help_overlay_toggles() {
    require_tuistory!();
    let s = Session::launch("shuire-e2e-help", &[]);
    s.wait("FILES");

    // `?` goes through `type`, not `press`: `press shift slash` yields
    // a chord, but shuire's key handler wants the literal `?` char.
    s.type_text("?");
    s.wait("Navigation");

    let with_help = s.snapshot();
    assert!(
        with_help.contains("朱入レ"),
        "expected help body:\n{with_help}"
    );

    s.press(&["esc"]);
    s.wait_idle();
    let dismissed = s.snapshot();
    assert!(
        !dismissed.contains("Navigation"),
        "help overlay should be dismissed:\n{dismissed}"
    );
}

#[test]
#[ignore]
fn scenario_file_filter() {
    require_tuistory!();
    let s = Session::launch("shuire-e2e-filter", &[]);
    s.wait("README.md");

    s.press(&["h"]);
    s.type_text("/");
    s.wait_idle();
    s.type_text("api");
    s.wait_idle();

    let snap = s.snapshot();
    assert!(
        snap.contains("api"),
        "expected /api filter marker in header:\n{snap}"
    );
    assert!(
        snap.contains("handlers.rs") || snap.contains("routes.rs"),
        "expected matching api/* files to remain:\n{snap}"
    );

    s.press(&["esc"]);
    s.wait_idle();
}

fn scratch_dir(tag: &str) -> PathBuf {
    let dir = std::env::temp_dir().join(format!("shuire-e2e-{}-{}", tag, std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).expect("create scratch dir");
    dir
}

#[test]
#[ignore]
fn scenario_stdout_emits_final_comments() {
    require_tuistory!();

    let scratch = scratch_dir("stdout");
    let script_path = scratch.join("run.sh");

    let marker = "e2e-stdout-marker-7a3b";
    let comment_json = format!(r#"{{"file":"README.md","line":1,"body":"{marker}"}}"#);

    // Why a wrapper script instead of redirecting stdout to a file:
    // shuire shares one stdout between ratatui's alternate-screen escape
    // sequences and the post-teardown `print!` dump. Redirecting eats the
    // UI; we need it in the PTY. So we let the final `print!` land on the
    // restored main screen, then echo a sentinel so the test can sync on
    // shuire having exited cleanly.
    let sample = format!("{MANIFEST_DIR}/testdata/sample.diff");
    let script_body = format!(
        "#!/bin/sh\n\
         {bin} --from-file {sample} --theme dark --no-emoji --clean \
         --comment '{json}'\n\
         echo __E2E_STDOUT_DONE__\n",
        bin = SHUIRE_BIN,
        json = comment_json,
    );
    std::fs::write(&script_path, script_body).expect("write wrapper script");
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))
            .expect("chmod +x");
    }

    let s = Session::launch_raw(
        "shuire-e2e-stdout",
        &format!("sh {}", script_path.display()),
    );
    s.wait("README.md");
    s.press(&["q"]);
    s.wait("__E2E_STDOUT_DONE__");

    let snap = s.snapshot();
    assert!(
        snap.contains(marker),
        "expected saved comment body on restored main screen:\n{snap}"
    );
    assert!(
        snap.contains("README.md:"),
        "expected location header on restored main screen:\n{snap}"
    );
}

#[test]
#[ignore]
fn scenario_clipboard_yank_all() {
    require_tuistory!();

    // arboard needs a running X11/Wayland session on Linux; skip gracefully
    // on headless hosts instead of failing the whole suite.
    let mut clipboard = match arboard::Clipboard::new() {
        Ok(cb) => cb,
        Err(e) => {
            eprintln!("skipping: clipboard backend unavailable ({e})");
            return;
        }
    };
    // Seed the clipboard so the post-`Y` assertion proves *this* run wrote
    // to it, not a leftover from a previous session.
    let sentinel = "e2e-clipboard-pre-sentinel";
    let _ = clipboard.set_text(sentinel);

    let marker = "e2e-clipboard-marker-8d4f";
    let comment_json = format!(r#"[{{"file":"README.md","line":1,"body":"{marker}"}}]"#);
    let s = Session::launch("shuire-e2e-clipboard", &["--comment", &comment_json]);
    s.wait("README.md");

    s.press(&["shift", "y"]);
    s.wait("Copied");
    s.wait_idle();

    let got = clipboard.get_text().expect("read clipboard after Y");
    assert_ne!(got, sentinel, "clipboard was never updated by `Y`");
    assert!(
        got.contains(marker),
        "expected marker in clipboard, got: {got:?}"
    );
    assert!(
        got.contains("README.md:"),
        "expected location header in clipboard, got: {got:?}"
    );
}

#[test]
#[ignore]
fn scenario_add_comment_roundtrip() {
    require_tuistory!();
    let s = Session::launch("shuire-e2e-comment", &[]);
    s.wait("README.md");

    s.press(&["l"]);
    s.press(&["j"]);
    s.press(&["i"]);
    s.wait("New Comment");

    let body = "e2e-roundtrip-marker";
    s.type_text(body);
    s.wait_idle();
    s.press(&["ctrl", "s"]);
    s.wait_idle();

    let snap = s.snapshot();
    assert!(
        snap.contains(body),
        "saved comment body should appear in the diff pane:\n{snap}"
    );
}

#[test]
#[ignore]
fn scenario_search_highlights_match() {
    require_tuistory!();
    let s = Session::launch("shuire-e2e-search", &[]);
    s.wait("README.md");

    // Search for a unique string that only exists in README.md's new hunk so
    // we can assert the cursor landed on that line.
    s.press(&["l"]); // focus diff pane
    s.type_text("/");
    s.wait_idle();
    s.type_text("enjoy");
    s.press(&["enter"]);
    s.wait_idle();

    let after_search = s.snapshot();
    assert!(
        after_search.contains("enjoy"),
        "match body should still be visible:\n{after_search}"
    );
    assert!(
        after_search.contains("Search: enjoy") || after_search.contains("/enjoy"),
        "expected active-search marker in status bar:\n{after_search}"
    );

    // Esc clears the active search; the marker should be gone.
    s.press(&["esc"]);
    s.wait_idle();
    let cleared = s.snapshot();
    assert!(
        !cleared.contains("Search: enjoy"),
        "Esc should clear the search marker:\n{cleared}"
    );
}

#[test]
#[ignore]
fn scenario_delete_comment_with_dd() {
    require_tuistory!();

    // Seed the comment on the file that ends up at the top of the tree (the
    // initial selection), so we don't have to navigate before the delete.
    let marker = "e2e-delete-marker-5b2c";
    let comment_json =
        format!(r#"{{"file":".github/workflows/ci.yml","line":1,"body":"{marker}"}}"#);
    let s = Session::launch("shuire-e2e-delete-comment", &["--comment", &comment_json]);
    s.wait("ci.yml");

    // Focus the diff pane and focus the comment: `j` from a line with a
    // comment attached lifts `comment_focus` to Some(0) instead of advancing
    // the cursor — which is what `dd` operates on.
    s.press(&["l"]);
    s.press(&["j"]);
    s.wait_idle();
    let before = s.snapshot();
    assert!(
        before.contains(marker),
        "seed comment should be visible before delete:\n{before}"
    );

    // `dd` deletes the focused comment.
    s.press(&["d"]);
    s.press(&["d"]);
    s.wait_idle();

    let after = s.snapshot();
    assert!(
        !after.contains(marker),
        "comment body should be gone after dd:\n{after}"
    );
    assert!(
        after.contains("Comment deleted"),
        "expected flash toast after delete:\n{after}"
    );
}

#[test]
#[ignore]
fn scenario_file_list_toggle_with_shift_f() {
    require_tuistory!();
    let s = Session::launch("shuire-e2e-filelist", &[]);
    s.wait("FILES");

    // With file list visible we should see the FILES header.
    let with_list = s.snapshot();
    assert!(with_list.contains("FILES"));

    // Shift-F hides the file list; the header should disappear.
    s.press(&["shift", "f"]);
    s.wait_idle();
    let hidden = s.snapshot();
    assert!(
        !hidden.contains("FILES"),
        "FILES header should be hidden after Shift-F:\n{hidden}"
    );

    // Toggle back.
    s.press(&["shift", "f"]);
    s.wait_idle();
    let shown = s.snapshot();
    assert!(
        shown.contains("FILES"),
        "FILES header should reappear:\n{shown}"
    );
}
