//! Text- und Importformat fuer Visual-Basic-for-DOS-Formulare.

use std::collections::{BTreeMap, HashSet};
use std::fmt;

use tb_frontend::forms::{
    self, FormCatalog, ObjectClass, PropertyDefault, PropertySpec, PropertyType,
};

use crate::forms::{FormsModel, PropertyValue};

#[derive(Debug, Clone, PartialEq)]
pub struct FormNode {
    pub class: ObjectClass,
    pub name: String,
    pub properties: BTreeMap<u16, PropertyValue>,
    pub children: Vec<FormNode>,
}

#[derive(Debug, Clone)]
pub struct FormFile {
    pub version: String,
    pub root: FormNode,
    pub code: String,
    original: Option<Original>,
}

#[derive(Debug, Clone)]
struct Original {
    version: String,
    root: FormNode,
    code: String,
    bytes: String,
}

impl PartialEq for FormFile {
    fn eq(&self, other: &Self) -> bool {
        self.version == other.version && self.root == other.root && self.code == other.code
    }
}

impl FormFile {
    pub fn new(version: impl Into<String>, root: FormNode, code: impl Into<String>) -> Self {
        Self {
            version: version.into(),
            root,
            code: code.into(),
            original: None,
        }
    }

    pub fn catalog(&self) -> FormCatalog {
        fn add(node: &FormNode, parent: Option<&str>, catalog: &mut FormCatalog) {
            if let Some((id, _)) = catalog.find(&node.name) {
                catalog.objects[id as usize].array = true;
            } else {
                let array = forms::property(node.class, "INDEX")
                    .and_then(|(property, _)| node.properties.get(&property))
                    .is_some();
                catalog.add(&node.name, node.class, parent, array);
            }
            for child in &node.children {
                add(child, Some(&node.name), catalog);
            }
        }
        let mut catalog = FormCatalog::default();
        add(&self.root, None, &mut catalog);
        catalog
    }

    pub fn code_line(&self) -> u32 {
        self.original.as_ref().map_or(1, |original| {
            original.bytes[..original.bytes.len() - original.code.len()]
                .bytes()
                .filter(|b| *b == b'\n')
                .count() as u32
                + 1
        })
    }

    pub fn initial_values(
        &self,
        catalog: &FormCatalog,
    ) -> Result<Vec<FormInitial>, tb_runtime::errors::RuntimeError> {
        fn collect(
            node: &FormNode,
            parent: Option<(u16, Option<i32>)>,
            catalog: &FormCatalog,
            form: &str,
            out: &mut Vec<FormInitial>,
            depth: usize,
        ) -> Result<(), tb_runtime::errors::RuntimeError> {
            let depth = depth + usize::from(node.class == ObjectClass::Menu);
            if depth > 6 {
                return Err(tb_runtime::errors::RuntimeError(5));
            }
            let object = catalog
                .objects
                .iter()
                .position(|o| {
                    o.name.eq_ignore_ascii_case(&node.name)
                        && ((o.array && catalog.belongs_to(o, form))
                            || o.parent == parent.map(|p| p.0))
                        && o.class == node.class
                })
                .ok_or(tb_runtime::errors::RuntimeError(420))? as u16;
            let index = forms::property(node.class, "INDEX")
                .and_then(|(id, _)| node.properties.get(&id))
                .and_then(|v| {
                    if let PropertyValue::Integer(n) = v {
                        Some(*n)
                    } else {
                        None
                    }
                })
                .unwrap_or(0);
            if out.iter().any(|v| v.object == object && v.index == index) {
                return Err(tb_runtime::errors::RuntimeError(5));
            }
            let mut properties = node.properties.clone();
            if let Some((id, _)) = forms::property(node.class, "PARENT") {
                properties.insert(id, PropertyValue::Object(parent));
            }
            out.push(FormInitial {
                object,
                index,
                properties,
            });
            for child in &node.children {
                collect(
                    child,
                    Some((object, (index != 0).then_some(index))),
                    catalog,
                    form,
                    out,
                    depth,
                )?;
            }
            Ok(())
        }
        let mut values = Vec::new();
        collect(&self.root, None, catalog, &self.root.name, &mut values, 0)?;
        Ok(values)
    }

    pub fn apply(&self, model: &mut FormsModel) -> Result<(), tb_runtime::errors::RuntimeError> {
        let catalog = FormCatalog {
            objects: model
                .objects
                .iter()
                .map(|o| o.description.clone())
                .collect(),
        };
        for initial in self.initial_values(&catalog)? {
            initial.apply(model)?;
        }
        Ok(())
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct FormInitial {
    pub object: u16,
    pub index: i32,
    pub properties: BTreeMap<u16, PropertyValue>,
}

impl FormInitial {
    pub fn apply(&self, model: &mut FormsModel) -> Result<(), tb_runtime::errors::RuntimeError> {
        if self.index != 0 && !model.is_loaded_at(self.object, Some(self.index)) {
            model.load_design_array(self.object, self.index)?;
        }
        for (property, value) in &self.properties {
            model.set_initial_at(self.object, Some(self.index), *property, value.clone())?;
        }
        Ok(())
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormError {
    pub file: String,
    pub line: usize,
    pub name: String,
    pub message: String,
}

impl fmt::Display for FormError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{}:{}: {}: {}",
            self.file, self.line, self.name, self.message
        )
    }
}

impl std::error::Error for FormError {}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BinaryWarning {
    pub offset: usize,
    pub name: String,
}

#[derive(Debug, Clone, PartialEq)]
pub struct BinaryRead {
    pub form: FormFile,
    pub skipped: Vec<BinaryWarning>,
}

pub(crate) fn error(
    file: &str,
    line: usize,
    name: impl Into<String>,
    message: impl Into<String>,
) -> FormError {
    FormError {
        file: file.into(),
        line,
        name: name.into(),
        message: message.into(),
    }
}

pub fn read_text(file: &str, source: &str) -> Result<FormFile, FormError> {
    let lines: Vec<&str> = source.split_inclusive('\n').collect();
    let first = lines.first().copied().unwrap_or("");
    let first = first.trim_end_matches(['\r', '\n']);
    let Some(version) = first
        .strip_prefix("VERSION ")
        .or_else(|| first.strip_prefix("Version "))
    else {
        return Err(error(file, 1, "VERSION", "VERSION-Zeile fehlt"));
    };
    if version != "1.00" && version != "2.00" {
        return Err(error(
            file,
            1,
            version,
            format!("unbekannte Version {version}"),
        ));
    }
    let mut at = 1;
    let root = parse_node(file, &lines, &mut at)?;
    if root.class != ObjectClass::Form {
        return Err(error(
            file,
            2,
            root.class.display_name(),
            "Wurzel ist kein Form-Block",
        ));
    }
    if at < lines.len() && lines[at].trim().is_empty() {
        at += 1;
    }
    let code = lines[at..].concat();
    let mut result = FormFile::new(version, root, code);
    result.original = Some(Original {
        version: result.version.clone(),
        root: result.root.clone(),
        code: result.code.clone(),
        bytes: source.into(),
    });
    Ok(result)
}

fn parse_node(file: &str, lines: &[&str], at: &mut usize) -> Result<FormNode, FormError> {
    let begin_line = *at + 1;
    let raw = lines
        .get(*at)
        .copied()
        .ok_or_else(|| error(file, begin_line, "Begin", "Block erwartet"))?;
    let text = raw.trim();
    let mut words = text.split_whitespace();
    if !words
        .next()
        .is_some_and(|word| word.eq_ignore_ascii_case("BEGIN"))
    {
        return Err(error(file, begin_line, text, "Begin-Block erwartet"));
    }
    let class_name = words.next().unwrap_or("");
    let Some(class) = ObjectClass::parse(class_name) else {
        return Err(error(file, begin_line, class_name, "unbekannte Klasse"));
    };
    let name = words.next().unwrap_or("");
    if name.is_empty() || words.next().is_some() {
        return Err(error(
            file,
            begin_line,
            class_name,
            "ungueltiger Begin-Block",
        ));
    }
    *at += 1;
    let mut node = FormNode {
        class,
        name: name.into(),
        properties: BTreeMap::new(),
        children: Vec::new(),
    };
    while let Some(raw) = lines.get(*at).copied() {
        let line_no = *at + 1;
        let text = raw.trim();
        if text.eq_ignore_ascii_case("END") {
            *at += 1;
            return Ok(node);
        }
        if text
            .split_whitespace()
            .next()
            .is_some_and(|word| word.eq_ignore_ascii_case("BEGIN"))
        {
            node.children.push(parse_node(file, lines, at)?);
            continue;
        }
        let Some((property_name, raw_value)) = text.split_once('=') else {
            return Err(error(
                file,
                line_no,
                &node.name,
                "Eigenschaft oder Begin/End erwartet",
            ));
        };
        let property_name = property_name.trim();
        let Some((id, spec)) = forms::property(class, property_name) else {
            return Err(error(
                file,
                line_no,
                property_name,
                format!("unbekannte Eigenschaft fuer {}", class.display_name()),
            ));
        };
        let value = parse_value(file, line_no, &node.name, spec, raw_value.trim())?;
        node.properties.insert(id, value);
        *at += 1;
    }
    Err(error(
        file,
        begin_line,
        &node.name,
        format!("offener Begin-Block {}", node.name),
    ))
}

fn parse_value(
    file: &str,
    line: usize,
    object: &str,
    spec: PropertySpec,
    raw: &str,
) -> Result<PropertyValue, FormError> {
    let bad = |message: String| error(file, line, format!("{}.{}", object, spec.name), message);
    match spec.ty {
        PropertyType::String => {
            if raw.len() < 2 || !raw.starts_with('"') || !raw.ends_with('"') {
                return Err(bad("Zeichenkette in Anfuehrungszeichen erwartet".into()));
            }
            let mut value = String::new();
            let mut chars = raw[1..raw.len() - 1].chars();
            while let Some(ch) = chars.next() {
                if ch == '"' && chars.next() != Some('"') {
                    return Err(bad("Anfuehrungszeichen muss verdoppelt werden".into()));
                }
                value.push(ch);
            }
            Ok(PropertyValue::String(value))
        }
        PropertyType::Integer => {
            let number = raw
                .strip_prefix("Char(")
                .or_else(|| raw.strip_prefix("QBColor("))
                .and_then(|value| value.strip_suffix(')'))
                .unwrap_or(raw)
                .parse::<i32>()
                .map_err(|_| bad(format!("ungueltiger Wert {raw}")))?;
            validate_range(file, line, object, spec, number)?;
            Ok(PropertyValue::Integer(number))
        }
        PropertyType::Single => raw
            .parse::<f32>()
            .map(PropertyValue::Single)
            .map_err(|_| bad(format!("ungueltiger Wert {raw}"))),
        PropertyType::Boolean => match raw.to_ascii_uppercase().as_str() {
            "TRUE" | "-1" | "1" => Ok(PropertyValue::Boolean(true)),
            "FALSE" | "0" => Ok(PropertyValue::Boolean(false)),
            _ => Err(bad(format!("ungueltiger boolescher Wert {raw}"))),
        },
        PropertyType::Object | PropertyType::IntegerArray => Err(bad(format!(
            "Werttyp fuer {} ist im Dateiformat nicht schreibbar",
            spec.name
        ))),
    }
}

fn validate_range(
    file: &str,
    location: usize,
    object: &str,
    spec: PropertySpec,
    number: i32,
) -> Result<(), FormError> {
    if spec.min.is_some_and(|min| number < min) || spec.max.is_some_and(|max| number > max) {
        return Err(error(
            file,
            location,
            format!("{}.{}", object, spec.name),
            format!("Wert {number} ausserhalb des Wertebereichs"),
        ));
    }
    Ok(())
}

pub fn write_text(form: &FormFile) -> String {
    if let Some(original) = &form.original {
        if original.version == form.version
            && original.root == form.root
            && original.code == form.code
        {
            return original.bytes.clone();
        }
    }
    let mut out = format!("VERSION {}\n", form.version);
    write_node(&form.root, 0, &mut out);
    if !form.code.is_empty() {
        out.push('\n');
        out.push_str(&form.code);
    }
    out
}

pub fn read_binary(file: &str, bytes: &[u8]) -> Result<BinaryRead, FormError> {
    if bytes.get(..4) != Some(&[0xfc, 0x08, 0x01, 0x00]) {
        return Err(error(file, 0, "Byte 0x0000", "Kennung FC 08 01 00 fehlt"));
    }
    let object_len = read_u16(bytes, 0x1e)
        .ok_or_else(|| error(file, 0x1e, "Objektbereich", "abgeschnittener Dateikopf"))?
        as usize;
    let object_end = 0x20usize
        .checked_add(object_len)
        .filter(|end| *end <= bytes.len())
        .ok_or_else(|| error(file, 0x1e, "Objektbereich", "ungueltige Laenge"))?;
    let table_at = read_u16(bytes, 0x1c)
        .map(|offset| offset as usize + 0x16)
        .filter(|start| *start >= object_end && *start < bytes.len())
        .ok_or_else(|| error(file, 0x1c, "Objektkatalog", "ungueltiger Verweis"))?;
    let symbols = read_symbol_table(bytes, table_at).map_err(|at| {
        error(
            file,
            at,
            format!("Byte 0x{at:04x}"),
            "unerkannte Katalogstruktur",
        )
    })?;
    if symbols[0].class != ObjectClass::Form {
        return Err(error(
            file,
            table_at,
            symbols[0].class.display_name(),
            "Wurzel ist kein Formular",
        ));
    }
    let strings = read_string_table(bytes, object_end, table_at)
        .ok_or_else(|| error(file, object_end, "Zeichenketten", "unerkannte Struktur"))?;
    let object_bytes = &bytes[0x20..object_end];
    let mut root = None;
    let mut skipped = None;
    let mut first_layout_error = None;
    read_object_records(&symbols, object_bytes, &mut |records| {
        let mut decoded = records
            .iter()
            .map(|record| decode_object(&symbols[record.symbol], record, object_bytes))
            .collect::<Vec<_>>();
        if let Err(failure) = validate_decoded(file, records, &decoded) {
            first_layout_error.get_or_insert(failure);
            return false;
        }
        let warnings = match assign_strings(
            file,
            &symbols,
            records,
            object_bytes,
            &strings,
            &mut decoded,
        ) {
            Ok(warnings) => warnings,
            Err(failure) => {
                first_layout_error.get_or_insert(failure);
                return false;
            }
        };
        root = build_tree(decoded);
        if root.is_none() {
            first_layout_error.get_or_insert_with(|| {
                error(
                    file,
                    0x20,
                    "Containerverweise",
                    "unerkannte Containerstruktur",
                )
            });
        }
        if root.is_some() {
            skipped = Some(warnings);
        }
        root.is_some()
    })
    .ok_or_else(|| {
        first_layout_error.unwrap_or_else(|| {
            error(
                file,
                0x20,
                format!("{}.Datensatzstruktur", symbols[0].name),
                "unerkannte Datensatzstruktur",
            )
        })
    })?;
    let catalog_end = symbols
        .last()
        .map(|symbol| symbol.offset + 4 + symbol.name.len())
        .unwrap_or(table_at);
    let code = crate::frm_pcode::decode(file, bytes, catalog_end)?;
    Ok(BinaryRead {
        form: FormFile::new("1.00", root.unwrap(), code),
        skipped: skipped.unwrap(),
    })
}

fn set_property(node: &mut FormNode, name: &str, value: PropertyValue) {
    if let Some((id, _)) = forms::property(node.class, name) {
        node.properties.insert(id, value);
    }
}

fn validate_decoded(
    file: &str,
    records: &[BinaryRecord],
    decoded: &[DecodedObject],
) -> Result<(), FormError> {
    for (record, object) in records.iter().zip(decoded) {
        let specs = forms::properties(object.node.class);
        for (id, value) in &object.node.properties {
            let Some(spec) = specs.get(*id as usize).copied() else {
                continue;
            };
            if let PropertyValue::Integer(number) = value {
                validate_range(file, 0x20 + record.start, &object.node.name, spec, *number)?;
            }
        }
    }
    Ok(())
}

pub(crate) fn read_u16(bytes: &[u8], at: usize) -> Option<u16> {
    Some(u16::from_le_bytes([*bytes.get(at)?, *bytes.get(at + 1)?]))
}

#[derive(Debug)]
struct BinarySymbol {
    offset: usize,
    class: ObjectClass,
    name: String,
    is_array: bool,
    unsupported: bool,
}

fn read_symbol_table(bytes: &[u8], mut at: usize) -> Result<Vec<BinarySymbol>, usize> {
    let mut symbols = Vec::new();
    loop {
        if at + 4 > bytes.len() {
            return Err(at);
        }
        let reference = read_u16(bytes, at).ok_or(at)?;
        let class_id = bytes[at + 2] & 0x7f;
        let length = bytes[at + 3] as usize;
        let mut class = ObjectClass::from_id(class_id).ok_or(at + 2)?;
        if length == 0 || length > 40 || at + 4 + length > bytes.len() {
            return Err(at + 3);
        }
        let name = &bytes[at + 4..at + 4 + length];
        if !name[0].is_ascii_alphabetic()
            || !name
                .iter()
                .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
        {
            return Err(at + 4);
        }
        if symbols.is_empty() && class != ObjectClass::Form {
            return Err(at + 2);
        }
        let name = String::from_utf8_lossy(name).into_owned();
        if class == ObjectClass::Screen
            && matches!(name.to_ascii_uppercase().as_str(), "VSPIN" | "HSPIN")
        {
            class = ObjectClass::Spin;
        }
        symbols.push(BinarySymbol {
            offset: at,
            class,
            name,
            is_array: bytes[at + 2] & 0x80 != 0,
            unsupported: class_id == ObjectClass::Screen.id() && class != ObjectClass::Spin,
        });
        at += 4 + length;
        if reference == 0 {
            return Ok(symbols);
        }
    }
}

#[derive(Debug)]
struct BinaryRecord {
    symbol: usize,
    start: usize,
    end: usize,
    class_flags: u8,
    common_flags: u8,
}

#[derive(Debug)]
struct BinaryString {
    pointer: u16,
    value: String,
}

fn record_len(class: ObjectClass) -> usize {
    match class {
        ObjectClass::Form => 38,
        ObjectClass::ComboBox => 39,
        ObjectClass::DirListBox
        | ObjectClass::DriveListBox
        | ObjectClass::HScrollBar
        | ObjectClass::ListBox
        | ObjectClass::VScrollBar => 32,
        ObjectClass::FileListBox => 36,
        ObjectClass::PictureBox => 31,
        ObjectClass::TextBox => 34,
        ObjectClass::Menu | ObjectClass::Frame => 26,
        ObjectClass::Screen | ObjectClass::Spin => 80,
        _ => 28,
    }
}

fn record_lengths(class: ObjectClass) -> &'static [usize] {
    match class {
        ObjectClass::Form => &[31, 33, 38],
        ObjectClass::CommandButton => &[23, 28],
        ObjectClass::CheckBox => &[28],
        ObjectClass::ComboBox => &[33, 39],
        ObjectClass::DirListBox | ObjectClass::DriveListBox => &[32],
        ObjectClass::FileListBox => &[32, 36],
        ObjectClass::ListBox => &[17, 28, 32],
        ObjectClass::TextBox => &[28, 34, 39],
        ObjectClass::Label => &[21, 26, 27, 28, 34, 36],
        ObjectClass::Frame => &[26, 31],
        ObjectClass::Menu => &[26, 28],
        ObjectClass::OptionButton => &[21, 28],
        ObjectClass::PictureBox => &[31],
        ObjectClass::HScrollBar | ObjectClass::VScrollBar => &[32],
        ObjectClass::Screen | ObjectClass::Spin => &[73, 80],
        ObjectClass::Timer => &[21, 28],
    }
}

fn read_object_records(
    symbols: &[BinarySymbol],
    bytes: &[u8],
    validate: &mut impl FnMut(&[BinaryRecord]) -> bool,
) -> Option<Vec<BinaryRecord>> {
    let mut pointer_start = bytes.len();
    let mut first = usize::MAX;
    while pointer_start >= 2 {
        let value = read_u16(bytes, pointer_start - 2)? as usize;
        if value == 0 || value >= first {
            break;
        }
        first = value;
        pointer_start -= 2;
    }
    let all_pointers = bytes[pointer_start..]
        .chunks_exact(2)
        .map(|value| u16::from_le_bytes([value[0], value[1]]) as usize)
        .collect::<Vec<_>>();
    if all_pointers
        .iter()
        .any(|value| *value == 0 || *value >= pointer_start + 3)
    {
        return None;
    }
    for dropped in 0..=all_pointers.len() {
        let start = pointer_start + dropped * 2;
        let data_end = start;
        let pointers = &all_pointers[dropped..];
        let mut records = Vec::new();
        let mut seen = vec![0u8; symbols.len()];
        let mut failed = HashSet::new();
        let mut validate_with_pointers = |records: &[BinaryRecord]| {
            pointers
                .iter()
                .all(|pointer| records.iter().any(|record| record.start + 3 == *pointer))
                && validate(records)
        };
        if walk_object_records(
            symbols,
            bytes,
            data_end,
            0,
            0,
            0,
            0,
            &mut records,
            &mut seen,
            &mut failed,
            &mut validate_with_pointers,
        ) {
            return Some(records);
        }
    }
    None
}

#[allow(clippy::too_many_arguments)]
fn walk_object_records(
    symbols: &[BinarySymbol],
    bytes: &[u8],
    data_end: usize,
    cursor: usize,
    symbol: usize,
    class_flags: u8,
    common_flags: u8,
    records: &mut Vec<BinaryRecord>,
    seen: &mut [u8],
    failed: &mut HashSet<(usize, usize, Vec<u8>)>,
    validate: &mut impl FnMut(&[BinaryRecord]) -> bool,
) -> bool {
    let state = (cursor, symbol, seen.to_vec());
    if failed.contains(&state)
        || cursor + 6 > data_end
        || (seen[symbol] != 0 && !symbols[symbol].is_array)
    {
        return false;
    }
    let index = read_u16(bytes, cursor + 4);
    if symbols[symbol].is_array
        && records
            .iter()
            .any(|record| record.symbol == symbol && read_u16(bytes, record.start + 4) == index)
    {
        return false;
    }
    seen[symbol] = seen[symbol].saturating_add(1);
    let mut distances = record_lengths(symbols[symbol].class).to_vec();
    distances.sort_by_key(|distance| distance.abs_diff(record_len(symbols[symbol].class)));
    for distance in distances {
        let next_start = cursor + distance;
        let Some(header) = next_start.checked_sub(7) else {
            continue;
        };
        if next_start >= data_end || header < cursor + 6 || header + 7 > data_end {
            continue;
        }
        let next_symbol = bytes[header] as usize;
        if next_symbol >= symbols.len()
            || bytes[header + 2] != 0
            || bytes[header + 1] & 0x7f
                != if symbols[next_symbol].class == ObjectClass::Spin {
                    ObjectClass::Screen.id()
                } else {
                    symbols[next_symbol].class.id()
                }
        {
            continue;
        }
        records.push(BinaryRecord {
            symbol,
            start: cursor,
            end: header,
            class_flags,
            common_flags,
        });
        if walk_object_records(
            symbols,
            bytes,
            data_end,
            next_start,
            next_symbol,
            bytes[header + 3],
            bytes[header + 4],
            records,
            seen,
            failed,
            validate,
        ) {
            return true;
        }
        records.pop();
    }
    let terminal_len = data_end - cursor;
    if !record_lengths(symbols[symbol].class).contains(&terminal_len)
        && !record_lengths(symbols[symbol].class).contains(&(terminal_len + 7))
    {
        seen[symbol] -= 1;
        failed.insert(state);
        return false;
    }
    records.push(BinaryRecord {
        symbol,
        start: cursor,
        end: data_end,
        class_flags,
        common_flags,
    });
    if seen.iter().all(|count| *count != 0) && validate(records) {
        return true;
    }
    records.pop();
    seen[symbol] -= 1;
    failed.insert(state);
    false
}

#[derive(Debug)]
struct DecodedObject {
    node: FormNode,
    start: usize,
    parent: u16,
    unsupported: bool,
}

fn record_byte(bytes: &[u8], start: usize, end: usize, offset: usize) -> Option<u8> {
    (start + offset < end).then(|| bytes[start + offset])
}

fn record_u16(bytes: &[u8], start: usize, end: usize, offset: usize) -> Option<u16> {
    (start + offset + 2 <= end)
        .then(|| read_u16(bytes, start + offset))
        .flatten()
}

fn decode_object(symbol: &BinarySymbol, record: &BinaryRecord, bytes: &[u8]) -> DecodedObject {
    let start = record.start;
    let end = record.end;
    let mut node = FormNode {
        class: symbol.class,
        name: symbol.name.clone(),
        properties: BTreeMap::new(),
        children: Vec::new(),
    };
    if symbol.class == ObjectClass::Form {
        let class_flags = record_byte(bytes, start, end, 3).unwrap_or(0);
        let common_flags = record_byte(bytes, start, end, 4).unwrap_or(0);
        set_property(
            &mut node,
            "CONTROLBOX",
            PropertyValue::Boolean(class_flags & 0x20 != 0),
        );
        set_property(
            &mut node,
            "AUTOREDRAW",
            PropertyValue::Boolean(class_flags & 0x08 != 0),
        );
        set_property(
            &mut node,
            "FORMTYPE",
            PropertyValue::Integer(i32::from(class_flags & 0x02 != 0)),
        );
        set_property(
            &mut node,
            "ENABLED",
            PropertyValue::Boolean(common_flags & 0x01 != 0),
        );
        set_property(
            &mut node,
            "MAXBUTTON",
            PropertyValue::Boolean(common_flags & 0x02 != 0),
        );
        set_property(
            &mut node,
            "MINBUTTON",
            PropertyValue::Boolean(common_flags & 0x04 != 0),
        );
        set_property(
            &mut node,
            "VISIBLE",
            PropertyValue::Boolean(common_flags & 0x80 != 0),
        );
        for (name, offset) in [
            ("TOP", 15),
            ("LEFT", 16),
            ("HEIGHT", 17),
            ("WIDTH", 18),
            ("CURRENTX", 19),
            ("CURRENTY", 20),
        ] {
            if let Some(value) = record_byte(bytes, start, end, offset) {
                set_property(&mut node, name, PropertyValue::Integer(value as i32));
            }
        }
        if let Some(value) = record_byte(bytes, start, end, 21) {
            set_property(&mut node, "BACKCOLOR", PropertyValue::Integer(value as i32));
        }
        if let Some(value) = record_byte(bytes, start, end, 22) {
            set_property(&mut node, "FORECOLOR", PropertyValue::Integer(value as i32));
        }
        if let Some(value) = record_byte(bytes, start, end, 26) {
            set_property(
                &mut node,
                "BORDERSTYLE",
                PropertyValue::Integer(value as i32),
            );
        }
    } else {
        set_property(
            &mut node,
            "ENABLED",
            PropertyValue::Boolean(record.common_flags & 0x01 != 0),
        );
        set_property(
            &mut node,
            "VISIBLE",
            PropertyValue::Boolean(record.common_flags & 0x80 != 0),
        );
        set_property(
            &mut node,
            "TABSTOP",
            PropertyValue::Boolean(record.common_flags & 0x40 != 0),
        );
        if symbol.class == ObjectClass::CommandButton {
            set_property(
                &mut node,
                "CANCEL",
                PropertyValue::Boolean(record.common_flags & 0x10 != 0),
            );
            set_property(
                &mut node,
                "DEFAULT",
                PropertyValue::Boolean(record.class_flags & 0x04 != 0),
            );
        }
        if symbol.class == ObjectClass::Menu {
            set_property(
                &mut node,
                "SEPARATOR",
                PropertyValue::Boolean(record.class_flags & 0x01 != 0),
            );
            set_property(
                &mut node,
                "CHECKED",
                PropertyValue::Boolean(record.class_flags & 0x40 != 0),
            );
        }
        for (name, offset) in [("TOP", 8), ("LEFT", 9), ("HEIGHT", 10), ("WIDTH", 11)] {
            if let Some(value) = record_byte(bytes, start, end, offset) {
                set_property(&mut node, name, PropertyValue::Integer(value as i32));
            }
        }
        if let Some(value) = record_u16(bytes, start, end, 4).filter(|_| symbol.is_array) {
            set_property(
                &mut node,
                "INDEX",
                PropertyValue::Integer(value as i16 as i32),
            );
        }
        if let Some(value) = record_byte(bytes, start, end, 13) {
            set_property(&mut node, "TABINDEX", PropertyValue::Integer(value as i32));
        }
        if !matches!(
            symbol.class,
            ObjectClass::HScrollBar | ObjectClass::VScrollBar
        ) {
            for (name, offset) in [("BACKCOLOR", 14), ("FORECOLOR", 15)] {
                if let Some(value) = record_byte(bytes, start, end, offset) {
                    set_property(&mut node, name, PropertyValue::Integer(value as i32));
                }
            }
        }
        match symbol.class {
            ObjectClass::ListBox => {
                set_property(
                    &mut node,
                    "SORTED",
                    PropertyValue::Boolean(record.class_flags & 0x80 != 0),
                );
            }
            ObjectClass::TextBox => {
                set_property(
                    &mut node,
                    "MULTILINE",
                    PropertyValue::Boolean(record.common_flags & 0x08 != 0),
                );
                if let Some(value) = record_byte(bytes, start, end, 19) {
                    set_property(
                        &mut node,
                        "BORDERSTYLE",
                        PropertyValue::Integer(value as i32),
                    );
                }
                if let Some(value) = record_byte(bytes, start, end, 20) {
                    set_property(
                        &mut node,
                        "SCROLLBARS",
                        PropertyValue::Integer(value as i32),
                    );
                }
            }
            ObjectClass::ComboBox => {
                if let Some(value) = record_byte(bytes, start, end, 31) {
                    set_property(&mut node, "STYLE", PropertyValue::Integer(value as i32));
                }
            }
            ObjectClass::Label => {
                set_property(
                    &mut node,
                    "AUTOSIZE",
                    PropertyValue::Boolean(record.class_flags & 0x20 != 0),
                );
                for (name, offset) in [("BORDERSTYLE", 19), ("ALIGNMENT", 20)] {
                    if let Some(value) = record_byte(bytes, start, end, offset) {
                        set_property(&mut node, name, PropertyValue::Integer(value as i32));
                    }
                }
            }
            ObjectClass::OptionButton | ObjectClass::CheckBox => {
                if let Some(value) = record_u16(bytes, start, end, 19) {
                    set_property(
                        &mut node,
                        "VALUE",
                        PropertyValue::Integer(value as i16 as i32),
                    );
                }
            }
            ObjectClass::PictureBox => {
                set_property(
                    &mut node,
                    "AUTOREDRAW",
                    PropertyValue::Boolean(record.class_flags & 0x08 != 0),
                );
                if let Some(value) = record_byte(bytes, start, end, 16) {
                    set_property(
                        &mut node,
                        "BORDERSTYLE",
                        PropertyValue::Integer(value as i32),
                    );
                }
                for (name, offset) in [("CURRENTX", 17), ("CURRENTY", 19)] {
                    if let Some(value) = record_u16(bytes, start, end, offset) {
                        set_property(&mut node, name, PropertyValue::Integer(value as i16 as i32));
                    }
                }
            }
            ObjectClass::HScrollBar | ObjectClass::VScrollBar => {
                if let Some(value) = record_byte(bytes, start, end, 14) {
                    set_property(&mut node, "ATTACHED", PropertyValue::Boolean(value == 0));
                }
                for (name, offset) in [
                    ("SMALLCHANGE", 17),
                    ("LARGECHANGE", 19),
                    ("MAX", 21),
                    ("MIN", 23),
                ] {
                    if let Some(value) = record_u16(bytes, start, end, offset) {
                        set_property(&mut node, name, PropertyValue::Integer(value as i16 as i32));
                    }
                }
                if let Some(value) = record_u16(bytes, start, end, 23) {
                    set_property(
                        &mut node,
                        "VALUE",
                        PropertyValue::Integer(value as i16 as i32),
                    );
                }
            }
            ObjectClass::Timer => {
                if let Some(value) = record_u16(bytes, start, end, 17) {
                    set_property(&mut node, "INTERVAL", PropertyValue::Integer(value as i32));
                }
            }
            _ => {}
        }
    }
    DecodedObject {
        node,
        start,
        parent: record_u16(bytes, start, end, 0).unwrap(),
        unsupported: symbol.unsupported,
    }
}

fn build_tree(mut objects: Vec<DecodedObject>) -> Option<FormNode> {
    if objects.first()?.node.class != ObjectClass::Form {
        return None;
    }
    for child in (1..objects.len()).rev() {
        if objects[child].unsupported {
            objects.remove(child);
            continue;
        }
        let parent = if objects[child].parent == 10 {
            0
        } else {
            objects[..child]
                .iter()
                .position(|object| object.start + 3 == objects[child].parent as usize)?
        };
        let node = objects.remove(child).node;
        objects[parent].node.children.insert(0, node);
    }
    Some(objects.remove(0).node)
}

fn assign_strings(
    file: &str,
    symbols: &[BinarySymbol],
    records: &[BinaryRecord],
    bytes: &[u8],
    strings: &[BinaryString],
    decoded: &mut [DecodedObject],
) -> Result<Vec<BinaryWarning>, FormError> {
    let mut warnings = records
        .iter()
        .filter(|record| symbols[record.symbol].unsupported)
        .map(|record| BinaryWarning {
            offset: 0x20 + record.start,
            name: format!("{}.CustomControl", symbols[record.symbol].name),
        })
        .collect::<Vec<_>>();
    warnings.extend(records.iter().filter_map(|record| {
        let symbol = &symbols[record.symbol];
        (symbol.class == ObjectClass::Menu
            && record.end - record.start >= 21
            && record_u16(bytes, record.start, record.end, 19).is_some_and(|value| value != 0))
        .then(|| BinaryWarning {
            offset: 0x20 + record.start + 19,
            name: format!("{}.Shortcut", symbol.name),
        })
    }));
    let by_pointer = strings
        .iter()
        .enumerate()
        .map(|(index, string)| (string.pointer, (index, &string.value)))
        .collect::<BTreeMap<_, _>>();
    let mut used = HashSet::new();
    for (offset, name) in [(9, "TAG"), (24, "CAPTION")] {
        if let Some(pointer) = record_u16(bytes, records[0].start, records[0].end, offset)
            .filter(|pointer| *pointer != 0)
        {
            assign_binary_string(
                file,
                symbols,
                records,
                decoded,
                &by_pointer,
                &mut used,
                0,
                name,
                pointer,
            )?;
        }
    }
    for (index, record) in records.iter().enumerate().skip(1) {
        let class = symbols[record.symbol].class;
        if let Some(pointer) =
            record_u16(bytes, record.start, record.end, 2).filter(|pointer| *pointer != 0)
        {
            assign_binary_string(
                file,
                symbols,
                records,
                decoded,
                &by_pointer,
                &mut used,
                index,
                "TAG",
                pointer,
            )?;
        }
        let (offset, name) = match class {
            ObjectClass::TextBox => (21, "TEXT"),
            ObjectClass::ComboBox => (29, "TEXT"),
            ObjectClass::FileListBox => (27, "PATTERN"),
            ObjectClass::Label if record.end - record.start >= 34 => (21, "CAPTION"),
            ObjectClass::CommandButton
            | ObjectClass::CheckBox
            | ObjectClass::OptionButton
            | ObjectClass::Frame
            | ObjectClass::Label
            | ObjectClass::Menu => (17, "CAPTION"),
            _ => continue,
        };
        if let Some(pointer) =
            record_u16(bytes, record.start, record.end, offset).filter(|pointer| *pointer != 0)
        {
            assign_binary_string(
                file,
                symbols,
                records,
                decoded,
                &by_pointer,
                &mut used,
                index,
                name,
                pointer,
            )?;
        }
    }
    for record in records.iter().filter(|record| {
        symbols[record.symbol].unsupported || symbols[record.symbol].class == ObjectClass::Spin
    }) {
        for offset in [17, 28, 37, 39, 53, 55, 64, 69] {
            let Some(pointer) =
                record_u16(bytes, record.start, record.end, offset).filter(|pointer| *pointer != 0)
            else {
                continue;
            };
            let (string_index, _) = by_pointer.get(&pointer).ok_or_else(|| {
                error(
                    file,
                    0x20 + record.start + offset,
                    format!("{}.CustomControl", symbols[record.symbol].name),
                    format!("ungueltiger Zeichenkettenverweis 0x{pointer:04x}"),
                )
            })?;
            used.insert(*string_index);
        }
    }
    let remaining = strings.len() - used.len();
    if remaining != 0 {
        return Err(error(
            file,
            0x20,
            "Zeichenketten",
            format!("{remaining} nicht zugeordnete Eintraege"),
        ));
    }
    Ok(warnings)
}

#[allow(clippy::too_many_arguments)]
fn assign_binary_string(
    file: &str,
    symbols: &[BinarySymbol],
    records: &[BinaryRecord],
    decoded: &mut [DecodedObject],
    by_pointer: &BTreeMap<u16, (usize, &String)>,
    used: &mut HashSet<usize>,
    index: usize,
    name: &'static str,
    pointer: u16,
) -> Result<(), FormError> {
    let (string_index, value) = by_pointer.get(&pointer).ok_or_else(|| {
        error(
            file,
            0x20 + records[index].start,
            format!("{}.{}", symbols[records[index].symbol].name, name),
            format!("ungueltiger Zeichenkettenverweis 0x{pointer:04x}"),
        )
    })?;
    used.insert(*string_index);
    set_property(
        &mut decoded[index].node,
        name,
        PropertyValue::String((*value).clone()),
    );
    Ok(())
}

fn read_string_table(bytes: &[u8], mut at: usize, end: usize) -> Option<Vec<BinaryString>> {
    let mut strings = Vec::new();
    while at < end {
        let pointer = u16::try_from(at.checked_sub(0x16)?).ok()?;
        let length = read_u16(bytes, at)? as usize;
        at += 2;
        let value = bytes.get(at..at.checked_add(length)?)?;
        if at + length > end {
            return None;
        }
        strings.push(BinaryString {
            pointer,
            value: decode_cp437(value),
        });
        at += length;
    }
    (at == end).then_some(strings)
}

pub(crate) fn decode_cp437(bytes: &[u8]) -> String {
    const LOW: [char; 31] = [
        '☺', '☻', '♥', '♦', '♣', '♠', '•', '◘', '○', '◙', '♂', '♀', '♪', '♫', '☼', '►', '◄', '↕',
        '‼', '¶', '§', '▬', '↨', '↑', '↓', '→', '←', '∟', '↔', '▲', '▼',
    ];
    const HIGH: [char; 128] = [
        'Ç', 'ü', 'é', 'â', 'ä', 'à', 'å', 'ç', 'ê', 'ë', 'è', 'ï', 'î', 'ì', 'Ä', 'Å', 'É', 'æ',
        'Æ', 'ô', 'ö', 'ò', 'û', 'ù', 'ÿ', 'Ö', 'Ü', '¢', '£', '¥', '₧', 'ƒ', 'á', 'í', 'ó', 'ú',
        'ñ', 'Ñ', 'ª', 'º', '¿', '⌐', '¬', '½', '¼', '¡', '«', '»', '░', '▒', '▓', '│', '┤', '╡',
        '╢', '╖', '╕', '╣', '║', '╗', '╝', '╜', '╛', '┐', '└', '┴', '┬', '├', '─', '┼', '╞', '╟',
        '╚', '╔', '╩', '╦', '╠', '═', '╬', '╧', '╨', '╤', '╥', '╙', '╘', '╒', '╓', '╫', '╪', '┘',
        '┌', '█', '▄', '▌', '▐', '▀', 'α', 'ß', 'Γ', 'π', 'Σ', 'σ', 'µ', 'τ', 'Φ', 'Θ', 'Ω', 'δ',
        '∞', 'φ', 'ε', '∩', '≡', '±', '≥', '≤', '⌠', '⌡', '÷', '≈', '°', '∙', '·', '√', 'ⁿ', '²',
        '■', '\u{a0}',
    ];
    bytes
        .iter()
        .map(|byte| match *byte {
            0 => '\0',
            b'\t' => '\t',
            1..=31 => LOW[*byte as usize - 1],
            32..=126 => *byte as char,
            127 => '⌂',
            _ => HIGH[*byte as usize - 128],
        })
        .collect()
}

fn write_node(node: &FormNode, depth: usize, out: &mut String) {
    let indent = "   ".repeat(depth);
    out.push_str(&format!(
        "{indent}Begin {} {}\n",
        node.class.display_name(),
        node.name
    ));
    let mut properties: Vec<_> = forms::properties(node.class)
        .into_iter()
        .enumerate()
        .collect();
    properties.sort_by_key(|(_, spec)| spec.name);
    for (id, spec) in properties {
        let Some(value) = node.properties.get(&(id as u16)) else {
            continue;
        };
        if spec.name != "INDEX" && *value == default_value(spec) {
            continue;
        }
        out.push_str(&"   ".repeat(depth + 1));
        out.push_str(&format!(
            "{:<16} =  {}\n",
            property_name(spec.name),
            format_value(value)
        ));
    }
    for child in &node.children {
        write_node(child, depth + 1, out);
    }
    out.push_str(&format!("{indent}End\n"));
}

fn default_value(spec: PropertySpec) -> PropertyValue {
    match spec.default {
        PropertyDefault::Integer(value) if spec.ty == PropertyType::IntegerArray => {
            PropertyValue::IntegerArray(vec![value; 18])
        }
        PropertyDefault::Integer(value) => PropertyValue::Integer(value),
        PropertyDefault::Single(value) => PropertyValue::Single(value),
        PropertyDefault::String(value) => PropertyValue::String(value.into()),
        PropertyDefault::Boolean(value) => PropertyValue::Boolean(value),
        PropertyDefault::Empty if spec.ty == PropertyType::Object => PropertyValue::Object(None),
        PropertyDefault::Empty if spec.ty == PropertyType::String => {
            PropertyValue::String(String::new())
        }
        PropertyDefault::Empty if spec.ty == PropertyType::Boolean => PropertyValue::Boolean(false),
        PropertyDefault::Empty => PropertyValue::Integer(0),
    }
}

fn format_value(value: &PropertyValue) -> String {
    match value {
        PropertyValue::Integer(value) => value.to_string(),
        PropertyValue::Single(value) => value.to_string(),
        PropertyValue::String(value) => format!("\"{}\"", value.replace('"', "\"\"")),
        PropertyValue::Boolean(value) => if *value { "-1" } else { "0" }.into(),
        PropertyValue::Object(_) | PropertyValue::IntegerArray(_) => "0".into(),
    }
}

fn property_name(name: &'static str) -> &'static str {
    match name {
        "AUTOREDRAW" => "AutoRedraw",
        "BACKCOLOR" => "BackColor",
        "BORDERSTYLE" => "BorderStyle",
        "CONTROLBOX" => "ControlBox",
        "CURRENTX" => "CurrentX",
        "CURRENTY" => "CurrentY",
        "FORMNAME" => "FormName",
        "FORMTYPE" => "FormType",
        "FORECOLOR" => "ForeColor",
        "LARGECHANGE" => "LargeChange",
        "LISTCOUNT" => "ListCount",
        "LISTINDEX" => "ListIndex",
        "MAXBUTTON" => "MaxButton",
        "MINBUTTON" => "MinButton",
        "MOUSEPOINTER" => "MousePointer",
        "MULTILINE" => "MultiLine",
        "READONLY" => "ReadOnly",
        "SCALEHEIGHT" => "ScaleHeight",
        "SCALEWIDTH" => "ScaleWidth",
        "SCROLLBARS" => "ScrollBars",
        "SELLENGTH" => "SelLength",
        "SELSTART" => "SelStart",
        "SELTEXT" => "SelText",
        "SMALLCHANGE" => "SmallChange",
        "TABINDEX" => "TabIndex",
        "TABSTOP" => "TabStop",
        "WINDOWSTATE" => "WindowState",
        _ => match name {
            "ALIGNMENT" => "Alignment",
            "ARCHIVE" => "Archive",
            "ATTACHED" => "Attached",
            "AUTOSIZE" => "AutoSize",
            "CANCEL" => "Cancel",
            "CAPTION" => "Caption",
            "CHECKED" => "Checked",
            "CTLNAME" => "CtlName",
            "DEFAULT" => "Default",
            "DRAGMODE" => "DragMode",
            "DRIVE" => "Drive",
            "ENABLED" => "Enabled",
            "FILENAME" => "FileName",
            "HEIGHT" => "Height",
            "HIDDEN" => "Hidden",
            "INDEX" => "Index",
            "INTERVAL" => "Interval",
            "LEFT" => "Left",
            "LIST" => "List",
            "MAX" => "Max",
            "MIN" => "Min",
            "NORMAL" => "Normal",
            "PARENT" => "Parent",
            "PATH" => "Path",
            "PATTERN" => "Pattern",
            "SEPARATOR" => "Separator",
            "SORTED" => "Sorted",
            "STYLE" => "Style",
            "SYSTEM" => "System",
            "TAG" => "Tag",
            "TEXT" => "Text",
            "TOP" => "Top",
            "VALUE" => "Value",
            "VISIBLE" => "Visible",
            "WIDTH" => "Width",
            "ACTIVEFORM" => "ActiveForm",
            "ACTIVECONTROL" => "ActiveControl",
            "CONTROLPANEL" => "ControlPanel",
            _ => name,
        },
    }
}

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

    const EXAMPLE: &str = "VERSION 1.00\nBegin Form Form1\n   Caption          =  \"Beispiel\"\n   Height           =  15\n   Begin CommandButton cmdOK\n      Caption          =  \"&OK\"\n   End\nEnd\n\nSUB cmdOK_Click ()\n    UNLOAD Form1\nEND SUB\n";

    #[test]
    fn explicit_default_is_preserved_until_canonical_write() {
        let original = "VERSION 1.00\r\nBegin Form Form1\r\n   Begin CommandButton Ok\r\n      Enabled = -1\r\n   End\r\nEnd\r\n";
        let mut form = read_text("default.frm", original).unwrap();
        assert_eq!(write_text(&form), original);
        form.root.name = "Changed".into();
        let canonical = write_text(&form);
        assert_eq!(
            canonical,
            "VERSION 1.00\nBegin Form Changed\n   Begin CommandButton Ok\n   End\nEnd\n"
        );
        assert_eq!(
            write_text(&read_text("canonical.frm", &canonical).unwrap()),
            canonical
        );
    }

    #[test]
    fn canonical_index_zero_remains_an_array_and_array_parents_are_preserved() {
        let mut form=read_text("array.frm", "VERSION 1.00\nBegin Form Form1\n Begin Frame A\n  Begin Label Item\n   Index = 0\n  End\n End\n Begin Frame B\n  Begin Label Item\n   Index = 1\n  End\n End\nEnd\n").unwrap();
        form.code = "END\n".into();
        let text = write_text(&form);
        assert!(text.contains("Index            =  0"));
        let roundtrip = read_text("roundtrip.frm", &text).unwrap();
        let catalog = roundtrip.catalog();
        let item = catalog.find("Item").unwrap().0;
        assert!(catalog.objects[item as usize].array);
        let a = catalog.find("A").unwrap().0;
        let b = catalog.find("B").unwrap().0;
        let mut model = FormsModel::new(catalog.objects, 80, 25);
        roundtrip.apply(&mut model).unwrap();
        let parent = forms::property(ObjectClass::Label, "PARENT").unwrap().0;
        assert_eq!(
            model.get_at(item, Some(0), parent).unwrap(),
            PropertyValue::Object(Some((a, None)))
        );
        assert_eq!(
            model.get_at(item, Some(1), parent).unwrap(),
            PropertyValue::Object(Some((b, None)))
        );
        form.root.children.pop();
        let single = read_text("single.frm", &write_text(&form)).unwrap();
        assert!(
            single.catalog().objects[item as usize].array,
            "Einzelelement mit Index 0 bleibt Array"
        );
    }

    #[test]
    fn reads_nested_form_and_preserves_source_exactly() {
        let form = read_text("test.frm", EXAMPLE).unwrap();
        assert_eq!(form.root.children[0].class, ObjectClass::CommandButton);
        assert_eq!(form.code, "SUB cmdOK_Click ()\n    UNLOAD Form1\nEND SUB\n");
        assert_eq!(write_text(&form), EXAMPLE);
    }

    #[test]
    fn text_form_control_arrays_share_one_catalog_object_and_keep_indices() {
        let form = read_text(
            "array.frm",
            "VERSION 1.00\nBegin Form Form1\n Begin CommandButton Command1\n  Index = 0\n  Caption = \"null\"\n End\n Begin CommandButton Command1\n  Index = 1\n  Caption = \"eins\"\n End\nEnd\n",
        )
        .unwrap();
        let catalog = form.catalog();
        assert_eq!(catalog.objects.len(), 2);
        assert!(catalog.objects[1].array);
        let mut model = FormsModel::new(catalog.objects, 80, 25);
        form.apply(&mut model).unwrap();
        let caption = forms::property(ObjectClass::CommandButton, "CAPTION")
            .unwrap()
            .0;
        assert_eq!(
            model.get_at(1, Some(0), caption).unwrap(),
            PropertyValue::String("null".into())
        );
        assert_eq!(
            model.get_at(1, Some(1), caption).unwrap(),
            PropertyValue::String("eins".into())
        );
        model.show(0, false).unwrap();
        model.events.clear();
        model.object_method(1, "SETFOCUS", vec![]).unwrap();
        assert_eq!(model.next_event().unwrap().array_index, Some(0));
    }

    #[test]
    fn rejects_version_class_property_value_and_open_block_with_location() {
        let cases = [
            ("VERSION 3.00\n", "3.00", 1),
            ("VERSION 1.00\nBegin Knopf x\nEnd\n", "Knopf", 2),
            (
                "VERSION 1.00\nBegin CommandButton x\n   Farbe = 3\nEnd\n",
                "Farbe",
                3,
            ),
            (
                "VERSION 1.00\nBegin CommandButton x\n   Height = 0\nEnd\n",
                "x.HEIGHT",
                3,
            ),
            (
                "VERSION 1.00\nBegin Form x\n   Caption = \"a\"b\"\nEnd\n",
                "x.CAPTION",
                3,
            ),
            ("VERSION 1.00\nBegin Form offen\n", "offen", 2),
        ];
        for (source, name, line) in cases {
            let failure = read_text("kaputt.frm", source).unwrap_err();
            assert_eq!(failure.file, "kaputt.frm");
            assert_eq!(failure.line, line);
            assert!(failure.to_string().contains(name));
        }
    }

    #[test]
    fn writes_documented_d1_and_roundtrips_description() {
        let mut root = FormNode {
            class: ObjectClass::Form,
            name: "Form1".into(),
            properties: BTreeMap::new(),
            children: vec![FormNode {
                class: ObjectClass::CommandButton,
                name: "cmdOK".into(),
                properties: BTreeMap::new(),
                children: Vec::new(),
            }],
        };
        root.properties.insert(
            forms::property(ObjectClass::Form, "Caption").unwrap().0,
            PropertyValue::String("Beispiel".into()),
        );
        root.properties.insert(
            forms::property(ObjectClass::Form, "Height").unwrap().0,
            PropertyValue::Integer(15),
        );
        root.children[0].properties.insert(
            forms::property(ObjectClass::CommandButton, "Caption")
                .unwrap()
                .0,
            PropertyValue::String("&OK".into()),
        );
        let form = FormFile::new("1.00", root, "");
        let expected = "VERSION 1.00\nBegin Form Form1\n   Caption          =  \"Beispiel\"\n   Height           =  15\n   Begin CommandButton cmdOK\n      Caption          =  \"&OK\"\n   End\nEnd\n";
        let text = write_text(&form);
        assert_eq!(text, expected);
        assert_eq!(read_text("test.frm", &text).unwrap(), form);
    }

    #[test]
    fn writes_no_properties_for_default_only_control() {
        let form = FormFile::new(
            "1.00",
            FormNode {
                class: ObjectClass::Form,
                name: "Form1".into(),
                properties: BTreeMap::new(),
                children: vec![FormNode {
                    class: ObjectClass::CommandButton,
                    name: "Command1".into(),
                    properties: BTreeMap::new(),
                    children: Vec::new(),
                }],
            },
            "",
        );
        assert_eq!(
            write_text(&form),
            "VERSION 1.00\nBegin Form Form1\n   Begin CommandButton Command1\n   End\nEnd\n"
        );
    }

    #[test]
    fn writes_read_only_boolean_only_when_it_differs_from_false() {
        let property = forms::property(ObjectClass::VScrollBar, "ATTACHED")
            .unwrap()
            .0;
        let mut root = FormNode {
            class: ObjectClass::VScrollBar,
            name: "VScroll1".into(),
            properties: BTreeMap::from([(property, PropertyValue::Boolean(false))]),
            children: Vec::new(),
        };
        assert!(!write_text(&FormFile::new("1.00", root.clone(), "")).contains("Attached"));

        root.properties
            .insert(property, PropertyValue::Boolean(true));
        assert!(write_text(&FormFile::new("1.00", root, "")).contains("Attached         =  -1"));
    }

    #[test]
    fn reads_real_vbdos_form_with_complete_documented_description() {
        let hex = include_str!("../tests/data/new.frm.hex");
        let bytes: Vec<u8> = hex
            .split_whitespace()
            .map(|byte| u8::from_str_radix(byte, 16).unwrap())
            .collect();
        let read = read_binary("new.frm", &bytes).unwrap();
        assert!(read.skipped.is_empty());
        assert_eq!(
            write_text(&read.form),
            "VERSION 1.00\nBegin Form New\n   Caption          =  \"New\"\n   FormType         =  1\n   Height           =  17\n   Left             =  15\n   MaxButton        =  0\n   Top              =  3\n   Visible          =  -1\n   Width            =  63\n   Begin CommandButton Command1\n      Caption          =  \"Command1\"\n      Height           =  3\n      Left             =  8\n      Top              =  11\n      Width            =  12\n   End\nEnd\n"
        );
    }

    #[test]
    fn binary_reader_rejects_magic_and_unknown_structure_with_offset() {
        assert!(read_binary("fremd.frm", b"kein formular")
            .unwrap_err()
            .to_string()
            .contains("0x0000"));
        let mut bytes = vec![0xfc, 0x08, 0x01, 0x00];
        bytes.resize(64, 0);
        assert!(read_binary("kaputt.frm", &bytes)
            .unwrap_err()
            .to_string()
            .contains("Objektkatalog"));

        let mut bytes: Vec<u8> = include_str!("../tests/data/new.frm.hex")
            .split_whitespace()
            .map(|byte| u8::from_str_radix(byte, 16).unwrap())
            .collect();
        bytes[0x1c..0x1e].copy_from_slice(&u16::MAX.to_le_bytes());
        let failure = read_binary("zeiger.frm", &bytes).unwrap_err();
        assert_eq!(failure.line, 0x1c);
        assert!(failure.to_string().contains("Objektkatalog"));

        let mut bytes: Vec<u8> = include_str!("../tests/data/new.frm.hex")
            .split_whitespace()
            .map(|byte| u8::from_str_radix(byte, 16).unwrap())
            .collect();
        bytes[0x75] = 0x7f;
        let failure = read_binary("mitten.frm", &bytes).unwrap_err();
        assert_eq!(failure.line, 0x75);
        assert!(failure.to_string().contains("Katalogstruktur"));

        let mut bytes: Vec<u8> = include_str!("../tests/data/new.frm.hex")
            .split_whitespace()
            .map(|byte| u8::from_str_radix(byte, 16).unwrap())
            .collect();
        bytes[0x75] = ObjectClass::Screen.id();
        bytes[0x40] = ObjectClass::Screen.id();
        let failure = read_binary("klassenkonflikt.frm", &bytes).unwrap_err();
        assert_eq!(failure.line, 0x20);

        let mut bytes: Vec<u8> = include_str!("../tests/data/new.frm.hex")
            .split_whitespace()
            .map(|byte| u8::from_str_radix(byte, 16).unwrap())
            .collect();
        bytes[0x46..0x48].copy_from_slice(&999u16.to_le_bytes());
        let failure = read_binary("struktur.frm", &bytes).unwrap_err();
        assert_eq!(failure.line, 0x20);
        assert_eq!(failure.name, "Containerverweise");
    }

    #[test]
    fn binary_reader_rejects_out_of_range_property_with_offset() {
        let mut bytes: Vec<u8> = include_str!("../tests/data/new.frm.hex")
            .split_whitespace()
            .map(|byte| u8::from_str_radix(byte, 16).unwrap())
            .collect();
        bytes[0x31] = 0;

        let failure = read_binary("bereich.frm", &bytes).unwrap_err();
        assert_eq!(failure.line, 0x20);
        assert_eq!(failure.name, "New.HEIGHT");
    }

    #[test]
    fn spin_symbol_wird_als_bedienbares_control_erkannt() {
        let mut bytes = Vec::new();
        bytes.extend_from_slice(&[1, 0, ObjectClass::Form.id(), 5]);
        bytes.extend_from_slice(b"Form1");
        bytes.extend_from_slice(&[0, 0, ObjectClass::Screen.id(), 5]);
        bytes.extend_from_slice(b"VSpin");
        let symbols = read_symbol_table(&bytes, 0).unwrap();
        assert_eq!(symbols[1].class, ObjectClass::Spin);
        assert!(!symbols[1].unsupported);
    }

    #[test]
    fn unsupported_custom_control_is_named_and_not_emitted_as_screen() {
        let symbols = vec![
            BinarySymbol {
                offset: 0,
                class: ObjectClass::Form,
                name: "Form1".into(),
                is_array: false,
                unsupported: false,
            },
            BinarySymbol {
                offset: 0,
                class: ObjectClass::Screen,
                name: "Custom1".into(),
                is_array: false,
                unsupported: true,
            },
        ];
        let records = vec![
            BinaryRecord {
                symbol: 0,
                start: 0,
                end: 31,
                class_flags: 0,
                common_flags: 0,
            },
            BinaryRecord {
                symbol: 1,
                start: 38,
                end: 111,
                class_flags: 0,
                common_flags: 0x81,
            },
        ];
        let mut bytes = vec![0; 111];
        bytes[24..26].copy_from_slice(&100u16.to_le_bytes());
        bytes[38..40].copy_from_slice(&10u16.to_le_bytes());
        for (offset, pointer) in [17, 28, 37, 39, 53, 55, 64, 69].into_iter().zip(101u16..) {
            bytes[38 + offset..40 + offset].copy_from_slice(&pointer.to_le_bytes());
        }
        let strings = (100..=108)
            .map(|pointer| BinaryString {
                pointer,
                value: if pointer == 100 {
                    "Form1".into()
                } else {
                    format!("Custom{pointer}")
                },
            })
            .collect::<Vec<_>>();
        let mut decoded = records
            .iter()
            .map(|record| decode_object(&symbols[record.symbol], record, &bytes))
            .collect::<Vec<_>>();

        let warnings = assign_strings(
            "custom.frm",
            &symbols,
            &records,
            &bytes,
            &strings,
            &mut decoded,
        )
        .unwrap();
        let root = build_tree(decoded).unwrap();

        assert_eq!(warnings[0].offset, 0x46);
        assert_eq!(warnings[0].name, "Custom1.CustomControl");
        assert!(root.children.is_empty());

        let mut strings_with_orphan = (100..=108)
            .map(|pointer| BinaryString {
                pointer,
                value: format!("String{pointer}"),
            })
            .collect::<Vec<_>>();
        strings_with_orphan.push(BinaryString {
            pointer: 109,
            value: "Orphan".into(),
        });
        let mut decoded = records
            .iter()
            .map(|record| decode_object(&symbols[record.symbol], record, &bytes))
            .collect::<Vec<_>>();
        assert!(assign_strings(
            "custom.frm",
            &symbols,
            &records,
            &bytes,
            &strings_with_orphan,
            &mut decoded,
        )
        .unwrap_err()
        .to_string()
        .contains("nicht zugeordnete Eintraege"));
    }

    #[test]
    fn unsupported_binary_property_is_named_and_unclaimed_strings_still_fail() {
        let symbols = vec![
            BinarySymbol {
                offset: 0,
                class: ObjectClass::Form,
                name: "Form1".into(),
                is_array: false,
                unsupported: false,
            },
            BinarySymbol {
                offset: 0,
                class: ObjectClass::Menu,
                name: "mnuOpen".into(),
                is_array: false,
                unsupported: false,
            },
        ];
        let records = vec![
            BinaryRecord {
                symbol: 0,
                start: 0,
                end: 31,
                class_flags: 0,
                common_flags: 0,
            },
            BinaryRecord {
                symbol: 1,
                start: 38,
                end: 59,
                class_flags: 0,
                common_flags: 0x81,
            },
        ];
        let mut bytes = vec![0; 59];
        bytes[57..59].copy_from_slice(&0x1234u16.to_le_bytes());
        let mut decoded = records
            .iter()
            .map(|record| decode_object(&symbols[record.symbol], record, &bytes))
            .collect::<Vec<_>>();
        let warnings =
            assign_strings("menu.frm", &symbols, &records, &bytes, &[], &mut decoded).unwrap();
        assert_eq!(warnings[0].offset, 0x59);
        assert_eq!(warnings[0].name, "mnuOpen.Shortcut");

        let strings = vec![BinaryString {
            pointer: 100,
            value: "orphan".into(),
        }];
        assert!(assign_strings(
            "menu.frm",
            &symbols,
            &records,
            &bytes,
            &strings,
            &mut decoded,
        )
        .unwrap_err()
        .to_string()
        .contains("nicht zugeordnete Eintraege"));
    }

    #[test]
    fn binary_strings_and_header_flags_map_to_the_named_properties() {
        let symbols = [
            (ObjectClass::Form, "Form1"),
            (ObjectClass::TextBox, "txtSearch"),
            (ObjectClass::ListBox, "lstFound"),
            (ObjectClass::CommandButton, "cmdSearch"),
        ]
        .into_iter()
        .map(|(class, name)| BinarySymbol {
            offset: 0,
            class,
            name: name.into(),
            is_array: false,
            unsupported: false,
        })
        .collect::<Vec<_>>();
        let records = vec![
            BinaryRecord {
                symbol: 0,
                start: 0,
                end: 31,
                class_flags: 0,
                common_flags: 0,
            },
            BinaryRecord {
                symbol: 1,
                start: 38,
                end: 63,
                class_flags: 0,
                common_flags: 0xc1,
            },
            BinaryRecord {
                symbol: 2,
                start: 70,
                end: 93,
                class_flags: 0,
                common_flags: 0x41,
            },
            BinaryRecord {
                symbol: 3,
                start: 100,
                end: 123,
                class_flags: 0x04,
                common_flags: 0xd1,
            },
        ];
        let mut bytes = vec![0; 123];
        bytes[3] = 0x28;
        bytes[4] = 0x81;
        bytes[19] = 2;
        bytes[20] = 3;
        bytes[22] = 7;
        bytes[24..26].copy_from_slice(&100u16.to_le_bytes());
        for start in [38, 70, 100] {
            bytes[start..start + 2].copy_from_slice(&10u16.to_le_bytes());
        }
        bytes[40..42].copy_from_slice(&101u16.to_le_bytes());
        bytes[59..61].copy_from_slice(&102u16.to_le_bytes());
        bytes[72..74].copy_from_slice(&103u16.to_le_bytes());
        bytes[102..104].copy_from_slice(&104u16.to_le_bytes());
        bytes[117..119].copy_from_slice(&105u16.to_le_bytes());
        let strings = [
            (100, "Search"),
            (101, ""),
            (102, "*.*"),
            (103, ""),
            (104, ""),
            (105, "&Search"),
        ]
        .into_iter()
        .map(|(pointer, value)| BinaryString {
            pointer,
            value: value.into(),
        })
        .collect::<Vec<_>>();
        let mut decoded = records
            .iter()
            .map(|record| decode_object(&symbols[record.symbol], record, &bytes))
            .collect::<Vec<_>>();
        assert!(assign_strings(
            "search.frm",
            &symbols,
            &records,
            &bytes,
            &strings,
            &mut decoded,
        )
        .unwrap()
        .is_empty());
        let root = build_tree(decoded).unwrap();

        let value = |node: &FormNode, name| {
            node.properties
                .get(&forms::property(node.class, name).unwrap().0)
                .cloned()
        };
        assert_eq!(
            value(&root, "CAPTION"),
            Some(PropertyValue::String("Search".into()))
        );
        assert_eq!(
            value(&root, "AUTOREDRAW"),
            Some(PropertyValue::Boolean(true))
        );
        assert_eq!(value(&root, "FORECOLOR"), Some(PropertyValue::Integer(7)));
        assert_eq!(value(&root, "CURRENTX"), Some(PropertyValue::Integer(2)));
        assert_eq!(value(&root, "CURRENTY"), Some(PropertyValue::Integer(3)));
        assert_eq!(
            value(&root.children[0], "TEXT"),
            Some(PropertyValue::String("*.*".into()))
        );
        assert_eq!(
            value(&root.children[1], "VISIBLE"),
            Some(PropertyValue::Boolean(false))
        );
        assert_eq!(
            value(&root.children[2], "CAPTION"),
            Some(PropertyValue::String("&Search".into()))
        );
        assert_eq!(
            value(&root.children[2], "DEFAULT"),
            Some(PropertyValue::Boolean(true))
        );
        assert_eq!(
            value(&root.children[2], "CANCEL"),
            Some(PropertyValue::Boolean(true))
        );
    }

    #[test]
    fn binary_class_specific_properties_match_the_reference_exports() {
        let decode = |class, class_flags, bytes: Vec<u8>| {
            decode_object(
                &BinarySymbol {
                    offset: 0,
                    class,
                    name: "Control1".into(),
                    is_array: false,
                    unsupported: false,
                },
                &BinaryRecord {
                    symbol: 0,
                    start: 0,
                    end: bytes.len(),
                    class_flags,
                    common_flags: 0xc1,
                },
                &bytes,
            )
            .node
        };
        let value = |node: &FormNode, name| {
            node.properties
                .get(&forms::property(node.class, name).unwrap().0)
                .cloned()
        };

        let decode_form = |class_flags| {
            let mut bytes = vec![0; 31];
            bytes[3] = class_flags;
            decode_object(
                &BinarySymbol {
                    offset: 0,
                    class: ObjectClass::Form,
                    name: "Form1".into(),
                    is_array: false,
                    unsupported: false,
                },
                &BinaryRecord {
                    symbol: 0,
                    start: 0,
                    end: bytes.len(),
                    class_flags: 0,
                    common_flags: 0,
                },
                &bytes,
            )
            .node
        };
        let auto_redraw = decode_form(0x08);
        assert_eq!(
            value(&auto_redraw, "AUTOREDRAW"),
            Some(PropertyValue::Boolean(true))
        );
        assert_eq!(
            value(&auto_redraw, "FORMTYPE"),
            Some(PropertyValue::Integer(0))
        );
        let mdi = decode_form(0x02);
        assert_eq!(
            value(&mdi, "AUTOREDRAW"),
            Some(PropertyValue::Boolean(false))
        );
        assert_eq!(value(&mdi, "FORMTYPE"), Some(PropertyValue::Integer(1)));

        let mut label = vec![0; 21];
        label[19] = 1;
        label[20] = 2;
        let label = decode(ObjectClass::Label, 0, label);
        assert_eq!(
            value(&label, "BORDERSTYLE"),
            Some(PropertyValue::Integer(1))
        );
        assert_eq!(value(&label, "ALIGNMENT"), Some(PropertyValue::Integer(2)));

        let mut picture = vec![0; 24];
        picture[14] = 7;
        picture[17..19].copy_from_slice(&2i16.to_le_bytes());
        picture[19..21].copy_from_slice(&(-3i16).to_le_bytes());
        let picture = decode(ObjectClass::PictureBox, 0x08, picture);
        assert_eq!(
            value(&picture, "AUTOREDRAW"),
            Some(PropertyValue::Boolean(true))
        );
        assert_eq!(
            value(&picture, "BORDERSTYLE"),
            Some(PropertyValue::Integer(0))
        );
        assert_eq!(value(&picture, "CURRENTX"), Some(PropertyValue::Integer(2)));
        assert_eq!(
            value(&picture, "CURRENTY"),
            Some(PropertyValue::Integer(-3))
        );

        let mut combo = vec![0; 33];
        combo[14] = 7;
        combo[31] = 2;
        let combo = decode(ObjectClass::ComboBox, 0, combo);
        assert_eq!(value(&combo, "STYLE"), Some(PropertyValue::Integer(2)));

        let mut text = vec![0; 21];
        text[19] = 1;
        text[20] = 3;
        let text = decode_object(
            &BinarySymbol {
                offset: 0,
                class: ObjectClass::TextBox,
                name: "Text1".into(),
                is_array: false,
                unsupported: false,
            },
            &BinaryRecord {
                symbol: 0,
                start: 0,
                end: text.len(),
                class_flags: 0,
                common_flags: 0x08,
            },
            &text,
        )
        .node;
        assert_eq!(
            value(&text, "MULTILINE"),
            Some(PropertyValue::Boolean(true))
        );
        assert_eq!(value(&text, "SCROLLBARS"), Some(PropertyValue::Integer(3)));

        let list = decode(ObjectClass::ListBox, 0x80, vec![0; 23]);
        assert_eq!(value(&list, "SORTED"), Some(PropertyValue::Boolean(true)));

        let label = decode(ObjectClass::Label, 0x20, vec![0; 21]);
        assert_eq!(
            value(&label, "AUTOSIZE"),
            Some(PropertyValue::Boolean(true))
        );

        let separator = decode(ObjectClass::Menu, 0x01, vec![0; 19]);
        assert_eq!(
            value(&separator, "SEPARATOR"),
            Some(PropertyValue::Boolean(true))
        );
        assert_eq!(
            value(&separator, "CHECKED"),
            Some(PropertyValue::Boolean(false))
        );
        let checked = decode(ObjectClass::Menu, 0x40, vec![0; 19]);
        assert_eq!(
            value(&checked, "SEPARATOR"),
            Some(PropertyValue::Boolean(false))
        );
        assert_eq!(
            value(&checked, "CHECKED"),
            Some(PropertyValue::Boolean(true))
        );

        let mut timer = vec![0; 19];
        timer[17..19].copy_from_slice(&1000u16.to_le_bytes());
        let timer = decode(ObjectClass::Timer, 0, timer);
        assert_eq!(
            value(&timer, "INTERVAL"),
            Some(PropertyValue::Integer(1000))
        );

        let mut scroll = vec![0; 25];
        scroll[14] = 1;
        for (offset, setting) in [(17, 2i16), (19, 3), (21, 10), (23, -4)] {
            scroll[offset..offset + 2].copy_from_slice(&setting.to_le_bytes());
        }
        let scroll = decode(ObjectClass::VScrollBar, 0, scroll);
        assert_eq!(
            value(&scroll, "ATTACHED"),
            Some(PropertyValue::Boolean(false))
        );
        assert_eq!(
            value(&scroll, "SMALLCHANGE"),
            Some(PropertyValue::Integer(2))
        );
        assert_eq!(
            value(&scroll, "LARGECHANGE"),
            Some(PropertyValue::Integer(3))
        );
        assert_eq!(value(&scroll, "MAX"), Some(PropertyValue::Integer(10)));
        assert_eq!(value(&scroll, "MIN"), Some(PropertyValue::Integer(-4)));
        assert_eq!(value(&scroll, "VALUE"), Some(PropertyValue::Integer(-4)));
        assert_eq!(value(&scroll, "BACKCOLOR"), None);

        let mut option = vec![0; 21];
        option[19..21].copy_from_slice(&u16::MAX.to_le_bytes());
        let option = decode(ObjectClass::OptionButton, 0, option);
        assert_eq!(value(&option, "VALUE"), Some(PropertyValue::Integer(-1)));
    }

    #[test]
    fn binary_layout_preserves_arrays_and_container_hierarchy() {
        let symbols = vec![
            BinarySymbol {
                offset: 0,
                class: ObjectClass::Form,
                name: "Form1".into(),
                is_array: false,
                unsupported: false,
            },
            BinarySymbol {
                offset: 0,
                class: ObjectClass::Frame,
                name: "Frame1".into(),
                is_array: false,
                unsupported: false,
            },
            BinarySymbol {
                offset: 0,
                class: ObjectClass::PictureBox,
                name: "Picture1".into(),
                is_array: true,
                unsupported: false,
            },
        ];
        let mut bytes = vec![0; 126];
        bytes[31..38].copy_from_slice(&[1, ObjectClass::Frame.id(), 0, 0, 0x81, 0, 0]);
        bytes[38..40].copy_from_slice(&10u16.to_le_bytes());
        bytes[57..64].copy_from_slice(&[2, ObjectClass::PictureBox.id(), 0, 0, 0xc1, 0, 0]);
        bytes[64..66].copy_from_slice(&41u16.to_le_bytes());
        bytes[88..95].copy_from_slice(&[2, ObjectClass::PictureBox.id(), 0, 0, 0xc1, 0, 0]);
        bytes[95..97].copy_from_slice(&41u16.to_le_bytes());
        bytes[68..70].copy_from_slice(&0u16.to_le_bytes());
        bytes[99..101].copy_from_slice(&1u16.to_le_bytes());

        let records = read_object_records(&symbols, &bytes, &mut |_| true).unwrap();
        let decoded: Vec<_> = records
            .iter()
            .map(|record| decode_object(&symbols[record.symbol], record, &bytes))
            .collect();
        let root = build_tree(decoded).unwrap();
        assert_eq!(root.children[0].children.len(), 2);
        assert_eq!(
            root.children[0].children[1]
                .properties
                .get(&forms::property(ObjectClass::PictureBox, "INDEX").unwrap().0),
            Some(&PropertyValue::Integer(1))
        );
    }

    #[test]
    fn binary_strings_are_decoded_from_cp437() {
        assert_eq!(decode_cp437(&[0x0e, b' ', 0x84]), "♫ ä");
        assert_eq!(decode_cp437(b"Cut\tCtrl+X"), "Cut\tCtrl+X");
    }
}
