//! Abgleich `docs/inventar.md` ↔ Implementierungsstand.
//!
//! Das Inventar ist die Sollseite der Leitplanke „Vollständigkeit ist das
//! Soll"; dieser Test hält es gegen den Code, damit der Status nicht
//! verrottet. Prüfregeln siehe `pruefe_status`.
//!
//! ponytail: Die Tabellen `builtin_fn`/`builtin_stmt`/`banned_feature` sind
//! private `match`-Ausdrücke ohne Namensliste. Statt sie öffentlich zu machen
//! oder die Namen ein zweites Mal zu pflegen, liest der Test die Stringliterale
//! aus dem Quelltext. Ceiling: Wird `sema.rs` umstrukturiert, schlägt
//! `abschnitt` fehl und nennt den fehlenden Anker — dann hier nachziehen oder
//! die Tabellen als `pub const`-Listen exportieren.

use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;

const IMPLEMENTIERT: &str = "implementiert";
const OFFEN: &str = "offen";
const NON_FEATURE: &str = "Non-Feature";

fn wurzel() -> PathBuf {
    // crates/tb-cli/ → Projektwurzel
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
}

// ---- Inventar lesen --------------------------------------------------------

#[derive(Debug, Clone)]
struct Eintrag {
    name: String,
    art: String,
    status: String,
    fundstelle: String,
    quelle: String,
    zeile: usize,
}

fn inventar() -> Vec<Eintrag> {
    let pfad = wurzel().join("docs/inventar.md");
    let text = std::fs::read_to_string(&pfad)
        .unwrap_or_else(|e| panic!("docs/inventar.md nicht lesbar: {e}"));
    let mut out = Vec::new();
    for (i, zeile) in text.lines().enumerate() {
        let zeile = zeile.trim();
        if !zeile.starts_with("| `") {
            continue;
        }
        let spalten: Vec<&str> = zeile
            .trim_matches('|')
            .split('|')
            .map(|s| s.trim())
            .collect();
        assert_eq!(
            spalten.len(),
            6,
            "docs/inventar.md Zeile {}: {} Spalten statt 6 — {zeile}",
            i + 1,
            spalten.len()
        );
        out.push(Eintrag {
            name: spalten[0].trim_matches('`').to_string(),
            art: spalten[1].to_string(),
            status: spalten[3].to_string(),
            fundstelle: spalten[4].to_string(),
            quelle: spalten[5].to_string(),
            zeile: i + 1,
        });
    }
    assert!(!out.is_empty(), "docs/inventar.md enthält keine Einträge");
    out
}

// ---- Implementierungsstand aus sema.rs -------------------------------------

fn sema_quelle() -> String {
    let pfad = wurzel().join("crates/tb-frontend/src/sema.rs");
    std::fs::read_to_string(&pfad).unwrap_or_else(|e| panic!("sema.rs nicht lesbar: {e}"))
}

/// Quelltextabschnitt zwischen zwei Ankern (exklusive Endanker).
fn abschnitt<'a>(quelle: &'a str, von: &str, bis: &str) -> &'a str {
    let a = quelle
        .find(von)
        .unwrap_or_else(|| panic!("Anker nicht gefunden in sema.rs: {von}"));
    let rest = &quelle[a..];
    let b = rest
        .find(bis)
        .unwrap_or_else(|| panic!("Endanker nicht gefunden in sema.rs: {bis}"));
    &rest[..b]
}

/// Alle Stringliterale eines Abschnitts.
fn literale(abschnitt: &str) -> BTreeSet<String> {
    let mut out = BTreeSet::new();
    let bytes: Vec<char> = abschnitt.chars().collect();
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == '"' {
            let mut s = String::new();
            i += 1;
            while i < bytes.len() && bytes[i] != '"' {
                s.push(bytes[i]);
                i += 1;
            }
            if !s.is_empty() {
                out.insert(s);
            }
        }
        i += 1;
    }
    out
}

struct Stand {
    signaturen: BTreeSet<String>,
    verboten: BTreeSet<String>,
    nicht_verfuegbar: BTreeSet<String>,
    /// Non-Features, die an ihrer Syntax statt an ihrem Namen erkannt werden.
    syntaktisch: BTreeSet<String>,
}

fn stand() -> Stand {
    let q = sema_quelle();
    let fn_tab = abschnitt(&q, "fn builtin_fn(", "/// Deklarierte Non-Features");
    let ban = abschnitt(
        &q,
        "fn banned_feature(",
        "/// Non-Features, die nicht über ihren Namen",
    );
    let stmt_tab = abschnitt(&q, "fn builtin_stmt(", "// ---- Einstiegspunkte");
    let unsup_fn = abschnitt(
        &q,
        "// --- Spätere Phasen: dokumentiert",
        "(HExpr::Unsupported(\"Funktion\")",
    );
    let unsup_stmt = abschnitt(
        &q,
        "// Bildschirm-/Datei-/System-Anweisungen späterer Phasen.",
        r#"HStmtKind::Unsupported("Anweisung")"#,
    );

    let mut signaturen = literale(fn_tab);
    signaturen.extend(literale(stmt_tab));
    let mut nicht_verfuegbar = literale(unsup_fn);
    nicht_verfuegbar.extend(literale(unsup_stmt));
    // Platzhalter der Sammelzweige sind keine Elementnamen.
    nicht_verfuegbar.remove("Funktion");
    nicht_verfuegbar.remove("Anweisung");

    let syn = abschnitt(
        &q,
        "pub const SYNTAKTISCH_ABGEWIESEN",
        "/// Ist `s` ein serieller Gerätename",
    );

    Stand {
        signaturen,
        verboten: literale(ban),
        nicht_verfuegbar,
        syntaktisch: literale(syn)
            .into_iter()
            .map(|s| s.to_uppercase())
            .collect(),
    }
}

// ---- Regeln ----------------------------------------------------------------

#[test]
fn statusvokabular_ist_beschraenkt() {
    for e in inventar() {
        assert!(
            matches!(
                e.art.as_str(),
                "Anweisung"
                    | "Funktion"
                    | "Metabefehl"
                    | "Routine"
                    | "Eigenschaft"
                    | "Methode"
                    | "Ereignis"
                    | "Datentyp"
                    | "Operator"
            ),
            "Zeile {}: `{}` trägt unzulässige Art `{}`",
            e.zeile,
            e.name,
            e.art
        );
        assert!(
            matches!(e.status.as_str(), IMPLEMENTIERT | OFFEN | NON_FEATURE),
            "Zeile {}: `{}` trägt unzulässigen Status `{}` \
             (erlaubt: {IMPLEMENTIERT}/{OFFEN}/{NON_FEATURE})",
            e.zeile,
            e.name,
            e.status
        );
    }
}

fn pruefe_forms(inv: &[Eintrag]) -> Vec<String> {
    use tb_frontend::forms::{events, method_is_implemented, methods, properties, ObjectClass};
    let mut erwartet = BTreeMap::new();
    for class in ObjectClass::ALL {
        for p in properties(class) {
            erwartet.insert(
                format!("{}.{}", class.name(), p.name),
                ("Eigenschaft", IMPLEMENTIERT, "tb-ui::forms"),
            );
        }
        for m in methods(class) {
            erwartet.insert(
                format!("{}.{m}", class.name()),
                if method_is_implemented(class, m) {
                    ("Methode", IMPLEMENTIERT, "tb-ui::forms")
                } else {
                    ("Methode", OFFEN, "-")
                },
            );
        }
        for e in events(class) {
            erwartet.insert(
                format!("{}_{e}", class.name()),
                ("Ereignis", IMPLEMENTIERT, "tb-vm::interp"),
            );
        }
    }
    let gefunden: BTreeMap<_, _> = inv
        .iter()
        .filter(|e| matches!(e.art.as_str(), "Eigenschaft" | "Methode" | "Ereignis"))
        .map(|e| (e.name.to_uppercase(), e))
        .collect();
    let mut fehler = Vec::new();
    for (name, (art, status, fundstelle)) in &erwartet {
        match gefunden.get(name) {
            None => fehler.push(format!("`{name}` fehlt")),
            Some(e)
                if e.art != *art
                    || e.status != *status
                    || e.fundstelle != *fundstelle
                    || e.quelle != "forms-referenz" =>
            {
                fehler.push(format!("`{name}`: Art/Status/Fundstelle/Quelle falsch"))
            }
            Some(_) => {}
        }
    }
    for name in gefunden.keys() {
        if !erwartet.contains_key(name) {
            fehler.push(format!("`{name}` ist nicht in der Klassentabelle"));
        }
    }
    fehler
}

#[test]
fn forms_inventar_stimmt_mit_der_klassentabelle_ueberein() {
    let fehler = pruefe_forms(&inventar());
    assert!(
        fehler.is_empty(),
        "Forms-Inventar weicht ab:\n  {}",
        fehler.join("\n  ")
    );
}

#[test]
fn falsch_gesetzte_eigenschaft_wird_erkannt() {
    let mut inv = inventar();
    let e = inv
        .iter_mut()
        .find(|e| e.name == "TEXTBOX.SELSTART")
        .unwrap();
    e.status = OFFEN.into();
    assert!(pruefe_forms(&inv)
        .iter()
        .any(|f| f.contains("TEXTBOX.SELSTART")));
}

#[test]
fn non_features_nennen_eine_fundstelle_in_der_sprachreferenz() {
    for e in inventar() {
        if e.status == NON_FEATURE {
            assert!(
                e.fundstelle.contains("sprachreferenz.md"),
                "Zeile {}: `{}` ist Non-Feature, nennt aber keine Fundstelle \
                 in docs/sprachreferenz.md (gefunden: `{}`)",
                e.zeile,
                e.name,
                e.fundstelle
            );
        }
    }
}

#[test]
fn offene_elemente_haben_keine_fundstelle() {
    for e in inventar() {
        if e.status == OFFEN {
            assert_eq!(
                e.fundstelle, "-",
                "Zeile {}: `{}` ist offen, trägt aber die Fundstelle `{}`",
                e.zeile, e.name, e.fundstelle
            );
        }
    }
}

/// Kernregel: Inventarstatus und Code dürfen nicht auseinanderlaufen.
///
/// Geprüft wird nur, was mechanisch belegbar ist — die Bibliothekselemente aus
/// `builtin_fn`/`builtin_stmt`, die Non-Feature-Liste und die
/// „nicht verfügbar"-Marker. Anweisungen, die der Parser direkt kennt
/// (`PRINT`, `IF`, …), und Metabefehle tauchen in keiner dieser Tabellen auf;
/// für sie gilt nur die schwächere Regel `parserelemente_sind_keine_non_features`.
#[test]
fn inventar_stimmt_mit_code_ueberein() {
    let inv = inventar();
    let st = stand();
    let mut fehler: Vec<String> = Vec::new();

    // Ein Name kann zweimal vorkommen (Funktion und Anweisung, z. B. `SCREEN`).
    // Schlüssel ist der Name in Großschreibung — der Dialekt ist
    // case-insensitiv, der Code führt die Namen groß.
    //
    // ponytail: Einträge mit klammerndem Zusatz (`GET (Grafik)`) sind
    // syntaktische Varianten eines Tokens, das auch eine unterstützte Form hat.
    // Sie sind über den Namen nicht prüfbar und bleiben hier außen vor; ihre
    // Ablehnung liegt im Parser. Ceiling: sobald der Parser eine Liste seiner
    // abgewiesenen Formen exportiert, hier gegenprüfen.
    let mut je_name: BTreeMap<String, Vec<&Eintrag>> = BTreeMap::new();
    for e in &inv {
        // Syntaktisch erkannte Formen tragen einen klammernden Zusatz oder
        // stehen namentlich in SYNTAKTISCH_ABGEWIESEN — sie werden unten
        // gegen diese Liste geprüft, nicht gegen `banned_feature`.
        if st.syntaktisch.contains(&e.name.to_uppercase()) {
            assert_eq!(
                e.status, NON_FEATURE,
                "Zeile {}: `{}` steht in SYNTAKTISCH_ABGEWIESEN,                  das Inventar sagt aber `{}`",
                e.zeile, e.name, e.status
            );
            continue;
        }
        // Ein klammernder Zusatz benennt die Form (`KEY (Ereignis)`); für den
        // Codeabgleich zählt das Grundtoken davor.
        let schluessel = e
            .name
            .split('(')
            .next()
            .unwrap_or(&e.name)
            .trim()
            .to_uppercase();
        je_name.entry(schluessel).or_default().push(e);
    }

    for (name, eintraege) in &je_name {
        let stati: BTreeSet<&str> = eintraege.iter().map(|e| e.status.as_str()).collect();
        let _ = &name;
        let zeilen: Vec<String> = eintraege.iter().map(|e| e.zeile.to_string()).collect();
        let wo = format!("Zeile(n) {}", zeilen.join(", "));

        let hat_signatur = st.signaturen.contains(name.as_str());
        // `ON COM(1) GOSUB …` scheitert am Gerätenamen: für die ON-Formen
        // zählt das zweite Token. Andere Mehrwortnamen (`LINE INPUT`,
        // `VIEW PRINT`) sind eigenständige unterstützte Anweisungen und
        // erben nichts vom Grundtoken.
        let ist_verboten = match name.strip_prefix("ON ") {
            Some(geraet) => st.verboten.contains(geraet),
            None => st.verboten.contains(name.as_str()),
        };
        let ist_marker = st.nicht_verfuegbar.contains(name.as_str());

        if ist_verboten && !stati.contains(NON_FEATURE) {
            fehler.push(format!(
                "`{name}` ({wo}): Code weist es als Non-Feature ab, Inventar sagt {stati:?}"
            ));
        }
        if !ist_verboten && stati.contains(NON_FEATURE) {
            fehler.push(format!(
                "`{name}` ({wo}): Inventar sagt Non-Feature, Code weist es nicht ab \
                 (fehlt in banned_feature)"
            ));
        }
        if ist_marker && !stati.contains(OFFEN) {
            fehler.push(format!(
                "`{name}` ({wo}): senkt auf den „nicht verfügbar\"-Marker ab, \
                 Inventar sagt {stati:?}"
            ));
        }
        if stati == BTreeSet::from([IMPLEMENTIERT]) && ist_marker {
            fehler.push(format!(
                "`{name}` ({wo}): Inventar sagt implementiert, senkt aber weiterhin \
                 auf den „nicht verfügbar\"-Marker ab"
            ));
        }
        // Nur wenn ALLE Formen dieses Namens offen sind — sonst erklärt die
        // implementierte Form (etwa `DATE$` als Funktion) die Signatur.
        if stati == BTreeSet::from([OFFEN]) && hat_signatur && !ist_marker && !ist_verboten {
            fehler.push(format!(
                "`{name}` ({wo}): Inventar sagt offen, aber der Code hat eine Signatur \
                 ohne „nicht verfügbar\"-Marker — entweder implementiert oder Marker fehlt"
            ));
        }
    }

    // Gegenrichtung: kennt der Code Elemente, die das Inventar nicht führt?
    // Ein Element darf im Code unter mehreren Schreibweisen stehen: die
    // Original-Hilfe schreibt die Finanzfunktionen mit Typsuffix (`FV#`), der
    // Code nimmt beide an. Für den Abgleich zählt das Inventar als getroffen,
    // wenn eine der Schreibweisen passt.
    let bekannt = |n: &str| {
        let up = n.to_uppercase();
        je_name.contains_key(&up)
            || ['#', '!', '&', '%', '@', '$']
                .iter()
                .any(|s| je_name.contains_key(&format!("{up}{s}")))
    };
    for name in st.signaturen.iter().chain(st.verboten.iter()) {
        if !bekannt(name) {
            fehler.push(format!(
                "`{name}`: im Code bekannt, fehlt aber in docs/inventar.md"
            ));
        }
    }

    assert!(
        fehler.is_empty(),
        "Inventar und Code laufen auseinander ({} Abweichungen):\n  {}",
        fehler.len(),
        fehler.join("\n  ")
    );
}

#[test]
fn abdeckungsstand_wird_ausgewiesen() {
    let inv = inventar();
    let zaehle = |s: &str| inv.iter().filter(|e| e.status == s).count();
    let (i, o, n) = (zaehle(IMPLEMENTIERT), zaehle(OFFEN), zaehle(NON_FEATURE));
    println!(
        "Abdeckung: implementiert {i} · offen {o} · Non-Feature {n} · gesamt {}",
        inv.len()
    );

    // Der Kopf der Tabelle nennt dieselben Zahlen — sonst ist die Doku stale.
    let text = std::fs::read_to_string(wurzel().join("docs/inventar.md")).unwrap();
    let erwartet = format!(
        "implementiert {i} · offen {o} · Non-Feature {n} · gesamt {}",
        inv.len()
    );
    assert!(
        text.contains(&erwartet),
        "Abdeckungszeile in docs/inventar.md stimmt nicht; erwartet: {erwartet}"
    );
    assert_eq!(i + o + n, inv.len(), "Statussumme ≠ Einträge");
}

// ---- Namentliche Ablehnung -------------------------------------------------

/// Ein im Inventar geführtes Element darf nie als unbekannter Bezeichner oder
/// mit einem generischen Syntaxfehler enden — die Ablehnung MUSS es nennen
/// (Guiding Principle, Capability `sprachinventar`).
///
/// Geprüft wird stichprobenartig je Ablehnungsweg, nicht über alle 285
/// Einträge: die vollständige Zuordnung leistet `inventar_stimmt_mit_code_ueberein`.
#[test]
fn dokumentierte_elemente_werden_namentlich_abgewiesen() {
    // (Quelltext, erwartetes Element, erwarteter Meldungsbestandteil)
    let faelle: &[(&str, &str, &str)] = &[
        // Non-Feature über die Namensliste
        ("X = PEEK(100)", "PEEK", "Feature unavailable"),
        ("Y = POINT(1, 2)", "POINT", "Feature unavailable"),
        ("CALLS Foo", "CALLS", "Feature unavailable"),
        // Non-Feature über die Syntax (teilt das Schlüsselwort)
        (
            "GET (1, 1)-(2, 2), A",
            "GET (Grafik)",
            "Feature unavailable",
        ),
        (
            r#"OPEN "COM1:9600,N,8,1" AS #1"#,
            "OPEN COM",
            "Feature unavailable",
        ),
        // Ereignisgerät als Funktionsform
        (
            "ON PEN GOSUB Ziel\nZiel:\nRETURN",
            "ON PEN",
            "Feature unavailable",
        ),
    ];

    for (quelle, element, erwartet) in faelle {
        let a = tb_frontend::analyze_source("probe.bas", quelle);
        let texte: Vec<String> = a.diagnostics.iter().map(|d| d.message.clone()).collect();
        assert!(
            texte.iter().any(|m| m.contains(erwartet)),
            "`{element}`: erwartete Meldung `{erwartet}`, bekam {texte:?} \
             (Quelle: {quelle:?})"
        );
        assert!(
            !texte.iter().any(|m| m.contains("not defined")),
            "`{element}`: wird als unbekannter Bezeichner abgewiesen statt \
             namentlich — {texte:?}"
        );
    }
}

/// Gegenprobe: ein Element mit „nicht verfügbar"-Marker kompiliert zwar, endet
/// aber zur Laufzeit in einem Fehler, der es benennt — nicht in einem
/// generischen Syntaxfehler.
#[test]
fn offene_elemente_erzeugen_keinen_syntaxfehler() {
    for quelle in [
        "CLS",
        "LOCATE 5, 10",
        "COLOR 14, 1",
        r#"OPEN "d.txt" FOR OUTPUT AS #1"#,
    ] {
        let a = tb_frontend::analyze_source("probe.bas", quelle);
        assert!(
            a.diagnostics.is_empty(),
            "{quelle:?}: offenes Element soll übersetzen und erst zur Laufzeit \
             namentlich abgewiesen werden, bekam {:?}",
            a.diagnostics
        );
    }
}

/// Jedes Inventarelement besitzt eine Signatur — auch die, deren
/// Laufzeitverhalten noch aussteht. Sie dürfen nie als unbekannter Bezeichner
/// enden (Capability `sprach-frontend`, Anforderung „Jedes dokumentierte
/// Element besitzt eine Signatur").
#[test]
fn bislang_signaturlose_elemente_haben_signaturen() {
    let quelle = r#"
DIM Zahlungen#(1 TO 3)
DIM D#, S$, I%, L&, C@, G!
D# = FV#(0.05, 10, -100, 0, 0)
D# = PV#(0.05, 10, -100, 0, 0)
D# = PMT#(0.05, 10, 1000, 0, 0)
D# = NPER#(0.05, -100, 1000, 0, 0)
D# = IPMT#(0.05, 1, 10, 1000, 0, 0)
D# = PPMT#(0.05, 1, 10, 1000, 0, 0)
D# = RATE#(10, -100, 1000, 0, 0, 0.1)
D# = NPV#(0.05, Zahlungen#())
D# = IRR#(Zahlungen#(), 0.1)
D# = MIRR#(Zahlungen#(), 0.05, 0.06)
D# = SLN#(1000, 100, 10)
D# = SYD#(1000, 100, 10, 1)
D# = DDB#(1000, 100, 10, 1)
S$ = MKI$(1)
S$ = MKL$(1)
S$ = MKS$(1)
S$ = MKD$(1)
S$ = MKC$(1)
S$ = MKSMBF$(1)
S$ = MKDMBF$(1)
I% = CVI("ab")
L& = CVL("abcd")
G! = CVS("abcd")
D# = CVD("abcdefgh")
C@ = CVC("abcdefgh")
G! = CVSMBF("abcd")
D# = CVDMBF("abcdefgh")
S$ = CURDIR$
S$ = DIR$("*.txt")
I% = LPOS(0)
L& = STACK
I% = ERDEV
S$ = ERDEV$
CHDRIVE "C"
STACK 2048
SetFormatCC 49
"#;
    let a = tb_frontend::analyze_source("signaturen.bas", quelle);
    let unbekannt: Vec<&tb_frontend::Diagnostic> = a
        .diagnostics
        .iter()
        .filter(|d| d.message.contains("not defined"))
        .collect();
    assert!(
        unbekannt.is_empty(),
        "Elemente werden als unbekannter Bezeichner abgewiesen: {unbekannt:?}"
    );
    assert!(
        a.diagnostics.is_empty(),
        "unerwartete Diagnosen: {:?}",
        a.diagnostics
    );
}

/// Die Signatur wirkt auch dort, wo das Laufzeitverhalten noch aussteht:
/// eine falsche Argumentanzahl wird gemeldet und nennt das Element.
#[test]
fn signatur_greift_ohne_laufzeitverhalten() {
    let a = tb_frontend::analyze_source("argzahl.bas", "S$ = MKL$(1, 2)");
    let texte: Vec<String> = a.diagnostics.iter().map(|d| d.message.clone()).collect();
    assert!(
        texte.iter().any(|m| m.contains("MKL$")),
        "Diagnose nennt `MKL$` nicht: {texte:?}"
    );
}

// Die Vorlagen sind feste Eingaben, keine Statusliste aus Implementierungstabellen.
fn probe_source(e: &Eintrag) -> (String, tb_frontend::forms::FormCatalog) {
    use tb_frontend::forms::{self, ObjectClass, PropertyType};
    let mut catalog = forms::FormCatalog::default();
    catalog.add("Form1", ObjectClass::Form, None, false);
    catalog.add("Probe", ObjectClass::TextBox, Some("Form1"), false);
    if !matches!(e.art.as_str(), "Eigenschaft" | "Methode" | "Ereignis") {
        let source = include_str!("../../../tests/support/inventar-quellen.tsv")
            .lines()
            .find_map(|line| {
                let mut columns = line.split('\t');
                (columns.next() == Some(e.name.as_str()) && columns.next() == Some(e.art.as_str()))
                    .then(|| columns.next().unwrap().replace("\\n", "\n"))
            })
            .unwrap_or_else(|| panic!("{}: Status {}, Programmvorlage fehlt", e.name, e.status));
        if e.name == "$FORM" {
            catalog.objects.clear();
        }
        return (source, catalog);
    }
    let (class_name, member) = e
        .name
        .split_once(if e.art == "Ereignis" { '_' } else { '.' })
        .unwrap();
    let class = ObjectClass::parse(class_name).unwrap();
    let mut catalog = forms::FormCatalog::default();
    catalog.add("Form1", ObjectClass::Form, None, false);
    let object = if class == ObjectClass::Form {
        "Form1"
    } else if class == ObjectClass::Screen {
        "SCREEN"
    } else {
        catalog.add("Probe", class, Some("Form1"), false);
        "Probe"
    };
    let source = match e.art.as_str() {
        "Eigenschaft" => {
            let (_, prop) = forms::property(class, &member.to_uppercase()).unwrap();
            let suffix = if matches!(
                member.to_uppercase().as_str(),
                "LIST" | "ITEMDATA" | "SELECTED" | "CONTROLPANEL"
            ) {
                "(0)"
            } else {
                ""
            };
            if prop.ty == PropertyType::Object {
                format!("DIM sink AS CONTROL\nsink = {object}.{member}{suffix}")
            } else {
                format!("PRINT {object}.{member}{suffix}")
            }
        }
        "Methode" => {
            let member = member.to_uppercase();
            let args = match member.as_str() {
                "MOVE" => " 1, 1",
                "DRAG" => " 0",
                "ADDITEM" => " \"x\"",
                "REMOVEITEM" => " 0",
                "TEXTHEIGHT" | "TEXTWIDTH" => "(\"x\")",
                _ => "",
            };
            let prefix = if forms::method_return_type(class, &member).is_some() {
                "PRINT "
            } else {
                ""
            };
            format!("{prefix}{object}.{member}{args}")
        }
        _ => {
            let args = forms::event_params(member)
                .unwrap()
                .iter()
                .map(|(name, ty)| {
                    format!(
                        "{name} AS {}",
                        match ty {
                            forms::EventParamType::Integer => "INTEGER",
                            forms::EventParamType::Single => "SINGLE",
                            forms::EventParamType::Control => "CONTROL",
                        }
                    )
                })
                .collect::<Vec<_>>()
                .join(", ");
            let prefix = if class == ObjectClass::Form {
                "Form"
            } else {
                object
            };
            format!("END\nSUB {prefix}_{member}({args})\nPRINT \"handler\"\nEND SUB")
        }
    };
    (source, catalog)
}

fn pruefe_ausfuehrbaren_pfad(e: &Eintrag, bound: impl Fn(u16) -> bool) -> Result<(), String> {
    let (src, catalog) = probe_source(e);
    if matches!(e.name.as_str(), "$DYNAMIC" | "$STATIC") {
        let parsed = tb_frontend::analyze_source("FORM1", &src);
        let expected = e.name == "$STATIC";
        if !parsed.module.body.iter().any(|s| matches!(s, tb_frontend::ast::Stmt::MetaArrays { static_arrays, .. } if *static_arrays == expected)) {
            return Err(format!("{}: Status {}, fehlender Metabefehl-Pfad", e.name, e.status));
        }
    }
    let compiled = if e.name == "$INCLUDE" {
        // Einbindung gehört zum CLI-Projektlader, nicht zum Einzelmodul-Frontend.
        let dir = std::env::temp_dir().join(format!("tb-inventar-include-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join("probe.bas"), src).unwrap();
        std::fs::write(dir.join("x.bi"), "CONST Included = 73\n").unwrap();
        let output = std::process::Command::new(env!("CARGO_BIN_EXE_tbc"))
            .args(["build", dir.join("probe.bas").to_str().unwrap()])
            .output()
            .unwrap();
        let result = if output.status.success() {
            tb_vm::bytecode::CompiledModule::from_tbc(
                &std::fs::read(dir.join("probe.tbc")).unwrap(),
            )
            .map_err(|err| {
                format!(
                    "{}: Status {}, ungültiges Kompilat: {err}",
                    e.name, e.status
                )
            })
        } else {
            Err(format!(
                "{}: Status {}, CLI-Pfad fehlgeschlagen: {}",
                e.name,
                e.status,
                String::from_utf8_lossy(&output.stderr)
            ))
        };
        std::fs::remove_dir_all(dir).unwrap();
        Ok(result?)
    } else {
        tb_vm::compile_source_with_forms("FORM1", &src, &catalog)
    };
    pruefe_kompilat(e, compiled, bound)
}

/// Feste Sollziele aus Sprache/Runtime-ABI; niemals aus dem gerade geprüften
/// Codegenerator gewonnen. Deklarationen prüfen ihr Modulresultat, Forms den
/// konkreten Member am tatsächlich abgesenkten Objekt.
fn pruefe_sollziel(e: &Eintrag, module: &tb_vm::bytecode::CompiledModule) -> Result<(), String> {
    use tb_frontend::forms;
    use tb_vm::bytecode::Instr;
    let code: Vec<_> = module.procs.iter().flat_map(|p| &p.code).collect();
    let member_matches = |object: u16, member: u16, method: bool| {
        module.objects.get(object as usize).is_some_and(|obj| {
            let name = if method {
                forms::methods(obj.class).get(member as usize).copied()
            } else {
                forms::properties(obj.class)
                    .get(member as usize)
                    .map(|p| p.name)
            };
            name.is_some_and(|name| {
                e.name
                    .eq_ignore_ascii_case(&format!("{}.{}", obj.class.name(), name))
            })
        })
    };
    let matches = match e.art.as_str() {
        "Eigenschaft" => code.iter().any(|i| match i {
            Instr::LoadObjectProperty(object, property, false)
            | Instr::LoadObjectIndexedProperty(object, property) => {
                member_matches(*object, *property, false)
            }
            _ => false,
        }),
        "Methode" => code.iter().any(|i| match i {
            Instr::ObjectMethod(object, method, _) | Instr::ObjectMethodFn(object, method, _) => {
                member_matches(*object, *method, true)
            }
            _ => false,
        }),
        "Ereignis" => module.event_procs.iter().any(|event| {
            let class = module.objects[event.object as usize].class;
            e.name
                .eq_ignore_ascii_case(&format!("{}_{}", class.name(), event.event))
                && module.procs[event.proc as usize].code.iter().any(|i| {
                    matches!(
                        i,
                        Instr::CallBuiltin(tb_runtime::builtins::ids::PRINT_VAL, _)
                    )
                })
        }),
        _ => {
            let expected = include_str!("../../../tests/support/inventar-quellen.tsv")
                .lines()
                .find_map(|line| {
                    let columns: Vec<_> = line.split('\t').collect();
                    (columns[0] == e.name && columns[1] == e.art).then_some(columns[3])
                })
                .unwrap();
            expected.split('|').all(|contract| {
                if let Some(id) = contract.strip_prefix("builtin:") {
                    let id: u16 = id.parse().unwrap();
                    return code
                        .iter()
                        .any(|i| matches!(i, Instr::CallBuiltin(actual, _) if *actual == id));
                }
                if let Some(op) = contract.strip_prefix("code:") {
                    return code.iter().any(|i| format!("{i:?}").starts_with(op));
                }
                if let Some(global) = contract.strip_prefix("global:") {
                    let (name, ty) = global.split_once(':').unwrap();
                    return module
                        .global_names
                        .iter()
                        .zip(&module.globals_init)
                        .any(|(actual, init)| actual == name && format!("{init:?}") == ty);
                }
                if let Some(name) = contract.strip_prefix("proc:") {
                    return module.procs.iter().any(|p| p.name == name);
                }
                match contract {
                    "base:1" => module.option_base == 1,
                    "data:1" => module.data.iter().any(|d| d.text == "1"),
                    "udt:R" => module
                        .udts
                        .iter()
                        .any(|u| u.name == "R" && u.fields == [tb_runtime::value::TypeInit::Int]),
                    "comment" => code
                        .iter()
                        .all(|i| matches!(i, Instr::Source(..) | Instr::Stmt(_) | Instr::End)),
                    _ => panic!("{}: unbekannter Sollvertrag {contract}", e.name),
                }
            })
        }
    };
    if matches {
        Ok(())
    } else {
        Err(format!(
            "{}: Status {}, falsches oder fehlendes Laufzeitziel: {code:?}",
            e.name, e.status
        ))
    }
}

fn pruefe_kompilat(
    e: &Eintrag,
    compiled: Result<tb_vm::bytecode::CompiledModule, Vec<tb_frontend::Diagnostic>>,
    bound: impl Fn(u16) -> bool,
) -> Result<(), String> {
    use tb_vm::bytecode::Instr;
    let actual = match compiled {
        Err(d) if d.iter().any(|d| d.message.contains("Feature unavailable")) => {
            let name = e
                .name
                .to_uppercase()
                .replace(" (EREIGNIS)", "")
                .replace(" (GRAFIK)", "");
            let name = name
                .strip_prefix("ON ")
                .or_else(|| name.strip_prefix("OPEN "))
                .unwrap_or(&name);
            if !d.iter().any(|d| {
                d.message.contains("Feature unavailable") && d.message.to_uppercase().contains(name)
            }) {
                return Err(format!(
                    "{}: Status {}, falsche Non-Feature-Diagnose: {d:?}",
                    e.name, e.status
                ));
            }
            NON_FEATURE
        }
        Err(d) => {
            return Err(format!(
                "{}: Status {}, kein erreichbarer HIR-Pfad: {d:?}",
                e.name, e.status
            ))
        }
        Ok(module) => {
            if module
                .procs
                .iter()
                .flat_map(|p| &p.code)
                .any(|i| matches!(i, Instr::Unsupported(_)))
            {
                OFFEN
            } else {
                for i in module.procs.iter().flat_map(|p| &p.code) {
                    if let Instr::CallBuiltin(id, _) = i {
                        if !bound(*id) {
                            return Err(format!(
                                "{}: Status {}, fehlende Runtime-Bindung {id}",
                                e.name, e.status
                            ));
                        }
                    }
                }
                module.validate().map_err(|d| {
                    format!(
                        "{}: Status {}, ungültiges Laufzeitziel: {d:?}",
                        e.name, e.status
                    )
                })?;
                pruefe_sollziel(e, &module)?;
                IMPLEMENTIERT
            }
        }
    };
    if e.status == actual {
        Ok(())
    } else {
        Err(format!(
            "{}: Status {}, vorgefunden {actual}",
            e.name, e.status
        ))
    }
}

#[test]
fn jeder_inventareintrag_erreicht_hir_und_laufzeitziel() {
    let errors: Vec<_> = inventar()
        .iter()
        .filter_map(|e| {
            pruefe_ausfuehrbaren_pfad(e, |id| {
                tb_runtime::builtins::builtin_table()
                    .get(id as usize)
                    .is_some()
            })
            .err()
        })
        .collect();
    assert!(
        errors.is_empty(),
        "{} Abweichungen:\n{}",
        errors.len(),
        errors.join("\n")
    );
}

#[test]
fn falscher_status_und_fehlende_runtimebindung_werden_namentlich_erkannt() {
    let inv = inventar();
    for (name, status) in [
        ("LOCATE", OFFEN),
        ("LOCATE", NON_FEATURE),
        ("PEEK", IMPLEMENTIERT),
    ] {
        let mut e = inv.iter().find(|e| e.name == name).unwrap().clone();
        e.status = status.into();
        let error = pruefe_ausfuehrbaren_pfad(&e, |_| true).unwrap_err();
        assert!(error.contains(name) && error.contains(status), "{error}");
    }
    let e = inv.iter().find(|e| e.name == "TIMEZONEKNOWN").unwrap();
    let error = pruefe_ausfuehrbaren_pfad(e, |_| false).unwrap_err();
    assert!(
        error.contains("TIMEZONEKNOWN")
            && error.contains(IMPLEMENTIERT)
            && error.contains("Runtime-Bindung"),
        "{error}"
    );
}

#[test]
fn implementiert_mit_unsupported_marker_faellt_durch() {
    let inv = inventar();
    let e = inv.iter().find(|e| e.name == "LOCATE").unwrap();
    let mut module = tb_vm::compile_source("TEST", "LOCATE 1,1").unwrap();
    module.procs[0]
        .code
        .push(tb_vm::bytecode::Instr::Unsupported(0));
    let error = pruefe_kompilat(e, Ok(module), |_| true).unwrap_err();
    assert!(
        error.contains("LOCATE") && error.contains(IMPLEMENTIERT) && error.contains(OFFEN),
        "{error}"
    );
}

#[test]
fn falsche_gueltige_builtinbindung_und_entfernte_absenkung_fallen_durch() {
    use tb_vm::bytecode::Instr;
    for e in inventar().iter().filter(|e| e.status == IMPLEMENTIERT) {
        let (mut src, catalog) = probe_source(e);
        if e.name == "$INCLUDE" {
            src = "PRINT 73#".into();
        }
        let expected = include_str!("../../../tests/support/inventar-quellen.tsv")
            .lines()
            .find_map(|line| {
                let columns: Vec<_> = line.split('\t').collect();
                (columns[0] == e.name && columns[1] == e.art).then_some(columns[3])
            });
        if let Some(id) = expected.and_then(|s| s.strip_prefix("builtin:")) {
            let id: u16 = id.split('|').next().unwrap().parse().unwrap();
            let mut module = tb_vm::compile_source_with_forms("FORM1", &src, &catalog).unwrap();
            let mut changed = false;
            for i in module.procs.iter_mut().flat_map(|p| &mut p.code) {
                if let Instr::CallBuiltin(actual, _) = i {
                    if *actual == id {
                        *actual = (id + 1) % tb_runtime::builtins::ids::COUNT;
                        changed = true;
                    }
                }
            }
            assert!(changed, "{}: Gegenprobe traf kein Ziel", e.name);
            let error = pruefe_kompilat(e, Ok(module), |_| true).unwrap_err();
            assert!(
                error.contains(&e.name) && error.contains(IMPLEMENTIERT),
                "{error}"
            );
        }
        if expected.is_some_and(|s| s.starts_with("builtin:") || s.starts_with("code:"))
            || matches!(e.art.as_str(), "Eigenschaft" | "Methode" | "Ereignis")
        {
            let mut module = tb_vm::compile_source_with_forms("FORM1", &src, &catalog).unwrap();
            for proc in &mut module.procs {
                proc.code.clear();
            }
            let error = pruefe_kompilat(e, Ok(module), |_| true).unwrap_err();
            assert!(
                error.contains(&e.name) && error.contains(IMPLEMENTIERT),
                "{error}"
            );
        }
    }
}

#[test]
fn forms_absenkung_muss_den_richtigen_member_erreichen() {
    use tb_vm::bytecode::Instr;
    for name in ["TextBox.Text", "Form.Show"] {
        let e = inventar()
            .into_iter()
            .find(|e| e.name.eq_ignore_ascii_case(name))
            .unwrap();
        let (src, catalog) = probe_source(&e);
        let mut module = tb_vm::compile_source_with_forms("FORM1", &src, &catalog).unwrap();
        let mut changed = false;
        for i in module.procs.iter_mut().flat_map(|p| &mut p.code) {
            match i {
                Instr::LoadObjectProperty(object, property, _) => {
                    *property =
                        tb_frontend::forms::property(module.objects[*object as usize].class, "TAG")
                            .unwrap()
                            .0;
                    changed = true;
                }
                Instr::ObjectMethod(object, method, _) => {
                    *method = tb_frontend::forms::methods(module.objects[*object as usize].class)
                        .iter()
                        .position(|m| *m == "HIDE")
                        .unwrap() as u16;
                    changed = true;
                }
                _ => {}
            }
        }
        assert!(changed);
        let error = pruefe_kompilat(&e, Ok(module), |_| true).unwrap_err();
        assert!(
            error.contains(&e.name) && error.contains(IMPLEMENTIERT),
            "{error}"
        );
    }
}

#[test]
fn gemeinsam_dispatchte_konverter_brauchen_den_richtigen_typselektor() {
    use tb_vm::bytecode::Instr;
    for name in ["MKI$", "CVD"] {
        let e = inventar().into_iter().find(|e| e.name == name).unwrap();
        let (src, catalog) = probe_source(&e);
        let mut module = tb_vm::compile_source_with_forms("FORM1", &src, &catalog).unwrap();
        let mut changed = false;
        for i in module.procs.iter_mut().flat_map(|p| &mut p.code) {
            if let Instr::PushLng(kind) = i {
                *kind = 2;
                changed = true;
            }
        }
        assert!(changed);
        assert!(pruefe_kompilat(&e, Ok(module), |_| true)
            .unwrap_err()
            .contains(name));
    }
}
