//! Phase-5 acceptance drives App through native input; CLI comparisons use run_chain itself.
use super::*;
use crossterm::event::{
    Event, KeyCode as K, KeyEvent, KeyModifiers as M, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::{backend::TestBackend, Terminal};
use std::{
    fs,
    sync::atomic::{AtomicUsize, Ordering},
};
use tb_ide::{
    app::{App, DialogKind, Execution, Hit, Mode},
    commands::Command,
    export::{ExportStatus, ProjectStamp},
};
use tb_runtime::host::{Ereignis, Host};
use tb_runtime::{snapshot, value::Value};
use tb_vm::interp::RunEvent;
use tb_vm::project_io::new_execution;
struct Temp(PathBuf);
impl Temp {
    fn new() -> Self {
        static N: AtomicUsize = AtomicUsize::new(0);
        let p = std::env::temp_dir().join(format!(
            "tb-accept-{}-{}",
            std::process::id(),
            N.fetch_add(1, Ordering::Relaxed)
        ));
        fs::create_dir_all(&p).unwrap();
        Self(p.canonicalize().unwrap())
    }
}
impl Drop for Temp {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.0);
    }
}
fn key(a: &mut App, k: K, m: M) {
    a.handle(Event::Key(KeyEvent::new(k, m)));
}
fn plain(a: &mut App, k: K) {
    key(a, k, M::NONE);
}
fn text(a: &mut App, s: &str) {
    for c in s.chars() {
        plain(a, K::Char(c));
    }
}
fn field(a: &mut App, n: usize, s: &str) {
    for _ in 0..30 {
        if a.dialog.as_ref().unwrap().focus == n {
            break;
        }
        plain(a, K::Tab);
    }
    assert_eq!(a.dialog.as_ref().unwrap().focus, n);
    key(a, K::Char('a'), M::CONTROL);
    plain(a, K::Delete);
    text(a, s);
}
fn choice(a: &mut App, n: usize, s: &str) {
    for _ in 0..30 {
        if a.dialog.as_ref().unwrap().focus == n {
            break;
        }
        plain(a, K::Tab);
    }
    for _ in 0..200 {
        if a.dialog.as_ref().unwrap().fields[n]
            .string()
            .eq_ignore_ascii_case(s)
        {
            return;
        }
        plain(a, K::Right);
    }
    panic!("choice missing {s}: {:?}", a.dialog);
}
fn submit(a: &mut App) {
    plain(a, K::Enter);
    assert!(a.dialog.is_none(), "{:?} / {}", a.dialog, a.message);
}
fn menu(a: &mut App, c: Command) {
    let menus = a.menus();
    let (m, i) = menus
        .iter()
        .enumerate()
        .find_map(|(m, menu)| {
            menu.items
                .iter()
                .position(|i| i.command == Some(c))
                .map(|i| (m, i))
        })
        .unwrap_or_else(|| panic!("menu missing {c:?}"));
    key(a, K::Char(menus[m].mnemonic), M::ALT);
    for _ in 0..i {
        plain(a, K::Down);
    }
    plain(a, K::Enter);
    assert_eq!(a.last_command, Some(c), "{}", a.message);
}
fn draw(a: &mut App) -> String {
    let mut t = Terminal::new(TestBackend::new(a.size.0, a.size.1)).unwrap();
    t.draw(|f| a.render(f)).unwrap();
    t.backend()
        .buffer()
        .content
        .chunks(a.size.0 as usize)
        .map(|r| r.iter().map(|c| c.symbol()).collect::<String>())
        .collect::<Vec<_>>()
        .join("\n")
}
fn click_hit(a: &mut App, predicate: impl Fn(&Hit) -> bool) {
    draw(a);
    let r = a
        .hits
        .iter()
        .rev()
        .find(|(_, h)| predicate(h))
        .unwrap_or_else(|| panic!("hit missing"))
        .0;
    a.handle(Event::Mouse(MouseEvent {
        kind: MouseEventKind::Down(MouseButton::Left),
        column: r.x,
        row: r.y,
        modifiers: M::NONE,
    }));
}
fn replace_code(a: &mut App, s: &str) {
    key(a, K::Char('a'), M::CONTROL);
    a.handle(Event::Paste(s.into()));
}
fn code(a: &App) -> String {
    a.project
        .document(a.active_document().unwrap())
        .unwrap()
        .code()
        .into()
}
fn cursor_line(a: &mut App, line: usize) {
    key(a, K::Home, M::CONTROL);
    for _ in 1..line {
        plain(a, K::Down);
    }
    plain(a, K::Home);
}
fn save_as(a: &mut App, path: &Path) {
    menu(a, Command::SaveAs);
    field(a, 0, &path.display().to_string());
    submit(a);
}
fn property(a: &mut App, name: &str, value: &str) {
    if !a.properties {
        plain(a, K::F(10));
    }
    click_hit(a, |h| matches!(h, Hit::DesignAction("property")));
    choice(a, 0, name);
    submit(a);
    plain(a, K::F(2));
    key(a, K::Char('a'), M::CONTROL);
    text(a, value);
    plain(a, K::Enter);
    assert!(a.dialog.is_none(), "{:?}", a.dialog);
    assert!(!a.value_focus);
}
fn tick(a: &mut App, now: u64) {
    for _ in 0..40 {
        a.tick_execution(now);
        if !matches!(a.execution, Execution::Running) {
            break;
        }
    }
}
fn open_code(a: &mut App, name: &str) {
    menu(a, Command::Code);
    if a.dialog.is_none() {
        menu(a, Command::Code);
    }
    choice(a, 0, name);
    submit(a);
    assert_eq!(a.mode, Mode::Environment);
}
fn create(a: &mut App, t: &Temp) -> PathBuf {
    assert_eq!(a.project.members().len(), 1);
    assert!(code(a).is_empty());
    assert!(draw(a).contains("Untitled"));
    menu(a, Command::NewProject);
    if a.dialog.is_some() {
        choice(a, 0, "Verwerfen");
        submit(a);
    }
    assert!(code(a).is_empty());
    menu(a, Command::SyntaxChecking);
    let include=format!("total=1\nCALL Bump(total)\nForm1.Hide\nCLS\nLINE INPUT input$\nPRINT total; input$; COMMAND$\nOPEN \"{}\" FOR OUTPUT AS #1\nPRINT #1, total\nCLOSE #1\nIF COMMAND$=\"error\" THEN ERROR 5\nEND\n",t.0.join("result.txt").display());
    replace_code(a, &include);
    menu(a, Command::SaveText);
    field(a, 0, &t.0.join("work.bi").display().to_string());
    submit(a);
    replace_code(a, "' Main module\n");
    save_as(a, &t.0.join("main.bas"));
    menu(a, Command::NewModule);
    field(a, 0, "Helper");
    submit(a);
    replace_code(a, "SUB Bump(n AS INTEGER)\nn=n+1\nEND SUB\n");
    save_as(a, &t.0.join("helper.bas"));
    menu(a, Command::NewForm);
    assert_eq!(a.mode, Mode::Designer);
    property(a, "CAPTION", "Abnahme");
    let designer_focus = a.active;
    plain(a, K::F(1));
    assert_eq!(a.help.page().path, "docs/forms-referenz.md");
    plain(a, K::Tab);
    plain(a, K::Enter);
    key(a, K::F(1), M::ALT);
    plain(a, K::Esc);
    assert_eq!(a.active, designer_focus);
    plain(a, K::F(12));
    plain(a, K::Enter);
    choice(a, 0, "Load");
    submit(a);
    let handler = code(a);
    assert!(handler.to_uppercase().contains("FORM_LOAD"), "{handler}");
    cursor_line(a, 1);
    key(a, K::End, M::CONTROL); // Replace body through the editor, retaining the generated event signature.
    replace_code(
        a,
        &format!(
            "DECLARE SUB Bump(n AS INTEGER)\nDIM SHARED total AS INTEGER\n{}",
            handler.replace("END SUB", "SHARED total\n'$INCLUDE: 'work.bi'\nEND SUB")
        ),
    );
    key(a, K::F(12), M::SHIFT);
    assert_eq!(a.mode, Mode::Designer);
    // Toolbox Enter is the documented keyboard equivalent of double-click placement.
    menu(a, Command::Toolbox);
    for _ in 0..2 {
        plain(a, K::Down);
    }
    plain(a, K::Enter);
    assert!(a.designer.selections.values().any(|s| !s.is_empty()));
    property(a, "INDEX", "0");
    property(a, "CAPTION", "Start");
    menu(a, Command::Toolbox);
    plain(a, K::Down);
    plain(a, K::Down);
    plain(a, K::Enter);
    property(a, "INDEX", "1");
    property(a, "CTLNAME", "CommandButton1");
    menu(a, Command::MenuDesign);
    plain(a, K::Insert);
    plain(a, K::Enter);
    field(a, 1, "&Datei");
    submit(a);
    // Save all members and the chosen startup through actual dialog fields.
    open_code(a, "main.bas");
    menu(a, Command::Startup);
    choice(a, 0, "Form1.frm");
    submit(a);
    let path = t.0.join("project.mak");
    menu(a, Command::SaveProject);
    field(a, 0, &path.display().to_string());
    submit(a);
    assert_eq!(a.project.members().len(), 3);
    assert!(!a.project.is_dirty());
    let form = fs::read_to_string(t.0.join("Form1.frm")).unwrap();
    let parsed = tb_ui::frm::read_text("Form1.frm", &form).unwrap();
    let array: Vec<_> = parsed
        .root
        .children
        .iter()
        .filter(|n| n.class == tb_frontend::forms::ObjectClass::CommandButton)
        .map(tb_ide::designer::node_key)
        .collect();
    assert_eq!(
        array,
        vec![
            ("COMMANDBUTTON1".into(), Some(0)),
            ("COMMANDBUTTON1".into(), Some(1))
        ]
    );
    assert!(form.to_uppercase().contains("BEGIN MENU"));
    assert!(form.to_uppercase().contains("FORM_LOAD"));
    path
}
fn exports(a: &mut App, t: &Temp) {
    a.export_backend = false;
    let stamp = ProjectStamp::capture(&a.project);
    for c in [Command::MakeExe, Command::MakeLibrary] {
        menu(a, c);
        assert!(draw(a).contains("Erzeugen: Phase 6"));
        let output = if c == Command::MakeLibrary { 1 } else { 3 };
        if c == Command::MakeExe {
            choice(a, 1, "linux");
            choice(a, 2, "x86_64");
        }
        field(a, output, "");
        plain(a, K::Enter);
        assert!(a.dialog.as_ref().unwrap().error.contains("Ausgabepfad"));
        field(a, output, &t.0.join("bad.tbc").display().to_string());
        plain(a, K::Enter);
        assert!(a.dialog.as_ref().unwrap().error.contains("TBC"));
        let existing = t.0.join("existing.tbl");
        fs::write(&existing, "original").unwrap();
        field(a, output, &existing.display().to_string());
        plain(a, K::Enter);
        assert!(a
            .dialog
            .as_ref()
            .unwrap()
            .error
            .contains("Überschreibentscheidung"));
        plain(a, K::Tab);
        plain(a, K::Char(' '));
        plain(a, K::Enter);
        assert_eq!(fs::read_to_string(existing).unwrap(), "original");
        let out = t.0.join("native-result.tbl");
        field(a, output, &out.display().to_string());
        plain(a, K::Enter);
        let request = a.dialog.as_ref().unwrap().request.clone().unwrap();
        assert_eq!(request.project, stamp);
        for status in [
            ExportStatus::Running,
            ExportStatus::Failed("Kontrollierter Fehler".into()),
            ExportStatus::Cancelled,
            ExportStatus::Success,
        ] {
            a.export_result(&request, status.clone()).unwrap();
            assert!(draw(a).contains(&status.text()));
            assert!(!out.exists());
        }
        plain(a, K::Esc);
        assert_eq!(ProjectStamp::capture(&a.project), stamp);
    }
}
#[test]
fn create_save_debug_help_exports_and_reopen() {
    let t = Temp::new();
    let mut a = App::new(&t.0, t.0.join("options"), (80, 25)).unwrap();
    let path = create(&mut a, &t);
    let saved: Vec<_> = [
        "main.bas",
        "helper.bas",
        "Form1.frm",
        "work.bi",
        "project.mak",
    ]
    .map(|p| fs::read(t.0.join(p)).unwrap())
    .into();
    exports(&mut a, &t);
    open_code(&mut a, "Form1.frm");
    let line = code(&a)
        .lines()
        .position(|l| l.contains("$INCLUDE"))
        .unwrap()
        + 1;
    cursor_line(&mut a, line);
    menu(&mut a, Command::IncludedFile);
    assert!(a
        .project
        .document(a.active_document().unwrap())
        .unwrap()
        .source_path()
        .ends_with("work.bi"));
    cursor_line(&mut a, 2);
    plain(&mut a, K::F(9));
    let code_focus = a.active;
    cursor_line(&mut a, 6);
    plain(&mut a, K::F(1));
    assert!(a.help.page().title.contains("Index") || a.help.page().path.starts_with("docs/"));
    plain(&mut a, K::Tab);
    plain(&mut a, K::Enter);
    key(&mut a, K::F(1), M::ALT);
    plain(&mut a, K::Esc);
    assert_eq!(a.active, code_focus);
    menu(&mut a, Command::CommandLine);
    field(&mut a, 0, "!");
    submit(&mut a);
    key(&mut a, K::F(5), M::SHIFT);
    tick(&mut a, 0);
    assert_eq!(a.execution, Execution::Paused, "{}", a.message);
    assert!(a
        .session
        .vm
        .as_ref()
        .unwrap()
        .current_file()
        .ends_with("work.bi"));
    assert_eq!(a.session.vm.as_ref().unwrap().current_line(), 2);
    plain(&mut a, K::F(10));
    tick(&mut a, 0);
    assert_eq!(a.execution, Execution::Paused);
    assert_eq!(a.session.vm.as_ref().unwrap().current_line(), 3);
    menu(&mut a, Command::AddWatch);
    field(&mut a, 0, "total");
    submit(&mut a);
    assert!(matches!(a.debugger.watches[0].value, Ok(Value::Int(2))));
    menu(&mut a, Command::Immediate);
    text(&mut a, "total=9");
    plain(&mut a, K::Enter);
    tick(&mut a, 0);
    assert_eq!(a.execution, Execution::Paused);
    plain(&mut a, K::F(5));
    tick(&mut a, 0);
    assert_eq!(a.execution, Execution::Waiting, "{}", a.message);
    menu(&mut a, Command::Output);
    a.handle(Event::Paste("abc\n".into()));
    tick(&mut a, 100);
    assert_eq!(a.execution, Execution::Ended, "{}", a.message);
    assert_eq!(snapshot::text(a.session.screen()), "? abc\n 9 abc!\n");
    assert!(fs::read_to_string(t.0.join("result.txt"))
        .unwrap()
        .contains('9'));
    for (p, bytes) in [
        "main.bas",
        "helper.bas",
        "Form1.frm",
        "work.bi",
        "project.mak",
    ]
    .iter()
    .zip(saved.iter())
    {
        assert_eq!(&fs::read(t.0.join(p)).unwrap(), bytes);
    }
    menu(&mut a, Command::NewProject);
    if a.dialog.is_some() {
        choice(&mut a, 0, "Verwerfen");
        submit(&mut a);
    }
    menu(&mut a, Command::OpenProject);
    field(&mut a, 0, &path.display().to_string());
    plain(&mut a, K::Enter);
    choice(&mut a, 0, "Verwerfen");
    submit(&mut a);
    assert_eq!(a.project.members().len(), 3);
    assert!(a
        .project
        .document(a.project.startup().unwrap())
        .unwrap()
        .source_path()
        .ends_with("Form1.frm"));
    assert!(!a.project.is_dirty());
    for id in a.project.members() {
        let doc = a.project.document(id).unwrap();
        match doc.content() {
            tb_vm::project_io::Content::Form(form) => assert_eq!(
                form.as_ref(),
                &tb_ui::frm::read_text(
                    &doc.source_path().display().to_string(),
                    &fs::read_to_string(doc.source_path()).unwrap()
                )
                .unwrap()
            ),
            _ => assert_eq!(doc.code(), fs::read_to_string(doc.source_path()).unwrap()),
        }
    }
    menu(&mut a, Command::CommandLine);
    field(&mut a, 0, "!");
    submit(&mut a);
    menu(&mut a, Command::Start);
    tick(&mut a, 0);
    assert_eq!(a.execution, Execution::Waiting);
    menu(&mut a, Command::Output);
    a.handle(Event::Paste("abc\n".into()));
    tick(&mut a, 100);
    assert_eq!(a.execution, Execution::Ended);
    assert_eq!(snapshot::text(a.session.screen()), "? abc\n 2 abc!\n");
}

struct ScriptHost {
    now: u64,
    sent: bool,
    events: std::collections::VecDeque<Ereignis>,
}
impl Host for ScriptHost {
    fn present(&mut self, _: &tb_runtime::screen::TextScreen) {}
    fn jetzt_ms(&mut self) -> u64 {
        self.now
    }
    fn next_event(&mut self, _: bool) -> Option<Ereignis> {
        self.events.pop_front()
    }
    fn warten(&mut self, _: Option<u64>) -> Option<Ereignis> {
        assert!(!self.sent, "Unexpected extra wait");
        self.sent = true;
        self.now = 100;
        self.events
            .extend("abc\r".chars().map(|c| Ereignis::Taste(c.to_string(), 0)));
        self.events.pop_front()
    }
}
#[test]
fn saved_project_ide_source_cli_and_tbc_have_identical_sessions() {
    let t = Temp::new();
    let mut a = App::new(&t.0, t.0.join("options"), (80, 25)).unwrap();
    let path = create(&mut a, &t);
    assert_eq!(
        super::cmd_build(&[path.display().to_string()]),
        ExitCode::SUCCESS
    );
    for command in ["!", "error"] {
        menu(&mut a, Command::CommandLine);
        field(&mut a, 0, command);
        submit(&mut a);
        fs::write(t.0.join("result.txt"), "initial").unwrap();
        menu(&mut a, Command::Start);
        let ide = a.session.vm.as_ref().unwrap();
        assert_eq!(ide.rt.command, command);
        assert_eq!((ide.rt.screen.cols(), ide.rt.screen.rows()), (80, 25));
        assert!(ide.forms.has_visible_forms());
        assert!(!ide.rt.dateien.ist_offen(1));
        assert!(a.session.host.events.is_empty());
        tick(&mut a, 0);
        assert_eq!(a.execution, Execution::Waiting, "{}", a.message);
        // Same input and time boundary as ScriptHost::warten, through native App input.
        a.handle(Event::Paste("abc\n".into()));
        tick(&mut a, 100);
        assert_eq!(
            a.execution,
            if command == "!" {
                Execution::Ended
            } else {
                Execution::Error
            },
            "{}",
            a.message
        );
        if command == "error" {
            assert!(a.message.contains('5'));
        }
        let output = snapshot::text(a.session.screen());
        let file = fs::read(t.0.join("result.txt")).unwrap();
        assert_eq!(output, format!("? abc\n 2 abc{command}\n"));
        assert!(String::from_utf8_lossy(&file).contains('2'));
        for target in [path.clone(), path.with_extension("tbc")] {
            fs::write(t.0.join("result.txt"), "initial").unwrap();
            let input = super::compile(Some(&target.display().to_string()))
                .unwrap()
                .1;
            let initial = new_execution(input, command, Some((80, 25)), None).unwrap();
            assert!(initial.forms.has_visible_forms());
            assert_eq!(initial.rt.command, command);
            assert!(!initial.rt.dateien.ist_offen(1));
            let mut host = ScriptHost {
                now: 0,
                sent: false,
                events: Default::default(),
            };
            let (event, vm) = super::run_chain(
                &[target.display().to_string(), command.into()],
                &mut host,
                Some((80, 25)),
            )
            .unwrap();
            if command == "!" {
                assert_eq!(event, RunEvent::Ended);
            } else {
                assert!(
                    matches!(event, RunEvent::Error { code: 5, .. }),
                    "{event:?}"
                );
            }
            assert_eq!(snapshot::text(&vm.rt.screen), output);
            assert_eq!(fs::read(t.0.join("result.txt")).unwrap(), file);
            assert!(!vm.rt.dateien.ist_offen(1));
            assert_eq!(host.now, 100);
        }
        menu(&mut a, Command::Restart);
        assert!(a.session.host.events.is_empty());
        assert!(!a.session.vm.as_ref().unwrap().rt.dateien.ist_offen(1));
        tick(&mut a, 0);
        assert_eq!(a.execution, Execution::Waiting);
        a.handle(Event::Paste("abc\n".into()));
        tick(&mut a, 100);
        assert_eq!(snapshot::text(a.session.screen()), output);
    }
}

#[test]
fn every_menu_command_has_native_keyboard_and_mouse_routing() {
    let t = Temp::new();
    for designer in [false, true] {
        let menus = tb_ide::commands::menus(designer);
        for (m, group) in menus.iter().enumerate() {
            for (i, item) in group.items.iter().enumerate() {
                let Some(command) = item.command else {
                    continue;
                };
                for mouse in [false, true] {
                    let mut a = App::new(&t.0, t.0.join("options"), (120, 45)).unwrap();
                    if designer {
                        menu(&mut a, Command::NewForm);
                    }
                    if mouse {
                        if designer {
                            plain(&mut a, K::F(11));
                            plain(&mut a, K::Esc);
                        }
                        click_hit(&mut a, |h| matches!(h,Hit::Menu(n) if *n==m));
                        click_hit(&mut a, |h| matches!(h,Hit::MenuItem(n) if *n==i));
                        assert_eq!(a.last_command, Some(command), "{command:?}");
                    } else {
                        menu(&mut a, command);
                    }
                    assert!(a.basic_events.is_empty(), "{command:?}");
                    assert!(!a.message.contains("folgt in Phase-5"));
                }
            }
        }
    }
}

#[test]
fn function_keys_conflict_contexts_and_errors_keep_a_single_input_owner() {
    let t = Temp::new();
    let mut a = App::new(&t.0, t.0.join("options"), (100, 30)).unwrap();
    menu(&mut a, Command::SyntaxChecking);
    replace_code(&mut a, "PRINT (\n");
    menu(&mut a, Command::Start);
    assert_eq!(a.execution, Execution::Error);
    assert!(a.session.vm.is_none());
    let invalid = code(&a);
    assert!(invalid.contains("PRINT ("));
    replace_code(&mut a, "LINE INPUT s$\nPRINT s$\nEND\n");
    plain(&mut a, K::F(5));
    tick(&mut a, 0);
    assert_eq!(a.execution, Execution::Waiting);
    plain(&mut a, K::F(4));
    assert!(a.session.fullscreen);
    plain(&mut a, K::F(4));
    assert!(!a.session.fullscreen);
    plain(&mut a, K::F(11));
    assert!(a.menu.is_some());
    plain(&mut a, K::Esc);
    plain(&mut a, K::F(2));
    assert!(matches!(
        a.dialog.as_ref().unwrap().kind,
        DialogKind::ChooseCode
    ));
    plain(&mut a, K::Esc);
    plain(&mut a, K::F(12));
    assert!(a.basic_events.is_empty());
    if a.dialog.is_some() {
        plain(&mut a, K::Esc);
    }
    key(&mut a, K::Pause, M::CONTROL);
    assert_eq!(a.execution, Execution::Paused);
    assert!(a.basic_events.is_empty());
    a.handle(Event::Resize(40, 10));
    assert!(draw(&mut a).contains("80×25"));
    a.handle(Event::Resize(100, 30));
    assert!(draw(&mut a).contains("Untitled"));
    plain(&mut a, K::F(5));
    menu(&mut a, Command::Output);
    text(&mut a, "one");
    plain(&mut a, K::Enter);
    tick(&mut a, 100);
    assert_eq!(snapshot::text(a.session.screen()), "? one\none\n");
    open_code(&mut a, "Untitled.bas");
    replace_code(&mut a, "RUN \"missing\"\n");
    menu(&mut a, Command::Start);
    tick(&mut a, 0);
    assert_eq!(a.execution, Execution::Error);
    assert!(a.message.contains("nicht gefunden"));
    open_code(&mut a, "Untitled.bas");
    assert!(code(&a).contains("missing"));
    replace_code(&mut a, "PRINT SHELL(\"exit 7\")\nEND\n");
    menu(&mut a, Command::Start);
    tick(&mut a, 0);
    assert_eq!(
        a.session.host.shell_request.take().as_deref(),
        Some("exit 7")
    );
    a.session.host.shell_result = Some(Ok(7));
    tick(&mut a, 0);
    assert_eq!(snapshot::text(a.session.screen()), " 7 \n");
    menu(&mut a, Command::NewForm);
    plain(&mut a, K::F(2));
    assert!(a.value_focus);
    plain(&mut a, K::Esc);
    plain(&mut a, K::F(10));
    assert!(!a.properties && a.menu.is_some());
    plain(&mut a, K::F(10));
    assert!(a.properties && a.menu.is_none());
    plain(&mut a, K::F(11));
    assert!(a.menu.is_some());
    plain(&mut a, K::Esc);
    plain(&mut a, K::F(12));
    assert!(matches!(
        a.dialog.as_ref().unwrap().kind,
        DialogKind::DesignObject(_)
    ));
    plain(&mut a, K::Esc);
    assert!(a.basic_events.is_empty());
}

#[test]
fn partial_save_and_external_edit_recover_without_losing_documents() {
    let t = Temp::new();
    let mut a = App::new(&t.0, t.0.join("options"), (100, 30)).unwrap();
    replace_code(&mut a, "PRINT 1\n");
    menu(&mut a, Command::NewModule);
    field(&mut a, 0, "Second");
    submit(&mut a);
    replace_code(&mut a, "PRINT 2\n");
    let ids = a.project.members();
    menu(&mut a, Command::SaveProject);
    field(&mut a, 0, &t.0.join("p.mak").display().to_string());
    field(&mut a, 2, &t.0.join("first.bas").display().to_string());
    field(
        &mut a,
        4,
        &t.0.join("absent/second.bas").display().to_string(),
    );
    plain(&mut a, K::Enter);
    assert!(!a.dialog.as_ref().unwrap().error.is_empty());
    assert!(!a.project.document(ids[0]).unwrap().is_dirty());
    assert!(a.project.document(ids[1]).unwrap().is_dirty());
    assert!(a
        .project
        .document(ids[1])
        .unwrap()
        .code()
        .contains("PRINT 2"));
    assert!(!t.0.join("p.mak").exists());
    field(&mut a, 4, &t.0.join("second.bas").display().to_string());
    submit(&mut a);
    open_code(&mut a, "first.bas");
    replace_code(&mut a, "PRINT 3\n");
    fs::write(t.0.join("first.bas"), "' external\n").unwrap();
    menu(&mut a, Command::SaveFile);
    plain(&mut a, K::Enter);
    assert!(a
        .dialog
        .as_ref()
        .unwrap()
        .error
        .contains("Überschreibentscheidung"));
    assert_eq!(
        fs::read_to_string(t.0.join("first.bas")).unwrap(),
        "' external\n"
    );
    assert!(code(&a).contains("PRINT 3"));
    plain(&mut a, K::Tab);
    plain(&mut a, K::Char(' '));
    submit(&mut a);
    assert!(!a.project.is_dirty());
    menu(&mut a, Command::Start);
    tick(&mut a, 0);
    assert_eq!(a.execution, Execution::Ended);
    assert_eq!(snapshot::text(a.session.screen()), " 3 \n 2 \n");
}

#[test]
fn reference_matrix_is_complete_and_points_to_runnable_tests() {
    let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
    let changes = root.join("openspec/changes");
    let name = "phase-5-08-integration-und-phasenabnahme";
    let active = changes.join(name);
    let change = if active.exists() {
        active
    } else {
        fs::read_dir(changes.join("archive"))
            .unwrap()
            .map(|p| p.unwrap().path())
            .find(|p| p.file_name().unwrap().to_string_lossy().ends_with(name))
            .unwrap()
    };
    let overview = fs::read_to_string(
        changes
            .join("archive/2026-09-06-phase-5-01-projekt-und-dokumentmodell/phase-5-uebersicht.md"),
    )
    .unwrap();
    let overview = overview
        .split("## Vollständige Zuordnung der Referenzbedienung")
        .nth(1)
        .unwrap()
        .split("## Explizite")
        .next()
        .unwrap();
    let matrix = fs::read_to_string(change.join("befehlsmatrix.md")).unwrap();
    let rows = |s: &str| {
        s.lines()
            .filter(|l| l.starts_with('|'))
            .skip(2)
            .map(|l| {
                l.split('|')
                    .skip(1)
                    .map(|c| c.trim().to_owned())
                    .collect::<Vec<_>>()
            })
            .collect::<Vec<_>>()
    };
    let expected = rows(overview);
    let actual = rows(&matrix);
    assert_eq!(expected.len(), 45);
    assert_eq!(actual.len(), expected.len());
    for (expected, actual) in expected.iter().zip(actual) {
        assert_eq!(&actual[..3], &expected[..3]);
        let tests: Vec<_> = actual[3].split('`').skip(1).step_by(2).collect();
        assert!(!tests.is_empty());
        for test in tests {
            let (file, function) = test.split_once("::").unwrap();
            let source = fs::read_to_string(root.join(file)).unwrap();
            assert!(
                source.contains(&format!("#[test]\nfn {function}(")),
                "{test}"
            );
        }
    }
}

fn finish_export(a: &mut App) {
    let start = std::time::Instant::now();
    while a.dialog.as_ref().unwrap().export_status == ExportStatus::Running {
        a.tick_export();
        assert!(start.elapsed().as_secs() < 15, "Export hängt");
        std::thread::sleep(std::time::Duration::from_millis(2));
    }
    assert_eq!(
        a.dialog.as_ref().unwrap().export_status,
        ExportStatus::Success,
        "{:?}",
        a.dialog
    );
    plain(a, K::Esc);
}

#[test]
#[ignore = "Echter Zielnachweis: TB_IDE_RUNTIME_DIR muss geprüfte Host-tbrt-Vorlage enthalten"]
fn real_native_ide_exports_and_source_free_cli_library_consumer() {
    let runtime_dir =
        PathBuf::from(std::env::var_os("TB_IDE_RUNTIME_DIR").expect("TB_IDE_RUNTIME_DIR fehlt"));
    let target = tb_export::Target::host().unwrap();
    let t = Temp::new();
    let mut a = App::new(&t.0, t.0.join("options"), (100, 30)).unwrap();
    a.runtime_dir = runtime_dir;
    let _project = create(&mut a, &t);
    a.project.open_document(&t.0.join("work.bi")).unwrap();
    let disk: Vec<_> = a
        .project
        .documents()
        .map(|(_, d)| {
            (
                d.source_path().to_path_buf(),
                fs::read(d.source_path()).unwrap(),
            )
        })
        .collect();
    let include = a.project.find_document(&t.0.join("work.bi")).unwrap();
    a.project.replace_text(include, 6..7, "7").unwrap();
    let stamp = ProjectStamp::capture(&a.project);
    // The real project still supports debugging before export.
    open_code(&mut a, "Form1.frm");
    plain(&mut a, K::F(8));
    tick(&mut a, 0);
    assert_eq!(a.execution, Execution::Paused, "{}", a.message);

    let exe = t.0.join(if cfg!(windows) {
        "program.exe"
    } else {
        "program"
    });
    menu(&mut a, Command::MakeExe);
    let (system, arch) = tb_ide::export::target_parts(target);
    choice(&mut a, 1, system);
    choice(&mut a, 2, arch);
    field(&mut a, 3, &exe.display().to_string());
    plain(&mut a, K::Enter);
    finish_export(&mut a);
    assert_eq!(
        tb_export::native_target(&fs::read(&exe).unwrap()).unwrap(),
        target
    );
    let library = t.0.join("library.tbl");
    menu(&mut a, Command::MakeLibrary);
    field(&mut a, 1, &library.display().to_string());
    click_hit(&mut a, |h| matches!(h, Hit::DialogSubmit));
    finish_export(&mut a);
    let mut compiler = tb_vm::project::ProjectCompiler::default();
    compiler.debug_symbols = true;
    let expected = a
        .project
        .sources()
        .unwrap()
        .compile_library(&mut compiler)
        .unwrap()
        .to_tbl()
        .unwrap();
    assert_eq!(fs::read(&library).unwrap(), expected);
    assert_eq!(ProjectStamp::capture(&a.project), stamp);
    assert!(a.project.document(include).unwrap().is_dirty());
    for (path, bytes) in disk {
        assert_eq!(fs::read(&path).unwrap(), bytes);
        fs::remove_file(path).unwrap();
    }
    fs::remove_file(t.0.join("project.mak")).unwrap();
    let consumer = t.0.join("consumer.bas");
    fs::write(
        &consumer,
        "DIM n AS INTEGER\nn=8\nCALL Bump(n)\nPRINT n\nEND\n",
    )
    .unwrap();
    let tbc = t.0.join("consumer.tbc");
    // Exactly the tbc link command implementation, with the library sources gone.
    build(
        &[
            consumer.display().to_string(),
            library.display().to_string(),
            "-o".into(),
            tbc.display().to_string(),
        ],
        true,
    )
    .unwrap();
    let cli = tb_vm::project_io::load_program(&tbc).unwrap();
    fs::write(t.0.join("consumer.mak"), "consumer.bas\nlibrary.tbl\n").unwrap();
    let mut other = App::new(&t.0, t.0.join("other-options"), (100, 30)).unwrap();
    other
        .load_initial_project(t.0.join("consumer.mak"))
        .unwrap();
    other.start_execution().unwrap();
    tick(&mut other, 0);
    assert_eq!(snapshot::text(other.session.screen()), " 9 \n");
    let mut cli_vm = new_execution(cli, "", None, None).unwrap();
    let mut host = tb_runtime::host::CaptureHost::default();
    assert!(matches!(cli_vm.run(&mut host), RunEvent::Ended));
    assert_eq!(
        snapshot::text(&cli_vm.rt.screen),
        snapshot::text(other.session.screen())
    );
    fs::remove_file(&library).unwrap();
    fs::remove_file(&tbc).unwrap();
    fs::remove_file(&consumer).unwrap();
    use std::io::Write;
    let mut child = std::process::Command::new(&exe)
        .arg("!")
        .current_dir(&t.0)
        .env("PATH", "")
        .stdin(std::process::Stdio::piped())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()
        .unwrap();
    child.stdin.take().unwrap().write_all(b"abc\n").unwrap();
    let start = std::time::Instant::now();
    while child.try_wait().unwrap().is_none() {
        if start.elapsed().as_secs() > 10 {
            let _ = child.kill();
            panic!("Natives IDE-Programm hängt");
        }
        std::thread::sleep(std::time::Duration::from_millis(5));
    }
    let result = child.wait_with_output().unwrap();
    assert!(
        result.status.success(),
        "{}",
        String::from_utf8_lossy(&result.stderr)
    );
    let output = String::from_utf8_lossy(&result.stdout).replace("\r\n", "\n");
    assert!(output.contains("8 abc!"), "{output:?}");
    assert!(fs::read_to_string(t.0.join("result.txt"))
        .unwrap()
        .contains('8'));
}
