//! Kompatibilitäts-Harness: jede Korpusdatei `tests/compat/*.bas` wird
//! kompiliert, ohne Terminal ausgeführt und ihr Bildschirmzustand gegen die
//! `.out` verglichen — getrimmt auf die belegte Fläche, mit Attributebene nur
//! bei gesetzten Farben (Entscheidung 2026-09-03, D3).
//!
//! Jede Korpusdatei nennt ihre Bildschirmgröße in der ersten Zeile:
//!
//! ```text
//! ' tb-screen: 80x25
//! ```
//!
//! Es gibt bewusst keinen stillschweigenden Vorgabewert — 80×25 ist überall
//! nur die untere Schranke, nie eine feste Größe, und der Korpus muss das
//! nachweisen können.

#[path = "../../../tests/support/prozessfrist.rs"]
mod prozessfrist;

use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::process::Command;
use tb_runtime::host::{CaptureHost, Host};
use tb_vm::interp::{RunEvent, Vm};

fn compat_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/compat")
}

/// `<cols>x<rows>` lesen.
fn groesse_lesen(name: &str, wert: &str) -> (usize, usize) {
    let (c, r) = wert
        .trim()
        .split_once(['x', 'X'])
        .unwrap_or_else(|| panic!("{name}: Größe {wert:?} nicht als <spalten>x<zeilen> lesbar"));
    let parse = |s: &str, was: &str| {
        s.trim()
            .parse::<usize>()
            .unwrap_or_else(|_| panic!("{name}: {was} {s:?} ist keine Zahl"))
    };
    (parse(c, "Spaltenzahl"), parse(r, "Zeilenzahl"))
}

/// Wert einer Kopfzeilen-Direktive `' <marke>: <wert>` aus den ersten Zeilen.
fn direktive(src: &str, marke: &str) -> Option<String> {
    src.lines()
        // Kopfzeilenfenster: Groesse, Zeitzone, Uhr, Tasten, Tempdir,
        // Resize passen zusammen in die ersten Zeilen.
        .take(8)
        .find(|l| l.contains(marke))
        .map(|l| l.split(marke).nth(1).unwrap().trim().to_string())
}

/// Bildschirmgrößen aus der Kopfzeile `' tb-screen: <cols>x<rows>[, …]`.
/// Fehlt sie, schlägt der Test fehl — ohne Vorgabewert. Mehrere Größen
/// bedeuten: das Programm läuft je Größe einmal, mit eigener Sollausgabe.
fn screen_groessen(name: &str, src: &str) -> Vec<(usize, usize)> {
    let wert = direktive(src, "tb-screen:").unwrap_or_else(|| {
        panic!(
            "{name}: keine Bildschirmgröße deklariert — erste Zeile braucht \
             `\' tb-screen: <spalten>x<zeilen>`"
        )
    });
    wert.split(',').map(|w| groesse_lesen(name, w)).collect()
}

/// Optionale Zeitzone `' tb-tz: ±HH:MM` oder `unknown` für UTC-Rückfall.
/// Ohne Angabe gilt die Zone des
/// Rechners — Zeitprogramme im Korpus setzen sie, damit ihre Sollausgabe
/// unabhängig vom Standort des Testrechners bleibt.
fn tz_direktive(name: &str, src: &str) -> Option<tb_runtime::datetime::Zeitzone> {
    let wert = direktive(src, "tb-tz:")?;
    if wert == "unknown" {
        return Some(tb_runtime::datetime::Zeitzone::Unbekannt);
    }
    let (vorzeichen, rest) = match wert.strip_prefix('-') {
        Some(r) => (-1i32, r),
        None => (1i32, wert.trim_start_matches('+')),
    };
    let (h, m) = rest
        .split_once(':')
        .unwrap_or_else(|| panic!("{name}: tb-tz erwartet ±HH:MM, fand {wert:?}"));
    let parse = |s: &str| {
        s.trim()
            .parse::<i32>()
            .unwrap_or_else(|_| panic!("{name}: {s:?} ist keine Zahl"))
    };
    Some(tb_runtime::datetime::Zeitzone::Fest(
        vorzeichen * (parse(h) * 3600 + parse(m) * 60),
    ))
}

/// Optionale Größenänderung mitten im Lauf:
/// `' tb-resize: nach <n> auf <cols>x<rows>`.
fn resize_direktive(name: &str, src: &str) -> Option<(usize, (usize, usize))> {
    let wert = direktive(src, "tb-resize:")?;
    let teile: Vec<&str> = wert.split_whitespace().collect();
    match teile.as_slice() {
        ["nach", n, "auf", g] => Some((
            n.parse()
                .unwrap_or_else(|_| panic!("{name}: {n:?} ist keine Zahl")),
            groesse_lesen(name, g),
        )),
        _ => panic!("{name}: tb-resize erwartet `nach <n> auf <spalten>x<zeilen>`, fand {wert:?}"),
    }
}

/// Vorbereitete Tastendrücke: `' tb-keys: <folge>`. Sondertasten als
/// `<F1>`…`<F12>`, sonst je ein Zeichen; `+` davor bedeutet Umschalt,
/// `^` Strg, `%` Alt — gebraucht für die benutzerdefinierten Trap-Tasten.
fn tasten_direktive(name: &str, src: &str) -> Vec<tb_runtime::host::Ereignis> {
    let Some(wert) = direktive(src, "tb-keys:") else {
        return Vec::new();
    };
    tastenfolge(name, &wert)
}

fn tastenfolge(name: &str, wert: &str) -> Vec<tb_runtime::host::Ereignis> {
    use tb_runtime::host::{taste, umschalt, Ereignis};
    let mut out = Vec::new();
    let zeichen: Vec<char> = wert.chars().collect();
    let mut i = 0;
    while i < zeichen.len() {
        let mut shift = 0u8;
        while i < zeichen.len() && matches!(zeichen[i], '+' | '^' | '%') {
            shift |= match zeichen[i] {
                '+' => umschalt::SHIFT,
                '^' => umschalt::CTRL,
                _ => umschalt::ALT,
            };
            i += 1;
        }
        if i >= zeichen.len() {
            panic!("{name}: tb-keys endet mit einem Umschaltzeichen");
        }
        if zeichen[i] == '<' {
            let ende = zeichen[i..]
                .iter()
                .position(|c| *c == '>')
                .unwrap_or_else(|| panic!("{name}: tb-keys: `<` ohne `>`"));
            let nm: String = zeichen[i + 1..i + ende].iter().collect();
            let code = match nm.to_uppercase().as_str() {
                "F1" => 0x3B,
                "F2" => 0x3C,
                "F3" => 0x3D,
                "F4" => 0x3E,
                "F5" => 0x3F,
                "F6" => 0x40,
                "F7" => 0x41,
                "F8" => 0x42,
                "F9" => 0x43,
                "F10" => 0x44,
                "F11" => 0x85,
                "F12" => 0x86,
                "UP" => 0x48,
                "LEFT" => 0x4B,
                "RIGHT" => 0x4D,
                "DOWN" => 0x50,
                "ENTER" => {
                    out.push(Ereignis::Taste(taste::ENTER.into(), shift));
                    i += ende + 1;
                    continue;
                }
                "ESC" => {
                    out.push(Ereignis::Taste(taste::ESC.into(), shift));
                    i += ende + 1;
                    continue;
                }
                "TAB" => {
                    out.push(Ereignis::Taste(taste::TAB.into(), shift));
                    i += ende + 1;
                    continue;
                }
                other => panic!("{name}: tb-keys kennt `<{other}>` nicht"),
            };
            out.push(Ereignis::Taste(taste::sonder(code), shift));
            i += ende + 1;
        } else {
            out.push(Ereignis::Taste(zeichen[i].to_string(), shift));
            i += 1;
        }
    }
    out
}

enum Ereignisschritt {
    Ereignis(tb_runtime::host::Ereignis),
    Zeit(u64),
}

fn ereignis_direktive(name: &str, src: &str) -> Vec<Ereignisschritt> {
    use tb_runtime::host::{Ereignis, MausArt, MausEreignis};
    let Some(wert) = direktive(src, "tb-events:") else {
        return Vec::new();
    };
    let mut out = Vec::new();
    for teil in wert.split('|').map(str::trim) {
        if let Some(keys) = teil.strip_prefix("key:") {
            out.extend(
                tastenfolge(name, keys)
                    .into_iter()
                    .map(Ereignisschritt::Ereignis),
            );
        } else if let Some(ms) = teil.strip_prefix("time:") {
            let ms = ms.trim().strip_suffix("ms").unwrap_or(ms.trim());
            out.push(Ereignisschritt::Zeit(
                ms.parse()
                    .unwrap_or_else(|_| panic!("{name}: ungültige Zeit {ms:?}")),
            ));
        } else if let Some(mouse) = teil.strip_prefix("mouse:") {
            let fields: Vec<_> = mouse.split(',').map(str::trim).collect();
            let [kind, button, shift, row, col] = fields.as_slice() else {
                panic!("{name}: mouse erwartet art,taste,shift,zeile,spalte");
            };
            let art = match *kind {
                "down" => MausArt::Druck,
                "up" => MausArt::Loslassen,
                "move" => MausArt::Bewegung,
                _ => panic!("{name}: unbekannte Mausart {kind:?}"),
            };
            let parse = |value: &str| {
                value
                    .parse::<usize>()
                    .unwrap_or_else(|_| panic!("{name}: ungültige Zahl {value:?}"))
            };
            out.push(Ereignisschritt::Ereignis(Ereignis::Maus(MausEreignis {
                art,
                taste: parse(button) as u8,
                shift: parse(shift) as u8,
                zeile: parse(row),
                spalte: parse(col),
            })));
        } else {
            panic!("{name}: unbekannter tb-events-Schritt {teil:?}");
        }
    }
    out
}

struct EreignisHost {
    inner: CaptureHost,
    schritte: VecDeque<Ereignisschritt>,
    pause: bool,
}

impl tb_runtime::host::Host for EreignisHost {
    fn present(&mut self, screen: &tb_runtime::screen::TextScreen) {
        self.inner.present(screen);
    }

    fn next_event(&mut self, blockierend: bool) -> Option<tb_runtime::host::Ereignis> {
        if !blockierend && self.pause {
            self.pause = false;
            return None;
        }
        loop {
            match self.schritte.pop_front() {
                Some(Ereignisschritt::Zeit(ms)) => {
                    self.inner.uhr_vorruecken(ms);
                    if !blockierend {
                        return None;
                    }
                }
                Some(Ereignisschritt::Ereignis(event)) => {
                    self.pause = true;
                    return Some(event);
                }
                None => return self.inner.next_event(blockierend),
            }
        }
    }

    fn warten(&mut self, deadline_ms: Option<u64>) -> Option<tb_runtime::host::Ereignis> {
        if let Some(event) = self.next_event(false) {
            return Some(event);
        }
        self.inner.warten(deadline_ms)
    }
    fn jetzt_ms(&mut self) -> u64 {
        self.inner.jetzt_ms()
    }
}

/// Virtuelle Uhr: `' tb-clock: <n>ms/Zustellpunkt`. Ohne die Direktive
/// steht die Uhr des Testhosts still — ein Programm mit Zeit-Traps liefe
/// dann endlos. Der Harness weist es deshalb ab, statt auf die Systemuhr
/// auszuweichen (design.md, D6).
fn clock_direktive(name: &str, src: &str) -> Option<u64> {
    let wert = direktive(src, "tb-clock:")?;
    let ms = wert
        .trim()
        .strip_suffix("ms/Zustellpunkt")
        .unwrap_or_else(|| panic!("{name}: tb-clock erwartet `<n>ms/Zustellpunkt`, fand {wert:?}"))
        .trim();
    Some(
        ms.parse()
            .unwrap_or_else(|_| panic!("{name}: {ms:?} ist keine Zahl")),
    )
}

/// Nutzt das Programm zeitgesteuerte Traps? Grobe, aber ausreichende
/// Prüfung: ohne `ON TIMER` gibt es keine Fälligkeit zu rechnen.
fn braucht_uhr(src: &str) -> bool {
    src.to_uppercase().contains("ON TIMER")
}

fn clock_schritt(name: &str, src: &str) -> Option<u64> {
    let schritt = clock_direktive(name, src);
    assert!(
        schritt.is_some() || !braucht_uhr(src),
        "{name}: nutzt zeitgesteuerte Traps, deklariert aber keinen Zeitverlauf — \
         erste Zeilen brauchen `' tb-clock: <n>ms/Zustellpunkt`"
    );
    schritt
}

/// Testhost mit virtueller Uhr: sie rückt bei jedem Blick auf die Zeit um
/// einen festen Schritt vor. Damit ist der Lauf reproduzierbar und
/// braucht keine Wartezeit.
struct UhrHost {
    inner: CaptureHost,
    schritt_ms: u64,
}

impl tb_runtime::host::Host for UhrHost {
    fn present(&mut self, s: &tb_runtime::screen::TextScreen) {
        self.inner.present(s);
    }
    fn next_event(&mut self, blockierend: bool) -> Option<tb_runtime::host::Ereignis> {
        self.inner.next_event(blockierend)
    }
    fn warten(&mut self, deadline_ms: Option<u64>) -> Option<tb_runtime::host::Ereignis> {
        if let Some(event) = self.next_event(false) {
            return Some(event);
        }
        self.inner.warten(deadline_ms)
    }
    fn jetzt_ms(&mut self) -> u64 {
        let jetzt = self.inner.jetzt_ms();
        self.inner.uhr_vorruecken(self.schritt_ms);
        jetzt
    }
}

/// Arbeitsverzeichnis für die Dauer eines Korpusprogramms. Programme mit
/// Datei-E/A laufen in einem frischen temporären Verzeichnis und hinterlassen
/// nichts im Projektbaum; beim Verlassen wird es samt Inhalt entfernt.
struct TempVerzeichnis {
    vorher: PathBuf,
    dir: PathBuf,
}

impl TempVerzeichnis {
    fn neu(name: &str) -> TempVerzeichnis {
        let vorher = std::env::current_dir().unwrap();
        let dir = std::env::temp_dir().join(format!(
            "tb_korpus_{}_{}",
            name.replace('.', "_"),
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        std::env::set_current_dir(&dir).unwrap();
        TempVerzeichnis { vorher, dir }
    }
}

impl Drop for TempVerzeichnis {
    fn drop(&mut self) {
        let _ = std::env::set_current_dir(&self.vorher);
        let _ = std::fs::remove_dir_all(&self.dir);
    }
}

/// Korpusdatei ausführen und den Bildschirm-Snapshot liefern.
fn run_corpus_file(path: &Path, groesse: Option<(usize, usize)>) -> String {
    let _frist = prozessfrist::Prozessfrist::neu(
        &path.display().to_string(),
        std::time::Duration::from_secs(30),
    );
    let raw = std::fs::read_to_string(path).unwrap();
    let name = path.file_name().unwrap().to_string_lossy().to_string();
    let form = if path
        .extension()
        .and_then(|ext| ext.to_str())
        .is_some_and(|ext| ext.eq_ignore_ascii_case("frm"))
    {
        Some(tb_ui::frm::read_text(&name, &raw).unwrap_or_else(|error| panic!("{error}")))
    } else {
        None
    };
    let src = form
        .as_ref()
        .map_or(raw.as_str(), |form| form.code.as_str());
    let modul_name = form.as_ref().map_or_else(
        || path.file_stem().unwrap().to_string_lossy().to_uppercase(),
        |form| form.root.name.to_uppercase(),
    );
    let (cols, rows) = groesse.unwrap_or_else(|| screen_groessen(&name, src)[0]);
    let module = match &form {
        Some(form) => tb_vm::compile_source_with_forms(&modul_name, src, &form.catalog()),
        None => tb_vm::compile_source(&modul_name, src),
    }
    .unwrap_or_else(|d| panic!("{}: Compile-Fehler: {d:?}", path.display()));
    let mut vm = Vm::new(module);
    if let Some(form) = &form {
        form.apply(&mut vm.forms).unwrap();
        vm.forms.show(0, false).unwrap();
    }
    vm.rt.screen.resize(cols, rows);
    if let Some(z) = tz_direktive(&name, src) {
        vm.rt.zeitzone = z;
    }
    // `' tb-tempdir` — das Programm arbeitet mit Dateien.
    let _temp = direktive(src, "tb-tempdir").map(|_| TempVerzeichnis::neu(&name));
    let mut host = CaptureHost::default();
    if let Some((nach, (c, r))) = resize_direktive(&name, src) {
        host.ereignis_nach(
            nach,
            tb_runtime::host::Ereignis::Groesse { cols: c, rows: r },
        );
    }
    for t in tasten_direktive(&name, src) {
        host.ereignis(t);
    }
    let schritt = clock_schritt(&name, src);
    let schritte = ereignis_direktive(&name, src);
    if !schritte.is_empty() {
        assert!(
            schritt.is_none(),
            "{name}: tb-events: time und tb-clock nicht mischen"
        );
        let mut host = EreignisHost {
            inner: host,
            schritte: schritte.into(),
            pause: false,
        };
        return run_to_snapshot(path, &mut vm, &mut host);
    }
    if let Some(schritt_ms) = schritt {
        let mut host = UhrHost {
            inner: host,
            schritt_ms,
        };
        return run_to_snapshot(path, &mut vm, &mut host);
    }
    run_to_snapshot(path, &mut vm, &mut host)
}

fn run_to_snapshot(path: &Path, vm: &mut Vm, host: &mut dyn Host) -> String {
    match vm.run(host) {
        RunEvent::Ended => tb_runtime::snapshot::snapshot(&vm.rt.screen),
        other => panic!(
            "{}: unerwartetes Laufzeitende {other:?}\nBildschirm bisher:\n{}",
            path.display(),
            tb_runtime::snapshot::snapshot(&vm.rt.screen)
        ),
    }
}

fn source_for_directives(path: &Path) -> String {
    let raw = std::fs::read_to_string(path).unwrap();
    if path
        .extension()
        .and_then(|ext| ext.to_str())
        .is_some_and(|ext| ext.eq_ignore_ascii_case("frm"))
    {
        tb_ui::frm::read_text(&path.display().to_string(), &raw)
            .unwrap()
            .code
    } else {
        raw
    }
}

/// Snapshots vergleichen: erst das Textbild, dann die Attributebene.
/// Bei Abweichung im Text zählt die Zeile, bei Attributen Zeile und Spalte.
fn assert_output_matches(file: &str, want: &str, got: &str) {
    if want == got {
        return;
    }
    let marke = tb_runtime::snapshot::ATTRIBUT_MARKE;
    let teile = |s: &str| match s.split_once(&format!("{marke}\n")) {
        Some((t, a)) => (t.to_string(), Some(a.to_string())),
        None => (s.to_string(), None),
    };
    let (want_text, want_attr) = teile(want);
    let (got_text, got_attr) = teile(got);

    if want_text != got_text {
        // `lines()` statt `split('\n')`: Snapshots enden je Zeile mit `\n`,
        // sonst zählt der leere Rest als zusätzliche Zeile und die
        // Zeilenanzahl-Meldung wäre nie erreichbar.
        let w: Vec<&str> = want_text.lines().collect();
        let g: Vec<&str> = got_text.lines().collect();
        for (i, (a, b)) in w.iter().zip(g.iter()).enumerate() {
            if a != b {
                panic!(
                    "{file}: Abweichung in Zeile {}:\n  Soll: {a:?}\n  Ist:  {b:?}",
                    i + 1
                );
            }
        }
        panic!(
            "{file}: Zeilenanzahl weicht ab (Soll {} / Ist {}).\nSoll:\n{want_text}\nIst:\n{got_text}",
            w.len(),
            g.len()
        );
    }

    match (want_attr, got_attr) {
        (Some(w), None) => panic!("{file}: Attributebene fehlt in der Ausgabe.\nSoll:\n{w}"),
        (None, Some(g)) => panic!(
            "{file}: unerwartete Attributebene — das Programm setzt Farben, \
             die Sollausgabe kennt keine.\nIst:\n{g}"
        ),
        (Some(w), Some(g)) => {
            for (z, (wz, gz)) in w.lines().zip(g.lines()).enumerate() {
                if wz != gz {
                    // Je Zelle zwei Hexziffern → Spalte aus dem Byteoffset.
                    let spalte = wz
                        .chars()
                        .zip(gz.chars())
                        .position(|(a, b)| a != b)
                        .map(|i| i / 2 + 1)
                        .unwrap_or(0);
                    let paar =
                        |s: &str| s.chars().skip((spalte - 1) * 2).take(2).collect::<String>();
                    panic!(
                        "{file}: Farbabweichung in Zeile {}, Spalte {spalte}:\n  \
                         Soll: {}\n  Ist:  {}",
                        z + 1,
                        paar(wz),
                        paar(gz)
                    );
                }
            }
            panic!("{file}: Attributebenen unterschiedlich lang.\nSoll:\n{w}\nIst:\n{g}");
        }
        (None, None) => unreachable!("Snapshots gleich, aber Vergleich schlug fehl"),
    }
}

#[test]
fn korpus_laeuft_mit_korrekter_ausgabe() {
    let dir = compat_dir();
    let mut checked = 0;
    let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)
        .expect("tests/compat fehlt")
        .map(|e| e.unwrap().path())
        .filter(|p| {
            p.extension()
                .and_then(|e| e.to_str())
                .is_some_and(|ext| matches!(ext, "bas" | "frm"))
        })
        .collect();
    entries.sort();
    for path in entries {
        let name = path.file_name().unwrap().to_string_lossy().to_string();
        let src = source_for_directives(&path);
        let groessen = screen_groessen(&name, &src);
        for (cols, rows) in &groessen {
            // Bei mehreren Größen trägt jede ihre eigene Sollausgabe.
            let out_path = if groessen.len() == 1 {
                path.with_extension("out")
            } else {
                path.with_extension(format!("{cols}x{rows}.out"))
            };
            let want = std::fs::read_to_string(&out_path)
                .unwrap_or_else(|_| panic!("{name}: Sollausgabe {} fehlt", out_path.display()));
            // .out-Dateien sind LF-normiert (.gitattributes); zur Sicherheit
            // CRLF des Checkouts entfernen.
            let want = want.replace("\r\n", "\n");
            let got = run_corpus_file(&path, Some((*cols, *rows)));
            let etikett = format!("{name} @ {cols}x{rows}");
            assert_output_matches(&etikett, &want, &got);
            let wiederholt = run_corpus_file(&path, Some((*cols, *rows)));
            assert_eq!(
                got, wiederholt,
                "{etikett}: zwei Läufe weichen voneinander ab"
            );
            checked += 1;
        }
    }
    assert!(checked >= 5, "zu wenige Korpusdateien gefunden: {checked}");
}

#[test]
fn uhr_direktive_wird_gelesen() {
    assert_eq!(
        clock_schritt("uhr.bas", "' tb-clock: 25ms/Zustellpunkt\nON TIMER(1)"),
        Some(25)
    );
}

#[test]
fn ereignisfolge_liefert_taste_maus_und_zeit_in_reihenfolge() {
    let steps = ereignis_direktive(
        "form.frm",
        "' tb-events: key:%o | mouse:down,1,0,5,7 | time:250ms | key:<ENTER>",
    );
    assert_eq!(steps.len(), 4);
    assert!(
        matches!(&steps[0], Ereignisschritt::Ereignis(tb_runtime::host::Ereignis::Taste(key, shift)) if key == "o" && *shift == 4)
    );
    assert!(
        matches!(&steps[1], Ereignisschritt::Ereignis(tb_runtime::host::Ereignis::Maus(mouse)) if mouse.zeile == 5 && mouse.spalte == 7)
    );
    assert!(matches!(steps[2], Ereignisschritt::Zeit(250)));
    assert!(
        matches!(&steps[3], Ereignisschritt::Ereignis(tb_runtime::host::Ereignis::Taste(key, _)) if key == tb_runtime::host::taste::ENTER)
    );
}

#[test]
#[should_panic(expected = "deklariert aber keinen Zeitverlauf")]
fn zeittrap_ohne_uhr_direktive_wird_abgewiesen() {
    clock_schritt("uhr.bas", "ON TIMER(1) GOSUB Tick");
}

// ---- tbc-Binary (Exit-Codes nach D6) ----------------------------------------

#[test]
fn tbc_run_hello() {
    let exe = env!("CARGO_BIN_EXE_tbc");
    let out = Command::new(exe)
        .args(["run"])
        .arg(compat_dir().join("hello.bas"))
        .output()
        .expect("tbc startet");
    assert!(out.status.success(), "{out:?}");
    assert_eq!(String::from_utf8_lossy(&out.stdout), "Hallo, Welt!\n");
}

/// OPEN darf keinen zweiten Writer auf dieselbe Datenbank anfordern.
/// Der Unterprozess wird auch bei einer Regression nach fünf Sekunden beendet.
#[test]
fn tbc_isam_open_in_transaktion_hat_eine_harte_frist() {
    use std::process::Stdio;
    use std::time::{Duration, Instant};

    let dir = std::env::temp_dir().join(format!("tb_isam_open_{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    let mut child = Command::new(env!("CARGO_BIN_EXE_tbc"))
        .arg("run")
        .arg(compat_dir().join("isamopen.bas"))
        .current_dir(&dir)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("tbc startet");
    let start = Instant::now();
    let beendet = loop {
        match child.try_wait() {
            Ok(Some(_)) => break true,
            Ok(None) if start.elapsed() < Duration::from_secs(5) => {
                std::thread::sleep(Duration::from_millis(10));
            }
            _ => {
                let _ = child.kill();
                break false;
            }
        }
    };
    let out = child.wait_with_output().unwrap();
    let _ = std::fs::remove_dir_all(&dir);
    assert!(
        beendet,
        "OPEN innerhalb einer Transaktion hat die Prozessfrist überschritten: {out:?}"
    );
    assert!(out.status.success(), "{out:?}");
    assert_eq!(
        String::from_utf8_lossy(&out.stdout),
        include_str!("../../../tests/compat/isamopen.out")
    );
}

#[test]
fn tbc_run_stop_exitcode() {
    let exe = env!("CARGO_BIN_EXE_tbc");
    let dir = std::env::temp_dir();
    let f = dir.join("tb_phase2_stop_test.bas");
    std::fs::write(&f, "PRINT \"x\"\nSTOP\n").unwrap();
    let out = Command::new(exe).args(["run"]).arg(&f).output().unwrap();
    assert_eq!(out.status.code(), Some(3), "{out:?}");
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(err.contains("STOP in line 2"), "{err}");
    let _ = std::fs::remove_file(&f);
}

#[test]
fn tbc_run_laufzeitfehler_exitcode() {
    let exe = env!("CARGO_BIN_EXE_tbc");
    let dir = std::env::temp_dir();
    let f = dir.join("tb_phase2_err_test.bas");
    std::fs::write(&f, "i% = 40000\n").unwrap();
    let out = Command::new(exe).args(["run"]).arg(&f).output().unwrap();
    assert_eq!(out.status.code(), Some(2), "{out:?}");
    let err = String::from_utf8_lossy(&out.stderr);
    assert!(err.contains("Overflow"), "{err}");
    let _ = std::fs::remove_file(&f);
}

#[test]
fn tbc_build_erzeugt_tbc() {
    let exe = env!("CARGO_BIN_EXE_tbc");
    let dir = std::env::temp_dir();
    let f = dir.join("tb_phase2_build_test.bas");
    std::fs::write(&f, "PRINT 1\n").unwrap();
    let out = Command::new(exe).args(["build"]).arg(&f).output().unwrap();
    assert!(out.status.success(), "{out:?}");
    let tbc = f.with_extension("tbc");
    let bytes = std::fs::read(&tbc).unwrap();
    assert_eq!(&bytes[..4], b"TBC\0");
    let _ = std::fs::remove_file(&f);
    let _ = std::fs::remove_file(&tbc);
}

#[test]
fn tbc_loest_include_projekt_und_run_datei_auf() {
    let exe = env!("CARGO_BIN_EXE_tbc");
    let dir = std::env::temp_dir().join(format!("tb_project_{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    std::fs::write(dir.join("werte.bi"), "CONST N = 7\n").unwrap();
    std::fs::write(
        dir.join("main.bas"),
        "'$INCLUDE: 'WERTE.BI'\nDECLARE SUB Ausgabe\nAusgabe\nEND\n",
    )
    .unwrap();
    std::fs::write(dir.join("lib.bas"), "SUB Ausgabe\nPRINT N\nEND SUB\n").unwrap();
    std::fs::write(dir.join("app.mak"), "MAIN.BAS\nLIB.BAS\n").unwrap();
    let project = Command::new(exe)
        .args(["run"])
        .arg(dir.join("app.mak"))
        .output()
        .unwrap();
    assert!(project.status.success(), "{project:?}");
    assert_eq!(String::from_utf8_lossy(&project.stdout), " 7 \n");

    std::fs::write(dir.join("start.bas"), "RUN \"ZIEL\"\n").unwrap();
    std::fs::write(dir.join("ziel.bas"), "PRINT \"weiter\"\nEND\n").unwrap();
    let chained = Command::new(exe)
        .args(["run"])
        .arg(dir.join("start.bas"))
        .output()
        .unwrap();
    assert!(chained.status.success(), "{chained:?}");
    assert_eq!(String::from_utf8_lossy(&chained.stdout), "weiter\n");
    let _ = std::fs::remove_dir_all(&dir);
}

// ---- Harness meldet Abweichungen benannt (Spec kompat-testkorpus) ----------

fn meldung(want: &str, got: &str) -> String {
    let r = std::panic::catch_unwind(|| assert_output_matches("probe.bas", want, got));
    let e = r.expect_err("erwartete eine Abweichung");
    e.downcast_ref::<String>()
        .cloned()
        .or_else(|| e.downcast_ref::<&str>().map(|s| s.to_string()))
        .unwrap_or_default()
}

#[test]
fn textabweichung_nennt_zeile_soll_und_ist() {
    let m = meldung("a\nb\n", "a\nX\n");
    assert!(m.contains("Zeile 2"), "{m}");
    assert!(m.contains("\"b\""), "{m}");
    assert!(m.contains("\"X\""), "{m}");
}

#[test]
fn zeilenanzahl_wird_benannt() {
    let m = meldung("a\nb\n", "a\n");
    assert!(m.contains("Zeilenanzahl"), "{m}");
}

#[test]
fn farbabweichung_nennt_zeile_spalte_soll_und_ist() {
    let marke = tb_runtime::snapshot::ATTRIBUT_MARKE;
    let want = format!("ab\n{marke}\n7070\n");
    let got = format!("ab\n{marke}\n70e1\n");
    let m = meldung(&want, &got);
    assert!(m.contains("Farbabweichung"), "{m}");
    assert!(m.contains("Zeile 1"), "{m}");
    assert!(m.contains("Spalte 2"), "{m}");
    assert!(m.contains("70") && m.contains("e1"), "{m}");
}

#[test]
fn fehlende_und_unerwartete_attributebene_werden_benannt() {
    let marke = tb_runtime::snapshot::ATTRIBUT_MARKE;
    let mit = format!("ab\n{marke}\n7070\n");
    assert!(meldung(&mit, "ab\n").contains("Attributebene fehlt"));
    assert!(meldung("ab\n", &mit).contains("unerwartete Attributebene"));
}

#[test]
fn fehlende_groessenangabe_wird_abgewiesen() {
    let dir = std::env::temp_dir().join("tb_korpus_ohne_groesse");
    std::fs::create_dir_all(&dir).unwrap();
    let f = dir.join("ohne.bas");
    std::fs::write(&f, "PRINT 1\n").unwrap();
    let r = std::panic::catch_unwind(|| run_corpus_file(&f, None));
    let e = r.expect_err("Datei ohne Größenangabe muss abgewiesen werden");
    let m = e.downcast_ref::<String>().cloned().unwrap_or_default();
    assert!(m.contains("keine Bildschirmgröße deklariert"), "{m}");
    let _ = std::fs::remove_dir_all(&dir);
}

/// Sollausgaben neu erzeugen (nur auf Anforderung):
/// `cargo test -p tb-cli --test compat -- --ignored erzeuge_sollausgaben`
///
/// Die erzeugten Dateien sind zu prüfen, bevor sie eingecheckt werden — ein
/// unerwarteter Diff ist ein Befund, kein Anpassungsgrund.
#[test]
#[ignore = "erzeugt Golden Files; nur bewusst aufrufen"]
fn erzeuge_sollausgaben() {
    let dir = compat_dir();
    let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)
        .expect("tests/compat fehlt")
        .map(|e| e.unwrap().path())
        .filter(|p| {
            p.extension()
                .and_then(|e| e.to_str())
                .is_some_and(|ext| matches!(ext, "bas" | "frm"))
        })
        .collect();
    entries.sort();
    for path in entries {
        let name = path.file_name().unwrap().to_string_lossy().to_string();
        let src = source_for_directives(&path);
        let groessen = screen_groessen(&name, &src);
        for (cols, rows) in &groessen {
            let out_path = if groessen.len() == 1 {
                path.with_extension("out")
            } else {
                path.with_extension(format!("{cols}x{rows}.out"))
            };
            let got = run_corpus_file(&path, Some((*cols, *rows)));
            std::fs::write(&out_path, &got).unwrap();
            println!("geschrieben: {}", out_path.display());
        }
    }
}
