//! Declarative end-to-end TUI scenario tests.
//!
//! These tests exercise the real `kigi-tui` binary through a PTY using
//! YAML scenarios under `tests/scenarios/`. They are ignored by default because
//! they build/spawn the pager and stream through a mock inference server.

use std::path::PathBuf;

use kigi_pager_pty_harness::{
    ScriptedRunConfig, ScriptedRunStatus, ScriptedScenario, ScriptedScenarioRunner, pager_binary,
};

fn scenario_path(name: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("scenarios")
        .join(name)
}

async fn run_scenario(name: &str) {
    let scenario = ScriptedScenario::from_file(&scenario_path(name)).expect("load scenario");
    let artifact_dir = std::env::var_os("CARGO_TARGET_TMPDIR")
        .map(PathBuf::from)
        .unwrap_or_else(std::env::temp_dir)
        .join("scripted-scenarios");
    let runner = ScriptedScenarioRunner::new(ScriptedRunConfig::new(
        pager_binary().expect("resolve pager binary"),
        artifact_dir,
    ));
    let report = runner.run(&scenario).await.expect("run scenario");

    assert_eq!(
        report.status,
        ScriptedRunStatus::Passed,
        "scenario {} did not pass; report: {report:#?}",
        report.scenario,
    );
    assert!(
        report.bugs.is_empty(),
        "scenario {} identified bugs: {:#?}",
        report.scenario,
        report.bugs
    );
    assert!(
        report
            .steps
            .iter()
            .any(|step| step.action == "screenshot" && !step.artifacts.is_empty()),
        "scenario {} did not capture screenshots/artifacts",
        report.scenario
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn scripted_welcome_screen() {
    run_scenario("welcome.yaml").await;
}

/// Resize while slash dropdown is open must not kill the pager.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_slash_resize_storm() {
    run_scenario("slash_resize_storm.yaml").await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn scripted_mock_response() {
    run_scenario("mock_response.yaml").await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn scripted_input_modalities() {
    run_scenario("input_modalities.yaml").await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn scripted_ansi_execute_output() {
    run_scenario("ansi_execute_output.yaml").await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn scripted_copy_selection() {
    run_scenario("copy_selection.yaml").await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn scripted_table_cell_selection() {
    run_scenario("table_cell_selection.yaml").await;
}

/// Type-to-find pickers carry vim-mode. With vim-mode ON the command
/// palette opens in INPUT (a letter filters immediately); Esc clears the query
/// then a second Esc drops to NAV (letters no longer filter); `i` re-enters
/// INPUT. The footer's `i search` hint is absent on open (input) and present in
/// nav.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_vim_modal_command_palette() {
    run_scenario("vim_modal_command_palette.yaml").await;
}

/// Tool header selection copies only the operand (path/command), not the
/// `Read ` / `Run ` / `$ ` label — regression guard for Selectable::Spans on
/// tool-call headers.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_tool_header_path_selection() {
    run_scenario("tool_header_path_selection.yaml").await;
}

/// Typed `[` must appear in the prompt without a follow-up keystroke.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_bracket_prompt_input() {
    run_scenario("bracket_prompt_input.yaml").await;
}

/// Double-clicking a `[Pasted: N lines]` chip expands it into plain
/// editable prompt text (user feedback: clicking the chip means "edit").
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_paste_chip_double_click() {
    run_scenario("paste_chip_double_click.yaml").await;
}

/// Pasting identical content twice expands the chip instead of stacking a
/// duplicate one ("paste again" means "show the text").
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_paste_chip_repaste() {
    run_scenario("paste_chip_repaste.yaml").await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore]
async fn scripted_image_inputs() {
    run_scenario("image_inputs.yaml").await;
}

/// Path-free image-chip preview and dismissal on a non-graphics PTY.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_image_chip_preview_path_free() {
    run_scenario("image_chip_preview_path_free.yaml").await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_image_normalize_corrupt_dropped() {
    run_scenario("image_normalize_corrupt_dropped.yaml").await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_image_normalize_persist_atomic() {
    run_scenario("image_normalize_persist_atomic.yaml").await;
}

/// Mermaid fence (the engine is always compiled in) → inline Unicode-art diagram
/// + clickable affordance row ([Open Image] [Copy Image Path] [Copy Source]) and
/// NO inline image. The diagram renders as text art and the run never panics.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_mermaid_affordances() {
    run_scenario("mermaid-affordances.yaml").await;
}

/// User report: file path with a space (`Demo App.app`) must render
/// fully and the ptyctl/PTY harness must carry OSC 8 for the *full* path
/// (`Demo%20App.app`), not a truncated link ending at `Demo`.
/// Screen assertions live in `path_space_hyperlink.yaml`; this wrapper also
/// checks `raw_output.bin` so CI has byte-level proof without only relying on
/// the ignored `pty_e2e` test.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_path_space_hyperlink() {
    const FILE_URL_MARKER: &str = "Demo%20App.app";

    let scenario = ScriptedScenario::from_file(&scenario_path("path_space_hyperlink.yaml"))
        .expect("load scenario");
    let artifact_dir = std::env::var_os("CARGO_TARGET_TMPDIR")
        .map(PathBuf::from)
        .unwrap_or_else(std::env::temp_dir)
        .join("scripted-scenarios");
    let runner = ScriptedScenarioRunner::new(ScriptedRunConfig::new(
        pager_binary().expect("resolve pager binary"),
        artifact_dir,
    ));
    let report = runner.run(&scenario).await.expect("run scenario");

    let raw_path = report.artifact_dir.join("raw_output.bin");
    let raw_bytes = std::fs::read(&raw_path).unwrap_or_else(|err| {
        panic!(
            "raw_output.bin missing at {} (report status: {:?}): {err}",
            raw_path.display(),
            report.status
        );
    });
    let raw = String::from_utf8_lossy(&raw_bytes);

    eprintln!(
        "\n=== PTY path-space hyperlink (scripted_path_space_hyperlink) ===\n\
         scenario status: {:?}\n\
         raw PTY bytes:   {}\n\
         OSC 8 present:   {}\n\
         full URL marker: {}\n\
         ===============================================================\n",
        report.status,
        raw_bytes.len(),
        raw.contains("\x1b]8;"),
        raw.contains(FILE_URL_MARKER),
    );

    assert_eq!(
        report.status,
        ScriptedRunStatus::Passed,
        "scenario failed: {:#?}",
        report.bugs
    );
    assert!(
        raw.contains("\x1b]8;"),
        "expected OSC 8 hyperlink sequences in ptyctl PTY stream (path should be clickable)"
    );
    assert!(
        raw.contains(FILE_URL_MARKER),
        "OSC 8 file:// URL must include `{FILE_URL_MARKER}` so click/underline covers \
         `Demo App.app`, not just `Demo` (pre-fix truncated at the space).\n\
         truncated-only would omit this marker."
    );
    let has_truncated_only =
        raw.contains("mac-arm64/Demo\x07") || raw.contains("mac-arm64/Demo\x1b\\");
    assert!(
        !has_truncated_only || raw.contains(FILE_URL_MARKER),
        "must not emit a truncated OSC 8 ending at `Demo` without the space suffix"
    );
}

/// Count Kitty APC action tokens in raw PTY bytes.
fn count_kitty_actions(raw: &[u8]) -> (usize, usize, usize) {
    let text = String::from_utf8_lossy(raw);
    let mut transmit_display = 0usize;
    let mut transmit_only = 0usize;
    let mut place_only = 0usize;
    for part in text.split("\x1b_G") {
        if part.is_empty() {
            continue;
        }
        let params = part.split(';').next().unwrap_or("");
        if params.contains("a=T") {
            transmit_display += 1;
        } else if params.contains("a=t") {
            transmit_only += 1;
        }
        if params.contains("a=p") {
            place_only += 1;
        }
    }
    (transmit_display, transmit_only, place_only)
}

/// PTY harness: paste one PNG, hammer redraws, prove uploads stay bounded.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_inline_image_memory() {
    let scenario = ScriptedScenario::from_file(&scenario_path("inline_image_memory.yaml"))
        .expect("load scenario");
    let artifact_dir = std::env::var_os("CARGO_TARGET_TMPDIR")
        .map(PathBuf::from)
        .unwrap_or_else(std::env::temp_dir)
        .join("scripted-scenarios");
    let runner = ScriptedScenarioRunner::new(ScriptedRunConfig::new(
        pager_binary().expect("resolve pager binary"),
        artifact_dir.clone(),
    ));
    let report = runner.run(&scenario).await.expect("run scenario");

    // Harness writes raw bytes under the per-run artifact dir (see scripted.rs).
    let raw_path = report.artifact_dir.join("raw_output.bin");
    let raw = std::fs::read(&raw_path).unwrap_or_else(|err| {
        panic!(
            "raw_output.bin missing at {} (report status: {:?}): {err}",
            raw_path.display(),
            report.status
        );
    });

    let (at, a_t, a_p) = count_kitty_actions(&raw);
    eprintln!(
        "\n=== PTY inline image memory (scripted_inline_image_memory) ===\n\
         scenario status: {:?}\n\
         raw PTY bytes:   {}\n\
         Kitty a=T (combined upload+display): {at}\n\
         Kitty a=t (transmit only):           {a_t}\n\
         Kitty a=p (place only):              {a_p}\n\
         ===============================================================\n",
        report.status,
        raw.len()
    );

    assert!(
        at + a_t + a_p > 0,
        "expected Kitty graphics in raw PTY output"
    );

    let uploads = at + a_t;
    assert!(
        uploads < 30,
        "too many image upload APCs (a=T + a=t = {uploads}); place={a_p}"
    );
    if a_p > 0 {
        assert!(
            a_p >= uploads,
            "expected place-only (a=p={a_p}) >= uploads (a=T+a=t={uploads})"
        );
    }

    assert_eq!(
        report.status,
        ScriptedRunStatus::Passed,
        "scenario failed: {:#?}",
        report.bugs
    );
}

/// enterprise deploy report: with `KIGI_GOAL=1`, `/goal` must
/// show in the slash menu on the welcome screen *before* the first user turn.
/// The scenario types `/goal` pre-session and asserts the dropdown carries the
/// builtin's description (which only renders when the command is advertised),
/// then captures screenshot artifacts.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_goal_slash_presession() {
    run_scenario("goal_slash_presession.yaml").await;
}

/// `/graph` gating mirror of the `/goal` pre-session scenarios: with
/// `KIGI_GRAPH=1` (+ the goal harness) the command must be advertised on
/// the welcome screen before any session exists.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_graph_slash_presession() {
    run_scenario("graph_slash_presession.yaml").await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_graph_slash_presession_disabled() {
    run_scenario("graph_slash_presession_disabled.yaml").await;
}

/// Counterpart to `scripted_goal_slash_presession`: with the goal flag
/// explicitly off (`KIGI_GOAL=0`; goal mode defaults on), `/goal` must stay
/// hidden pre-session (gate fail-closed) while an
/// AlwaysOn builtin (`/compact`) still shows — proving it's the gate, not a
/// broken dropdown.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_goal_slash_presession_disabled() {
    run_scenario("goal_slash_presession_disabled.yaml").await;
}

/// Full folder-trust session: with `KIGI_FOLDER_TRUST=1` and a git repo that
/// ships a repo-local `.mcp.json` (declared via the scenario `workspace`), the
/// trust question renders before any session, accepting it (`y`) lets the
/// session proceed, and a submitted prompt streams the mock response. The
/// declarative counterpart to the programmatic `folder_trust_*` PTY tests.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_folder_trust_prompt() {
    run_scenario("folder_trust_prompt.yaml").await;
}

/// Dashboard `/model` list: mouse-clicking a model must accept the completion
/// into the dispatch prompt and must not attach the session row under the
/// dropdown (click-through regression).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_dashboard_model_list_click() {
    run_scenario("dashboard_model_list_click.yaml").await;
}

/// Shortcuts cheatsheet end-to-end: open the modal, inline-expand a hint (→) and
/// collapse it (←), open the man-style detail screen (Enter) showing long_help,
/// return to browse (Esc), and close with the global Ctrl+X chord.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_shortcuts_help_detail() {
    run_scenario("shortcuts_help_detail.yaml").await;
}

/// Undo tip happy path: a substantial Ctrl+U wipe shows the "… to undo"
/// banner (which then clears on its own TTL — no early retire on undo).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_undo_tip_clear_shows() {
    run_scenario("undo_tip_clear_shows.yaml").await;
}

/// Undo tip Ctrl+C variant: clearing a substantial draft with Ctrl+C shows
/// the banner too.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_undo_tip_ctrl_c_clear_shows() {
    run_scenario("undo_tip_ctrl_c_clear_shows.yaml").await;
}

/// Undo tip no-show edge: wiping a short (<20 char) draft shows nothing.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_undo_tip_short_draft_no_show() {
    run_scenario("undo_tip_short_draft_no_show.yaml").await;
}

/// Undo tip no-show edge: on a short terminal the renderability gate refuses
/// the banner even after a substantial wipe.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_undo_tip_short_terminal_no_show() {
    run_scenario("undo_tip_short_terminal_no_show.yaml").await;
}

/// Undo tip no-show edge: accepting an @-file completion is not a wipe and
/// must not hint.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_undo_tip_completion_accept_no_show() {
    run_scenario("undo_tip_completion_accept_no_show.yaml").await;
}

/// Plan-nudge happy path: with contextual hints enabled, typing a planning
/// keyword shows the "Planning? Check out plan mode via shift+tab" banner.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_plan_nudge_shows() {
    run_scenario("plan_nudge_shows.yaml").await;
}

/// Plan-nudge opt-out edge: contextual hints ship default-OFF, so this scenario
/// also pins `KIGI_CONTEXTUAL_HINTS=0` (belt-and-suspenders); the same planning
/// keyword shows nothing.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_plan_nudge_opt_out_no_show() {
    run_scenario("plan_nudge_opt_out_no_show.yaml").await;
}

/// Follow-up: a mid-message `/test-skill` token stays teal in the
/// scrollback echo after submit (screenshots capture composer + echo styling).
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_mid_text_skill_token_echo() {
    run_scenario("mid_text_skill_token_echo.yaml").await;
}

/// Auto-compact: shrinking to 14 rows drops the sticky prev-question header
/// (compact chrome engages); growing back to 32 rows restores it.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_auto_compact_resize() {
    run_scenario("auto_compact_resize.yaml").await;
}

/// Small-screen tip happy path: a 24-row terminal (in the 21..=28 band) shows
/// the one-shot "Tight on space? Try /compact-mode" banner, which expires on
/// its TTL and never re-shows after resizes.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_small_screen_tip_band() {
    run_scenario("small_screen_tip_band.yaml").await;
}

/// Small-screen tip no-show edge: a 40-row terminal is above the band.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_small_screen_tip_no_show_tall() {
    run_scenario("small_screen_tip_no_show_tall.yaml").await;
}

/// Small-screen tip no-show edge: at 14 rows auto-compact engages instead and
/// the banner row cannot render.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_small_screen_tip_no_show_tiny() {
    run_scenario("small_screen_tip_no_show_tiny.yaml").await;
}

/// Inline edit-and-resubmit happy path: Enter on a selected previous prompt
/// opens the in-place editor; Enter with changed text raises the inline
/// rewind popup ("Resubmit from here", no files-only row); `b` + `y`
/// rewinds the conversation and resubmits the edited text as a fresh turn
/// (distinct per-turn mock sentinels prove the tail was truncated), with no
/// "Reverted conversation" note.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_inline_edit_resubmit() {
    run_scenario("inline_edit_resubmit.yaml").await;
}

/// Enter on an unchanged inline edit just closes the editor: no rewind
/// popup, no resubmit, transcript untouched.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_inline_edit_unchanged_exit() {
    run_scenario("inline_edit_unchanged_exit.yaml").await;
}

/// Esc from the inline resubmit popup returns to the still-open editor with
/// the edit intact; a second Esc discards the edit and restores the original
/// prompt text in place.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "scripted scenario; run with cargo test -- --ignored"]
async fn scripted_inline_edit_dismiss_returns_to_editor() {
    run_scenario("inline_edit_dismiss_returns_to_editor.yaml").await;
}

/// Sanity-check: every scenario YAML in the list below must parse. Runs at
/// `cargo test` time without `--ignored` so a malformed YAML breaks CI
/// immediately rather than only when the scripted runner is opted in.
#[test]
fn scenarios_parse() {
    for name in [
        "image_normalize_corrupt_dropped.yaml",
        "image_normalize_persist_atomic.yaml",
        "mermaid-affordances.yaml",
        "path_space_hyperlink.yaml",
        "goal_slash_presession.yaml",
        "goal_slash_presession_disabled.yaml",
        "graph_slash_presession.yaml",
        "graph_slash_presession_disabled.yaml",
        "folder_trust_prompt.yaml",
        "dashboard_model_list_click.yaml",
        "paste_chip_double_click.yaml",
        "paste_chip_repaste.yaml",
        "slash_resize_storm.yaml",
        "shortcuts_help_detail.yaml",
        "undo_tip_clear_shows.yaml",
        "undo_tip_ctrl_c_clear_shows.yaml",
        "undo_tip_short_draft_no_show.yaml",
        "undo_tip_short_terminal_no_show.yaml",
        "undo_tip_completion_accept_no_show.yaml",
        "plan_nudge_shows.yaml",
        "plan_nudge_opt_out_no_show.yaml",
        "mid_text_skill_token_echo.yaml",
        "auto_compact_resize.yaml",
        "small_screen_tip_band.yaml",
        "small_screen_tip_no_show_tall.yaml",
        "small_screen_tip_no_show_tiny.yaml",
        "inline_edit_resubmit.yaml",
        "inline_edit_unchanged_exit.yaml",
        "inline_edit_dismiss_returns_to_editor.yaml",
    ] {
        let path = scenario_path(name);
        let scenario =
            ScriptedScenario::from_file(&path).unwrap_or_else(|err| panic!("parse {name}: {err}"));
        assert!(!scenario.steps.is_empty(), "{name} has no steps");
    }
}

#[test]
fn ansi_execute_output_scenario_parses() {
    let path = scenario_path("ansi_execute_output.yaml");
    let scenario = ScriptedScenario::from_file(&path).unwrap_or_else(|err| panic!("parse: {err}"));
    assert!(!scenario.steps.is_empty(), "scenario has no steps");
}

#[test]
fn vim_modal_command_palette_scenario_parses() {
    let path = scenario_path("vim_modal_command_palette.yaml");
    let scenario = ScriptedScenario::from_file(&path).unwrap_or_else(|err| panic!("parse: {err}"));
    assert!(!scenario.steps.is_empty(), "scenario has no steps");
}
