//! Builtin-Dispatch-Tabelle (`CALL_BUILTIN`-ABI): Argumente kommen vom
//! Operandenstack der VM, der Index steht im Opcode. Die Tabelle ist ohne
//! Änderung am Opcode-Satz erweiterbar — Phase 3 füllt sie auf.
//!
//! Die Indizes (`ids::*`) sind stabil; der Codegenerator (`tb-vm`)
//! bildet `hir::Builtin` über ein erschöpfendes `match` darauf ab.

use crate::console::PrintState;
use crate::datetime as dt;
use crate::errors::RuntimeError;
use crate::fileio as fio;
use crate::finanz as fin;
use crate::format;
use crate::host::{Ereignis, Host};
use crate::screen::TextScreen;
use crate::value;
use crate::value::{as_f64, cur_to_f64, f64_to_cur, Value};
use std::collections::VecDeque;
use std::rc::Rc;

/// Laufzeitzustand der Bibliothek: Bildschirm, Eingabepuffer, PRNG,
/// Kommandozeile.
pub struct RtState {
    /// Der Textbildschirm — Ziel aller Bildschirmwirkungen.
    pub screen: TextScreen,
    /// Offene Dateien nach Dateinummer.
    pub dateien: crate::fileio::Dateien,
    /// ISAM-Zustand: Tabellenbindungen, Cursor, laufende Transaktion.
    pub isam: crate::isam::Isam,
    pub print: PrintState,
    /// Hostreihenfolge bis zur Annahme durch Trap, Formular oder Konsole.
    pub eingaben: VecDeque<Eingabe>,
    /// Bereits angenommener Rest einer von INPUT$ geteilten Taste.
    tastenrest: String,
    /// Abbruchwunsch (Strg+Untbr) wurde gemeldet.
    pub abbruch: bool,
    /// Eingabeende wurde gemeldet.
    pub ende: bool,
    /// Funktionstasten-Makros: F1–F10, dann F11/F12 (`KEY 30`/`KEY 31`).
    pub key_makros: [String; 12],
    /// Ist die Softkey-Zeile eingeblendet (`KEY ON`)?
    pub key_zeile: bool,
    /// Währungszeichen für `$$` in `PRINT USING` (siehe `SetFormatCC`).
    pub waehrung: crate::using::Waehrung,
    /// Ablaufverfolgung (`TRON`/`TROFF`) eingeschaltet?
    pub trace: bool,
    /// Zustände der klassischen Ereignis-Traps (Sprachreferenz §8).
    pub traps: crate::traps::Traps,
    /// Trefferliste und Zeiger für `DIR$`.
    pub dir_treffer: Vec<String>,
    pub dir_index: usize,
    /// Versatz der Programmuhr in Sekunden. `DATE$ =` und `TIME$ =` können
    /// die Systemuhr nicht stellen; sie verschieben stattdessen diesen
    /// Versatz (dokumentierte Abweichung). Er wirkt auf die Ortszeit.
    pub uhr_offset: i64,
    /// Woher der UTC-Versatz kommt. Vorgabe ist die Zone des Rechners;
    /// bei fehlgeschlagener Auflösung gilt für diese Abfrage UTC.
    pub zeitzone: dt::Zeitzone,
    /// Auflösung der Systemzone; durch Einbetter auch ohne Systemänderung ersetzbar.
    pub zonen_ermittler: fn(i64) -> Option<i32>,
    rng: u32,
    rnd_last: f32,
    pub command: String,
    pub clipboard: String,
}

/// Eine noch nicht verbrauchte Eingabe. Unter KEY STOP wird der Trap
/// einmal gemerkt; die Taste bleibt danach unabhängig vom Trap lesbar.
#[derive(Debug)]
pub struct Eingabe {
    pub ereignis: Ereignis,
    trap_gemerkt: bool,
}

impl From<Ereignis> for Eingabe {
    fn from(ereignis: Ereignis) -> Self {
        Self {
            ereignis,
            trap_gemerkt: false,
        }
    }
}

impl Default for RtState {
    fn default() -> Self {
        RtState {
            screen: TextScreen::new(),
            dateien: Default::default(),
            isam: Default::default(),
            print: PrintState::default(),
            eingaben: VecDeque::new(),
            tastenrest: String::new(),
            abbruch: false,
            ende: false,
            // Das Vorbild belegt die Makros im Interpreter vor; ein
            // kompiliertes Programm startet mit leeren Makros.
            key_makros: Default::default(),
            key_zeile: false,
            waehrung: Default::default(),
            trace: false,
            traps: crate::traps::Traps::neu(),
            dir_treffer: Vec::new(),
            dir_index: 0,
            uhr_offset: 0,
            zeitzone: dt::Zeitzone::System,
            zonen_ermittler: dt::utc_versatz,
            // Startzustand des Vorbild-PRNG; die exakte
            // PRNG-Kompatibilität ist Aufgabe in PLAN.md Phase 3
            // („RND/RANDOMIZE — kompatibler PRNG").
            rng: 0x50000,
            rnd_last: 0.0,
            command: String::new(),
            clipboard: String::new(),
        }
    }
}

impl RtState {
    /// Ortszeit und Gültigkeit stammen aus derselben aktuellen Zonenauflösung.
    pub fn zeitpunkt(&self) -> (i64, bool) {
        let utc = dt::jetzt_utc();
        let zone = match self.zeitzone {
            dt::Zeitzone::System => (self.zonen_ermittler)(utc),
            dt::Zeitzone::Fest(n) => Some(n),
            dt::Zeitzone::Unbekannt => None,
        };
        (
            utc + i64::from(zone.unwrap_or(0)) + self.uhr_offset,
            zone.is_some(),
        )
    }

    fn jetzt_serial(&self) -> f64 {
        self.zeitpunkt().0 as f64 / 86_400.0 + dt::serial_aus_ymd(1970, 1, 1)
    }

    /// Höchstens n Codepoints annehmen, übrige Zeichen vor der nächsten Taste lesen.
    pub fn tastenanteil(&mut self, mut taste: String, n: usize) -> String {
        if let Some((ende, _)) = taste.char_indices().nth(n) {
            self.tastenrest = taste.split_off(ende);
        }
        taste
    }

    /// Ereignisse vom Host abholen und einsortieren: Größenänderungen wirken
    /// sofort auf den Bildschirm, Abbruch und Eingabeende werden gemerkt,
    /// Tastendrücke landen in der Warteschlange für `INKEY$`/`INPUT`.
    ///
    /// Mit `blockierend = true` wird gewartet, bis mindestens ein Ereignis
    /// kam oder das Eingabeende erreicht ist.
    pub fn pump(&mut self, host: &mut dyn Host, blockierend: bool) {
        loop {
            let Some(e) = host.next_event(blockierend) else {
                return;
            };
            self.ereignis(e);
            if blockierend {
                return;
            }
        }
    }

    pub fn ereignis(&mut self, e: Ereignis) {
        match e {
            Ereignis::Taste(..) | Ereignis::Maus(_) => self.eingaben.push_back(e.into()),
            Ereignis::Groesse { cols, rows } => self.screen.resize(cols, rows),
            Ereignis::Abbruch => self.abbruch = true,
            Ereignis::Ende => self.ende = true,
            Ereignis::Signal(n) => {
                let gemeldet = self.traps.melden(crate::traps::Quelle::Signal(n));
                if n == 1 && !gemeldet {
                    self.abbruch = true;
                }
            }
        }
    }

    /// Bildschirm anzeigen und anstehende Ereignisse einsammeln.
    /// Zustellpunkt an Tick-Grenzen und vor blockierender Eingabe.
    pub fn tick(&mut self, host: &mut dyn Host) {
        host.present(&self.screen);
        self.pump(host, false);
        self.traps_pruefen(host);
    }

    /// Was am Zustellpunkt vor der eigentlichen Zustellung zu tun ist:
    /// Zeit prüfen und getrappte Tasten aus der Schlange nehmen.
    ///
    /// Die Tasten werden **hier** aussortiert, nicht beim Einsortieren
    /// (design.md, D5): so wirkt ein `KEY(n) OFF` sofort und auch auf
    /// Tasten, die schon in der Schlange liegen.
    pub fn traps_pruefen(&mut self, host: &mut dyn Host) {
        if !self.traps.aktiv() {
            return;
        }
        if self.traps.naechste_frist().is_some() {
            self.traps.zeit_pruefen(host.jetzt_ms());
        }
        self.tasten_traps_pruefen();
    }

    /// Getrappte Tasten aus der Schlange nehmen. Ohne Host, damit die VM
    /// das an jeder Anweisungsgrenze tun kann, ohne die Uhr zu lesen.
    pub fn tasten_traps_pruefen(&mut self) {
        while self.taste_trappen(0) {}
    }

    /// Prüft eine wartende Taste genau bis zu ihrer Annahme. ON verbraucht
    /// sie, STOP merkt den Trap einmal und belässt sie im Eingabestrom.
    pub fn taste_trappen(&mut self, index: usize) -> bool {
        if !self.traps.aktiv() {
            return false;
        }
        let Some(eingabe) = self.eingaben.get_mut(index) else {
            return false;
        };
        let Ereignis::Taste(s, shift) = &eingabe.ereignis else {
            return false;
        };
        if eingabe.trap_gemerkt {
            return false;
        }
        let Some(code) = crate::host::taste::scancode_von(s) else {
            return false;
        };
        let Some(n) = self.traps.taste_faengt(code, *shift) else {
            return false;
        };
        self.traps.melden(crate::traps::Quelle::Key(n));
        if self.traps.ist_an(crate::traps::Quelle::Key(n)) {
            self.eingaben.remove(index);
            true
        } else {
            eingabe.trap_gemerkt = true;
            false
        }
    }

    /// Nur bereits gepufferte Tasten annehmen, ohne weitere Hostereignisse
    /// abzuholen. Die VM kann zwischen Abholen und Blockieren zustellen.
    pub fn gepufferte_taste(&mut self) -> Option<String> {
        if !self.tastenrest.is_empty() {
            return Some(std::mem::take(&mut self.tastenrest));
        }
        while let Some(i) = self
            .eingaben
            .iter()
            .position(|e| matches!(e.ereignis, Ereignis::Taste(..)))
        {
            if self.taste_trappen(i) {
                continue;
            }
            if let Ereignis::Taste(t, _) = self.eingaben.remove(i).unwrap().ereignis {
                return Some(t);
            }
        }
        None
    }

    /// Nächster Tastendruck; auch während blockierender Eingabe gelten
    /// ON/OFF/STOP. Mausereignisse bleiben dem VM-Zustellpunkt vorbehalten.
    pub fn naechste_taste(&mut self, host: &mut dyn Host, blockierend: bool) -> Option<String> {
        let mut abgeholt = false;
        loop {
            if let Some(t) = self.gepufferte_taste() {
                return Some(t);
            }
            if self.ende || self.abbruch || (!blockierend && abgeholt) {
                return None;
            }
            self.pump(host, blockierend);
            abgeholt = true;
        }
    }

    /// Zeileneingabe auf dem Bildschirm: Zeichen werden geechot, Rückschritt
    /// löscht, Enter schließt ab. `None` = Eingabeende (Fehler 62).
    pub fn read_line(&mut self, host: &mut dyn Host) -> Option<String> {
        let mut zeile = String::new();
        loop {
            host.present(&self.screen);
            let t = self.naechste_taste(host, true)?;
            match t.as_str() {
                crate::host::taste::ENTER => {
                    self.screen.print("\n");
                    return Some(zeile);
                }
                crate::host::taste::BACKSPACE => {
                    if zeile.pop().is_some() {
                        self.screen.rueckschritt();
                    }
                }
                _ => {
                    // Sondertasten (Nullzeichen + Kennung) erzeugen keine Eingabe.
                    if let Some(c) = einzelzeichen(&t) {
                        zeile.push(c);
                        self.screen.print(&c.to_string());
                    }
                }
            }
        }
    }

    /// Softkey-Zeile ein-/ausblenden (`KEY ON`/`KEY OFF`). Eingeblendet
    /// belegt sie die unterste Zeile; der Scrollbereich endet darüber, damit
    /// die Ausgabe sie nicht überschreibt.
    pub fn softkey_zeile(&mut self, ein: bool) {
        if ein == self.key_zeile {
            return;
        }
        self.key_zeile = ein;
        let rows = self.screen.rows();
        if ein {
            let _ = self.screen.view_print(1, rows - 1);
            self.softkey_zeile_zeichnen();
        } else {
            let (z, s) = (self.screen.csrlin(), self.screen.pos());
            let _ = self.screen.view_print(1, rows);
            let _ = self.screen.locate(rows, 1);
            self.screen.print(&" ".repeat(self.screen.cols()));
            let _ = self.screen.locate(z, s);
        }
    }

    /// Die Softkey-Zeile neu zeichnen: Tastennummer plus die ersten sechs
    /// Zeichen des Makros, wie beim Vorbild.
    fn softkey_zeile_zeichnen(&mut self) {
        let (z, s) = (self.screen.csrlin(), self.screen.pos());
        let breite = self.screen.cols();
        let mut zeile = String::new();
        for (i, m) in self.key_makros.iter().take(10).enumerate() {
            zeile.push_str(&format!("{}", i + 1));
            zeile.extend(m.chars().take(6));
            zeile.push(' ');
        }
        zeile.truncate(breite);
        let rows = self.screen.rows();
        let _ = self.screen.locate(rows, 1);
        self.screen.print(&format!("{zeile:<breite$}"));
        let _ = self.screen.locate(z.min(rows - 1), s);
    }

    /// Startwert setzen. Die 16 Bit aus dem Argument bilden die Bits 8–23
    /// des 24-Bit-Zustands; das niederwertige Byte ist fest. Dadurch liefert
    /// derselbe Startwert stets dieselbe Folge — unabhängig davon, wie viele
    /// `RND`-Aufrufe vorher liefen.
    pub fn saat_setzen(&mut self, x: f64) {
        let b = x.to_bits();
        let m = ((b >> 32) ^ (b >> 48)) as u16;
        self.rng = (m as u32) << 8;
    }

    fn rng_next(&mut self) -> f32 {
        self.rng = self.rng.wrapping_mul(0xFD43FD).wrapping_add(0xC39EC3) & 0xFF_FFFF;
        self.rnd_last = self.rng as f32 / 16_777_216.0;
        self.rnd_last
    }
}

/// Ein Tastendruck als druckbares Zeichen — Sondertasten (führendes
/// Nullzeichen) und Steuerzeichen liefern `None`.
fn einzelzeichen(t: &str) -> Option<char> {
    let mut it = t.chars();
    let c = it.next()?;
    if it.next().is_some() || c == '\0' || (c as u32) < 0x20 {
        return None;
    }
    Some(c)
}

pub type BuiltinFn =
    fn(&mut RtState, &mut dyn Host, &mut [Value]) -> Result<Option<Value>, RuntimeError>;

/// Stabile Tabellenindizes (Ordnung = `hir::Builtin` des Frontends).
pub mod ids {
    pub const LEN: u16 = 0;
    pub const LEFT_S: u16 = 1;
    pub const RIGHT_S: u16 = 2;
    pub const MID_S: u16 = 3;
    pub const INSTR: u16 = 4;
    pub const UCASE_S: u16 = 5;
    pub const LCASE_S: u16 = 6;
    pub const LTRIM_S: u16 = 7;
    pub const RTRIM_S: u16 = 8;
    pub const SPACE_S: u16 = 9;
    pub const STRING_S: u16 = 10;
    pub const CHR_S: u16 = 11;
    pub const ASC: u16 = 12;
    pub const STR_S: u16 = 13;
    pub const VAL: u16 = 14;
    pub const HEX_S: u16 = 15;
    pub const OCT_S: u16 = 16;
    pub const MID_ASSIGN: u16 = 17;
    pub const ABS: u16 = 18;
    pub const SGN: u16 = 19;
    pub const INT_F: u16 = 20;
    pub const FIX: u16 = 21;
    pub const SQR: u16 = 22;
    pub const EXP: u16 = 23;
    pub const LOG: u16 = 24;
    pub const SIN: u16 = 25;
    pub const COS: u16 = 26;
    pub const TAN: u16 = 27;
    pub const ATN: u16 = 28;
    pub const RND: u16 = 29;
    pub const RANDOMIZE: u16 = 30;
    pub const PRINT_VAL: u16 = 31;
    pub const PRINT_STR_LIT: u16 = 32;
    pub const PRINT_COMMA: u16 = 33;
    pub const PRINT_TAB: u16 = 34;
    pub const PRINT_SPC: u16 = 35;
    pub const PRINT_NEWLINE: u16 = 36;
    pub const PRINT_USING: u16 = 37;
    pub const FORMAT_S: u16 = 38;
    pub const SET_FORMAT_CC: u16 = 39;
    pub const CLS: u16 = 40;
    pub const COLOR: u16 = 41;
    pub const LOCATE: u16 = 42;
    pub const WIDTH: u16 = 43;
    pub const VIEW_PRINT: u16 = 44;
    pub const SCREEN_STMT: u16 = 45;
    pub const KEY_ASSIGN: u16 = 46;
    pub const KEY_LIST: u16 = 47;
    pub const KEY_DISPLAY: u16 = 48;
    pub const CSRLIN: u16 = 49;
    pub const POS_FN: u16 = 50;
    pub const SCREEN_FN: u16 = 51;
    pub const INKEY_S: u16 = 52;
    pub const INPUT_S: u16 = 53;
    pub const TIMER: u16 = 54;
    pub const DATE_S: u16 = 55;
    pub const TIME_S: u16 = 56;
    pub const DATE_SET: u16 = 57;
    pub const TIME_SET: u16 = 58;
    pub const NOW: u16 = 59;
    pub const DATE_SERIAL: u16 = 60;
    pub const TIME_SERIAL: u16 = 61;
    pub const DATE_VALUE: u16 = 62;
    pub const TIME_VALUE: u16 = 63;
    pub const DAY_F: u16 = 64;
    pub const MONTH_F: u16 = 65;
    pub const YEAR_F: u16 = 66;
    pub const WEEKDAY_F: u16 = 67;
    pub const HOUR_F: u16 = 68;
    pub const MINUTE_F: u16 = 69;
    pub const SECOND_F: u16 = 70;
    pub const COMMAND_S: u16 = 71;
    pub const DOEVENTS: u16 = 72;
    pub const SLEEP: u16 = 73;
    pub const BEEP: u16 = 74;
    pub const FV: u16 = 75;
    pub const PV: u16 = 76;
    pub const PMT: u16 = 77;
    pub const NPER: u16 = 78;
    pub const IPMT: u16 = 79;
    pub const PPMT: u16 = 80;
    pub const RATE: u16 = 81;
    pub const NPV: u16 = 82;
    pub const IRR: u16 = 83;
    pub const MIRR: u16 = 84;
    pub const SLN: u16 = 85;
    pub const SYD: u16 = 86;
    pub const DDB: u16 = 87;
    pub const OPEN: u16 = 88;
    pub const CLOSE: u16 = 89;
    pub const CLOSE_ALL: u16 = 90;
    pub const PRINT_ZIEL: u16 = 91;
    pub const WRITE_FILE: u16 = 92;
    pub const EOF_F: u16 = 93;
    pub const LOF_F: u16 = 94;
    pub const LOC_F: u16 = 95;
    pub const SEEK_F: u16 = 96;
    pub const SEEK_STMT: u16 = 97;
    pub const FREEFILE: u16 = 98;
    pub const FILEATTR: u16 = 99;
    pub const LOCK_STMT: u16 = 100;
    pub const KILL: u16 = 101;
    pub const NAME_STMT: u16 = 102;
    pub const FILES: u16 = 103;
    pub const CHDIR: u16 = 104;
    pub const CHDRIVE: u16 = 105;
    pub const MKDIR: u16 = 106;
    pub const RMDIR: u16 = 107;
    pub const CURDIR_S: u16 = 108;
    pub const DIR_S: u16 = 109;
    pub const LPOS: u16 = 110;
    pub const SHELL_STMT: u16 = 111;
    pub const SHELL_FN: u16 = 112;
    pub const MK_S: u16 = 113;
    pub const CV_F: u16 = 114;
    pub const ENVIRON_S: u16 = 115;
    pub const ENVIRON_SET: u16 = 116;
    pub const FRE: u16 = 117;
    pub const CLEAR: u16 = 118;
    pub const TRON: u16 = 119;
    pub const TROFF: u16 = 120;
    pub const STACK_FN: u16 = 121;
    pub const STACK_STMT: u16 = 122;
    pub const ERDEV: u16 = 123;
    pub const ERDEV_S: u16 = 124;
    // ISAM (Change `phase-3-isam`)
    pub const ISAM_OPEN: u16 = 125;
    pub const ISAM_CREATE_INDEX: u16 = 126;
    pub const ISAM_DELETE_INDEX: u16 = 127;
    pub const ISAM_SET_INDEX: u16 = 128;
    pub const ISAM_GET_INDEX_S: u16 = 129;
    pub const ISAM_INSERT: u16 = 130;
    pub const ISAM_RETRIEVE: u16 = 131;
    pub const ISAM_UPDATE: u16 = 132;
    pub const ISAM_DELETE: u16 = 133;
    pub const ISAM_DELETE_TABLE: u16 = 134;
    pub const ISAM_MOVE_FIRST: u16 = 135;
    pub const ISAM_MOVE_LAST: u16 = 136;
    pub const ISAM_MOVE_NEXT: u16 = 137;
    pub const ISAM_MOVE_PREVIOUS: u16 = 138;
    pub const ISAM_SEEK_EQ: u16 = 139;
    pub const ISAM_SEEK_GT: u16 = 140;
    pub const ISAM_SEEK_GE: u16 = 141;
    pub const ISAM_BEGIN_TRANS: u16 = 142;
    pub const ISAM_COMMIT_TRANS: u16 = 143;
    pub const ISAM_ROLLBACK: u16 = 144;
    pub const ISAM_SAVEPOINT: u16 = 145;
    pub const ISAM_SETMEM: u16 = 146;
    pub const ISAM_BOF: u16 = 147;
    /// `SetUEvent` — loest das benutzerdefinierte Ereignis aus (§8).
    pub const SETUEVENT: u16 = 148;
    pub const MSGBOX: u16 = 149;
    pub const INPUTBOX_S: u16 = 150;
    pub const CLIPBOARD_ADD: u16 = 151;
    pub const CLIPBOARD_GET: u16 = 152;
    pub const GRAPHICS_LINE: u16 = 153;
    pub const GRAPHICS_PAINT: u16 = 154;
    pub const GRAPHICS_VIEW: u16 = 155;
    pub const TIMEZONEKNOWN: u16 = 156;
    pub const COUNT: u16 = 157;
}

/// Dispatch-Tabelle in Index-Reihenfolge.
pub fn builtin_table() -> &'static [BuiltinFn] {
    const TABLE: &[BuiltinFn] = &[
        bi_len,
        bi_left,
        bi_right,
        bi_mid,
        bi_instr,
        bi_ucase,
        bi_lcase,
        bi_ltrim,
        bi_rtrim,
        bi_space,
        bi_string,
        bi_chr,
        bi_asc,
        bi_str,
        bi_val,
        bi_hex,
        bi_oct,
        bi_mid_assign,
        bi_abs,
        bi_sgn,
        bi_int,
        bi_fix,
        bi_sqr,
        bi_exp,
        bi_log,
        bi_sin,
        bi_cos,
        bi_tan,
        bi_atn,
        bi_rnd,
        bi_randomize,
        bi_print_val,
        bi_print_val, // PRINT_STR_LIT: identisch (Strings per Tag)
        bi_print_comma,
        bi_print_tab,
        bi_print_spc,
        bi_print_newline,
        bi_print_using,
        bi_format_s,
        bi_set_format_cc,
        bi_cls,
        bi_color,
        bi_locate,
        bi_width,
        bi_view_print,
        bi_screen_stmt,
        bi_key_assign,
        bi_key_list,
        bi_key_display,
        bi_csrlin,
        bi_pos,
        bi_screen_fn,
        bi_inkey,
        bi_input_s,
        bi_timer,
        bi_date,
        bi_time,
        bi_date_set,
        bi_time_set,
        bi_now,
        bi_date_serial,
        bi_time_serial,
        bi_date_value,
        bi_time_value,
        bi_day,
        bi_month,
        bi_year,
        bi_weekday,
        bi_hour,
        bi_minute,
        bi_second,
        bi_command,
        bi_doevents,
        bi_sleep,
        bi_beep,
        bi_fv,
        bi_pv,
        bi_pmt,
        bi_nper,
        bi_ipmt,
        bi_ppmt,
        bi_rate,
        bi_npv,
        bi_irr,
        bi_mirr,
        bi_sln,
        bi_syd,
        bi_ddb,
        bi_open,
        bi_close,
        bi_close_all,
        bi_print_ziel,
        bi_write_file,
        bi_eof,
        bi_lof,
        bi_loc,
        bi_seek_f,
        bi_seek_stmt,
        bi_freefile,
        bi_fileattr,
        bi_lock,
        bi_kill,
        bi_name,
        bi_files,
        bi_chdir,
        bi_chdrive,
        bi_mkdir,
        bi_rmdir,
        bi_curdir,
        bi_dir,
        bi_lpos,
        bi_shell_stmt,
        bi_shell_fn,
        bi_mk,
        bi_cv,
        bi_environ_s,
        bi_environ_set,
        bi_fre,
        bi_clear,
        bi_tron,
        bi_troff,
        bi_stack_fn,
        bi_stack_stmt,
        bi_erdev,
        bi_erdev_s,
        // ISAM
        bi_isam_open,
        bi_isam_create_index,
        bi_isam_delete_index,
        bi_isam_set_index,
        bi_isam_get_index,
        bi_isam_insert,
        bi_isam_retrieve,
        bi_isam_update,
        bi_isam_delete,
        bi_isam_delete_table,
        bi_isam_move_first,
        bi_isam_move_last,
        bi_isam_move_next,
        bi_isam_move_previous,
        bi_isam_seek_eq,
        bi_isam_seek_gt,
        bi_isam_seek_ge,
        bi_isam_begin_trans,
        bi_isam_commit_trans,
        bi_isam_rollback,
        bi_isam_savepoint,
        bi_isam_setmem,
        bi_isam_bof,
        bi_setuevent,
        bi_msgbox,
        bi_inputbox,
        bi_clipboard_add,
        bi_clipboard_get,
        bi_graphics_line,
        bi_graphics_paint,
        bi_graphics_view,
        bi_timezoneknown,
    ];
    debug_assert_eq!(TABLE.len(), ids::COUNT as usize);
    TABLE
}

fn bi_clipboard_add(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    st.clipboard.push_str(&arg_str(a, 0)?);
    Ok(None)
}

fn bi_clipboard_get(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    Ok(Some(Value::Str(Rc::from(st.clipboard.as_str()))))
}

// ---- Argument-Hilfen --------------------------------------------------------

fn arg_str(args: &[Value], i: usize) -> Result<Rc<str>, RuntimeError> {
    match args.get(i) {
        Some(Value::Str(s)) => Ok(s.clone()),
        _ => Err(RuntimeError::TYPE_MISMATCH),
    }
}

fn arg_i32(args: &[Value], i: usize) -> Result<i32, RuntimeError> {
    match args.get(i) {
        Some(Value::Lng(v)) => Ok(*v),
        Some(Value::Int(v)) => Ok(*v as i32),
        Some(v) => Ok(as_f64(v) as i32),
        None => Err(RuntimeError::ILLEGAL_FUNCTION_CALL),
    }
}

fn arg_f64(args: &[Value], i: usize) -> Result<f64, RuntimeError> {
    match args.get(i) {
        Some(v) => Ok(as_f64(v)),
        None => Err(RuntimeError::ILLEGAL_FUNCTION_CALL),
    }
}

fn s_ok(s: String) -> Result<Option<Value>, RuntimeError> {
    Ok(Some(Value::Str(Rc::from(s.as_str()))))
}

// ---- Strings ----------------------------------------------------------------

fn bi_len(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let s = arg_str(a, 0)?;
    let n = s.chars().count();
    Ok(Some(Value::Int(i16::try_from(n).unwrap_or(i16::MAX))))
}

fn bi_left(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let s = arg_str(a, 0)?;
    let n = arg_i32(a, 1)?;
    if n < 0 {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    s_ok(s.chars().take(n as usize).collect())
}

fn bi_right(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let s = arg_str(a, 0)?;
    let n = arg_i32(a, 1)?;
    if n < 0 {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    let len = s.chars().count();
    let skip = len.saturating_sub(n as usize);
    s_ok(s.chars().skip(skip).collect())
}

fn bi_mid(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let s = arg_str(a, 0)?;
    let start = arg_i32(a, 1)?;
    let len = arg_i32(a, 2)?; // -1 = Rest
    if start < 1 || len < -1 {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    let iter = s.chars().skip((start - 1) as usize);
    if len < 0 {
        s_ok(iter.collect())
    } else {
        s_ok(iter.take(len as usize).collect())
    }
}

fn bi_instr(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let start = arg_i32(a, 0)?;
    let s = arg_str(a, 1)?;
    let t = arg_str(a, 2)?;
    if start < 1 {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    let chars: Vec<char> = s.chars().collect();
    let slen = chars.len();
    if start as usize > slen {
        // Vorbild: Start hinter Stringende → 0
        return Ok(Some(Value::Int(0)));
    }
    if t.is_empty() {
        return Ok(Some(Value::Int(start as i16)));
    }
    let hay: String = chars[(start - 1) as usize..].iter().collect();
    match hay.find(&*t) {
        Some(byte_pos) => {
            let char_pos = hay[..byte_pos].chars().count();
            Ok(Some(Value::Int((start as usize + char_pos) as i16)))
        }
        None => Ok(Some(Value::Int(0))),
    }
}

fn bi_ucase(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let s = arg_str(a, 0)?;
    s_ok(s.to_uppercase())
}

fn bi_lcase(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let s = arg_str(a, 0)?;
    s_ok(s.to_lowercase())
}

fn bi_ltrim(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let s = arg_str(a, 0)?;
    s_ok(s.trim_start_matches(' ').to_string())
}

fn bi_rtrim(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let s = arg_str(a, 0)?;
    s_ok(s.trim_end_matches(' ').to_string())
}

fn bi_space(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let n = arg_i32(a, 0)?;
    if n < 0 {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    s_ok(" ".repeat(n as usize))
}

fn bi_string(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let n = arg_i32(a, 0)?;
    if n < 0 {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    let ch = match a.get(1) {
        Some(Value::Str(s)) => s
            .chars()
            .next()
            .ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?,
        Some(v) => {
            let code = as_f64(v) as i64;
            char::from_u32(u32::try_from(code).map_err(|_| RuntimeError::ILLEGAL_FUNCTION_CALL)?)
                .ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?
        }
        None => return Err(RuntimeError::ILLEGAL_FUNCTION_CALL),
    };
    s_ok(ch.to_string().repeat(n as usize))
}

fn bi_chr(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let n = arg_i32(a, 0)?;
    let ch = u32::try_from(n)
        .ok()
        .and_then(char::from_u32)
        .ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?;
    s_ok(ch.to_string())
}

fn bi_asc(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let s = arg_str(a, 0)?;
    match s.chars().next() {
        // Codepoints > 32767 passen nicht in INTEGER → Overflow wie beim
        // Vorbild bei Bereichsüberschreitung.
        Some(c) => i16::try_from(c as u32)
            .map(|v| Some(Value::Int(v)))
            .map_err(|_| RuntimeError::OVERFLOW),
        None => Err(RuntimeError::ILLEGAL_FUNCTION_CALL),
    }
}

fn bi_str(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let v = a.first().ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?;
    s_ok(format::format_str_fn(v))
}

fn bi_val(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let s = arg_str(a, 0)?;
    Ok(Some(Value::Dbl(format::val(&s))))
}

fn bi_hex(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let n = arg_i32(a, 0)?;
    s_ok(format!("{:X}", n as u32))
}

fn bi_oct(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let n = arg_i32(a, 0)?;
    s_ok(format!("{:o}", n as u32))
}

/// MID$-Anweisung als Funktion: (ziel, start, länge, ersatz) → neuer
/// String; die Länge des Ziels bleibt unverändert.
fn bi_mid_assign(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let target = arg_str(a, 0)?;
    let start = arg_i32(a, 1)?;
    let len = arg_i32(a, 2)?; // -1 = Länge des Ersatzes
    let repl = arg_str(a, 3)?;
    if start < 1 {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    let tchars: Vec<char> = target.chars().collect();
    let rchars: Vec<char> = repl.chars().collect();
    let start0 = (start - 1) as usize;
    if start0 >= tchars.len() {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    let max_repl = if len < 0 {
        rchars.len()
    } else {
        (len as usize).min(rchars.len())
    };
    let n = max_repl.min(tchars.len() - start0);
    let mut out = tchars.clone();
    out[start0..start0 + n].copy_from_slice(&rchars[..n]);
    s_ok(out.into_iter().collect())
}

// ---- Mathematik --------------------------------------------------------------

fn bi_abs(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    Ok(Some(
        match a.first().ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)? {
            Value::Int(v) => Value::Int(v.checked_abs().ok_or(RuntimeError::OVERFLOW)?),
            Value::Lng(v) => Value::Lng(v.checked_abs().ok_or(RuntimeError::OVERFLOW)?),
            Value::Sng(v) => Value::Sng(v.abs()),
            Value::Dbl(v) => Value::Dbl(v.abs()),
            Value::Cur(v) => Value::Cur(v.checked_abs().ok_or(RuntimeError::OVERFLOW)?),
            _ => return Err(RuntimeError::TYPE_MISMATCH),
        },
    ))
}

fn bi_sgn(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let x = arg_f64(a, 0)?;
    Ok(Some(Value::Int(if x > 0.0 {
        1
    } else if x < 0.0 {
        -1
    } else {
        0
    })))
}

fn bi_int(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    Ok(Some(
        match a.first().ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)? {
            v @ (Value::Int(_) | Value::Lng(_)) => v.clone(),
            Value::Sng(v) => Value::Sng(v.floor()),
            Value::Dbl(v) => Value::Dbl(v.floor()),
            Value::Cur(v) => Value::Cur(f64_to_cur(cur_to_f64(*v).floor())?),
            _ => return Err(RuntimeError::TYPE_MISMATCH),
        },
    ))
}

fn bi_fix(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    Ok(Some(
        match a.first().ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)? {
            v @ (Value::Int(_) | Value::Lng(_)) => v.clone(),
            Value::Sng(v) => Value::Sng(v.trunc()),
            Value::Dbl(v) => Value::Dbl(v.trunc()),
            Value::Cur(v) => Value::Cur(f64_to_cur(cur_to_f64(*v).trunc())?),
            _ => return Err(RuntimeError::TYPE_MISMATCH),
        },
    ))
}

fn bi_sqr(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let x = arg_f64(a, 0)?;
    if x < 0.0 {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    Ok(Some(Value::Dbl(x.sqrt())))
}

fn bi_exp(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let x = arg_f64(a, 0)?;
    let r = x.exp();
    if !r.is_finite() {
        return Err(RuntimeError::OVERFLOW);
    }
    Ok(Some(Value::Dbl(r)))
}

fn bi_log(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let x = arg_f64(a, 0)?;
    if x <= 0.0 {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    Ok(Some(Value::Dbl(x.ln())))
}

fn bi_sin(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    Ok(Some(Value::Dbl(arg_f64(a, 0)?.sin())))
}
fn bi_cos(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    Ok(Some(Value::Dbl(arg_f64(a, 0)?.cos())))
}
fn bi_tan(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    Ok(Some(Value::Dbl(arg_f64(a, 0)?.tan())))
}
fn bi_atn(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    Ok(Some(Value::Dbl(arg_f64(a, 0)?.atan())))
}

fn bi_rnd(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let v = if a.is_empty() {
        st.rng_next()
    } else {
        let x = arg_f64(a, 0)?;
        if x == 0.0 {
            st.rnd_last
        } else {
            if x < 0.0 {
                // Neu aussäen aus dem Argument (deterministisch).
                st.saat_setzen(-x);
            }
            st.rng_next()
        }
    };
    Ok(Some(Value::Sng(v)))
}

fn bi_randomize(
    st: &mut RtState,
    host: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let x = match a.first() {
        Some(_) => arg_f64(a, 0)?,
        // `RANDOMIZE` ohne Argument erfragt den Startwert (Vorbild).
        None => {
            st.screen.print("Random Number Seed (-32768 to 32767)? ");
            let Some(zeile) = st.read_line(host) else {
                return Err(RuntimeError(62)); // Input past end of file
            };
            crate::format::val(&zeile)
        }
    };
    st.saat_setzen(x);
    Ok(None)
}

// ---- Konsole ------------------------------------------------------------------

fn bi_print_val(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let v = a.first().ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?;
    st.print.print_value(&mut st.screen, &mut st.dateien, v)?;
    Ok(None)
}

fn bi_print_comma(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    st.print.print_comma(&mut st.screen, &mut st.dateien)?;
    Ok(None)
}

fn bi_print_tab(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let n = arg_i32(a, 0)?;
    st.print.print_tab(&mut st.screen, &mut st.dateien, n)?;
    Ok(None)
}

fn bi_print_spc(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let n = arg_i32(a, 0)?;
    st.print.print_spc(&mut st.screen, &mut st.dateien, n)?;
    Ok(None)
}

fn bi_print_newline(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    st.print.print_newline(&mut st.screen, &mut st.dateien)?;
    Ok(None)
}

// ---- System ----------------------------------------------------------------------

/// Ersatzwerte für Größen ohne Entsprechung auf heutigen Plattformen
/// (dokumentierte Abweichung, s. docs/sprachreferenz.md).
pub const FRE_ERSATZ: i32 = 65_536;
pub const STACK_ERSATZ: i32 = 8_192;

fn bi_environ_s(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // Mit String: Wert der Variablen. Mit Zahl: der n-te Eintrag als
    // `NAME=WERT` (Vorbild).
    match a.first() {
        Some(Value::Str(name)) => s_ok(std::env::var(name.as_ref()).unwrap_or_default()),
        Some(_) => {
            let n = arg_i32(a, 0)?;
            if n < 1 {
                return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
            }
            let mut alle: Vec<(String, String)> = std::env::vars().collect();
            alle.sort();
            s_ok(
                alle.get(n as usize - 1)
                    .map(|(k, v)| format!("{k}={v}"))
                    .unwrap_or_default(),
            )
        }
        None => Err(RuntimeError::ILLEGAL_FUNCTION_CALL),
    }
}

fn bi_environ_set(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // `ENVIRON "NAME=WERT"`; ohne Wert wird die Variable entfernt.
    let text = arg_str(a, 0)?;
    let (name, wert) = text
        .split_once('=')
        .ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?;
    if name.is_empty() {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    if wert.is_empty() {
        unsafe { std::env::remove_var(name) };
    } else {
        unsafe { std::env::set_var(name, wert) };
    }
    Ok(None)
}

fn bi_fre(
    _: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    Ok(Some(Value::Lng(FRE_ERSATZ)))
}

fn bi_clear(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // `CLEAR` schließt alle Dateien und setzt den Druckzustand zurück.
    // Variablen räumt der Codegenerator nicht — das übernimmt der Runner
    // beim Neustart (dokumentierte Abweichung).
    st.dateien.alle_schliessen()?;
    st.print.ziel = crate::console::Ziel::Bildschirm;
    st.print.spalte = 0;
    Ok(None)
}

fn bi_tron(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    st.trace = true;
    Ok(None)
}

fn bi_troff(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    st.trace = false;
    Ok(None)
}

fn bi_stack_fn(
    _: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    Ok(Some(Value::Lng(STACK_ERSATZ)))
}

fn bi_stack_stmt(
    _: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // Die Stapelgröße bestimmt die Wirtsplattform; die Anweisung ist
    // folgenlos (dokumentierte Abweichung).
    Ok(None)
}

fn bi_erdev(
    _: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // Gerätefehlercodes des Vorbilds gibt es nicht; 0 = kein Fehler.
    Ok(Some(Value::Int(0)))
}

fn bi_erdev_s(
    _: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    s_ok(String::new())
}

// ---- Datei-E/A --------------------------------------------------------------------

fn bi_open(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // (dateiname$, nummer, modus, reclen)
    let pfad = arg_str(a, 0)?;
    let nummer = arg_i32(a, 1)?;
    let modus = fio::Modus::aus_text(&arg_str(a, 2)?).ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?;
    let reclen = arg_i32(a, 3)?.max(1) as usize;
    if st.isam.ist_isam(nummer) {
        return Err(RuntimeError::FILE_ALREADY_OPEN);
    }
    st.dateien
        .oeffnen(nummer, &pfad, modus, reclen)
        .map(|_| None)
}

fn bi_close(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let n = arg_i32(a, 0)?;
    if st.isam.ist_isam(n) {
        return st.isam.schliessen(n).map(|_| None);
    }
    st.dateien.schliessen(n).map(|_| None)
}

fn bi_close_all(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    st.isam.bindungen_schliessen();
    st.dateien.alle_schliessen().map(|_| None)
}

/// Ausgabeziel von `PRINT` umschalten: −1 = Bildschirm, −2 = Drucker,
/// sonst die Dateinummer.
fn bi_print_ziel(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let n = arg_i32(a, 0)?;
    st.print.ziel = match n {
        -1 => crate::console::Ziel::Bildschirm,
        -2 => crate::console::Ziel::Drucker,
        n => {
            if !st.dateien.ist_offen(n) {
                return Err(RuntimeError(52));
            }
            crate::console::Ziel::Datei(n)
        }
    };
    // Datei- und Druckerspalte beginnen bei jedem Wechsel neu am Zeilenanfang
    // nur dann, wenn zuvor der Bildschirm aktiv war.
    if n == -1 {
        st.print.spalte = 0;
    }
    Ok(None)
}

fn bi_write_file(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // Elemente komma-getrennt, Strings in Anführungszeichen, dann Umbruch.
    let mut zeile = String::new();
    for (i, v) in a.iter().enumerate() {
        if i > 0 {
            zeile.push(',');
        }
        match v {
            Value::Str(s) => zeile.push_str(&fio::write_element(s, true)),
            _ => zeile.push_str(&fio::write_element(&format::format_print(v), false)),
        }
    }
    zeile.push('\n');
    st.print.write(&mut st.screen, &mut st.dateien, &zeile)?;
    Ok(None)
}

fn bi_eof(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let n = arg_i32(a, 0)?;
    // Bei einer ISAM-Dateinummer meldet EOF das Ende der Tabelle in der
    // Ordnung des aktiven Index, nicht das Dateiende.
    if st.isam.ist_isam(n) {
        return Ok(Some(Value::Int(if st.isam.eof(n) { -1 } else { 0 })));
    }
    let e = st.dateien.get(n)?.eof()?;
    Ok(Some(Value::Int(if e { -1 } else { 0 })))
}

fn bi_lof(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let n = arg_i32(a, 0)?;
    // ISAM: Zahl der Sätze in der Tabelle.
    if st.isam.ist_isam(n) {
        return Ok(Some(Value::Lng(st.isam.satzzahl(n)? as i32)));
    }
    let l = st.dateien.get(n)?.laenge()?;
    Ok(Some(Value::Lng(l as i32)))
}

fn bi_loc(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let n = arg_i32(a, 0)?;
    // ISAM: Kennung des aktuellen Satzes, 0 wenn unpositioniert.
    if st.isam.ist_isam(n) {
        return Ok(Some(Value::Lng(st.isam.satznummer(n)? as i32)));
    }
    let d = st.dateien.get(n)?;
    // Position der letzten Operation (1-basiert): Datensatz bzw. Byte.
    Ok(Some(Value::Lng(d.position.saturating_sub(1) as i32)))
}

fn bi_seek_f(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let d = st.dateien.get(arg_i32(a, 0)?)?;
    Ok(Some(Value::Lng(d.position as i32)))
}

fn bi_seek_stmt(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let pos = arg_i32(a, 1)?;
    if pos < 1 {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    st.dateien
        .get(arg_i32(a, 0)?)?
        .position_setzen(pos as u64)?;
    Ok(None)
}

fn bi_freefile(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let nummer = (st.dateien.freie_nummer()..=i16::MAX as i32)
        .find(|n| !st.dateien.ist_offen(*n) && !st.isam.ist_isam(*n))
        .ok_or(RuntimeError::TOO_MANY_FILES)?;
    Ok(Some(Value::Int(nummer as i16)))
}

fn bi_fileattr(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let d = st.dateien.get(arg_i32(a, 0)?)?;
    // 1 = Modus. Andere Kennzahlen (Betriebssystem-Handle) bildet der
    // Dialekt nicht ab; sie liefern 0 (dokumentierte Abweichung).
    let wert = if arg_i32(a, 1)? == 1 {
        match d.modus {
            fio::Modus::Input => 1,
            fio::Modus::Output => 2,
            fio::Modus::Random => 4,
            fio::Modus::Append => 8,
            fio::Modus::Binary => 32,
        }
    } else {
        0
    };
    Ok(Some(Value::Lng(wert)))
}

fn bi_lock(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // Die Datei muss offen sein; echte Sperren bietet die Plattform nicht
    // einheitlich (dokumentierte Abweichung).
    st.dateien.get(arg_i32(a, 0)?)?;
    Ok(None)
}

fn bi_kill(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    fio::loeschen(&arg_str(a, 0)?).map(|_| None)
}

fn bi_name(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    fio::umbenennen(&arg_str(a, 0)?, &arg_str(a, 1)?).map(|_| None)
}

fn bi_files(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let muster = match a.first() {
        Some(Value::Str(s)) if !s.is_empty() => s.to_string(),
        _ => "*".to_string(),
    };
    for n in fio::suchen(&muster)? {
        st.screen.print(&n);
        st.screen.print("\n");
    }
    Ok(None)
}

fn bi_chdir(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    fio::verzeichnis_wechseln(&arg_str(a, 0)?).map(|_| None)
}

fn bi_chdrive(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // Laufwerksbuchstaben gibt es nur auf Windows; anderswo ist die
    // Anweisung folgenlos (dokumentierte Abweichung).
    let _ = arg_str(a, 0)?;
    Ok(None)
}

fn bi_mkdir(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    fio::verzeichnis_anlegen(&arg_str(a, 0)?).map(|_| None)
}

fn bi_rmdir(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    fio::verzeichnis_entfernen(&arg_str(a, 0)?).map(|_| None)
}

fn bi_curdir(
    _: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    s_ok(fio::aktuelles_verzeichnis())
}

fn bi_dir(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // `DIR$(muster$)` beginnt eine Suche, `DIR$("")` liefert den nächsten
    // Treffer, zuletzt den leeren String.
    let muster = arg_str(a, 0)?;
    if !muster.is_empty() {
        st.dir_treffer = fio::suchen(&muster)?;
        st.dir_index = 0;
    }
    let t = st
        .dir_treffer
        .get(st.dir_index)
        .cloned()
        .unwrap_or_default();
    if !t.is_empty() {
        st.dir_index += 1;
    }
    s_ok(t)
}

fn bi_lpos(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // Spalte im Druckerpuffer (1-basiert).
    let spalte = match st.print.drucker.rfind('\n') {
        Some(i) => st.print.drucker[i + 1..].chars().count(),
        None => st.print.drucker.chars().count(),
    };
    Ok(Some(Value::Int(spalte as i16 + 1)))
}

fn bi_shell_stmt(
    _: &mut RtState,
    host: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let command = match a.first() {
        Some(Value::Str(s)) => s.as_ref(),
        _ => "",
    };
    host.shell(command).map(|_| None)
}
fn bi_shell_fn(
    _: &mut RtState,
    host: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    host.shell(&arg_str(a, 0)?).map(|code| code.map(Value::Lng))
}

/// `MKI$`/`MKL$`/`MKS$`/`MKD$`/`MKC$` — Zahl in ihre Bytedarstellung.
/// Das zweite Argument wählt die Breite (2/4/4/8/8) und den Typ.
fn bi_mk(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let art = arg_i32(a, 1)?;
    let bytes: Vec<u8> = match art {
        0 => (arg_i32(a, 0)? as i16).to_le_bytes().to_vec(),
        1 => arg_i32(a, 0)?.to_le_bytes().to_vec(),
        2 => (arg_f64(a, 0)? as f32).to_le_bytes().to_vec(),
        3 => arg_f64(a, 0)?.to_le_bytes().to_vec(),
        _ => match a.first() {
            Some(Value::Cur(c)) => c.to_le_bytes().to_vec(),
            _ => value::f64_to_cur(arg_f64(a, 0)?)?.to_le_bytes().to_vec(),
        },
    };
    // Bytes als Zeichen mit Codepoint 0–255 ablegen (verlustfrei umkehrbar).
    s_ok(bytes.iter().map(|b| *b as char).collect::<String>())
}

/// `CVI`/`CVL`/`CVS`/`CVD`/`CVC` — Bytedarstellung zurück in eine Zahl.
fn bi_cv(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let text = arg_str(a, 0)?;
    let art = arg_i32(a, 1)?;
    let breite = match art {
        0 => 2,
        1 | 2 => 4,
        _ => 8,
    };
    let bytes: Vec<u8> = text
        .chars()
        .map(|c| u8::try_from(c as u32).map_err(|_| RuntimeError::ILLEGAL_FUNCTION_CALL))
        .collect::<Result<_, _>>()?;
    if bytes.len() != breite {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    Ok(Some(match art {
        0 => Value::Int(i16::from_le_bytes([bytes[0], bytes[1]])),
        1 => Value::Lng(i32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])),
        2 => Value::Sng(f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])),
        3 => Value::Dbl(f64::from_le_bytes(bytes[..8].try_into().unwrap())),
        _ => Value::Cur(i64::from_le_bytes(bytes[..8].try_into().unwrap())),
    }))
}

// ---- Finanzmathematik -----------------------------------------------------------

/// Zahlungsreihe aus einem Array-Argument lesen.
fn arg_reihe(a: &[Value], i: usize) -> Result<Vec<f64>, RuntimeError> {
    match a.get(i) {
        Some(Value::Arr(arr)) => Ok(arr.borrow().data.iter().map(as_f64).collect()),
        _ => Err(RuntimeError::TYPE_MISMATCH),
    }
}

macro_rules! finanz_fn {
    ($name:ident, |$a:ident| $ausdruck:expr) => {
        fn $name(
            _: &mut RtState,
            _: &mut dyn Host,
            $a: &mut [Value],
        ) -> Result<Option<Value>, RuntimeError> {
            Ok(Some(Value::Dbl($ausdruck?)))
        }
    };
}

finanz_fn!(bi_fv, |a| fin::fv(
    arg_f64(a, 0)?,
    arg_f64(a, 1)?,
    arg_f64(a, 2)?,
    arg_f64(a, 3)?,
    arg_f64(a, 4)?
));
finanz_fn!(bi_pv, |a| fin::pv(
    arg_f64(a, 0)?,
    arg_f64(a, 1)?,
    arg_f64(a, 2)?,
    arg_f64(a, 3)?,
    arg_f64(a, 4)?
));
finanz_fn!(bi_pmt, |a| fin::pmt(
    arg_f64(a, 0)?,
    arg_f64(a, 1)?,
    arg_f64(a, 2)?,
    arg_f64(a, 3)?,
    arg_f64(a, 4)?
));
finanz_fn!(bi_nper, |a| fin::nper(
    arg_f64(a, 0)?,
    arg_f64(a, 1)?,
    arg_f64(a, 2)?,
    arg_f64(a, 3)?,
    arg_f64(a, 4)?
));
finanz_fn!(bi_ipmt, |a| fin::ipmt(
    arg_f64(a, 0)?,
    arg_f64(a, 1)?,
    arg_f64(a, 2)?,
    arg_f64(a, 3)?,
    arg_f64(a, 4)?,
    arg_f64(a, 5)?
));
finanz_fn!(bi_ppmt, |a| fin::ppmt(
    arg_f64(a, 0)?,
    arg_f64(a, 1)?,
    arg_f64(a, 2)?,
    arg_f64(a, 3)?,
    arg_f64(a, 4)?,
    arg_f64(a, 5)?
));
finanz_fn!(bi_rate, |a| fin::rate(
    arg_f64(a, 0)?,
    arg_f64(a, 1)?,
    arg_f64(a, 2)?,
    arg_f64(a, 3)?,
    arg_f64(a, 4)?,
    arg_f64(a, 5)?
));
finanz_fn!(bi_npv, |a| fin::npv(arg_f64(a, 0)?, &arg_reihe(a, 1)?));
finanz_fn!(bi_irr, |a| fin::irr(&arg_reihe(a, 0)?, arg_f64(a, 1)?));
finanz_fn!(bi_mirr, |a| fin::mirr(
    &arg_reihe(a, 0)?,
    arg_f64(a, 1)?,
    arg_f64(a, 2)?
));
finanz_fn!(bi_sln, |a| fin::sln(
    arg_f64(a, 0)?,
    arg_f64(a, 1)?,
    arg_f64(a, 2)?
));
finanz_fn!(bi_syd, |a| fin::syd(
    arg_f64(a, 0)?,
    arg_f64(a, 1)?,
    arg_f64(a, 2)?,
    arg_f64(a, 3)?
));
finanz_fn!(bi_ddb, |a| fin::ddb(
    arg_f64(a, 0)?,
    arg_f64(a, 1)?,
    arg_f64(a, 2)?,
    arg_f64(a, 3)?
));

// ---- Formatierung ---------------------------------------------------------------

fn bi_print_using(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let fmt = arg_str(a, 0)?;
    let text = crate::using::using(&fmt, &a[1..], &st.waehrung)?;
    st.print.write(&mut st.screen, &mut st.dateien, &text)?;
    Ok(None)
}

fn bi_format_s(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // `FORMAT$(wert [, format$])` — ohne Format wie `LTRIM$(STR$(wert))`.
    let wert = a.first().cloned().unwrap_or(Value::Int(0));
    let text = match a.get(1) {
        Some(Value::Str(f)) => crate::using::using(f, std::slice::from_ref(&wert), &st.waehrung)?,
        None => format::format_print(&wert).trim().to_string(),
        Some(_) => return Err(RuntimeError::TYPE_MISMATCH),
    };
    Ok(Some(Value::Str(Rc::from(text.as_str()))))
}

fn bi_set_format_cc(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // Ländercode → Währungszeichen. Die Tabelle des Vorbilds ist nicht
    // vollständig belegt; nicht aufgeführte Codes behalten "$"
    // (dokumentierte Abweichung).
    let code = arg_i32(a, 0)?;
    st.waehrung.zeichen = match code {
        1 => "$",
        33 => "F",
        39 => "L",
        44 => "£",
        49 => "DM",
        81 => "¥",
        _ => "$",
    }
    .to_string();
    Ok(None)
}

// ---- Bildschirm -----------------------------------------------------------------

/// Ausgelassenes Argument (`LOCATE , 5`) kommt als -1 herein.
fn opt(a: &[Value], i: usize) -> Result<Option<i32>, RuntimeError> {
    match a.get(i) {
        None => Ok(None),
        Some(_) => {
            let v = arg_i32(a, i)?;
            Ok(if v < 0 { None } else { Some(v) })
        }
    }
}

fn bi_cls(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // `CLS`, `CLS 0` und `CLS 2` löschen bei uns dasselbe: den Textbereich.
    // Einen getrennten Grafikbereich gibt es nicht (Non-Feature).
    st.screen.cls();
    Ok(None)
}

fn bi_color(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let fg = opt(a, 0)?.unwrap_or(st.screen.fg as i32);
    let bg = opt(a, 1)?.unwrap_or(st.screen.bg as i32);
    // Vordergrund 0–31 (16–31 = blinkend, als hell simuliert), Hintergrund 0–7.
    if !(0..=31).contains(&fg) || !(0..=7).contains(&bg) {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    st.screen.set_color(fg as u8, bg as u8);
    Ok(None)
}

fn bi_locate(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let zeile = opt(a, 0)?.unwrap_or(st.screen.csrlin() as i32);
    let spalte = opt(a, 1)?.unwrap_or(st.screen.pos() as i32);
    // Drittes Argument steuert die Cursorsichtbarkeit; Startzeile/Endzeile
    // der Cursorform (4./5.) bildet ein Terminal nicht ab und bleibt folgenlos.
    if let Some(sichtbar) = opt(a, 2)? {
        st.screen.cursor_visible = sichtbar != 0;
    }
    if zeile < 1 || spalte < 1 {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    st.screen
        .locate(zeile as usize, spalte as usize)
        .map_err(|_| RuntimeError::ILLEGAL_FUNCTION_CALL)?;
    Ok(None)
}

fn bi_width(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // Die Größe bestimmt das Terminal; WIDTH hebt den Puffer höchstens an
    // (dokumentierte Abweichung). Kleinere Werte bleiben folgenlos.
    let cols = opt(a, 0)?.unwrap_or(st.screen.cols() as i32);
    let rows = opt(a, 1)?.unwrap_or(st.screen.rows() as i32);
    if cols < 1 || rows < 1 {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    st.screen.resize(cols as usize, rows as usize);
    Ok(None)
}

fn bi_view_print(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    match (opt(a, 0)?, opt(a, 1)?) {
        // `VIEW PRINT` ohne Argumente: voller Bildschirm.
        (None, None) => {
            let rows = st.screen.rows();
            st.screen
                .view_print(1, rows)
                .map_err(|_| RuntimeError::ILLEGAL_FUNCTION_CALL)?;
        }
        (Some(t), Some(b)) if t >= 1 && b >= t => {
            st.screen
                .view_print(t as usize, b as usize)
                .map_err(|_| RuntimeError::ILLEGAL_FUNCTION_CALL)?;
        }
        _ => return Err(RuntimeError::ILLEGAL_FUNCTION_CALL),
    }
    Ok(None)
}

fn bi_screen_stmt(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // Die Terminaldarstellung bildet die historischen Pixelmodi auf Zellen ab;
    // Seitenargumente haben im einzelnen Puffer keine zusätzliche Wirkung.
    match opt(a, 0)? {
        None | Some(0..=13) => {
            st.screen.graphics_view(None).unwrap();
            st.screen.cls();
            Ok(None)
        }
        Some(_) => Err(RuntimeError::ILLEGAL_FUNCTION_CALL),
    }
}

fn bi_graphics_line(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let mut from = (arg_i32(a, 0)?, arg_i32(a, 1)?);
    if from.0 == i32::MIN {
        from = st.screen.graphics_position();
    }
    let mut to = (arg_i32(a, 2)?, arg_i32(a, 3)?);
    if arg_i32(a, 5)? != 0 {
        to.0 += from.0;
        to.1 += from.1;
    }
    let color = match arg_i32(a, 4)? {
        -1 => i32::from(st.screen.fg),
        color @ 0..=15 => color,
        _ => return Err(RuntimeError::ILLEGAL_FUNCTION_CALL),
    };
    st.screen
        .graphics_line(from, to, color, arg_i32(a, 6)? as u8);
    Ok(None)
}

fn bi_graphics_paint(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let color = match a.get(2) {
        Some(Value::Int(v)) => i32::from(*v),
        Some(Value::Lng(v)) => *v,
        Some(Value::Str(_)) | None => i32::from(st.screen.fg),
        _ => return Err(RuntimeError::TYPE_MISMATCH),
    };
    if !(0..=15).contains(&color) {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    st.screen
        .graphics_paint(arg_i32(a, 0)?, arg_i32(a, 1)?, color);
    Ok(None)
}

fn bi_graphics_view(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let x1 = arg_i32(a, 0)?;
    if x1 < 0 {
        st.screen.graphics_view(None).unwrap();
        return Ok(None);
    }
    let view = (x1, arg_i32(a, 1)?, arg_i32(a, 2)?, arg_i32(a, 3)?);
    st.screen
        .graphics_view(Some(view))
        .map_err(|_| RuntimeError::ILLEGAL_FUNCTION_CALL)?;
    let fill = arg_i32(a, 4)?;
    if fill >= 0 {
        st.screen
            .graphics_line((view.0, view.1), (view.2, view.3), fill, 2);
    }
    let border = arg_i32(a, 5)?;
    if border >= 0 {
        st.screen
            .graphics_line((view.0, view.1), (view.2, view.3), border, 1);
    }
    Ok(None)
}

/// Nummer eines Funktionstasten-Makros → Index 0–11.
fn key_index(n: i32) -> Option<usize> {
    match n {
        1..=10 => Some(n as usize - 1),
        30 => Some(10),
        31 => Some(11),
        _ => None,
    }
}

fn bi_key_assign(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let n = arg_i32(a, 0)?;
    let text = arg_str(a, 1)?;
    // 15–25 weisen kein Makro zu, sondern erklären eine Trap-Taste über
    // Tastaturflagbyte und Scancode (Sprachreferenz §8).
    if (15..=25).contains(&n) {
        let mut it = text.chars();
        let (Some(flag), Some(scan)) = (it.next(), it.next()) else {
            return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
        };
        if it.next().is_some() || flag as u32 > 255 || scan as u32 > 255 {
            return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
        }
        st.traps.benutzertaste(n as u8, flag as u8, scan as u8);
        return Ok(None);
    }
    let i = key_index(n).ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?;
    // Höchstens 15 Zeichen; überzählige werden verworfen (Vorbild).
    st.key_makros[i] = text.chars().take(15).collect();
    if st.key_zeile {
        st.softkey_zeile_zeichnen();
    }
    Ok(None)
}

fn bi_key_list(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // Alle zwölf Makros untereinander, wie im Vorbild.
    for m in st.key_makros.clone() {
        st.screen.print(&m);
        st.screen.print("\n");
    }
    Ok(None)
}

fn bi_key_display(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let ein = arg_i32(a, 0)? != 0;
    st.softkey_zeile(ein);
    Ok(None)
}

fn bi_csrlin(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    Ok(Some(Value::Int(st.screen.csrlin() as i16)))
}

fn bi_pos(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    Ok(Some(Value::Int(st.screen.pos() as i16)))
}

fn bi_screen_fn(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let zeile = arg_i32(a, 0)?;
    let spalte = arg_i32(a, 1)?;
    let farbe = opt(a, 2)?.unwrap_or(0);
    if zeile < 1
        || spalte < 1
        || zeile as usize > st.screen.rows()
        || spalte as usize > st.screen.cols()
    {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    let z = st.screen.cell(zeile as usize, spalte as usize);
    let wert = if farbe != 0 {
        // Farbattribut wie im Vorbild: Hintergrund*16 + Vordergrund.
        (z.bg as i32) * 16 + z.fg as i32
    } else {
        z.ch as i32
    };
    Ok(Some(Value::Int(wert.min(i16::MAX as i32) as i16)))
}

fn bi_inkey(
    st: &mut RtState,
    host: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // Nicht blockierend: leerer String, wenn keine Taste anliegt.
    let t = st.naechste_taste(host, false).unwrap_or_default();
    Ok(Some(Value::Str(Rc::from(t.as_str()))))
}

fn bi_input_s(
    st: &mut RtState,
    host: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let n = arg_i32(a, 0)?;
    if n < 0 {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    if a.len() > 1 {
        let nummer = arg_i32(a, 1)?;
        let datei = st.dateien.get(nummer)?;
        return s_ok(datei.zeichen_lesen(n as usize)?);
    }
    // Blockierend, ohne Echo auf dem Bildschirm.
    let mut s = String::new();
    let mut rest = n as usize;
    while rest > 0 {
        host.present(&st.screen);
        let Some(t) = st.naechste_taste(host, true) else {
            return Err(RuntimeError(62)); // Input past end of file
        };
        let t = st.tastenanteil(t, rest);
        rest -= t.chars().count();
        s.push_str(&t);
    }
    Ok(Some(Value::Str(Rc::from(s.as_str()))))
}

// ---- Sonstiges ------------------------------------------------------------------

fn bi_timezoneknown(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    Ok(Some(Value::Int(if st.zeitpunkt().1 { -1 } else { 0 })))
}

fn bi_timer(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // Sekunden seit der **lokalen** Mitternacht, mit Bruchteil.
    let bruchteil = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs_f64().fract())
        .unwrap_or(0.0);
    let ganze = st.zeitpunkt().0.rem_euclid(86_400);
    Ok(Some(Value::Sng(ganze as f32 + bruchteil as f32)))
}

fn bi_date(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let secs = st.zeitpunkt().0;
    let (y, m, d) = dt::civil_from_days(secs.div_euclid(86_400));
    s_ok(format!("{m:02}-{d:02}-{y:04}"))
}

fn bi_time(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let s = st.zeitpunkt().0.rem_euclid(86_400);
    s_ok(format!(
        "{:02}:{:02}:{:02}",
        s / 3600,
        (s / 60) % 60,
        s % 60
    ))
}

fn bi_date_set(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let t = arg_str(a, 0)?;
    let ziel = dt::datevalue(&t).ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?;
    // Nur den Datumsanteil verschieben, die Tageszeit bleibt.
    let jetzt = st.jetzt_serial();
    let diff_tage = ziel.floor() - jetzt.floor();
    st.uhr_offset += (diff_tage * 86_400.0) as i64;
    Ok(None)
}

fn bi_time_set(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let t = arg_str(a, 0)?;
    let ziel = dt::timevalue(&t).ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)?;
    let jetzt = st.jetzt_serial();
    let diff = ziel - (jetzt - jetzt.floor());
    st.uhr_offset += (diff * 86_400.0).round() as i64;
    Ok(None)
}

fn bi_now(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    Ok(Some(Value::Dbl(st.jetzt_serial())))
}

fn bi_date_serial(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let (y, m, d) = (
        arg_i32(a, 0)? as i64,
        arg_i32(a, 1)? as i64,
        arg_i32(a, 2)? as i64,
    );
    // Zweistellige Jahre wie beim Vorbild als 19xx deuten.
    let y = if (0..100).contains(&y) { y + 1900 } else { y };
    if !dt::gueltig(y, m, d) {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    Ok(Some(Value::Dbl(dt::serial_aus_ymd(y, m as u32, d as u32))))
}

fn bi_time_serial(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let (h, m, s) = (
        arg_i32(a, 0)? as i64,
        arg_i32(a, 1)? as i64,
        arg_i32(a, 2)? as i64,
    );
    if !(0..24).contains(&h) || !(0..60).contains(&m) || !(0..60).contains(&s) {
        return Err(RuntimeError::ILLEGAL_FUNCTION_CALL);
    }
    Ok(Some(Value::Dbl(dt::serial_aus_hms(h, m, s))))
}

fn bi_date_value(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let t = arg_str(a, 0)?;
    dt::datevalue(&t)
        .map(|v| Some(Value::Dbl(v)))
        .ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)
}

fn bi_time_value(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let t = arg_str(a, 0)?;
    dt::timevalue(&t)
        .map(|v| Some(Value::Dbl(v)))
        .ok_or(RuntimeError::ILLEGAL_FUNCTION_CALL)
}

/// Gemeinsame Auswertung der Zerlegungsfunktionen.
fn dt_teil(a: &[Value], f: impl Fn(f64) -> i32) -> Result<Option<Value>, RuntimeError> {
    let s = arg_f64(a, 0)?;
    Ok(Some(Value::Int(f(s) as i16)))
}

fn bi_day(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    dt_teil(a, |s| dt::ymd_aus_serial(s).2 as i32)
}

fn bi_month(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    dt_teil(a, |s| dt::ymd_aus_serial(s).1 as i32)
}

fn bi_year(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    dt_teil(a, |s| dt::ymd_aus_serial(s).0 as i32)
}

fn bi_weekday(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    dt_teil(a, |s| dt::weekday(s) as i32)
}

fn bi_hour(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    dt_teil(a, |s| dt::hms_aus_serial(s).0 as i32)
}

fn bi_minute(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    dt_teil(a, |s| dt::hms_aus_serial(s).1 as i32)
}

fn bi_second(
    _: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    dt_teil(a, |s| dt::hms_aus_serial(s).2 as i32)
}

fn bi_command(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    s_ok(st.command.clone())
}

fn bi_doevents(
    _: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // Ereigniszustellung kommt mit Phase 4.
    Ok(Some(Value::Int(0)))
}

fn bi_sleep(
    st: &mut RtState,
    host: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let secs = if a.is_empty() { 0.0 } else { arg_f64(a, 0)? };
    // Der Bytecode nutzt Instr::Sleep für Handlerzustellung. Auch direkte
    // Bibliotheksaufrufe dürfen keine eigene reale Uhr verwenden.
    let deadline = (secs > 0.0).then(|| host.jetzt_ms().saturating_add((secs * 1000.0) as u64));
    st.tick(host);
    loop {
        if st.ende || st.abbruch {
            break;
        }
        if let Some(deadline) = deadline {
            if host.jetzt_ms() >= deadline {
                break;
            }
        } else if st.naechste_taste(host, false).is_some() {
            break;
        }
        if let Some(event) = host.warten(deadline) {
            st.ereignis(event);
        }
    }
    Ok(None)
}

fn bi_setuevent(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // „sets a flag that BASIC checks before executing the next statement"
    st.traps.melden(crate::traps::Quelle::UEvent);
    Ok(None)
}

fn bi_beep(
    _: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // ponytail: Der Signalton geht direkt an das Terminal, nicht durch den
    // Zellenpuffer — er hinterlässt dort nichts. Ceiling: sobald der Host
    // mehr als Anzeigen kann, wird daraus ein eigener Host-Aufruf.
    eprint!("\u{0007}");
    Ok(None)
}

// ---- ISAM -------------------------------------------------------------------
//
// Die Anweisungen legen ihre Argumente in der Reihenfolge der Original-Hilfe
// ab; die Dateinummer ist überall das erste (bei `DELETETABLE` der
// Datenbankname). Die Fachlogik steht in `crate::isam`.

fn bi_isam_open(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // (datenbank$, nummer, tabelle$, spaltennamen$, udt-index)
    let datenbank = arg_str(a, 0)?;
    let nummer = arg_i32(a, 1)?;
    let tabelle = arg_str(a, 2)?;
    let spalten = arg_str(a, 3)?;
    let udt = arg_i32(a, 4)? as u16;
    if st.dateien.ist_offen(nummer) {
        return Err(RuntimeError::FILE_ALREADY_OPEN);
    }
    st.isam
        .oeffnen(nummer, &datenbank, &tabelle, &spalten, udt)
        .map(|_| None)
}

fn bi_isam_create_index(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // (nummer, indexname$, eindeutig%, spalte$ …)
    let nummer = arg_i32(a, 0)?;
    let name = arg_str(a, 1)?;
    let eindeutig = arg_i32(a, 2)? != 0;
    let mut spalten = Vec::new();
    for i in 3..a.len() {
        spalten.push(arg_str(a, i)?.to_string());
    }
    st.isam
        .index_anlegen(nummer, &name, eindeutig, &spalten)
        .map(|_| None)
}

fn bi_isam_delete_index(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let nummer = arg_i32(a, 0)?;
    let name = arg_str(a, 1)?;
    st.isam.index_loeschen(nummer, &name).map(|_| None)
}

fn bi_isam_set_index(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // Ohne Indexnamen gilt der NULL-Index (Einfügereihenfolge).
    let nummer = arg_i32(a, 0)?;
    let name = if a.len() > 1 {
        arg_str(a, 1)?.to_string()
    } else {
        String::new()
    };
    st.isam.index_setzen(nummer, &name).map(|_| None)
}

fn bi_isam_get_index(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let n = arg_i32(a, 0)?;
    st.isam
        .index_name(n)
        .map(|s| Some(Value::Str(Rc::from(s.as_str()))))
}

fn bi_isam_insert(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let nummer = arg_i32(a, 0)?;
    let satz = a.get(1).cloned().ok_or(RuntimeError::TYPE_MISMATCH)?;
    st.isam.einfuegen(nummer, &satz).map(|_| None)
}

fn bi_isam_retrieve(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // Das Satzargument ist ein geteiltes Record-Handle: die gelesenen
    // Feldwerte gehen direkt in die Variable des Programms.
    let nummer = arg_i32(a, 0)?;
    let ziel = match a.get(1) {
        Some(Value::Rec(r)) => r.clone(),
        _ => return Err(RuntimeError::TYPE_MISMATCH),
    };
    let gelesen = st.isam.satz_lesen(nummer)?;
    let Value::Rec(quelle) = gelesen else {
        return Err(RuntimeError::TYPE_MISMATCH);
    };
    let werte = quelle.borrow().fields.clone();
    let mut z = ziel.borrow_mut();
    if z.fields.len() != werte.len() {
        return Err(RuntimeError::TYPE_MISMATCH);
    }
    z.fields = werte;
    Ok(None)
}

fn bi_isam_update(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let nummer = arg_i32(a, 0)?;
    let satz = a.get(1).cloned().ok_or(RuntimeError::TYPE_MISMATCH)?;
    st.isam.aendern(nummer, &satz).map(|_| None)
}

fn bi_isam_delete(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    st.isam.satz_loeschen(arg_i32(a, 0)?).map(|_| None)
}

fn bi_isam_delete_table(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let datenbank = arg_str(a, 0)?;
    let tabelle = arg_str(a, 1)?;
    st.isam.tabelle_loeschen(&datenbank, &tabelle).map(|_| None)
}

fn isam_bewegen(
    st: &mut RtState,
    a: &[Value],
    richtung: crate::isam::Richtung,
) -> Result<Option<Value>, RuntimeError> {
    st.isam.bewegen(arg_i32(a, 0)?, richtung).map(|_| None)
}

fn bi_isam_move_first(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    isam_bewegen(st, a, crate::isam::Richtung::Erster)
}

fn bi_isam_move_last(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    isam_bewegen(st, a, crate::isam::Richtung::Letzter)
}

fn bi_isam_move_next(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    isam_bewegen(st, a, crate::isam::Richtung::Naechster)
}

fn bi_isam_move_previous(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    isam_bewegen(st, a, crate::isam::Richtung::Voriger)
}

fn isam_suchen(
    st: &mut RtState,
    a: &[Value],
    art: crate::isam::Suchart,
) -> Result<Option<Value>, RuntimeError> {
    let nummer = arg_i32(a, 0)?;
    st.isam.suchen(nummer, art, &a[1..]).map(|_| None)
}

fn bi_isam_seek_eq(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    isam_suchen(st, a, crate::isam::Suchart::Gleich)
}

fn bi_isam_seek_gt(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    isam_suchen(st, a, crate::isam::Suchart::Groesser)
}

fn bi_isam_seek_ge(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    isam_suchen(st, a, crate::isam::Suchart::GroesserGleich)
}

fn bi_isam_begin_trans(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    st.isam.trans_beginn().map(|_| None)
}

fn bi_isam_commit_trans(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    st.isam.trans_festschreiben().map(|_| None)
}

fn bi_isam_rollback(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    // Ohne Argument: bis zum letzten Sicherungspunkt bzw. Transaktionsbeginn.
    let kennung = if a.is_empty() { 0 } else { arg_i32(a, 0)? };
    st.isam.ruecknahme(kennung).map(|_| None)
}

fn bi_isam_savepoint(
    st: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    st.isam.sicherungspunkt().map(|k| Some(Value::Int(k)))
}

fn bi_isam_setmem(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let delta = arg_i32(a, 0)? as i64;
    let frei = st.isam.setmem(delta);
    Ok(Some(Value::Lng(
        frei.clamp(i32::MIN as i64, i32::MAX as i64) as i32,
    )))
}

fn bi_isam_bof(
    st: &mut RtState,
    _: &mut dyn Host,
    a: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    let n = arg_i32(a, 0)?;
    Ok(Some(Value::Int(if st.isam.bof(n) { -1 } else { 0 })))
}

fn bi_msgbox(
    _: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    Err(RuntimeError::FEATURE_UNAVAILABLE)
}

fn bi_inputbox(
    _: &mut RtState,
    _: &mut dyn Host,
    _: &mut [Value],
) -> Result<Option<Value>, RuntimeError> {
    Err(RuntimeError::FEATURE_UNAVAILABLE)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::host::CaptureHost;

    fn call(id: u16, args: Vec<Value>) -> Result<Option<Value>, RuntimeError> {
        let mut st = RtState::default();
        let mut host = CaptureHost::default();
        let mut a = args;
        builtin_table()[id as usize](&mut st, &mut host, &mut a)
    }

    fn s(v: &str) -> Value {
        Value::Str(Rc::from(v))
    }

    #[test]
    fn input_s_dispatch_bewahrt_codepointreste_auch_ohne_vm() {
        let mut st = RtState::default();
        let mut host = CaptureHost::default();
        host.ereignis(Ereignis::Taste("\0;".into(), 0));
        host.ereignis(Ereignis::Taste("ä😀".into(), 0));
        for (n, expected) in [(0, ""), (1, "\0"), (2, ";ä"), (1, "😀")] {
            let got = bi_input_s(&mut st, &mut host, &mut [Value::Int(n)]).unwrap();
            assert!(matches!(got, Some(Value::Str(s)) if &*s == expected));
        }
        host.ereignis(Ereignis::Ende);
        assert_eq!(
            bi_input_s(&mut st, &mut host, &mut [Value::Int(1)]).unwrap_err(),
            RuntimeError(62)
        );
        assert_eq!(
            bi_input_s(&mut st, &mut host, &mut [Value::Int(-1)]).unwrap_err(),
            RuntimeError(5)
        );
    }

    #[test]
    fn zonenquelle_ausfall_und_erholung_gelten_fuer_alle_zeitfunktionen() {
        thread_local! {
            static ZONE: std::cell::Cell<Option<i32>> = const { std::cell::Cell::new(None) };
        }
        let mut st = RtState {
            zonen_ermittler: |_| ZONE.get(),
            uhr_offset: 4321,
            ..RtState::default()
        };
        let mut host = CaptureHost::default();
        for zone in [None, Some(7200), None, Some(0)] {
            ZONE.set(zone);
            let before = dt::jetzt_utc();
            let (time, known) = st.zeitpunkt();
            assert_eq!(known, zone.is_some());
            let shift = i64::from(zone.unwrap_or(0)) + st.uhr_offset;
            assert!((before + shift..=dt::jetzt_utc() + shift).contains(&time));
            assert!(
                matches!(bi_timezoneknown(&mut st, &mut host, &mut []).unwrap(), Some(Value::Int(n)) if n == if known { -1 } else { 0 })
            );
            let Some(Value::Dbl(now)) = bi_now(&mut st, &mut host, &mut []).unwrap() else {
                panic!()
            };
            let seconds = ((now - dt::serial_aus_ymd(1970, 1, 1)) * 86_400.0).round() as i64;
            assert!((before + shift..=dt::jetzt_utc() + shift).contains(&seconds));
            let Some(Value::Str(date)) = bi_date(&mut st, &mut host, &mut []).unwrap() else {
                panic!()
            };
            let Some(Value::Str(clock)) = bi_time(&mut st, &mut host, &mut []).unwrap() else {
                panic!()
            };
            let date_time = ((dt::datevalue(&date).unwrap() + dt::timevalue(&clock).unwrap()
                - dt::serial_aus_ymd(1970, 1, 1))
                * 86_400.0)
                .round() as i64;
            assert!((before + shift..=dt::jetzt_utc() + shift).contains(&date_time));
            let Some(Value::Sng(timer)) = bi_timer(&mut st, &mut host, &mut []).unwrap() else {
                panic!()
            };
            assert!((timer as f64 - seconds.rem_euclid(86_400) as f64).abs() < 2.0);
            assert_eq!(st.uhr_offset, 4321);
        }
    }

    #[test]
    fn stringfunktionen_randfaelle() {
        // VAL liest Präfix
        let Some(Value::Dbl(v)) = call(ids::VAL, vec![s(" 12.5abc")]).unwrap() else {
            panic!()
        };
        assert_eq!(v, 12.5);
        // STR$ mit führendem Leerzeichen
        let Some(Value::Str(r)) = call(ids::STR_S, vec![Value::Int(42)]).unwrap() else {
            panic!()
        };
        assert_eq!(&*r, " 42");
        // ASC("") → Fehler 5
        assert_eq!(
            call(ids::ASC, vec![s("")]).unwrap_err(),
            RuntimeError::ILLEGAL_FUNCTION_CALL
        );
        // MID$-Anweisung
        let Some(Value::Str(r)) = call(
            ids::MID_ASSIGN,
            vec![s("hallo"), Value::Lng(2), Value::Lng(2), s("EY")],
        )
        .unwrap() else {
            panic!()
        };
        assert_eq!(&*r, "hEYlo");
        // LEN zählt Zeichen (Unicode)
        let Some(Value::Int(n)) = call(ids::LEN, vec![s("äöü")]).unwrap() else {
            panic!()
        };
        assert_eq!(n, 3);
        // INSTR
        let Some(Value::Int(p)) = call(
            ids::INSTR,
            vec![Value::Lng(1), s("Terminal Basic"), s("Basic")],
        )
        .unwrap() else {
            panic!()
        };
        assert_eq!(p, 10);
    }

    #[test]
    fn builtin_ueber_tabelle() {
        // Spec-Szenario: LEN über Tabellenindex liefert 3.
        let Some(Value::Int(n)) = call(ids::LEN, vec![s("abc")]).unwrap() else {
            panic!()
        };
        assert_eq!(n, 3);
    }
    #[test]
    fn setuevent_meldet_das_benutzerereignis() {
        use crate::traps::{Quelle, Zustand};
        let mut st = RtState::default();
        let mut h = crate::host::CaptureHost::default();
        st.traps.definieren(Quelle::UEvent, Some(11));
        st.traps.setzen(Quelle::UEvent, Zustand::An, 0);
        assert_eq!(st.traps.naechstes(), None);
        bi_setuevent(&mut st, &mut h, &mut []).unwrap();
        assert_eq!(st.traps.naechstes(), Some((Quelle::UEvent, 11)));
    }

    #[test]
    fn signal_aus_dem_host_meldet_den_trap() {
        use crate::host::{Ereignis, Host};
        use crate::traps::{Quelle, Zustand};
        let mut st = RtState::default();
        let mut h = crate::host::CaptureHost::default();
        st.traps.definieren(Quelle::Signal(1), Some(22));
        st.traps.setzen(Quelle::Signal(1), Zustand::An, 0);
        h.ereignis(Ereignis::Signal(1));
        st.pump(&mut h, false);
        assert_eq!(st.traps.naechstes(), Some((Quelle::Signal(1), 22)));
        assert!(h.next_event(false).is_none());
    }

    #[test]
    fn signal_stop_merkt_beide_quellen_und_off_verwirft() {
        use crate::host::{CaptureHost, Ereignis};
        use crate::traps::{Quelle, Zustand};
        for n in [1, 2] {
            let mut st = RtState::default();
            let mut host = CaptureHost::default();
            st.traps.definieren(Quelle::Signal(n), Some(22));
            st.traps.setzen(Quelle::Signal(n), Zustand::Gestoppt, 0);
            for _ in 0..3 {
                host.ereignis(Ereignis::Signal(n));
            }
            st.pump(&mut host, false);
            assert!(!st.abbruch);
            assert_eq!(st.traps.naechstes(), None);
            st.traps.setzen(Quelle::Signal(n), Zustand::An, 0);
            assert_eq!(st.traps.naechstes(), Some((Quelle::Signal(n), 22)));
            st.traps.handler_beendet(Quelle::Signal(n));
            assert_eq!(st.traps.naechstes(), None);
            st.traps.setzen(Quelle::Signal(n), Zustand::Gestoppt, 0);
            host.ereignis(Ereignis::Signal(n));
            st.pump(&mut host, false);
            st.traps.setzen(Quelle::Signal(n), Zustand::Aus, 0);
            st.traps.setzen(Quelle::Signal(n), Zustand::An, 0);
            assert_eq!(st.traps.naechstes(), None);
        }
    }

    #[test]
    fn key_stop_merkt_einmal_und_laesst_alle_tasten_auch_nach_on_lesbar() {
        use crate::host::{CaptureHost, Ereignis};
        use crate::traps::{Quelle, Zustand};
        let mut st = RtState::default();
        let mut host = CaptureHost::default();
        st.traps.definieren(Quelle::Key(15), Some(22));
        st.traps.benutzertaste(15, 0, 30);
        st.traps.setzen(Quelle::Key(15), Zustand::Gestoppt, 0);
        for _ in 0..3 {
            host.ereignis(Ereignis::Taste("a".into(), 0));
        }
        st.pump(&mut host, false);
        for i in 0..3 {
            assert!(!st.taste_trappen(i));
        }
        assert_eq!(st.naechste_taste(&mut host, false).as_deref(), Some("a"));
        st.traps.setzen(Quelle::Key(15), Zustand::An, 0);
        assert_eq!(st.traps.naechstes(), Some((Quelle::Key(15), 22)));
        st.traps.handler_beendet(Quelle::Key(15));
        for _ in 0..2 {
            assert_eq!(st.naechste_taste(&mut host, false).as_deref(), Some("a"));
        }
        assert_eq!(st.traps.naechstes(), None);
    }

    #[test]
    fn sigint_ohne_aktiven_trap_bleibt_abbruch() {
        use crate::host::Ereignis;
        let mut st = RtState::default();
        let mut h = crate::host::CaptureHost::default();
        h.ereignis(Ereignis::Signal(1));
        st.pump(&mut h, false);
        assert!(st.abbruch);
    }

    #[test]
    fn mausereignisse_haeufen_sich_nicht_an() {
        use crate::host::{Ereignis, MausArt, MausEreignis};
        let mut st = RtState::default();
        let mut h = crate::host::CaptureHost::default();
        for i in 0..500 {
            h.ereignis(Ereignis::Maus(MausEreignis {
                art: MausArt::Bewegung,
                taste: 0,
                shift: 0,
                zeile: 1 + i % 20,
                spalte: 1 + i % 70,
            }));
        }
        st.pump(&mut h, false);
        assert!(st
            .eingaben
            .iter()
            .all(|e| matches!(e.ereignis, Ereignis::Maus(_))));
    }

    #[test]
    fn getrappte_taste_verlaesst_den_eingabestrom() {
        use crate::host::{taste, CaptureHost, Ereignis};
        use crate::traps::{Quelle, Zustand};
        let mut st = RtState::default();
        let mut h = CaptureHost::default();
        st.traps.definieren(Quelle::Key(1), Some(77));
        st.traps.setzen(Quelle::Key(1), Zustand::An, 0);
        h.ereignis(Ereignis::Taste(taste::sonder(0x3B), 0)); // F1
        st.pump(&mut h, false);
        st.traps_pruefen(&mut h);
        assert_eq!(st.traps.naechstes(), Some((Quelle::Key(1), 77)));
        assert_eq!(st.naechste_taste(&mut h, false), None, "F1 ist weg");
    }

    #[test]
    fn nicht_getrappte_taste_bleibt_im_strom() {
        use crate::host::{taste, CaptureHost, Ereignis};
        use crate::traps::{Quelle, Zustand};
        let mut st = RtState::default();
        let mut h = CaptureHost::default();
        st.traps.definieren(Quelle::Key(1), Some(77));
        st.traps.setzen(Quelle::Key(1), Zustand::Aus, 0);
        h.ereignis(Ereignis::Taste(taste::sonder(0x3B), 0));
        st.pump(&mut h, false);
        st.traps_pruefen(&mut h);
        assert_eq!(st.traps.naechstes(), None);
        assert_eq!(
            st.naechste_taste(&mut h, false),
            Some(taste::sonder(0x3B)),
            "bei OFF bleibt F1 lesbar"
        );
    }

    #[test]
    fn off_wirkt_auf_bereits_wartende_tasten() {
        use crate::host::{taste, CaptureHost, Ereignis};
        use crate::traps::{Quelle, Zustand};
        let mut st = RtState::default();
        let mut h = CaptureHost::default();
        st.traps.definieren(Quelle::Key(1), Some(77));
        st.traps.setzen(Quelle::Key(1), Zustand::An, 0);
        h.ereignis(Ereignis::Taste(taste::sonder(0x3B), 0));
        st.pump(&mut h, false); // Taste liegt in der Schlange
        st.traps.setzen(Quelle::Key(1), Zustand::Aus, 0);
        st.traps_pruefen(&mut h);
        assert_eq!(
            st.naechste_taste(&mut h, false),
            Some(taste::sonder(0x3B)),
            "das OFF wirkt auch auf schon Wartendes (design.md D5)"
        );
    }

    #[test]
    fn benutzertaste_ueber_key_anweisung() {
        use crate::host::{umschalt, CaptureHost};
        use crate::traps::{Quelle, Zustand};
        let mut st = RtState::default();
        let mut h = CaptureHost::default();
        st.traps.definieren(Quelle::Key(15), Some(88));
        st.traps.setzen(Quelle::Key(15), Zustand::An, 0);
        // KEY 15, CHR$(4) + CHR$(30)  — Strg+A
        let def = format!("{}{}", 4u8 as char, 0x1Eu8 as char);
        bi_key_assign(
            &mut st,
            &mut h,
            &mut [Value::Int(15), Value::Str(def.into())],
        )
        .unwrap();
        h.taste_mit("a", umschalt::CTRL);
        st.pump(&mut h, false);
        st.traps_pruefen(&mut h);
        assert_eq!(st.traps.naechstes(), Some((Quelle::Key(15), 88)));
        assert_eq!(st.naechste_taste(&mut h, false), None);
    }

    #[test]
    fn benutzertaste_ohne_umschalt_bleibt_im_strom() {
        use crate::host::CaptureHost;
        use crate::traps::{Quelle, Zustand};
        let mut st = RtState::default();
        let mut h = CaptureHost::default();
        st.traps.definieren(Quelle::Key(15), Some(88));
        st.traps.setzen(Quelle::Key(15), Zustand::An, 0);
        let def = format!("{}{}", 4u8 as char, 0x1Eu8 as char);
        bi_key_assign(
            &mut st,
            &mut h,
            &mut [Value::Int(15), Value::Str(def.into())],
        )
        .unwrap();
        h.taste_mit("a", 0); // ohne Strg
        st.pump(&mut h, false);
        st.traps_pruefen(&mut h);
        assert_eq!(st.traps.naechstes(), None);
        assert_eq!(st.naechste_taste(&mut h, false), Some("a".to_string()));
    }
}
