use flate2::read::GzDecoder;
use pyo3::exceptions::PyDeprecationWarning;
use pyo3::prelude::*;
use pyo3::types::PyModule;
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufReader, Read};
use std::str;
use thiserror::Error;

use arrow::array::{ArrayRef, Float64Builder, StringBuilder, UInt32Builder};
use arrow::datatypes::{DataType, Field, Schema};
use arrow::record_batch::RecordBatch;
use parquet::arrow::ArrowWriter;
use parquet::file::properties::WriterProperties;

#[derive(Error, Debug)]
enum Zs2Error {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
    #[error("UTF-8 error: {0}")]
    Utf8(#[from] std::str::Utf8Error),
    #[error("Bad marker: missing 0xDEADBEAF")]
    BadMarker,
    #[error("Parse error at offset {offset}: {msg}")]
    Parse { offset: usize, msg: String },
    #[error("Arrow error: {0}")]
    Arrow(#[from] arrow::error::ArrowError),
    #[error("Parquet error: {0}")]
    Parquet(#[from] parquet::errors::ParquetError),
}

type Res<T> = Result<T, Zs2Error>;

const MAX_DECOMPRESSED_ZS2_BYTES: usize = 512 * 1024 * 1024;
const MAX_UTF16_CHAR_COUNT: usize = 16_000_000;
const MAX_ARRAY_ELEMENTS: usize = 50_000_000;
const MAX_NESTING_DEPTH: usize = 512;

impl From<Zs2Error> for PyErr {
    fn from(err: Zs2Error) -> PyErr {
        pyo3::exceptions::PyRuntimeError::new_err(err.to_string())
    }
}

fn read_zs2_with_limits(input_zs2: &str) -> Res<Vec<u8>> {
    let f = File::open(input_zs2)?;
    let mut gz = GzDecoder::new(BufReader::new(f));
    let mut data = Vec::<u8>::new();
    let mut chunk = [0u8; 64 * 1024];

    loop {
        let read = gz.read(&mut chunk)?;
        if read == 0 {
            break;
        }

        if data.len().saturating_add(read) > MAX_DECOMPRESSED_ZS2_BYTES {
            return Err(Zs2Error::Parse {
                offset: data.len(),
                msg: format!(
                    "decompressed input exceeds limit of {} bytes",
                    MAX_DECOMPRESSED_ZS2_BYTES
                ),
            });
        }

        data.extend_from_slice(&chunk[..read]);
    }

    if data.len() < 4 || data[0..4] != [0xAF, 0xBE, 0xAD, 0xDE] {
        return Err(Zs2Error::BadMarker);
    }

    Ok(data)
}

fn emit_deprecation_warning(py: Python<'_>, old_name: &str, new_name: &str) -> PyResult<()> {
    let warnings = PyModule::import(py, "warnings")?;
    let msg = format!(
        "'{}' is deprecated and will be removed in a future release; use '{}' instead.",
        old_name, new_name
    );
    warnings.call_method1("warn", (msg, py.get_type::<PyDeprecationWarning>(), 2usize))?;
    Ok(())
}

#[pyfunction]
#[pyo3(signature = (input_zs2, output_parquet, include_u32=None))]
fn zs2_to_parquet(
    input_zs2: &str,
    output_parquet: &str,
    include_u32: Option<bool>,
) -> PyResult<()> {
    let include_u32 = include_u32.unwrap_or(false);

    // 1) Decompress gz with safety limits and validate marker
    let data = read_zs2_with_limits(input_zs2)?;

    // 3) Scan stream
    let mut i = 4usize;
    let n = data.len();

    // Name stack reflects nesting caused by 0xDD and ended by 0xFF
    let mut name_stack: Vec<String> = Vec::with_capacity(8);

    // Output builders (long format)
    let mut b_series = StringBuilder::new();
    let mut b_subtyp = StringBuilder::new();
    let mut b_index = UInt32Builder::new();
    let mut b_value = Float64Builder::new();

    while i < n {
        // 0xFF means "end of nesting" with no name
        if data[i] == 0xFF {
            name_stack.pop();
            i += 1;
            continue;
        }

        // Read name (length-prefixed ASCII)
        ensure_len(i + 1, n, i)?;
        let name_len = data[i] as usize;
        i += 1;
        ensure_len(i + name_len, n, i)?;
        let name = str::from_utf8(&data[i..i + name_len])?.to_string();
        i += name_len;

        if i >= n {
            break;
        }
        let dtype = data[i];

        match dtype {
            0xEE => {
                // EE block: [EE][u16 subtype][u32 count][payload...]
                ensure_len(i + 1 + 2 + 4, n, i)?;
                i += 1;
                let sub = u16::from_le_bytes([data[i], data[i + 1]]);
                i += 2;
                let cnt =
                    u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
                i += 4;
                if cnt > MAX_ARRAY_ELEMENTS {
                    return Err(Zs2Error::Parse {
                        offset: i,
                        msg: format!(
                            "array element count {cnt} exceeds limit {MAX_ARRAY_ELEMENTS}"
                        ),
                    }
                    .into());
                }

                // Compose series name from current path + leaf name
                let series = if name_stack.is_empty() {
                    name.clone()
                } else {
                    let mut s = name_stack.join("/");
                    if !name.is_empty() {
                        s.push('/');
                        s.push_str(&name);
                    }
                    s
                };

                match sub {
                    0x0004 => {
                        // float32 list
                        let need = cnt * 4;
                        ensure_len(i + need, n, i)?;
                        // read f32 -> f64
                        for idx in 0..cnt {
                            let off = i + idx * 4;
                            let v = f32::from_le_bytes([
                                data[off],
                                data[off + 1],
                                data[off + 2],
                                data[off + 3],
                            ]) as f64;
                            b_series.append_value(&series);
                            b_subtyp.append_value("EE04");
                            b_index.append_value(idx as u32);
                            b_value.append_value(v);
                        }
                        i += need;
                    }
                    0x0005 => {
                        // float64 list
                        let need = cnt * 8;
                        ensure_len(i + need, n, i)?;
                        for idx in 0..cnt {
                            let off = i + idx * 8;
                            let v = f64::from_le_bytes([
                                data[off],
                                data[off + 1],
                                data[off + 2],
                                data[off + 3],
                                data[off + 4],
                                data[off + 5],
                                data[off + 6],
                                data[off + 7],
                            ]);
                            b_series.append_value(&series);
                            b_subtyp.append_value("EE05");
                            b_index.append_value(idx as u32);
                            b_value.append_value(v);
                        }
                        i += need;
                    }
                    0x0016 => {
                        // u32 list (index/time/etc). Optional to include or skip.
                        let need = cnt * 4;
                        ensure_len(i + need, n, i)?;
                        if include_u32 {
                            for idx in 0..cnt {
                                let off = i + idx * 4;
                                let v = u32::from_le_bytes([
                                    data[off],
                                    data[off + 1],
                                    data[off + 2],
                                    data[off + 3],
                                ]) as f64;
                                b_series.append_value(&series);
                                b_subtyp.append_value("EE16");
                                b_index.append_value(idx as u32);
                                b_value.append_value(v);
                            }
                        }
                        i += need;
                    }
                    0x0011 => {
                        // u8 list — we skip in v1 to keep the Parquet tidy; will add toggle later
                        // Need to read length (cnt) bytes and skip them.
                        let need = cnt;
                        ensure_len(i + need, n, i)?;
                        i += need;
                    }
                    0x0000 => {
                        // empty list
                    }
                    _ => {
                        // Unknown EE subtype — attempt to skip (best-effort)
                        // We don't know the unit size; bail gracefully by not advancing beyond bounds.
                        return Err(Zs2Error::Parse {
                            offset: i,
                            msg: format!("Unknown EE subtype 0x{sub:04X}"),
                        }
                        .into());
                    }
                }
            }

            0xAA | 0x00 => {
                // UTF-16LE string with bit31 length marker
                // [0xAA|0x00][u32 len_with_marker][len*2 bytes]
                ensure_len(i + 1 + 4, n, i)?;
                i += 1;
                let raw = u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
                i += 4;
                let char_count = (raw & 0x7FFF_FFFF) as usize;
                if char_count > MAX_UTF16_CHAR_COUNT {
                    return Err(Zs2Error::Parse {
                        offset: i,
                        msg: format!(
                            "UTF-16 char count {char_count} exceeds limit {MAX_UTF16_CHAR_COUNT}"
                        ),
                    }
                    .into());
                }
                let need = char_count * 2;
                ensure_len(i + need, n, i)?;
                // We don't use the value here; just skip.
                i += need;
            }

            0xDD => {
                // Start of nested section. Skip the small dd-payload and push name on the stack.
                // Layout: [0xDD][u8 len][len bytes ASCII]
                ensure_len(i + 2, n, i)?;
                let len = data[i + 1] as usize;
                ensure_len(i + 2 + len, n, i)?;
                i += 2 + len;
                if name_stack.len() >= MAX_NESTING_DEPTH {
                    return Err(Zs2Error::Parse {
                        offset: i,
                        msg: format!("nesting depth exceeds limit {MAX_NESTING_DEPTH}"),
                    }
                    .into());
                }
                name_stack.push(name);
            }

            0xFF => {
                // Handled at top; unreachable here (we tested it already).
                i += 1;
            }

            // Basic numeric scalars we don't need: just skip their fixed width
            0x11 | 0x22 | 0x33 | 0x44 => {
                i += 1 + 4;
            }
            0x55 | 0x66 => {
                i += 1 + 2;
            }
            0x88 | 0x99 => {
                i += 1 + 1;
            }
            0xBB => {
                i += 1 + 4;
            }
            0xCC => {
                i += 1 + 8;
            }

            _ => {
                return Err(Zs2Error::Parse {
                    offset: i,
                    msg: format!("Unknown data type tag 0x{dtype:02X} for name {name}"),
                }
                .into());
            }
        }
    }

    // 4) Build Arrow table
    let schema = std::sync::Arc::new(Schema::new(vec![
        Field::new("series", DataType::Utf8, false),
        Field::new("subtype", DataType::Utf8, false),
        Field::new("index", DataType::UInt32, false),
        Field::new("value", DataType::Float64, false),
    ]));

    let a_series: ArrayRef = std::sync::Arc::new(b_series.finish());
    let a_subtyp: ArrayRef = std::sync::Arc::new(b_subtyp.finish());
    let a_index: ArrayRef = std::sync::Arc::new(b_index.finish());
    let a_value: ArrayRef = std::sync::Arc::new(b_value.finish());

    let batch = RecordBatch::try_new(
        std::sync::Arc::clone(&schema),
        vec![a_series, a_subtyp, a_index, a_value],
    )
    .map_err(Zs2Error::from)?;

    // 5) Write Parquet
    let file = File::create(output_parquet)?;
    let props = WriterProperties::builder().build();
    let mut writer = ArrowWriter::try_new(file, std::sync::Arc::clone(&schema), Some(props))
        .map_err(Zs2Error::from)?;
    writer.write(&batch).map_err(Zs2Error::from)?;
    writer.close().map_err(Zs2Error::from)?;

    Ok(())
}

#[pyfunction]
fn zs2_channels_to_parquet(input_zs2: &str, output_parquet: &str) -> PyResult<()> {
    // 1) Decompress with safety limits and validate marker
    let data = read_zs2_with_limits(input_zs2)?;

    let n = data.len();

    // ===== PASS 1: Extract parameter dictionary and channel mappings =====
    #[derive(Default)]
    struct ParamDictEntry {
        param_id: Option<u32>,
        short_name: String,
        param_name: String,
        unit_name: String,
    }

    let mut eig_dict_by_elem: HashMap<u32, ParamDictEntry> = HashMap::new();
    let mut cm_dict_by_elem: HashMap<u32, ParamDictEntry> = HashMap::new();
    let mut channel_trs_ids: HashMap<String, u32> = HashMap::new(); // "sample_{s}/ch_{idx}" -> TrsChannelId
    let mut samples_seen: Vec<u32> = Vec::new(); // list of sample indices found

    let mut i = 4usize;
    let mut name_stack: Vec<String> = Vec::with_capacity(8);

    while i < n {
        if data[i] == 0xFF {
            if !name_stack.is_empty() {
                name_stack.pop();
            }
            i += 1;
            continue;
        }

        ensure_len(i + 1, n, i)?;
        let name_len = data[i] as usize;
        i += 1;
        ensure_len(i + name_len, n, i)?;
        let name = str::from_utf8(&data[i..i + name_len])?.to_string();
        i += name_len;

        if i >= n {
            break;
        }

        let dtype = data[i];
        let path = name_stack.join("/") + "/" + &name;
        let is_eigenschaftenliste = path.contains("/EigenschaftenListe/");
        let is_channel_manager = path.contains("/SeriesDef/TestTaskDefs/")
            && path.contains("/ChannelManager/ChannelManager/Elem");
        let elem_idx_opt = if is_eigenschaftenliste || is_channel_manager {
            extract_elem_index(&path)
        } else {
            None
        };

        match dtype {
            0xEE => {
                ensure_len(i + 1 + 2 + 4, n, i)?;
                i += 1;
                let sub = u16::from_le_bytes([data[i], data[i + 1]]);
                i += 2;
                let cnt = u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
                i += 4;
                if cnt as usize > MAX_ARRAY_ELEMENTS {
                    return Err(Zs2Error::Parse {
                        offset: i,
                        msg: format!(
                            "array element count {} exceeds limit {}",
                            cnt, MAX_ARRAY_ELEMENTS
                        ),
                    }
                    .into());
                }

                // Extract parameter IDs from EigenschaftenListe
                if is_eigenschaftenliste && path.ends_with("/ID") && sub == 0x0016 && cnt >= 1 {
                    let need = cnt as usize * 4;
                    ensure_len(i + need, n, i)?;
                    let param_id =
                        u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
                    if let Some(elem_idx) = elem_idx_opt {
                        eig_dict_by_elem.entry(elem_idx).or_default().param_id = Some(param_id);
                    }
                    i += need;
                    continue;
                }

                if is_channel_manager && path.ends_with("/ID") && sub == 0x0016 && cnt >= 1 {
                    let need = cnt as usize * 4;
                    ensure_len(i + need, n, i)?;
                    let param_id =
                        u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
                    if let Some(elem_idx) = elem_idx_opt {
                        cm_dict_by_elem.entry(elem_idx).or_default().param_id = Some(param_id);
                    }
                    i += need;
                    continue;
                }

                // Skip array data
                let bytes_per_item = match sub {
                    0x0004 | 0x0016 | 0x0005 => {
                        if sub == 0x0005 {
                            8
                        } else {
                            4
                        }
                    }
                    0x0011 => 1usize,
                    _ => 0usize,
                };
                let need = (cnt as usize) * bytes_per_item;
                ensure_len(i + need, n, i)?;
                i += need;
            }

            0xAA | 0x00 => {
                ensure_len(i + 1 + 4, n, i)?;
                i += 1;
                let raw = u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
                i += 4;
                let char_count = (raw & 0x7FFF_FFFF) as usize;
                if char_count > MAX_UTF16_CHAR_COUNT {
                    return Err(Zs2Error::Parse {
                        offset: i,
                        msg: format!(
                            "UTF-16 char count {char_count} exceeds limit {MAX_UTF16_CHAR_COUNT}"
                        ),
                    }
                    .into());
                }
                let need = char_count * 2;
                ensure_len(i + need, n, i)?;

                // Extract parameter names from Name/Text in EigenschaftenListe
                if is_eigenschaftenliste && path.ends_with("/Name/Text") {
                    if let Ok(text) = decode_utf16le(&data[i..i + need]) {
                        if let Some(elem_idx) = elem_idx_opt {
                            eig_dict_by_elem.entry(elem_idx).or_default().param_name = text;
                        }
                    }
                }

                if is_eigenschaftenliste && path.ends_with("/Kurzzeichen/Text") {
                    if let Ok(text) = decode_utf16le(&data[i..i + need]) {
                        if let Some(elem_idx) = elem_idx_opt {
                            eig_dict_by_elem.entry(elem_idx).or_default().short_name = text;
                        }
                    }
                }

                // Extract unit names from EinheitName in EigenschaftenListe
                if is_eigenschaftenliste && path.ends_with("/EinheitName") {
                    if let Ok(text) = decode_utf16le(&data[i..i + need]) {
                        if let Some(elem_idx) = elem_idx_opt {
                            eig_dict_by_elem.entry(elem_idx).or_default().unit_name = text;
                        }
                    }
                }

                if is_channel_manager && path.ends_with("/Name/Text") {
                    if let Ok(text) = decode_utf16le(&data[i..i + need]) {
                        if let Some(elem_idx) = elem_idx_opt {
                            cm_dict_by_elem.entry(elem_idx).or_default().param_name = text;
                        }
                    }
                }

                if is_channel_manager && path.ends_with("/Kurzzeichen/Text") {
                    if let Ok(text) = decode_utf16le(&data[i..i + need]) {
                        if let Some(elem_idx) = elem_idx_opt {
                            cm_dict_by_elem.entry(elem_idx).or_default().short_name = text;
                        }
                    }
                }

                if is_channel_manager && path.ends_with("/UnitTableName") {
                    if let Ok(text) = decode_utf16le(&data[i..i + need]) {
                        if let Some(elem_idx) = elem_idx_opt {
                            cm_dict_by_elem.entry(elem_idx).or_default().unit_name = text;
                        }
                    }
                }

                // Extract actual unit symbols from Einheit/Kurzzeichen in ChannelManager
                if is_channel_manager && path.ends_with("/Einheit/Kurzzeichen") {
                    if let Ok(text) = decode_utf16le(&data[i..i + need]) {
                        if let Some(elem_idx) = elem_idx_opt {
                            // Override UnitTableName with actual unit symbol
                            cm_dict_by_elem.entry(elem_idx).or_default().unit_name = text;
                        }
                    }
                }

                i += need;
            }

            0xDD => {
                ensure_len(i + 2, n, i)?;
                let len = data[i + 1] as usize;
                ensure_len(i + 2 + len, n, i)?;
                i += 2 + len;
                if name_stack.len() >= MAX_NESTING_DEPTH {
                    return Err(Zs2Error::Parse {
                        offset: i,
                        msg: format!("nesting depth exceeds limit {MAX_NESTING_DEPTH}"),
                    }
                    .into());
                }
                name_stack.push(name);
            }

            0xFF => i += 1,
            0x11 => {
                // Extract TrsChannelId mappings from DataChannels (0x11 is a scalar byte)
                if path.contains("/DataChannels/") && path.contains("/TrsChannelId") {
                    ensure_len(i + 1 + 4, n, i)?;
                    i += 1;
                    let trs_id =
                        u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
                    i += 4;

                    if let Some(sample_idx) = extract_sample_index(&path) {
                        if let Some(ch_idx) = extract_data_channel_index(&path) {
                            if !samples_seen.contains(&sample_idx) {
                                samples_seen.push(sample_idx);
                            }
                            let key = format!("sample_{}/ch_{}", sample_idx, ch_idx);
                            channel_trs_ids.insert(key, trs_id);
                        }
                    }
                } else {
                    i += 1 + 4;
                }
            }
            0x22 | 0x33 | 0x44 => {
                // Extract ID from scalar u32/i32/f32 in EigenschaftenListe
                ensure_len(i + 1 + 4, n, i)?;
                i += 1;
                let raw_u32 = u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
                i += 4;

                if is_eigenschaftenliste && path.ends_with("/ID") {
                    if let Some(elem_idx) = elem_idx_opt {
                        eig_dict_by_elem.entry(elem_idx).or_default().param_id = Some(raw_u32);
                    }
                }

                if is_channel_manager && path.ends_with("/ID") {
                    if let Some(elem_idx) = elem_idx_opt {
                        cm_dict_by_elem.entry(elem_idx).or_default().param_id = Some(raw_u32);
                    }
                }
            }
            0x55 | 0x66 => {
                // Extract ID from scalar u16 in EigenschaftenListe
                ensure_len(i + 1 + 2, n, i)?;
                i += 1;
                let raw_u16 = u16::from_le_bytes([data[i], data[i + 1]]);
                i += 2;

                if is_eigenschaftenliste && path.ends_with("/ID") {
                    if let Some(elem_idx) = elem_idx_opt {
                        eig_dict_by_elem.entry(elem_idx).or_default().param_id =
                            Some(raw_u16 as u32);
                    }
                }

                if is_channel_manager && path.ends_with("/ID") {
                    if let Some(elem_idx) = elem_idx_opt {
                        cm_dict_by_elem.entry(elem_idx).or_default().param_id =
                            Some(raw_u16 as u32);
                    }
                }
            }
            0x88 | 0x99 => i += 1 + 1,
            0xBB => i += 1 + 4,
            0xCC => i += 1 + 8,
            _ => {
                return Err(Zs2Error::Parse {
                    offset: i,
                    msg: format!("Unknown data type tag 0x{dtype:02X}"),
                }
                .into())
            }
        }
    }

    // ===== Build inverse mapping: param_id -> (name, unit) =====
    let mut trs_to_name: HashMap<u32, String> = HashMap::new();
    let mut trs_to_unit: HashMap<u32, String> = HashMap::new();

    // Build UnitTableName -> unit symbol mapping (e.g. UT_Displacement -> mm)
    // from EigenschaftenListe text fields.
    let mut unit_table_to_symbol: HashMap<String, String> = HashMap::new();
    for entry in eig_dict_by_elem.values() {
        let unit = entry.unit_name.trim();
        if unit.is_empty() {
            continue;
        }

        let short_key = entry.short_name.trim();
        if !short_key.is_empty() {
            unit_table_to_symbol
                .entry(short_key.to_string())
                .or_insert_with(|| unit.to_string());
        }

        let name_key = entry.param_name.trim();
        if !name_key.is_empty() {
            unit_table_to_symbol
                .entry(name_key.to_string())
                .or_insert_with(|| unit.to_string());
        }
    }

    // Process ChannelManager first for names (using actual channel names from ChannelManager)
    for (_, entry) in cm_dict_by_elem {
        if let Some(param_id) = entry.param_id {
            let name = if !entry.param_name.trim().is_empty() {
                entry.param_name
            } else {
                entry.short_name
            };
            trs_to_name.insert(param_id, name);
            // Use resolved UnitTableName when available (e.g. UT_Force -> N).
            let mut resolved_unit = unit_table_to_symbol
                .get(entry.unit_name.trim())
                .cloned()
                .unwrap_or(entry.unit_name);
            if resolved_unit.starts_with("UT_") {
                if let Some(symbol) = infer_unit_from_unit_table_name(&resolved_unit) {
                    resolved_unit = symbol.to_string();
                }
            }
            trs_to_unit.entry(param_id).or_insert(resolved_unit);
        }
    }

    // Process EigenschaftenListe last for units - these have actual unit symbols (N, mm, s, etc.)
    // and should override the UnitTableName values from ChannelManager
    for (_, entry) in eig_dict_by_elem {
        if let Some(param_id) = entry.param_id {
            let name = if !entry.param_name.trim().is_empty() {
                entry.param_name
            } else {
                entry.short_name
            };
            // Keep the channel name from ChannelManager if it exists (it's more descriptive)
            trs_to_name.entry(param_id).or_insert(name);
            // Override with EigenschaftenListe unit if available (actual unit symbols)
            if !entry.unit_name.trim().is_empty() {
                let mut resolved_unit = unit_table_to_symbol
                    .get(entry.unit_name.trim())
                    .cloned()
                    .unwrap_or(entry.unit_name);
                if resolved_unit.starts_with("UT_") {
                    if let Some(symbol) = infer_unit_from_unit_table_name(&resolved_unit) {
                        resolved_unit = symbol.to_string();
                    }
                }
                trs_to_unit.insert(param_id, resolved_unit);
            }
        }
    }

    // ===== PASS 2: Extract channel data with semantic names =====
    // Export only DataArray payloads as signal values.
    // ValidityArray entries (often u32 status flags) are intentionally skipped.
    i = 4;
    name_stack.clear();

    let mut sample_idx_builder = UInt32Builder::new();
    let mut channel_idx_builder = UInt32Builder::new();
    let mut channel_name_builder = StringBuilder::new();
    let mut unit_name_builder = StringBuilder::new();
    let mut timepoint_builder = UInt32Builder::new();
    let mut value_builder = Float64Builder::new();
    let mut data_type_builder = StringBuilder::new();

    while i < n {
        if data[i] == 0xFF {
            if !name_stack.is_empty() {
                name_stack.pop();
            }
            i += 1;
            continue;
        }

        ensure_len(i + 1, n, i)?;
        let name_len = data[i] as usize;
        i += 1;
        ensure_len(i + name_len, n, i)?;
        let name = str::from_utf8(&data[i..i + name_len])?.to_string();
        i += name_len;

        if i >= n {
            break;
        }

        let dtype = data[i];
        let path = name_stack.join("/") + "/" + &name;

        if dtype == 0xEE {
            ensure_len(i + 1 + 2 + 4, n, i)?;
            i += 1;
            let sub = u16::from_le_bytes([data[i], data[i + 1]]);
            i += 2;
            let cnt = u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
            i += 4;
            if cnt as usize > MAX_ARRAY_ELEMENTS {
                return Err(Zs2Error::Parse {
                    offset: i,
                    msg: format!(
                        "array element count {} exceeds limit {}",
                        cnt, MAX_ARRAY_ELEMENTS
                    ),
                }
                .into());
            }

            // Check if this is a real-time capture channel
            if path.contains("/DataChannels/")
                && path.contains("RealTimeCapture/Trs/SingleGroupDataBlock")
            {
                if let (Some(sample_idx), Some(ch_idx)) = (
                    extract_sample_index(&path),
                    extract_data_channel_index(&path),
                ) {
                    let bytes_per_item: usize = match sub {
                        0x0004 => 4,
                        0x0005 => 8,
                        0x0016 => 4,
                        0x0011 => 1,
                        0x0000 => 0,
                        _ => {
                            return Err(Zs2Error::Parse {
                                offset: i,
                                msg: format!(
                                    "Unknown EE subtype 0x{sub:04X} in channel block at path {path}"
                                ),
                            }
                            .into());
                        }
                    };

                    let need = (cnt as usize) * bytes_per_item;
                    ensure_len(i + need, n, i)?;
                    let blob = &data[i..i + need];

                    // Only export signal arrays, skip validity/status side arrays.
                    if !path.ends_with("/DataArray") {
                        i += need;
                        continue;
                    }

                    let data_type_name = match sub {
                        0x0004 => "f32",
                        0x0005 => "f64",
                        0x0016 => "u32",
                        0x0011 => "u8",
                        0x0000 => "empty",
                        _ => {
                            return Err(Zs2Error::Parse {
                                offset: i,
                                msg: format!(
                                    "Unknown EE subtype 0x{sub:04X} in channel block at path {path}"
                                ),
                            }
                            .into());
                        }
                    };

                    // Look up channel name from TrsChannelId
                    let key = format!("sample_{}/ch_{}", sample_idx, ch_idx);
                    let trs_id = channel_trs_ids.get(&key).copied();
                    let ch_name = if let Some(tid) = trs_id {
                        trs_to_name
                            .get(&tid)
                            .cloned()
                            .unwrap_or_else(|| format!("Ch{}", ch_idx))
                    } else {
                        format!("Ch{}", ch_idx)
                    };

                    // Look up unit name from TrsChannelId -> unit_names
                    let unit = if let Some(tid) = trs_id {
                        let explicit = trs_to_unit.get(&tid).cloned().unwrap_or_default();
                        if explicit.trim().is_empty() {
                            infer_unit_from_channel_name(&ch_name).to_string()
                        } else {
                            explicit
                        }
                    } else {
                        infer_unit_from_channel_name(&ch_name).to_string()
                    };

                    if bytes_per_item == 0 {
                        i += need;
                        continue;
                    }

                    for (tp, chunk) in blob.chunks(bytes_per_item).enumerate() {
                        let value = match sub {
                            0x0004 => {
                                if chunk.len() >= 4 {
                                    f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])
                                        as f64
                                } else {
                                    f64::NAN
                                }
                            }
                            0x0005 => {
                                if chunk.len() >= 8 {
                                    f64::from_le_bytes([
                                        chunk[0], chunk[1], chunk[2], chunk[3], chunk[4], chunk[5],
                                        chunk[6], chunk[7],
                                    ])
                                } else {
                                    f64::NAN
                                }
                            }
                            0x0016 => {
                                if chunk.len() >= 4 {
                                    u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])
                                        as f64
                                } else {
                                    f64::NAN
                                }
                            }
                            0x0011 => {
                                if !chunk.is_empty() {
                                    chunk[0] as f64
                                } else {
                                    f64::NAN
                                }
                            }
                            _ => f64::NAN,
                        };

                        if value.is_finite() {
                            sample_idx_builder.append_value(sample_idx);
                            channel_idx_builder.append_value(ch_idx);
                            channel_name_builder.append_value(&ch_name);
                            unit_name_builder.append_value(&unit);
                            timepoint_builder.append_value(tp as u32);
                            value_builder.append_value(value);
                            data_type_builder.append_value(data_type_name);
                        }
                    }

                    i += need;
                    continue;
                }
            }

            // Skip non-channel data
            let bytes_per_item = match sub {
                0x0004 | 0x0016 | 0x0005 => {
                    if sub == 0x0005 {
                        8
                    } else {
                        4
                    }
                }
                0x0011 => 1,
                0x0000 => 0,
                _ => {
                    return Err(Zs2Error::Parse {
                        offset: i,
                        msg: format!("Unknown EE subtype 0x{sub:04X} at path {path}"),
                    }
                    .into())
                }
            };
            let need = (cnt as usize) * bytes_per_item;
            ensure_len(i + need, n, i)?;
            i += need;
        } else if dtype == 0xAA || dtype == 0x00 {
            ensure_len(i + 1 + 4, n, i)?;
            i += 1;
            let raw = u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
            i += 4;
            let char_count = (raw & 0x7FFF_FFFF) as usize;
            if char_count > MAX_UTF16_CHAR_COUNT {
                return Err(Zs2Error::Parse {
                    offset: i,
                    msg: format!(
                        "UTF-16 char count {char_count} exceeds limit {MAX_UTF16_CHAR_COUNT}"
                    ),
                }
                .into());
            }
            let need = char_count * 2;
            ensure_len(i + need, n, i)?;
            i += need;
        } else if dtype == 0xDD {
            ensure_len(i + 2, n, i)?;
            let len = data[i + 1] as usize;
            ensure_len(i + 2 + len, n, i)?;
            i += 2 + len;
            if name_stack.len() >= MAX_NESTING_DEPTH {
                return Err(Zs2Error::Parse {
                    offset: i,
                    msg: format!("nesting depth exceeds limit {MAX_NESTING_DEPTH}"),
                }
                .into());
            }
            name_stack.push(name);
        } else {
            match dtype {
                0xFF => i += 1,
                0x11 | 0x22 | 0x33 | 0x44 => i += 1 + 4,
                0x55 | 0x66 => i += 1 + 2,
                0x88 | 0x99 => i += 1 + 1,
                0xBB => i += 1 + 4,
                0xCC => i += 1 + 8,
                _ => {
                    return Err(Zs2Error::Parse {
                        offset: i,
                        msg: format!("Unknown data type tag 0x{dtype:02X}"),
                    }
                    .into())
                }
            }
        }
    }

    // Build and write Parquet
    let schema = std::sync::Arc::new(Schema::new(vec![
        Field::new("sample_idx", DataType::UInt32, false),
        Field::new("channel_idx", DataType::UInt32, false),
        Field::new("channel_name", DataType::Utf8, false),
        Field::new("unit", DataType::Utf8, true),
        Field::new("timepoint", DataType::UInt32, false),
        Field::new("value", DataType::Float64, true),
        Field::new("data_type", DataType::Utf8, false),
    ]));

    let batch = RecordBatch::try_new(
        std::sync::Arc::clone(&schema),
        vec![
            std::sync::Arc::new(sample_idx_builder.finish()),
            std::sync::Arc::new(channel_idx_builder.finish()),
            std::sync::Arc::new(channel_name_builder.finish()),
            std::sync::Arc::new(unit_name_builder.finish()),
            std::sync::Arc::new(timepoint_builder.finish()),
            std::sync::Arc::new(value_builder.finish()),
            std::sync::Arc::new(data_type_builder.finish()),
        ],
    )
    .map_err(Zs2Error::from)?;

    let file = File::create(output_parquet)?;
    let props = WriterProperties::builder().build();
    let mut writer = ArrowWriter::try_new(file, std::sync::Arc::clone(&schema), Some(props))
        .map_err(Zs2Error::from)?;
    writer.write(&batch).map_err(Zs2Error::from)?;
    writer.close().map_err(Zs2Error::from)?;

    Ok(())
}

/// Extract evaluated parameters from EigenschaftenListe to Parquet
#[pyfunction]
fn zs2_evaluated_params_to_parquet(
    py: Python<'_>,
    input_zs2: &str,
    output_parquet: &str,
) -> PyResult<()> {
    emit_deprecation_warning(
        py,
        "zs2_evaluated_params_to_parquet",
        "zs2_export_enriched_params_to_parquet",
    )?;

    export_enriched_params_impl(input_zs2, output_parquet)
}

fn export_enriched_params_impl(input_zs2: &str, output_parquet: &str) -> PyResult<()> {
    let data = read_zs2_with_limits(input_zs2)?;

    #[derive(Default)]
    struct DictEntry {
        param_id: Option<u32>,
        short_name: String,
        param_name: String,
        unit: String,
        unit_table_key: String,
    }

    #[derive(Default)]
    struct SampleParam {
        param_id: Option<u32>,
        value_numeric: Option<f64>,
        value_text: Option<String>,
    }

    #[derive(Default)]
    struct ChannelUnitEntry {
        param_id: Option<u32>,
        unit_name: String,
    }

    /// A single unit entry inside a UnitTable (e.g. "mm" with Factor=1.0)
    #[derive(Default)]
    struct UnitTableUnit {
        display_name: String,
        factor: Option<f64>,
    }

    let mut dict_by_elem: HashMap<u32, DictEntry> = HashMap::new();
    let mut cm_units_by_elem: HashMap<u32, ChannelUnitEntry> = HashMap::new();
    let mut sample_params: HashMap<(u32, u32), SampleParam> = HashMap::new();

    // UnitTables tracking: ut_keys[table_idx] = key name, ut_units[(table_idx, unit_idx)] = unit info
    let mut ut_keys: HashMap<u32, String> = HashMap::new();
    let mut ut_units: HashMap<(u32, u32), UnitTableUnit> = HashMap::new();

    let mut i = 4usize;
    let n = data.len();
    let mut name_stack: Vec<String> = Vec::with_capacity(8);

    // Strict path validators to prevent ID mismatches from nested structures
    let is_direct_param_id = |path: &str| {
        path.contains("/EvalContext/ParamContext/ParameterListe/Elem")
            && path.rsplit('/').next() == Some("ID")
            && path.matches("/ParameterListe/Elem").count() == 1
    };

    while i < n {
        if data[i] == 0xFF {
            if !name_stack.is_empty() {
                name_stack.pop();
            }
            i += 1;
            continue;
        }

        ensure_len(i + 1, n, i)?;
        let name_len = data[i] as usize;
        i += 1;
        ensure_len(i + name_len, n, i)?;
        let name = str::from_utf8(&data[i..i + name_len])?.to_string();
        i += name_len;

        if i >= n {
            break;
        }

        let dtype = data[i];
        let path = name_stack.join("/") + "/" + &name;
        let leaf = path.rsplit('/').next().unwrap_or("");
        let sample_key = extract_direct_sample_parameter_key(&path);
        let in_global_dict = path.contains("/Series/EvalContext/ParamContext/EigenschaftenListe/");
        let in_channel_manager = path.contains("/SeriesDef/TestTaskDefs/")
            && path.contains("/ChannelManager/ChannelManager/Elem");
        let in_unit_tables = path.starts_with("/UnitTables/") || path.contains("/UnitTables/");
        let dict_elem_idx = if in_global_dict {
            extract_elem_index(&path)
        } else {
            None
        };
        let cm_elem_idx = if in_channel_manager {
            extract_elem_index(&path)
        } else {
            None
        };

        match dtype {
            0xAA | 0x00 => {
                ensure_len(i + 1 + 4, n, i)?;
                i += 1;
                let raw = u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
                i += 4;
                let char_count = (raw & 0x7FFF_FFFF) as usize;
                if char_count > MAX_UTF16_CHAR_COUNT {
                    return Err(Zs2Error::Parse {
                        offset: i,
                        msg: format!(
                            "UTF-16 char count {char_count} exceeds limit {MAX_UTF16_CHAR_COUNT}"
                        ),
                    }
                    .into());
                }
                let need = char_count * 2;
                ensure_len(i + need, n, i)?;
                let text = decode_utf16le(&data[i..i + need])?;
                i += need;

                if let Some(elem_idx) = dict_elem_idx {
                    let entry = dict_by_elem.entry(elem_idx).or_default();
                    if path.ends_with("/Name/Text") {
                        entry.param_name = text.trim().to_string();
                    } else if path.ends_with("/Kurzzeichen/Text") {
                        entry.short_name = text.trim().to_string();
                    } else if path.ends_with("/EinheitName")
                        || path.ends_with("/Einheit/Kurzzeichen")
                    {
                        entry.unit = text.trim().to_string();
                    }
                }

                if let Some(elem_idx) = cm_elem_idx {
                    if path.ends_with("/UnitTableName") || path.ends_with("/Einheit/Kurzzeichen") {
                        cm_units_by_elem.entry(elem_idx).or_default().unit_name =
                            text.trim().to_string();
                    }
                }

                // UnitTables key and unit display name extraction
                if in_unit_tables {
                    let trimmed = text.trim().to_string();
                    if !trimmed.is_empty() {
                        // UnitTables/Key{N}
                        if let Some(key_idx) = extract_unit_table_key_index(&path) {
                            ut_keys.insert(key_idx, trimmed.clone());
                        }
                        // UnitTables/Elem{N}/Units/Elem{M}/DisplayName/Text
                        if path.ends_with("/DisplayName/Text") {
                            if let Some((tbl_idx, unit_idx)) =
                                extract_unit_table_unit_indices(&path)
                            {
                                ut_units
                                    .entry((tbl_idx, unit_idx))
                                    .or_default()
                                    .display_name = trimmed.clone();
                            }
                        }
                        // UnitTables/Elem{N}/Units/Elem{M}/Name (fallback if no DisplayName)
                        if path.ends_with("/Name") && path.contains("/Units/Elem") {
                            if let Some((tbl_idx, unit_idx)) =
                                extract_unit_table_unit_indices(&path)
                            {
                                let entry = ut_units.entry((tbl_idx, unit_idx)).or_default();
                                if entry.display_name.is_empty() {
                                    entry.display_name = trimmed;
                                }
                            }
                        }
                    }
                }
            }

            0x22 => {
                ensure_len(i + 1 + 4, n, i)?;
                i += 1;
                let raw_u32 = u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
                let val =
                    i32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as f64;
                i += 4;

                if in_global_dict && path.ends_with("/ID") {
                    if let Some(elem_idx) = dict_elem_idx {
                        dict_by_elem.entry(elem_idx).or_default().param_id = Some(raw_u32);
                    }
                }

                if in_channel_manager && path.ends_with("/ID") {
                    if let Some(elem_idx) = cm_elem_idx {
                        cm_units_by_elem.entry(elem_idx).or_default().param_id = Some(raw_u32);
                    }
                }

                if let Some((s_idx, p_idx)) = sample_key {
                    let entry = sample_params.entry((s_idx, p_idx)).or_default();
                    if leaf == "ID" {
                        entry.param_id = Some(raw_u32);
                    } else if is_likely_value_leaf(leaf) {
                        entry.value_numeric = Some(val);
                    }
                }
            }

            0x44 => {
                ensure_len(i + 1 + 4, n, i)?;
                i += 1;
                let val =
                    f32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as f64;
                i += 4;

                if let Some((s_idx, p_idx)) = sample_key {
                    let entry = sample_params.entry((s_idx, p_idx)).or_default();
                    if is_direct_param_id(&path) {
                        entry.param_id = Some(val as u32);
                    } else if is_likely_value_leaf(leaf) {
                        entry.value_numeric = Some(val);
                    }
                }
            }

            0xCC => {
                ensure_len(i + 1 + 8, n, i)?;
                i += 1;
                let val = f64::from_le_bytes([
                    data[i],
                    data[i + 1],
                    data[i + 2],
                    data[i + 3],
                    data[i + 4],
                    data[i + 5],
                    data[i + 6],
                    data[i + 7],
                ]);
                i += 8;

                if let Some((s_idx, p_idx)) = sample_key {
                    let entry = sample_params.entry((s_idx, p_idx)).or_default();
                    if is_direct_param_id(&path) {
                        entry.param_id = Some(val as u32);
                    } else if is_likely_value_leaf(leaf) {
                        entry.value_numeric = Some(val);
                    }
                }

                // UnitTables Factor extraction
                if in_unit_tables && path.ends_with("/Factor") {
                    if let Some((tbl_idx, unit_idx)) = extract_unit_table_unit_indices(&path) {
                        ut_units.entry((tbl_idx, unit_idx)).or_default().factor = Some(val);
                    }
                }
            }

            0xDD => {
                ensure_len(i + 2, n, i)?;
                let len = data[i + 1] as usize;
                ensure_len(i + 2 + len, n, i)?;
                i += 2 + len;
                if name_stack.len() >= MAX_NESTING_DEPTH {
                    return Err(Zs2Error::Parse {
                        offset: i,
                        msg: format!("nesting depth exceeds limit {MAX_NESTING_DEPTH}"),
                    }
                    .into());
                }
                name_stack.push(name);
            }

            0xEE => {
                ensure_len(i + 1 + 2 + 4, n, i)?;
                i += 1;
                let sub = u16::from_le_bytes([data[i], data[i + 1]]);
                i += 2;
                let cnt = u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
                i += 4;
                if cnt as usize > MAX_ARRAY_ELEMENTS {
                    return Err(Zs2Error::Parse {
                        offset: i,
                        msg: format!(
                            "array element count {} exceeds limit {}",
                            cnt, MAX_ARRAY_ELEMENTS
                        ),
                    }
                    .into());
                }

                let bytes_per_item = match sub {
                    0x0004 | 0x0016 => 4,
                    0x0005 => 8,
                    0x0011 => 1,
                    _ => 0,
                };
                let need = (cnt as usize) * bytes_per_item;
                ensure_len(i + need, n, i)?;

                if sub == 0x0016 && cnt >= 1 && need >= 4 {
                    let raw_u32 =
                        u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);

                    if in_global_dict && path.ends_with("/ID") {
                        if let Some(elem_idx) = dict_elem_idx {
                            dict_by_elem.entry(elem_idx).or_default().param_id = Some(raw_u32);
                        }
                    }

                    if is_direct_param_id(&path) {
                        if let Some((s_idx, p_idx)) = sample_key {
                            sample_params.entry((s_idx, p_idx)).or_default().param_id =
                                Some(raw_u32);
                        }
                    }
                }

                if let Some((s_idx, p_idx)) = sample_key {
                    if sub == 0x0011 {
                        let blob = &data[i..i + need];
                        let entry = sample_params.entry((s_idx, p_idx)).or_default();
                        if leaf == "QS_ValPar" {
                            if entry.value_numeric.is_none() {
                                entry.value_numeric = decode_qs_valpar_f64(blob);
                            }
                        } else if leaf == "QS_TextPar" && entry.value_text.is_none() {
                            entry.value_text = decode_qs_textpar(blob);
                        }
                    }
                }

                // Extract UnitTable key from QS_ValSetting in EigenschaftenListe
                if in_global_dict && sub == 0x0011 && leaf == "QS_ValSetting" {
                    let blob = &data[i..i + need];
                    if let Some(ut_key) = decode_unit_table_key_from_blob(blob) {
                        if let Some(elem_idx) = dict_elem_idx {
                            dict_by_elem.entry(elem_idx).or_default().unit_table_key = ut_key;
                        }
                    }
                }

                i += need;
            }

            0x11 | 0x33 => {
                ensure_len(i + 1 + 4, n, i)?;
                i += 1;
                let raw_u32 = u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
                let val = raw_u32 as f64;
                i += 4;

                if in_global_dict && path.ends_with("/ID") {
                    if let Some(elem_idx) = dict_elem_idx {
                        dict_by_elem.entry(elem_idx).or_default().param_id = Some(raw_u32);
                    }
                }

                if in_channel_manager && path.ends_with("/ID") {
                    if let Some(elem_idx) = cm_elem_idx {
                        cm_units_by_elem.entry(elem_idx).or_default().param_id = Some(raw_u32);
                    }
                }

                if let Some((s_idx, p_idx)) = sample_key {
                    let entry = sample_params.entry((s_idx, p_idx)).or_default();
                    if is_direct_param_id(&path) {
                        entry.param_id = Some(raw_u32);
                    } else if is_likely_value_leaf(leaf) {
                        entry.value_numeric = Some(val);
                    }
                }
            }
            0x55 | 0x66 => {
                ensure_len(i + 1 + 2, n, i)?;
                i += 1;
                let raw_u16 = u16::from_le_bytes([data[i], data[i + 1]]);
                i += 2;

                if in_global_dict && path.ends_with("/ID") {
                    if let Some(elem_idx) = dict_elem_idx {
                        dict_by_elem.entry(elem_idx).or_default().param_id = Some(raw_u16 as u32);
                    }
                }

                if in_channel_manager && path.ends_with("/ID") {
                    if let Some(elem_idx) = cm_elem_idx {
                        cm_units_by_elem.entry(elem_idx).or_default().param_id =
                            Some(raw_u16 as u32);
                    }
                }

                if let Some((s_idx, p_idx)) = sample_key {
                    let entry = sample_params.entry((s_idx, p_idx)).or_default();
                    if is_direct_param_id(&path) {
                        entry.param_id = Some(raw_u16 as u32);
                    } else if is_likely_value_leaf(leaf) {
                        entry.value_numeric = Some(raw_u16 as f64);
                    }
                }
            }
            0x88 | 0x99 => i += 1 + 1,
            0xBB => i += 1 + 4,

            _ => {
                return Err(Zs2Error::Parse {
                    offset: i,
                    msg: format!("Unknown data type tag 0x{dtype:02X}"),
                }
                .into())
            }
        }
    }

    let mut sample_idx_builder = UInt32Builder::new();
    let mut param_id_builder = UInt32Builder::new();
    let mut short_name_builder = StringBuilder::new();
    let mut param_name_builder = StringBuilder::new();
    let mut unit_builder = StringBuilder::new();
    let mut value_builder = Float64Builder::new();
    let mut value_text_builder = StringBuilder::new();

    let mut dict_by_param_id: HashMap<u32, (String, String, String)> = HashMap::new();

    // Build UnitTable key -> base unit symbol mapping
    // For each UnitTable, find the unit entry with Factor == 1.0 and use its DisplayName
    let mut ut_key_to_symbol: HashMap<String, String> = HashMap::new();
    for (tbl_idx, key_name) in &ut_keys {
        let mut base_unit: Option<String> = None;
        // Find the unit entry with Factor == 1.0 (base unit)
        for ((ti, _ui), unit_info) in &ut_units {
            if ti == tbl_idx && unit_info.factor == Some(1.0) && !unit_info.display_name.is_empty()
            {
                base_unit = Some(unit_info.display_name.clone());
                break;
            }
        }
        // Fallback: use first unit (index 0) if no Factor=1.0 found
        if base_unit.is_none() {
            if let Some(unit_info) = ut_units.get(&(*tbl_idx, 0)) {
                if !unit_info.display_name.is_empty() {
                    base_unit = Some(unit_info.display_name.clone());
                }
            }
        }
        if let Some(symbol) = base_unit {
            ut_key_to_symbol.insert(key_name.clone(), symbol);
        }
    }

    for (_, d) in dict_by_elem {
        if let Some(pid) = d.param_id {
            let mut unit = d.unit.clone();
            // If no direct EinheitName, resolve via QS_ValSetting UnitTable key
            if unit.trim().is_empty() && !d.unit_table_key.trim().is_empty() {
                if let Some(symbol) = ut_key_to_symbol.get(d.unit_table_key.trim()) {
                    unit = symbol.clone();
                } else if let Some(mapped) =
                    infer_unit_from_unit_table_name(d.unit_table_key.trim())
                {
                    unit = mapped.to_string();
                }
            }
            dict_by_param_id.insert(pid, (d.short_name, d.param_name, unit));
        }
    }

    let mut cm_unit_by_param_id: HashMap<u32, String> = HashMap::new();
    for (_, d) in cm_units_by_elem {
        if let Some(pid) = d.param_id {
            if !d.unit_name.trim().is_empty() {
                let mut unit = d.unit_name.trim().to_string();
                if let Some(mapped) = infer_unit_from_unit_table_name(&unit) {
                    unit = mapped.to_string();
                }
                cm_unit_by_param_id.insert(pid, unit);
            }
        }
    }

    let mut sorted_rows: Vec<_> = sample_params.into_iter().collect();
    sorted_rows.sort_by_key(|((sample_idx, plist_idx), _)| (*sample_idx, *plist_idx));

    for ((sample_idx, _plist_idx), entry) in sorted_rows {
        if let Some(pid) = entry.param_id {
            let (short_name, param_name, mut unit) = dict_by_param_id
                .get(&pid)
                .cloned()
                .unwrap_or((String::new(), String::new(), String::new()));

            if unit.trim().is_empty() {
                if let Some(cm_unit) = cm_unit_by_param_id.get(&pid) {
                    unit = cm_unit.clone();
                }
            }

            sample_idx_builder.append_value(sample_idx);
            param_id_builder.append_value(pid);
            short_name_builder.append_value(short_name);
            param_name_builder.append_value(param_name);
            unit_builder.append_value(unit);

            if let Some(val) = entry.value_numeric {
                value_builder.append_value(val);
            } else {
                value_builder.append_null();
            }

            if let Some(text) = &entry.value_text {
                value_text_builder.append_value(text);
            } else {
                value_text_builder.append_null();
            }
        }
    }

    let schema = std::sync::Arc::new(Schema::new(vec![
        Field::new("sample_idx", DataType::UInt32, false),
        Field::new("param_id", DataType::UInt32, false),
        Field::new("short_name", DataType::Utf8, false),
        Field::new("param_name", DataType::Utf8, false),
        Field::new("unit", DataType::Utf8, false),
        Field::new("value", DataType::Float64, true),
        Field::new("value_text", DataType::Utf8, true),
    ]));

    let batch = RecordBatch::try_new(
        std::sync::Arc::clone(&schema),
        vec![
            std::sync::Arc::new(sample_idx_builder.finish()),
            std::sync::Arc::new(param_id_builder.finish()),
            std::sync::Arc::new(short_name_builder.finish()),
            std::sync::Arc::new(param_name_builder.finish()),
            std::sync::Arc::new(unit_builder.finish()),
            std::sync::Arc::new(value_builder.finish()),
            std::sync::Arc::new(value_text_builder.finish()),
        ],
    )
    .map_err(Zs2Error::from)?;

    let file = File::create(output_parquet)?;
    let props = WriterProperties::builder().build();
    let mut writer = ArrowWriter::try_new(file, std::sync::Arc::clone(&schema), Some(props))
        .map_err(Zs2Error::from)?;
    writer.write(&batch).map_err(Zs2Error::from)?;
    writer.close().map_err(Zs2Error::from)?;

    Ok(())
}

/// Alias for `zs2_evaluated_params_to_parquet` with a clearer API name.
#[pyfunction]
fn zs2_export_enriched_params_to_parquet(input_zs2: &str, output_parquet: &str) -> PyResult<()> {
    export_enriched_params_impl(input_zs2, output_parquet)
}

#[inline]
fn ensure_len(want: usize, n: usize, at: usize) -> Res<()> {
    if want > n {
        return Err(Zs2Error::Parse {
            offset: at,
            msg: format!("unexpected EOF (need {want}, have {n})"),
        });
    }
    Ok(())
}

fn extract_sample_index(path: &str) -> Option<u32> {
    if let Some(start) = path.find("/SeriesElements/Elem") {
        let rest = &path[start + 20..];
        if let Some(end) = rest.find('/') {
            if let Ok(idx) = rest[..end].parse::<u32>() {
                return Some(idx);
            }
        }
    }
    None
}

fn extract_data_channel_index(path: &str) -> Option<u32> {
    if let Some(start) = path.find("/DataChannels/Elem") {
        let rest = &path[start + 18..];
        if let Some(end) = rest.find('/') {
            if let Ok(idx) = rest[..end].parse::<u32>() {
                return Some(idx);
            }
        }
    }
    None
}

fn extract_elem_index(path: &str) -> Option<u32> {
    if let Some((_, rest)) = path.rsplit_once("/EigenschaftenListe/Elem") {
        let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
        if !digits.is_empty() {
            if let Ok(idx) = digits.parse::<u32>() {
                return Some(idx);
            }
        }
    }

    if let Some((_, rest)) = path.rsplit_once("/Elem") {
        let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
        if !digits.is_empty() {
            if let Ok(idx) = digits.parse::<u32>() {
                return Some(idx);
            }
        }
    }
    None
}

fn extract_direct_sample_parameter_key(path: &str) -> Option<(u32, u32)> {
    let marker = "/SeriesElements/Elem";
    let start = path.find(marker)?;
    let rest = &path[start + marker.len()..];

    let sample_digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
    if sample_digits.is_empty() {
        return None;
    }
    let sample_idx = sample_digits.parse::<u32>().ok()?;

    let after_sample = &rest[sample_digits.len()..];
    let direct_tail = "/EvalContext/ParamContext/ParameterListe/Elem";
    if !after_sample.starts_with(direct_tail) {
        return None;
    }

    let param_rest = &after_sample[direct_tail.len()..];
    let plist_digits: String = param_rest
        .chars()
        .take_while(|c| c.is_ascii_digit())
        .collect();
    if plist_digits.is_empty() {
        return None;
    }
    let plist_idx = plist_digits.parse::<u32>().ok()?;

    Some((sample_idx, plist_idx))
}

/// Extract UnitTable key index from path like ".../UnitTables/Key{N}"
fn extract_unit_table_key_index(path: &str) -> Option<u32> {
    if let Some(pos) = path.find("/UnitTables/Key") {
        let rest = &path[pos + "/UnitTables/Key".len()..];
        let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
        if !digits.is_empty() && rest.len() == digits.len() {
            return digits.parse::<u32>().ok();
        }
    }
    None
}

/// Extract (table_idx, unit_idx) from path like ".../UnitTables/Elem{N}/Units/Elem{M}/..."
fn extract_unit_table_unit_indices(path: &str) -> Option<(u32, u32)> {
    let marker1 = "/UnitTables/Elem";
    let pos1 = path.find(marker1)?;
    let rest1 = &path[pos1 + marker1.len()..];
    let digits1: String = rest1.chars().take_while(|c| c.is_ascii_digit()).collect();
    if digits1.is_empty() {
        return None;
    }
    let tbl_idx = digits1.parse::<u32>().ok()?;

    let marker2 = "/Units/Elem";
    let rest2 = &rest1[digits1.len()..];
    let pos2 = rest2.find(marker2)?;
    let rest3 = &rest2[pos2 + marker2.len()..];
    let digits2: String = rest3.chars().take_while(|c| c.is_ascii_digit()).collect();
    if digits2.is_empty() {
        return None;
    }
    let unit_idx = digits2.parse::<u32>().ok()?;

    Some((tbl_idx, unit_idx))
}

/// Decode a UnitTable key name (e.g. "UT_Length", "Arbeit/Masse") from a QS_ValSetting blob.
/// The key is stored as a UTF-16LE string embedded in the byte blob.
fn decode_unit_table_key_from_blob(blob: &[u8]) -> Option<String> {
    // Scan for "UT_" prefix in UTF-16LE: U=0x55 0x00, T=0x54 0x00, _=0x5F 0x00
    let ut_prefix = [0x55u8, 0x00, 0x54, 0x00, 0x5F, 0x00];
    for start in 0..blob.len().saturating_sub(6) {
        if blob[start..start + 6] == ut_prefix {
            if let Some(s) = extract_utf16le_string(&blob[start..]) {
                if s.len() >= 4 {
                    return Some(s);
                }
            }
        }
    }

    // Also scan for other known UnitTable keys that don't start with "UT_"
    // These are still valid UTF-16LE strings with alphabetic characters
    // Look for any reasonable UTF-16LE string starting with uppercase letter followed by lowercase
    for start in 0..blob.len().saturating_sub(8) {
        let c0 = u16::from_le_bytes([blob[start], blob[start + 1]]);
        let c1 = u16::from_le_bytes([blob[start + 2], blob[start + 3]]);
        // Must start with uppercase letter
        if (0x41..=0x5A).contains(&c0) && (0x61..=0x7A).contains(&c1) {
            if let Some(s) = extract_utf16le_string(&blob[start..]) {
                // Must contain '/' to be a compound key like "Arbeit/Masse"
                // or be long enough to be meaningful
                if s.len() >= 6 && s.contains('/') {
                    return Some(s);
                }
            }
        }
    }

    None
}

/// Extract a UTF-16LE string from bytes until null terminator or non-printable character
fn extract_utf16le_string(blob: &[u8]) -> Option<String> {
    let mut chars = Vec::new();
    let mut j = 0;
    while j + 1 < blob.len() {
        let code = u16::from_le_bytes([blob[j], blob[j + 1]]);
        if code == 0 || code > 0x7F {
            break;
        }
        let ch = code as u8 as char;
        if !ch.is_ascii_alphanumeric() && !"_/+%".contains(ch) {
            break;
        }
        chars.push(ch);
        j += 2;
        if chars.len() >= 60 {
            break;
        }
    }
    if chars.is_empty() {
        None
    } else {
        Some(chars.iter().collect())
    }
}

fn decode_qs_valpar_f64(blob: &[u8]) -> Option<f64> {
    if blob.len() >= 9 {
        let v = f64::from_le_bytes([
            blob[1], blob[2], blob[3], blob[4], blob[5], blob[6], blob[7], blob[8],
        ]);
        if v.is_finite() && v.abs() < 1.0e12 {
            return Some(v);
        }
    }
    None
}

fn decode_qs_textpar(blob: &[u8]) -> Option<String> {
    // Deterministic format observed in QS_TextPar blobs:
    // [type: u8][char_count: u32 with high-bit marker][utf16_text...][trailing metadata]
    // Example: 01 49 00 00 80 <73 UTF-16 chars> 03 00 00 80 30 00 3a 00 64 00 ...
    if blob.len() >= 5 {
        let raw = u32::from_le_bytes([blob[1], blob[2], blob[3], blob[4]]);
        let char_count = (raw & 0x7FFF_FFFF) as usize;
        let need = char_count.saturating_mul(2);
        if char_count > 0 && blob.len() >= 5 + need {
            let text_bytes = &blob[5..5 + need];
            if text_bytes.len().is_multiple_of(2) {
                let mut units = Vec::with_capacity(text_bytes.len() / 2);
                for c in text_bytes.chunks_exact(2) {
                    units.push(u16::from_le_bytes([c[0], c[1]]));
                }
                let s = String::from_utf16_lossy(&units)
                    .trim_end_matches('\0')
                    .trim()
                    .to_string();
                if !s.is_empty() {
                    return Some(s);
                }
            }
        }
    }

    None
}

fn decode_utf16le(bytes: &[u8]) -> Res<String> {
    if !bytes.len().is_multiple_of(2) {
        return Ok(String::new());
    }

    // Convert bytes to u16 code units
    let mut code_units: Vec<u16> = Vec::with_capacity(bytes.len() / 2);
    for chunk in bytes.chunks_exact(2) {
        code_units.push(u16::from_le_bytes([chunk[0], chunk[1]]));
    }

    // Use Rust's built-in UTF-16 decoder which properly handles surrogate pairs
    let result = String::from_utf16_lossy(&code_units);
    Ok(result.trim_end_matches('\0').to_string())
}

fn decode_numeric_from_qs_valpar(blob: &[u8]) -> Option<f64> {
    // QS_ValPar format: [header_byte][f64_value][optional_text]
    // The f64 is at offset 1, not 0
    if blob.len() >= 9 {
        let v = f64::from_le_bytes([
            blob[1], blob[2], blob[3], blob[4], blob[5], blob[6], blob[7], blob[8],
        ]);
        if v.is_finite() && v.abs() < 1.0e15 && (v == 0.0 || v.abs() > 1.0e-100) {
            return Some(v);
        }
    }

    None
}

fn is_likely_value_leaf(leaf: &str) -> bool {
    let l = leaf.to_lowercase();
    if [
        "id", "idx", "index", "count", "anzahl", "typ", "type", "nr", "nummer",
    ]
    .iter()
    .any(|bad| l == *bad)
    {
        return false;
    }
    l.contains("wert") || l.contains("val") || l.contains("value") || l.contains("result")
}

/// Extract per-sample evaluated test results from:
/// Document/Body/batch/Series/SeriesElements/Elem{sample}/.../EvalContext/ParamContext/ParameterListe/Elem{param}
#[pyfunction]
fn zs2_parameterliste_results_to_parquet(
    py: Python<'_>,
    input_zs2: &str,
    output_parquet: &str,
) -> PyResult<()> {
    emit_deprecation_warning(
        py,
        "zs2_parameterliste_results_to_parquet",
        "zs2_export_sample_results_to_parquet",
    )?;

    export_sample_results_impl(input_zs2, output_parquet)
}

fn export_sample_results_impl(input_zs2: &str, output_parquet: &str) -> PyResult<()> {
    let data = read_zs2_with_limits(input_zs2)?;

    #[derive(Clone)]
    struct ResultData {
        param_id: Option<u32>,
        param_id_depth: usize,
        name: String,
        unit: String,
        value_text: Option<String>,
        value_numeric: Option<f64>,
        name_depth: usize,
        unit_depth: usize,
        text_depth: usize,
        numeric_depth: usize,
    }

    impl Default for ResultData {
        fn default() -> Self {
            Self {
                param_id: None,
                param_id_depth: usize::MAX,
                name: String::new(),
                unit: String::new(),
                value_text: None,
                value_numeric: None,
                name_depth: usize::MAX,
                unit_depth: usize::MAX,
                text_depth: usize::MAX,
                numeric_depth: usize::MAX,
            }
        }
    }

    #[derive(Default, Clone)]
    struct GlobalDefData {
        param_id: Option<u32>,
        name: String,
        unit: String,
    }

    let mut results_by_key: HashMap<(u32, u32, u8), ResultData> = HashMap::new();
    let mut global_defs_by_elem: HashMap<u32, GlobalDefData> = HashMap::new();

    let mut i = 4usize;
    let n = data.len();
    let mut name_stack: Vec<String> = Vec::with_capacity(8);

    let is_direct_result_id_path = |path: &str| {
        if let Some((_, rest)) = path.split_once("/EvalContext/ParamContext/ParameterListe/Elem") {
            let digits_len = rest.chars().take_while(|c| c.is_ascii_digit()).count();
            if digits_len == 0 {
                return false;
            }
            return rest[digits_len..].eq_ignore_ascii_case("/ID");
        }
        false
    };

    let is_direct_global_id_path = |path: &str| {
        if let Some((_, rest)) = path.rsplit_once("/EigenschaftenListe/Elem") {
            let digits_len = rest.chars().take_while(|c| c.is_ascii_digit()).count();
            if digits_len == 0 {
                return false;
            }
            return rest[digits_len..].eq_ignore_ascii_case("/ID");
        }
        false
    };

    while i < n {
        if data[i] == 0xFF {
            if !name_stack.is_empty() {
                name_stack.pop();
            }
            i += 1;
            continue;
        }

        ensure_len(i + 1, n, i)?;
        let name_len = data[i] as usize;
        i += 1;
        ensure_len(i + name_len, n, i)?;
        let name = str::from_utf8(&data[i..i + name_len])?.to_string();
        i += name_len;

        if i >= n {
            break;
        }

        let dtype = data[i];
        let path = name_stack.join("/") + "/" + &name;
        let path_depth = path.matches('/').count();

        // Extract key using lenient matching (allows other nodes between markers)
        // Use split_once for ParameterListe/Elem to keep the data-level element index.
        let key = if let Some((_, rest)) = path.split_once("/SeriesElements/Elem") {
            let sample_digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
            if !sample_digits.is_empty() && sample_digits.len() < 20 {
                let sample_idx = if let Ok(n) = sample_digits.parse::<u32>() {
                    n
                } else {
                    return Err(Zs2Error::Parse {
                        offset: i,
                        msg: "invalid sample idx".to_string(),
                    }
                    .into());
                };
                let after_sample = &rest[sample_digits.len()..];
                let param_tail = after_sample
                    .split_once(
                        "/SeriesElements/Elem0/EvalContext/ParamContext/ParameterListe/Elem",
                    )
                    .map(|(_, t)| (t, 2u8))
                    .or_else(|| {
                        after_sample
                            .split_once("/EvalContext/ParamContext/ParameterListe/Elem")
                            .map(|(_, t)| (t, 1u8))
                    });
                if let Some((rest2, branch_rank)) = param_tail {
                    let plist_digits: String =
                        rest2.chars().take_while(|c| c.is_ascii_digit()).collect();
                    if !plist_digits.is_empty() && plist_digits.len() < 20 {
                        if let Ok(param_idx) = plist_digits.parse::<u32>() {
                            Some((sample_idx, param_idx, branch_rank))
                        } else {
                            None
                        }
                    } else {
                        None
                    }
                } else {
                    None
                }
            } else {
                None
            }
        } else {
            None
        };

        let leaf = path.rsplit('/').next().unwrap_or("");

        match dtype {
            0xAA | 0x00 => {
                ensure_len(i + 1 + 4, n, i)?;
                i += 1;
                let raw = u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
                i += 4;
                let char_count = (raw & 0x7FFF_FFFF) as usize;
                if char_count > MAX_UTF16_CHAR_COUNT {
                    return Err(Zs2Error::Parse {
                        offset: i,
                        msg: format!(
                            "UTF-16 char count {char_count} exceeds limit {MAX_UTF16_CHAR_COUNT}"
                        ),
                    }
                    .into());
                }
                let need = char_count * 2;
                ensure_len(i + need, n, i)?;
                let text = decode_utf16le(&data[i..i + need])?;
                i += need;

                if let Some(k) = key {
                    let entry = results_by_key.entry(k).or_default();
                    let trimmed = text.trim();
                    if path.ends_with("/Name/Text") {
                        if !trimmed.is_empty()
                            && (entry.name.is_empty() || path_depth < entry.name_depth)
                        {
                            entry.name = trimmed.to_string();
                            entry.name_depth = path_depth;
                        }
                    } else if path.ends_with("/EinheitName")
                        && !trimmed.is_empty()
                        && (entry.unit.is_empty() || path_depth < entry.unit_depth)
                    {
                        entry.unit = trimmed.to_string();
                        entry.unit_depth = path_depth;
                    }
                } else if path.contains("/Series/EvalContext/ParamContext/EigenschaftenListe/") {
                    if let Some(param_idx) = extract_elem_index(&path) {
                        let def = global_defs_by_elem.entry(param_idx).or_default();
                        let trimmed = text.trim();
                        if path.ends_with("/Name/Text") && !trimmed.is_empty() {
                            def.name = trimmed.to_string();
                        } else if path.ends_with("/EinheitName") && !trimmed.is_empty() {
                            def.unit = trimmed.to_string();
                        }
                    }
                }
            }

            0x22 => {
                ensure_len(i + 1 + 4, n, i)?;
                i += 1;
                let val = i32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
                i += 4;

                if let Some(k) = key {
                    if is_direct_result_id_path(&path) && val >= 0 {
                        let entry = results_by_key.entry(k).or_default();
                        if entry.param_id.is_none() || path_depth < entry.param_id_depth {
                            entry.param_id = Some(val as u32);
                            entry.param_id_depth = path_depth;
                        }
                    }
                } else if path.contains("/Series/EvalContext/ParamContext/EigenschaftenListe/")
                    && is_direct_global_id_path(&path)
                    && val >= 0
                {
                    if let Some(param_idx) = extract_elem_index(&path) {
                        global_defs_by_elem.entry(param_idx).or_default().param_id =
                            Some(val as u32);
                    }
                }
            }

            0x44 => {
                ensure_len(i + 1 + 4, n, i)?;
                i += 1 + 4;
            }

            0xCC => {
                ensure_len(i + 1 + 8, n, i)?;
                i += 1 + 8;
            }

            0xDD => {
                ensure_len(i + 2, n, i)?;
                let len = data[i + 1] as usize;
                ensure_len(i + 2 + len, n, i)?;
                i += 2 + len;
                if name_stack.len() >= MAX_NESTING_DEPTH {
                    return Err(Zs2Error::Parse {
                        offset: i,
                        msg: format!("nesting depth exceeds limit {MAX_NESTING_DEPTH}"),
                    }
                    .into());
                }
                name_stack.push(name);
            }

            0xEE => {
                ensure_len(i + 1 + 2 + 4, n, i)?;
                i += 1;
                let sub = u16::from_le_bytes([data[i], data[i + 1]]);
                i += 2;
                let cnt = u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
                i += 4;
                if cnt as usize > MAX_ARRAY_ELEMENTS {
                    return Err(Zs2Error::Parse {
                        offset: i,
                        msg: format!(
                            "array element count {} exceeds limit {}",
                            cnt, MAX_ARRAY_ELEMENTS
                        ),
                    }
                    .into());
                }

                let bytes_per_item: usize = match sub {
                    0x0004 | 0x0016 => 4,
                    0x0005 => 8,
                    0x0011 => 1,
                    _ => 0,
                };
                let need = (cnt as usize) * bytes_per_item;
                ensure_len(i + need, n, i)?;

                if let Some(k) = key {
                    if sub == 0x0011 {
                        let blob = &data[i..i + need];
                        let entry = results_by_key.entry(k).or_default();

                        if leaf == "QS_TextPar" {
                            if let Some(decoded) = decode_qs_textpar(blob) {
                                if entry.value_text.is_none() || path_depth < entry.text_depth {
                                    entry.value_text = Some(decoded);
                                    entry.text_depth = path_depth;
                                }
                            }
                        } else if leaf == "QS_ValPar" {
                            if let Some(v) = decode_numeric_from_qs_valpar(blob) {
                                if entry.value_numeric.is_none() || path_depth < entry.numeric_depth
                                {
                                    entry.value_numeric = Some(v);
                                    entry.numeric_depth = path_depth;
                                }
                            }
                        }
                    }
                }

                i += need;
            }

            0x11 | 0x33 => {
                ensure_len(i + 1 + 4, n, i)?;
                i += 1;
                let val = u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
                i += 4;

                if let Some(k) = key {
                    if is_direct_result_id_path(&path) {
                        let entry = results_by_key.entry(k).or_default();
                        if entry.param_id.is_none() || path_depth < entry.param_id_depth {
                            entry.param_id = Some(val);
                            entry.param_id_depth = path_depth;
                        }
                    }
                } else if path.contains("/Series/EvalContext/ParamContext/EigenschaftenListe/")
                    && is_direct_global_id_path(&path)
                {
                    if let Some(param_idx) = extract_elem_index(&path) {
                        global_defs_by_elem.entry(param_idx).or_default().param_id = Some(val);
                    }
                }
            }
            0x55 | 0x66 => {
                ensure_len(i + 1 + 2, n, i)?;
                i += 1;
                let val = u16::from_le_bytes([data[i], data[i + 1]]) as u32;
                i += 2;

                if is_direct_result_id_path(&path) || is_direct_global_id_path(&path) {
                    if let Some(k) = key {
                        if is_direct_result_id_path(&path) {
                            let entry = results_by_key.entry(k).or_default();
                            if entry.param_id.is_none() || path_depth < entry.param_id_depth {
                                entry.param_id = Some(val);
                                entry.param_id_depth = path_depth;
                            }
                        }
                    } else if path.contains("/Series/EvalContext/ParamContext/EigenschaftenListe/")
                        && is_direct_global_id_path(&path)
                    {
                        if let Some(param_idx) = extract_elem_index(&path) {
                            global_defs_by_elem.entry(param_idx).or_default().param_id = Some(val);
                        }
                    }
                }
            }
            0x88 | 0x99 => {
                ensure_len(i + 1 + 1, n, i)?;
                i += 1;
                let val = data[i] as u32;
                i += 1;

                if is_direct_result_id_path(&path) || is_direct_global_id_path(&path) {
                    if let Some(k) = key {
                        if is_direct_result_id_path(&path) {
                            let entry = results_by_key.entry(k).or_default();
                            if entry.param_id.is_none() || path_depth < entry.param_id_depth {
                                entry.param_id = Some(val);
                                entry.param_id_depth = path_depth;
                            }
                        }
                    } else if path.contains("/Series/EvalContext/ParamContext/EigenschaftenListe/")
                        && is_direct_global_id_path(&path)
                    {
                        if let Some(param_idx) = extract_elem_index(&path) {
                            global_defs_by_elem.entry(param_idx).or_default().param_id = Some(val);
                        }
                    }
                }
            }
            0xBB => {
                ensure_len(i + 1 + 4, n, i)?;
                i += 1;
                let val = u32::from_le_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
                i += 4;

                if is_direct_result_id_path(&path) || is_direct_global_id_path(&path) {
                    if let Some(k) = key {
                        if is_direct_result_id_path(&path) {
                            let entry = results_by_key.entry(k).or_default();
                            if entry.param_id.is_none() || path_depth < entry.param_id_depth {
                                entry.param_id = Some(val);
                                entry.param_id_depth = path_depth;
                            }
                        }
                    } else if path.contains("/Series/EvalContext/ParamContext/EigenschaftenListe/")
                        && is_direct_global_id_path(&path)
                    {
                        if let Some(param_idx) = extract_elem_index(&path) {
                            global_defs_by_elem.entry(param_idx).or_default().param_id = Some(val);
                        }
                    }
                }
            }

            _ => {
                return Err(Zs2Error::Parse {
                    offset: i,
                    msg: format!("Unknown data type tag 0x{dtype:02X}"),
                }
                .into())
            }
        }
    }

    let mut sample_id_builder = UInt32Builder::new();
    let mut result_id_builder = UInt32Builder::new();
    let mut result_name_builder = StringBuilder::new();
    let mut unit_builder = StringBuilder::new();
    let mut value_text_builder = StringBuilder::new();
    let mut value_builder = Float64Builder::new();

    let mut sorted_results: Vec<_> = results_by_key.into_iter().collect();
    sorted_results
        .sort_by_key(|((sample_id, result_id, branch), _)| (*sample_id, *result_id, *branch));

    let mut global_param_defs: HashMap<u32, (String, String)> = HashMap::new();
    for def in global_defs_by_elem.values() {
        if let Some(param_id) = def.param_id {
            global_param_defs.insert(param_id, (def.name.clone(), def.unit.clone()));
        }
    }

    for ((sample_id, elem_idx, _branch), result_data) in sorted_results {
        let result_id = result_data.param_id.unwrap_or(elem_idx);

        let (def_name, def_unit) = global_param_defs
            .get(&result_id)
            .cloned()
            .unwrap_or((String::new(), String::new()));

        let mut final_name = if result_data.name.is_empty() {
            def_name
        } else {
            result_data.name.clone()
        };
        let final_unit = if result_data.unit.is_empty() {
            def_unit
        } else {
            result_data.unit.clone()
        };

        if final_name.is_empty() {
            final_name = format!("Param_{result_id}");
        }

        if result_data.value_numeric.is_some() || result_data.value_text.is_some() {
            sample_id_builder.append_value(sample_id);
            result_id_builder.append_value(result_id);
            result_name_builder.append_value(&final_name);
            unit_builder.append_value(&final_unit);

            if let Some(text) = &result_data.value_text {
                value_text_builder.append_value(text);
            } else {
                value_text_builder.append_null();
            }

            if let Some(val) = result_data.value_numeric {
                value_builder.append_value(val);
            } else {
                value_builder.append_null();
            }
        }
    }

    let schema = std::sync::Arc::new(Schema::new(vec![
        Field::new("sample_id", DataType::UInt32, false),
        Field::new("result_id", DataType::UInt32, false),
        Field::new("result_name", DataType::Utf8, false),
        Field::new("unit", DataType::Utf8, true),
        Field::new("value_text", DataType::Utf8, true),
        Field::new("value", DataType::Float64, true),
    ]));

    let batch = RecordBatch::try_new(
        std::sync::Arc::clone(&schema),
        vec![
            std::sync::Arc::new(sample_id_builder.finish()),
            std::sync::Arc::new(result_id_builder.finish()),
            std::sync::Arc::new(result_name_builder.finish()),
            std::sync::Arc::new(unit_builder.finish()),
            std::sync::Arc::new(value_text_builder.finish()),
            std::sync::Arc::new(value_builder.finish()),
        ],
    )
    .map_err(Zs2Error::from)?;

    let file = File::create(output_parquet)?;
    let props = WriterProperties::builder().build();
    let mut writer = ArrowWriter::try_new(file, std::sync::Arc::clone(&schema), Some(props))
        .map_err(Zs2Error::from)?;
    writer.write(&batch).map_err(Zs2Error::from)?;
    writer.close().map_err(Zs2Error::from)?;

    Ok(())
}

/// Alias for `zs2_parameterliste_results_to_parquet` with a clearer API name.
#[pyfunction]
fn zs2_export_sample_results_to_parquet(input_zs2: &str, output_parquet: &str) -> PyResult<()> {
    export_sample_results_impl(input_zs2, output_parquet)
}

fn infer_unit_from_channel_name(channel_name: &str) -> &'static str {
    let lower = channel_name.to_lowercase();

    if lower.contains("kraft") {
        "N"
    } else if lower.contains("weg") || lower.contains("dehnung") {
        "mm"
    } else if lower.contains("zeit") || lower.contains("datum") {
        "s"
    } else if lower.contains("belastungspunkt") {
        "index"
    } else {
        ""
    }
}

fn infer_unit_from_unit_table_name(unit_table_name: &str) -> Option<&'static str> {
    match unit_table_name {
        "UT_Displacement" | "UT_Length" => Some("mm"),
        "UT_Force" => Some("N"),
        "UT_Force/Area" | "UT_Stress" => Some("MPa"),
        "UT_Time" => Some("s"),
        "UT_Temperature" => Some("°C"),
        "UT_Velocity" => Some("mm/s"),
        "UT_Force/Time" => Some("N/s"),
        "UT_Strain/Time" => Some("1/s"),
        "UT_NoUnit" => Some(""),
        _ => None,
    }
}

#[pymodule]
fn zs2fast(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(zs2_to_parquet, m)?)?;
    m.add_function(wrap_pyfunction!(zs2_channels_to_parquet, m)?)?;
    m.add_function(wrap_pyfunction!(zs2_evaluated_params_to_parquet, m)?)?;
    m.add_function(wrap_pyfunction!(zs2_parameterliste_results_to_parquet, m)?)?;
    m.add_function(wrap_pyfunction!(zs2_export_enriched_params_to_parquet, m)?)?;
    m.add_function(wrap_pyfunction!(zs2_export_sample_results_to_parquet, m)?)?;
    Ok(())
}
