//! MATLAB-compatible `filewrite` builtin for RunMat.

use std::io::Write;
use std::path::{Path, PathBuf};

use runmat_builtins::{CharArray, IntValue, LogicalArray, StringArray, Tensor, Value};
use runmat_macros::runtime_builtin;

use crate::builtins::common::spec::{
    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
    ReductionNaN, ResidencyPolicy, ShapeRequirements,
};
use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
use runmat_filesystem::OpenOptions;

#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::filetext::filewrite")]
pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
    name: "filewrite",
    op_kind: GpuOpKind::Custom("io-file-write"),
    supported_precisions: &[],
    broadcast: BroadcastSemantics::None,
    provider_hooks: &[],
    constant_strategy: ConstantStrategy::InlineLiteral,
    residency: ResidencyPolicy::GatherImmediately,
    nan_mode: ReductionNaN::Include,
    two_pass_threshold: None,
    workgroup_size: None,
    accepts_nan_mode: false,
    notes: "Performs synchronous host file I/O; GPU providers do not participate.",
};

#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::filetext::filewrite")]
pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
    name: "filewrite",
    shape: ShapeRequirements::Any,
    constant_strategy: ConstantStrategy::InlineLiteral,
    elementwise: None,
    reduction: None,
    emits_nan: false,
    notes: "Standalone host-side operation; never fused with other kernels.",
};

const BUILTIN_NAME: &str = "filewrite";

fn filewrite_error(message: impl Into<String>) -> RuntimeError {
    build_runtime_error(message)
        .with_builtin(BUILTIN_NAME)
        .build()
}

fn map_control_flow(err: RuntimeError) -> RuntimeError {
    let identifier = err.identifier().map(str::to_string);
    let mut builder = build_runtime_error(format!("{BUILTIN_NAME}: {}", err.message()))
        .with_builtin(BUILTIN_NAME)
        .with_source(err);
    if let Some(identifier) = identifier {
        builder = builder.with_identifier(identifier);
    }
    builder.build()
}

fn map_control_flow_with_context(err: RuntimeError, context: &str) -> RuntimeError {
    let identifier = err.identifier().map(str::to_string);
    let mut builder = build_runtime_error(format!("{BUILTIN_NAME}: {context}: {}", err.message()))
        .with_builtin(BUILTIN_NAME)
        .with_source(err);
    if let Some(identifier) = identifier {
        builder = builder.with_identifier(identifier);
    }
    builder.build()
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FileEncoding {
    Auto,
    Utf8,
    Ascii,
    Latin1,
    Raw,
}

impl FileEncoding {
    fn from_label(label: &str) -> Option<Self> {
        match label.trim().to_ascii_lowercase().as_str() {
            "auto" | "default" | "system" | "native" => Some(FileEncoding::Auto),
            "utf-8" | "utf8" | "utf_8" | "unicode" => Some(FileEncoding::Utf8),
            "ascii" | "us-ascii" | "us_ascii" | "usascii" => Some(FileEncoding::Ascii),
            "latin1" | "latin-1" | "latin_1" | "iso-8859-1" | "iso8859-1" | "iso88591" => {
                Some(FileEncoding::Latin1)
            }
            "raw" | "bytes" | "byte" | "binary" => Some(FileEncoding::Raw),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WriteMode {
    Overwrite,
    Append,
}

impl WriteMode {
    fn from_label(label: &str) -> Option<Self> {
        match label.trim().to_ascii_lowercase().as_str() {
            "overwrite" | "replace" | "truncate" => Some(WriteMode::Overwrite),
            "append" | "add" => Some(WriteMode::Append),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, Copy)]
struct FilewriteOptions {
    encoding: FileEncoding,
    write_mode: WriteMode,
}

impl Default for FilewriteOptions {
    fn default() -> Self {
        Self {
            encoding: FileEncoding::Auto,
            write_mode: WriteMode::Overwrite,
        }
    }
}

#[runtime_builtin(
    name = "filewrite",
    category = "io/filetext",
    summary = "Write text or raw bytes to a file.",
    keywords = "filewrite,io,write file,text file,append,encoding",
    accel = "cpu",
    type_resolver(crate::builtins::io::type_resolvers::filewrite_type),
    builtin_path = "crate::builtins::io::filetext::filewrite"
)]
async fn filewrite_builtin(
    path: Value,
    data: Value,
    rest: Vec<Value>,
) -> crate::BuiltinResult<Value> {
    let path = gather_if_needed_async(&path)
        .await
        .map_err(map_control_flow)?;
    let data = gather_if_needed_async(&data)
        .await
        .map_err(map_control_flow)?;
    let rest = gather_values(&rest).await?;
    let options = parse_options(&rest)?;
    let resolved = resolve_path(&path)?;
    let payload = prepare_payload(&data, options.encoding)?;
    let written = write_bytes(&resolved, &payload, options.write_mode)?;
    Ok(Value::Num(written as f64))
}

async fn gather_values(values: &[Value]) -> BuiltinResult<Vec<Value>> {
    let mut out = Vec::with_capacity(values.len());
    for value in values {
        out.push(
            gather_if_needed_async(value)
                .await
                .map_err(map_control_flow)?,
        );
    }
    Ok(out)
}

fn parse_options(args: &[Value]) -> BuiltinResult<FilewriteOptions> {
    if args.is_empty() {
        return Ok(FilewriteOptions::default());
    }

    let mut options = FilewriteOptions::default();
    let mut idx = 0usize;
    let mut encoding_specified = false;
    let mut write_mode_specified = false;

    if !args.is_empty() && !is_keyword(&args[0])? {
        match encoding_from_value(&args[0]) {
            Ok(enc) => {
                options.encoding = enc;
                encoding_specified = true;
                idx = 1;
            }
            Err(err) => {
                if args.len() == 1 {
                    return Err(err);
                }
            }
        }
    }

    if !(args.len() - idx).is_multiple_of(2) {
        return Err(filewrite_error(
            "filewrite: expected keyword/value argument pairs",
        ));
    }

    while idx < args.len() {
        let key = keyword_name(&args[idx])?;
        let value = &args[idx + 1];
        if key.eq_ignore_ascii_case("encoding") {
            if encoding_specified {
                return Err(filewrite_error("filewrite: duplicate 'Encoding' argument"));
            }
            options.encoding = encoding_from_value(value)?;
            encoding_specified = true;
        } else if key.eq_ignore_ascii_case("writemode") {
            if write_mode_specified {
                return Err(filewrite_error("filewrite: duplicate 'WriteMode' argument"));
            }
            options.write_mode = write_mode_from_value(value)?;
            write_mode_specified = true;
        } else {
            return Err(filewrite_error(format!(
                "filewrite: unrecognised option '{}'",
                key
            )));
        }
        idx += 2;
    }

    Ok(options)
}

fn is_keyword(value: &Value) -> BuiltinResult<bool> {
    let text = keyword_name(value)?;
    Ok(text.eq_ignore_ascii_case("encoding") || text.eq_ignore_ascii_case("writemode"))
}

fn keyword_name(value: &Value) -> BuiltinResult<String> {
    match value {
        Value::String(s) => Ok(s.clone()),
        Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
        Value::CharArray(_) => Err(filewrite_error(
            "filewrite: keyword names must be 1-by-N character vectors",
        )),
        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
        Value::StringArray(_) => Err(filewrite_error(
            "filewrite: keyword inputs must be scalar string arrays",
        )),
        other => Err(filewrite_error(format!(
            "filewrite: expected keyword as string scalar or character vector, got {other:?}"
        ))),
    }
}

fn encoding_from_value(value: &Value) -> BuiltinResult<FileEncoding> {
    let label = keyword_name(value)?;
    match FileEncoding::from_label(&label) {
        Some(enc) => Ok(enc),
        None if label.trim().is_empty() => Err(filewrite_error(
            "filewrite: encoding name must not be empty",
        )),
        None => Err(filewrite_error(format!(
            "filewrite: unsupported encoding '{}'",
            label
        ))),
    }
}

fn write_mode_from_value(value: &Value) -> BuiltinResult<WriteMode> {
    let label = keyword_name(value)?;
    match WriteMode::from_label(&label) {
        Some(mode) => Ok(mode),
        None if label.trim().is_empty() => {
            Err(filewrite_error("filewrite: write mode must not be empty"))
        }
        None => Err(filewrite_error(format!(
            "filewrite: unsupported write mode '{}'; use 'overwrite' or 'append'",
            label
        ))),
    }
}

fn resolve_path(value: &Value) -> BuiltinResult<PathBuf> {
    match value {
        Value::String(s) => normalize_path(s),
        Value::CharArray(ca) if ca.rows == 1 => {
            let path: String = ca.data.iter().collect();
            normalize_path(&path)
        }
        Value::CharArray(_) => Err(filewrite_error(
            "filewrite: filename must be a 1-by-N character vector",
        )),
        Value::StringArray(sa) if sa.data.len() == 1 => normalize_path(&sa.data[0]),
        Value::StringArray(_) => Err(filewrite_error(
            "filewrite: string array filename inputs must be scalar",
        )),
        other => Err(filewrite_error(format!(
            "filewrite: expected filename as string scalar or character vector, got {other:?}"
        ))),
    }
}

fn normalize_path(raw: &str) -> BuiltinResult<PathBuf> {
    if raw.is_empty() {
        return Err(filewrite_error("filewrite: filename must not be empty"));
    }
    Ok(Path::new(raw).to_path_buf())
}

enum Payload {
    Text(Vec<char>),
    Bytes(Vec<u8>),
}

fn prepare_payload(data: &Value, encoding: FileEncoding) -> BuiltinResult<Vec<u8>> {
    let payload = extract_payload(data)?;
    match payload {
        Payload::Text(chars) => encode_text(chars, encoding),
        Payload::Bytes(bytes) => encode_bytes(bytes, encoding),
    }
}

fn extract_payload(data: &Value) -> BuiltinResult<Payload> {
    match data {
        Value::CharArray(ca) => Ok(Payload::Text(char_array_to_text(ca))),
        Value::String(s) => Ok(Payload::Text(s.chars().collect())),
        Value::StringArray(sa) => Ok(Payload::Text(string_array_to_text(sa))),
        Value::Num(n) => {
            let byte = float_to_byte(*n)
                .map_err(|err| map_control_flow_with_context(err, "filewrite: numeric value"))?;
            Ok(Payload::Bytes(vec![byte]))
        }
        Value::Int(i) => {
            let byte = int_value_to_byte(i)
                .map_err(|err| map_control_flow_with_context(err, "filewrite: integer value"))?;
            Ok(Payload::Bytes(vec![byte]))
        }
        Value::Bool(flag) => Ok(Payload::Bytes(vec![if *flag { 1 } else { 0 }])),
        Value::Tensor(t) => Ok(Payload::Bytes(tensor_to_bytes(t)?)),
        Value::LogicalArray(la) => Ok(Payload::Bytes(logical_to_bytes(la))),
        Value::Cell(_) => Err(filewrite_error(
            "filewrite: cell arrays are not supported inputs",
        )),
        Value::GpuTensor(_) => Err(filewrite_error(
            "filewrite: internal error: GPU tensor should be gathered",
        )),
        Value::Complex(_, _) | Value::ComplexTensor(_) => Err(filewrite_error(
            "filewrite: complex data must be converted to text before writing",
        )),
        other => Err(filewrite_error(format!(
            "filewrite: unsupported data type {other:?}; expected text or uint8-compatible array"
        ))),
    }
}

fn char_array_to_text(ca: &CharArray) -> Vec<char> {
    if ca.rows <= 1 {
        return ca.data.clone();
    }
    let mut out = Vec::with_capacity(ca.rows * (ca.cols + 1));
    for row in 0..ca.rows {
        for col in 0..ca.cols {
            out.push(ca.data[row * ca.cols + col]);
        }
        if row + 1 < ca.rows {
            out.push('\n');
        }
    }
    out
}

fn string_array_to_text(sa: &StringArray) -> Vec<char> {
    if sa.data.is_empty() {
        return Vec::new();
    }
    let mut combined = String::new();
    for (idx, entry) in sa.data.iter().enumerate() {
        combined.push_str(entry);
        if idx + 1 < sa.data.len() {
            combined.push('\n');
        }
    }
    combined.chars().collect()
}

fn tensor_to_bytes(tensor: &Tensor) -> BuiltinResult<Vec<u8>> {
    let mut out = Vec::with_capacity(tensor.data.len());
    for (idx, value) in tensor.data.iter().enumerate() {
        match float_to_byte(*value) {
            Ok(byte) => out.push(byte),
            Err(msg) => {
                return Err(filewrite_error(format!(
                    "filewrite: numeric element {} {msg}",
                    idx
                )));
            }
        }
    }
    Ok(out)
}

fn logical_to_bytes(array: &LogicalArray) -> Vec<u8> {
    array
        .data
        .iter()
        .map(|value| if *value != 0 { 1 } else { 0 })
        .collect()
}

fn float_to_byte(value: f64) -> BuiltinResult<u8> {
    if !value.is_finite() {
        return Err(filewrite_error(format!(
            "value {value} is not finite; cannot write as raw byte"
        )));
    }
    let rounded = value.round();
    if (value - rounded).abs() > 1e-9 {
        return Err(filewrite_error(format!(
            "value {value} is not an integer in the range 0..255"
        )));
    }
    let int = rounded as i128;
    if !(0..=255).contains(&int) {
        return Err(filewrite_error(format!(
            "value {value} is not in the range 0..255"
        )));
    }
    Ok(int as u8)
}

fn int_value_to_byte(value: &IntValue) -> BuiltinResult<u8> {
    match value {
        IntValue::I8(v) => signed_to_byte(*v as i64),
        IntValue::I16(v) => signed_to_byte(*v as i64),
        IntValue::I32(v) => signed_to_byte(*v as i64),
        IntValue::I64(v) => signed_to_byte(*v),
        IntValue::U8(v) => Ok(*v),
        IntValue::U16(v) => unsigned_to_byte(*v as u64),
        IntValue::U32(v) => unsigned_to_byte(*v as u64),
        IntValue::U64(v) => unsigned_to_byte(*v),
    }
}

fn signed_to_byte(value: i64) -> BuiltinResult<u8> {
    if !(0..=255).contains(&value) {
        return Err(filewrite_error(format!(
            "value {value} is not in the range 0..255"
        )));
    }
    Ok(value as u8)
}

fn unsigned_to_byte(value: u64) -> BuiltinResult<u8> {
    if value > 255 {
        return Err(filewrite_error(format!(
            "value {value} is not in the range 0..255"
        )));
    }
    Ok(value as u8)
}

fn encode_text(chars: Vec<char>, encoding: FileEncoding) -> BuiltinResult<Vec<u8>> {
    match encoding {
        FileEncoding::Auto | FileEncoding::Utf8 => {
            Ok(chars.iter().collect::<String>().into_bytes())
        }
        FileEncoding::Ascii => encode_ascii_chars(&chars),
        FileEncoding::Latin1 | FileEncoding::Raw => encode_latin_chars(&chars, encoding),
    }
}

fn encode_ascii_chars(chars: &[char]) -> BuiltinResult<Vec<u8>> {
    let mut out = Vec::with_capacity(chars.len());
    for &ch in chars {
        if ch as u32 > 0x7F {
            return Err(filewrite_error(format!(
                "filewrite: character '{}' (U+{:04X}) cannot be encoded as ASCII",
                ch, ch as u32
            )));
        }
        out.push(ch as u8);
    }
    Ok(out)
}

fn encode_latin_chars(chars: &[char], encoding: FileEncoding) -> BuiltinResult<Vec<u8>> {
    let mut out = Vec::with_capacity(chars.len());
    for &ch in chars {
        if ch as u32 > 0xFF {
            return Err(filewrite_error(format!(
                "filewrite: character '{}' (U+{:04X}) cannot be encoded as {}",
                ch,
                ch as u32,
                match encoding {
                    FileEncoding::Latin1 => "Latin-1",
                    FileEncoding::Raw => "raw bytes",
                    _ => unreachable!(),
                }
            )));
        }
        out.push(ch as u8);
    }
    Ok(out)
}

fn encode_bytes(bytes: Vec<u8>, encoding: FileEncoding) -> BuiltinResult<Vec<u8>> {
    if matches!(encoding, FileEncoding::Ascii) {
        for (idx, byte) in bytes.iter().enumerate() {
            if *byte > 0x7F {
                return Err(filewrite_error(format!(
                    "filewrite: byte 0x{byte:02X} at index {idx} cannot be encoded as ASCII"
                )));
            }
        }
    }
    Ok(bytes)
}

fn write_bytes(path: &Path, payload: &[u8], mode: WriteMode) -> BuiltinResult<usize> {
    let mut options = OpenOptions::new();
    options.create(true);
    match mode {
        WriteMode::Overwrite => {
            options.write(true).truncate(true);
        }
        WriteMode::Append => {
            options.write(true).append(true);
        }
    }

    let mut file = options.open(path).map_err(|err| {
        build_runtime_error(format!(
            "filewrite: unable to open '{}': {}",
            path.display(),
            err
        ))
        .with_builtin(BUILTIN_NAME)
        .with_source(err)
        .build()
    })?;

    file.write_all(payload).map_err(|err| {
        build_runtime_error(format!(
            "filewrite: unable to write to '{}': {}",
            path.display(),
            err
        ))
        .with_builtin(BUILTIN_NAME)
        .with_source(err)
        .build()
    })?;

    file.flush().map_err(|err| {
        build_runtime_error(format!(
            "filewrite: unable to flush '{}': {}",
            path.display(),
            err
        ))
        .with_builtin(BUILTIN_NAME)
        .with_source(err)
        .build()
    })?;

    Ok(payload.len())
}

#[cfg(test)]
pub(crate) mod tests {
    use super::*;
    use crate::builtins::common::test_support;
    use crate::RuntimeError;
    use runmat_time::unix_timestamp_ms;
    use std::io::Read;

    fn unwrap_error_message(err: RuntimeError) -> String {
        err.message().to_string()
    }

    fn run_filewrite(path: Value, data: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
        futures::executor::block_on(filewrite_builtin(path, data, rest))
    }

    fn unique_path(prefix: &str) -> PathBuf {
        let millis = unix_timestamp_ms();
        let mut path = std::env::temp_dir();
        path.push(format!("runmat_{prefix}_{}_{}", std::process::id(), millis));
        path
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn filewrite_writes_text_content() {
        let path = unique_path("filewrite_text");
        let contents = "RunMat filewrite\nLine two\n";

        let result = run_filewrite(
            Value::from(path.to_string_lossy().to_string()),
            Value::from(contents),
            Vec::new(),
        )
        .expect("filewrite");

        match result {
            Value::Num(n) => assert_eq!(n as usize, contents.len()),
            other => panic!("expected numeric byte count, got {other:?}"),
        }

        let written = test_support::fs::read_to_string(&path).expect("read filewrite output");
        assert_eq!(written, contents);

        let _ = test_support::fs::remove_file(&path);
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn filewrite_appends_when_requested() {
        let path = unique_path("filewrite_append");
        test_support::fs::write(&path, "first\n").expect("write baseline");

        run_filewrite(
            Value::from(path.to_string_lossy().to_string()),
            Value::from("second\n"),
            vec![Value::from("WriteMode"), Value::from("append")],
        )
        .expect("filewrite append");

        let written = test_support::fs::read_to_string(&path).expect("read appended file");
        assert_eq!(written, "first\nsecond\n");

        let _ = test_support::fs::remove_file(&path);
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn filewrite_errors_on_invalid_ascii() {
        let path = unique_path("filewrite_ascii_error");
        let err = unwrap_error_message(
            run_filewrite(
                Value::from(path.to_string_lossy().to_string()),
                Value::from("café"),
                vec![Value::from("Encoding"), Value::from("ascii")],
            )
            .unwrap_err(),
        );
        assert!(
            err.contains("cannot be encoded as ASCII"),
            "unexpected error message: {err}"
        );
        assert!(!path.exists());
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn filewrite_writes_raw_bytes_from_tensor() {
        let path = unique_path("filewrite_raw_bytes");
        let tensor = Tensor::new(vec![0.0, 127.0, 255.0], vec![3, 1]).expect("tensor");
        run_filewrite(
            Value::from(path.to_string_lossy().to_string()),
            Value::Tensor(tensor),
            vec![Value::from("Encoding"), Value::from("raw")],
        )
        .expect("filewrite raw");

        let mut bytes = Vec::new();
        runmat_filesystem::File::open(&path)
            .expect("open raw file")
            .read_to_end(&mut bytes)
            .expect("read raw file");
        assert_eq!(bytes, vec![0u8, 127u8, 255u8]);

        let _ = test_support::fs::remove_file(&path);
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn filewrite_numeric_scalar_writes_byte() {
        let path = unique_path("filewrite_numeric_scalar");
        run_filewrite(
            Value::from(path.to_string_lossy().to_string()),
            Value::Num(65.0),
            Vec::new(),
        )
        .expect("filewrite numeric scalar");

        let bytes = test_support::fs::read(&path).expect("read numeric scalar file");
        assert_eq!(bytes, vec![65u8]);

        let _ = test_support::fs::remove_file(&path);
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn filewrite_bool_scalar_writes_byte() {
        let path = unique_path("filewrite_bool_scalar");
        run_filewrite(
            Value::from(path.to_string_lossy().to_string()),
            Value::Bool(true),
            Vec::new(),
        )
        .expect("filewrite bool scalar");

        let bytes = test_support::fs::read(&path).expect("read bool scalar file");
        assert_eq!(bytes, vec![1u8]);

        let _ = test_support::fs::remove_file(&path);
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn filewrite_writes_logical_array_bytes() {
        let path = unique_path("filewrite_logical_array");
        let logical = LogicalArray::new(vec![0, 1, 2], vec![3]).expect("logical array");
        run_filewrite(
            Value::from(path.to_string_lossy().to_string()),
            Value::LogicalArray(logical),
            Vec::new(),
        )
        .expect("filewrite logical array");

        let bytes = test_support::fs::read(&path).expect("read logical array file");
        assert_eq!(bytes, vec![0u8, 1u8, 1u8]);

        let _ = test_support::fs::remove_file(&path);
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn filewrite_errors_on_numeric_out_of_range() {
        let path = unique_path("filewrite_out_of_range");
        let err = unwrap_error_message(
            run_filewrite(
                Value::from(path.to_string_lossy().to_string()),
                Value::Num(300.0),
                Vec::new(),
            )
            .unwrap_err(),
        );
        assert!(
            err.contains("range 0..255"),
            "unexpected error message: {err}"
        );
        assert!(!path.exists());
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn filewrite_errors_on_non_integer_numeric() {
        let path = unique_path("filewrite_non_integer");
        let err = unwrap_error_message(
            run_filewrite(
                Value::from(path.to_string_lossy().to_string()),
                Value::Num(std::f64::consts::PI),
                Vec::new(),
            )
            .unwrap_err(),
        );
        assert!(
            err.contains("not an integer"),
            "unexpected error message: {err}"
        );
        assert!(!path.exists());
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn filewrite_rejects_ascii_numeric_bytes_above_range() {
        let path = unique_path("filewrite_ascii_bytes");
        let tensor = Tensor::new(vec![255.0], vec![1, 1]).expect("tensor");
        let err = unwrap_error_message(
            run_filewrite(
                Value::from(path.to_string_lossy().to_string()),
                Value::Tensor(tensor),
                vec![Value::from("Encoding"), Value::from("ascii")],
            )
            .unwrap_err(),
        );
        assert!(
            err.contains("cannot be encoded as ASCII"),
            "unexpected error message: {err}"
        );
        assert!(!path.exists());
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn filewrite_positional_encoding_argument() {
        let path = unique_path("filewrite_positional_encoding");
        run_filewrite(
            Value::from(path.to_string_lossy().to_string()),
            Value::from("Espa\u{00F1}a"),
            vec![Value::from("latin1")],
        )
        .expect("filewrite positional encoding");

        let bytes = test_support::fs::read(&path).expect("read latin1 file");
        assert_eq!(bytes, b"Espa\xF1a");

        let _ = test_support::fs::remove_file(&path);
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn filewrite_utf8_encoding_allows_arbitrary_bytes() {
        let path = unique_path("filewrite_utf8_numeric");
        let tensor = Tensor::new(vec![0.0, 255.0], vec![2, 1]).expect("tensor");
        run_filewrite(
            Value::from(path.to_string_lossy().to_string()),
            Value::Tensor(tensor),
            vec![Value::from("Encoding"), Value::from("utf-8")],
        )
        .expect("filewrite utf8 numeric");

        let bytes = test_support::fs::read(&path).expect("read utf8 numeric file");
        assert_eq!(bytes, vec![0u8, 255u8]);

        let _ = test_support::fs::remove_file(&path);
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn filewrite_rejects_unknown_option() {
        let path = unique_path("filewrite_unknown_option");
        let err = unwrap_error_message(
            run_filewrite(
                Value::from(path.to_string_lossy().to_string()),
                Value::from("data"),
                vec![Value::from("Mode"), Value::from("append")],
            )
            .unwrap_err(),
        );
        assert!(
            err.contains("unrecognised option"),
            "unexpected error message: {err}"
        );
        assert!(!path.exists());
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn filewrite_rejects_duplicate_encoding() {
        let path = unique_path("filewrite_duplicate_encoding");
        let err = unwrap_error_message(
            run_filewrite(
                Value::from(path.to_string_lossy().to_string()),
                Value::from("data"),
                vec![
                    Value::from("utf-8"),
                    Value::from("Encoding"),
                    Value::from("ascii"),
                ],
            )
            .unwrap_err(),
        );
        assert!(
            err.contains("duplicate 'Encoding'"),
            "unexpected error message: {err}"
        );
        assert!(!path.exists());
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn filewrite_rejects_duplicate_writemode() {
        let path = unique_path("filewrite_duplicate_writemode");
        let err = unwrap_error_message(
            run_filewrite(
                Value::from(path.to_string_lossy().to_string()),
                Value::from("data"),
                vec![
                    Value::from("WriteMode"),
                    Value::from("append"),
                    Value::from("WriteMode"),
                    Value::from("overwrite"),
                ],
            )
            .unwrap_err(),
        );
        assert!(
            err.contains("duplicate 'WriteMode'"),
            "unexpected error message: {err}"
        );
        assert!(!path.exists());
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn filewrite_rejects_invalid_writemode_value() {
        let path = unique_path("filewrite_invalid_writemode");
        let err = unwrap_error_message(
            run_filewrite(
                Value::from(path.to_string_lossy().to_string()),
                Value::from("data"),
                vec![Value::from("WriteMode"), Value::from("invalid")],
            )
            .unwrap_err(),
        );
        assert!(
            err.contains("unsupported write mode"),
            "unexpected error message: {err}"
        );
        assert!(!path.exists());
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn filewrite_rejects_invalid_encoding_value() {
        let path = unique_path("filewrite_invalid_encoding");
        let err = unwrap_error_message(
            run_filewrite(
                Value::from(path.to_string_lossy().to_string()),
                Value::from("data"),
                vec![Value::from("Encoding"), Value::from("utf-32")],
            )
            .unwrap_err(),
        );
        assert!(
            err.contains("unsupported encoding"),
            "unexpected error message: {err}"
        );
        assert!(!path.exists());
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn filewrite_accepts_char_array_filename() {
        let path = unique_path("filewrite_char_path");
        let path_str = path.to_string_lossy();
        let chars: Vec<char> = path_str.chars().collect();
        let char_array = CharArray::new(chars, 1, path_str.len()).expect("char array path");

        run_filewrite(
            Value::CharArray(char_array),
            Value::from("hello"),
            Vec::new(),
        )
        .expect("filewrite char path");

        let written = test_support::fs::read_to_string(&path).expect("read char path file");
        assert_eq!(written, "hello");

        let _ = test_support::fs::remove_file(&path);
    }

    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
    #[test]
    fn filewrite_string_array_stores_newlines() {
        let path = unique_path("filewrite_string_array");
        let array = StringArray::new(vec!["a".into(), "b".into(), "c".into()], vec![3, 1])
            .expect("string array");
        run_filewrite(
            Value::from(path.to_string_lossy().to_string()),
            Value::StringArray(array),
            Vec::new(),
        )
        .expect("filewrite string array");

        let written = test_support::fs::read_to_string(&path).expect("read string array file");
        assert_eq!(written, "a\nb\nc");

        let _ = test_support::fs::remove_file(&path);
    }
}
