//! `export-fmu` command: compile an Entropyk JSON model into an FMI 2.0
//! Co-Simulation `.fmu` archive.
//!
//! Pipeline:
//! 1. Read the model JSON (`ScenarioConfig`) and the IO-map JSON (`fmu_io.json`).
//! 2. Stage a generated crate under `target/fmu-build/fmu_crate/` that re-exports
//!    the `entropyk-fmi` runtime with the model baked into `embedded.rs` (no
//!    filesystem access at runtime).
//! 3. `cargo build --release --target <target>` the staged cdylib.
//! 4. Generate `modelDescription.xml` from the IO map.
//! 5. Zip `modelDescription.xml` + `binaries/<platform>/<lib>` + `resources/`
//!    into the output `.fmu`.

use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use crate::config::ScenarioConfig;
use crate::error::{CliError, CliResult};

/// IO map (mirrors `bindings/fmi/src/io_map.rs`).
#[derive(Debug, Clone, serde::Deserialize)]
pub struct FmuIoSpec {
    #[serde(default)]
    pub inputs: Vec<IoVar>,
    #[serde(default)]
    pub outputs: Vec<IoVar>,
}

#[derive(Debug, Clone, serde::Deserialize)]
pub struct IoVar {
    pub name: String,
    #[serde(default)]
    pub component: String,
    #[serde(default)]
    pub param: String,
    pub kind: String,
    #[serde(default)]
    pub edge: Option<usize>,
}

/// Entry point for `entropyk-cli export-fmu`.
pub fn run_export_fmu(
    config_path: &Path,
    io_path: &Path,
    target: &str,
    out_path: &Path,
    verbose: bool,
) -> CliResult<()> {
    println!("  Model:    {}", config_path.display());
    println!("  IO map:   {}", io_path.display());
    println!("  Target:   {target}");
    println!("  Output:   {}", out_path.display());

    let model_json = fs::read_to_string(config_path)
        .map_err(|e| CliError::Config(format!("read model {}: {e}", config_path.display())))?;
    let io_json = fs::read_to_string(io_path)
        .map_err(|e| CliError::Config(format!("read io {}: {e}", io_path.display())))?;

    // Validate the model parses.
    let config = ScenarioConfig::from_json(&model_json)?;
    let io: FmuIoSpec = serde_json::from_str(&io_json)
        .map_err(|e| CliError::Config(format!("parse io map: {e}")))?;

    let target = if target == "host" {
        resolve_host_triple()?
    } else {
        target.to_string()
    };

    let manifest_dir = env_manifest_dir();
    let fmi_src = manifest_dir.join("bindings").join("fmi").join("src");
    if !fmi_src.is_dir() {
        return Err(CliError::Config(format!(
            "FMI runtime source not found at {} (expected bindings/fmi/src)",
            fmi_src.display()
        )));
    }

    let stage_dir = manifest_dir.join("target").join("fmu-build");
    fs::create_dir_all(&stage_dir)?;
    let stage = stage_dir.join("fmu_crate");

    stage_crate(&stage, &fmi_src, &manifest_dir, &model_json, &io_json)?;

    let platform = platform_folder(&target)
        .ok_or_else(|| CliError::Config(format!("unsupported target: {target}")))?;

    build_cdylib(&stage, &target, verbose)?;

    let lib = locate_cdylib(&stage, &target)?;
    let model_name = config.name.unwrap_or_else(|| "entropyk_model".to_string());

    let xml = render_model_description(&model_name, &io);
    assemble_fmu(out_path, &xml, &lib, platform, &model_json, &io_json)?;

    println!("  OK FMU written: {}", out_path.display());
    Ok(())
}

/// Absolute path to the workspace root.
///
/// `CARGO_MANIFEST_DIR` for the `cli` crate is `<root>/crates/cli`; the
/// workspace root is two levels up.
fn env_manifest_dir() -> PathBuf {
    let cli_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    cli_dir
        .parent()
        .and_then(|p| p.parent())
        .map(PathBuf::from)
        .unwrap_or(cli_dir)
}

/// Resolve the host Rust target triple via `rustc -vV`.
fn resolve_host_triple() -> CliResult<String> {
    let out = Command::new("rustc")
        .arg("-vV")
        .output()
        .map_err(|e| CliError::Simulation(format!("invoke rustc -vV: {e}")))?;
    let s = String::from_utf8_lossy(&out.stdout);
    for line in s.lines() {
        if let Some(triple) = line.strip_prefix("host: ") {
            return Ok(triple.trim().to_string());
        }
    }
    Err(CliError::Simulation(
        "could not parse host triple from `rustc -vV`".to_string(),
    ))
}

/// Map a Rust target triple to an FMI 2.0 `binaries/<platform>` folder name.
fn platform_folder(target: &str) -> Option<&'static str> {
    match target {
        "x86_64-unknown-linux-gnu" | "x86_64-unknown-linux-musl" => Some("linux64"),
        "aarch64-unknown-linux-gnu" | "aarch64-unknown-linux-musl" => Some("linuxaarch64"),
        "arm-unknown-linux-gnueabihf" => Some("linuxarm32"),
        "i686-unknown-linux-gnu" => Some("linux32"),
        "x86_64-pc-windows-msvc" | "x86_64-pc-windows-gnu" => Some("win64"),
        "i686-pc-windows-msvc" => Some("win32"),
        "x86_64-apple-darwin" | "aarch64-apple-darwin" => Some("darwin64"),
        _ => None,
    }
}

/// Locate the compiled cdylib by listing the release dir (robust against
/// `Path::exists()` false-negatives observed on some Windows paths).
fn locate_cdylib(stage: &Path, target: &str) -> CliResult<PathBuf> {
    let wanted = [
        "libentropyk_fmu.so",
        "entropyk_fmi.dll",
        "libentropyk_fmu.dylib",
    ];
    let dirs = [
        stage.join("target").join(target).join("release"),
        stage.join("target").join("release"),
    ];
    for dir in &dirs {
        let hits: Vec<PathBuf> = std::fs::read_dir(dir)
            .map(|rd| {
                rd.flatten()
                    .filter_map(|e| {
                        let p = e.path();
                        if wanted.iter().any(|w| p.ends_with(w)) {
                            Some(p)
                        } else {
                            None
                        }
                    })
                    .collect()
            })
            .unwrap_or_default();
        if let Some(p) = hits.into_iter().next() {
            return Ok(p);
        }
    }
    Err(CliError::Simulation(format!(
        "cdylib not found under {} (looked for {:?})",
        stage.join("target").display(),
        wanted
    )))
}

/// Stage a standalone crate that re-exports the FMI runtime with the model
/// baked into `embedded.rs`.
fn stage_crate(
    stage: &Path,
    fmi_src: &Path,
    manifest_dir: &Path,
    model_json: &str,
    io_json: &str,
) -> CliResult<()> {
    if stage.exists() {
        let _ = fs::remove_dir_all(stage);
    }
    fs::create_dir_all(stage.join("src"))?;

    for name in &["lib.rs", "fmi2.rs", "model.rs", "io_map.rs"] {
        fs::copy(fmi_src.join(name), stage.join("src").join(name))?;
    }

    let embedded = format!(
        "pub const MODEL_JSON: &str = {lit_model};\n\
         pub const IO_JSON: &str = {lit_io};\n\
         pub fn load_model() -> Result<(String, String), String> {{\n\
         \x20    Ok((MODEL_JSON.to_string(), IO_JSON.to_string()))\n\
         }}\n",
        lit_model = rust_str_literal(model_json),
        lit_io = rust_str_literal(io_json),
    );
    fs::write(stage.join("src").join("embedded.rs"), embedded)?;

    let toml = format!(
        "[package]\n\
         name = \"fmu_crate\"\n\
         version = \"0.1.0\"\n\
         edition = \"2021\"\n\n\
         [lib]\n\
         name = \"entropyk_fmi\"\n\
         crate-type = [\"cdylib\"]\n\n\
         [dependencies]\n\
         entropyk = {{ path = {entropyk_path:?} }}\n\
         entropyk-cli = {{ path = {cli_path:?} }}\n\
         entropyk-solver = {{ path = {solver_path:?} }}\n\
         serde = {{ version = \"1.0\", features = [\"derive\"] }}\n\
         serde_json = \"1.0\"\n\
         libc = \"0.2\"\n\n\
         [workspace]\n",
        entropyk_path = manifest_dir.join("crates").join("entropyk"),
        cli_path = manifest_dir.join("crates").join("cli"),
        solver_path = manifest_dir.join("crates").join("solver"),
    );
    fs::write(stage.join("Cargo.toml"), toml)?;

    Ok(())
}

/// Escape a string as a Rust string literal.
fn rust_str_literal(s: &str) -> String {
    let mut out = String::from('"');
    for c in s.chars() {
        match c {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            other => out.push(other),
        }
    }
    out.push('"');
    out
}

/// `cargo build --release --target <target>` in the staged crate.
fn build_cdylib(stage: &Path, target: &str, verbose: bool) -> CliResult<()> {
    let mut cmd = Command::new("cargo");
    cmd.current_dir(stage).arg("build").arg("--release");
    if !target.is_empty() && target != "host" {
        cmd.arg("--target").arg(target);
    }
    if !verbose {
        cmd.arg("-q");
    }
    let status = cmd
        .status()
        .map_err(|e| CliError::Simulation(format!("failed to invoke cargo: {e}")))?;
    if !status.success() {
        return Err(CliError::Simulation(format!(
            "cargo build failed for staged FMI crate (target {target})"
        )));
    }
    Ok(())
}

/// Render the FMI 2.0 Co-Sim `modelDescription.xml` from the IO map.
fn render_model_description(model_name: &str, io: &FmuIoSpec) -> String {
    let guid = format!("{:x}", md5_fmi_guid(model_name));
    let mut out = String::new();
    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
    out.push_str(&format!(
        "<fmiModelDescription fmiVersion=\"2.0\" modelName=\"{model_name}\" guid=\"{{{guid}}}\" \
         generationTool=\"entropyk-cli\" variableNamingConvention=\"flat\" \
         numberOfEventIndicators=\"0\">\n"
    ));
    out.push_str(
        "  <CoSimulation modelIdentifier=\"entropyk_fmi\" \
        canHandleVariableCommunicationStepSize=\"true\" canGetAndSetFMUstate=\"false\" \
        canSerializeFMUstate=\"false\"/>\n",
    );
    out.push_str("  <ModelVariables>\n");

    let mut vr: u32 = 0;
    for v in &io.inputs {
        out.push_str(&format!(
            "    <ScalarVariable name=\"{name}\" valueReference=\"{vr}\" \
             causality=\"input\" variability=\"continuous\" initial=\"exact\">\n      <Real start=\"0.0\"/>\n    </ScalarVariable>\n",
            name = xml_escape(&v.name)
        ));
        vr += 1;
    }
    for v in &io.outputs {
        out.push_str(&format!(
            "    <ScalarVariable name=\"{name}\" valueReference=\"{vr}\" \
             causality=\"output\" variability=\"continuous\" initial=\"calculated\">\n      <Real/>\n    </ScalarVariable>\n",
            name = xml_escape(&v.name)
        ));
        vr += 1;
    }
    out.push_str("  </ModelVariables>\n");

    out.push_str("  <ModelStructure>\n");
    let n_in = io.inputs.len() as u32;
    for i in 0..io.outputs.len() as u32 {
        out.push_str(&format!(
            "    <Outputs><Unknown index=\"{}\"/></Outputs>\n",
            n_in + i + 1
        ));
    }
    out.push_str("    <Derivatives/>\n");
    out.push_str("    <InitialUnknowns>\n");
    for i in 0..io.outputs.len() as u32 {
        out.push_str(&format!("      <Unknown index=\"{}\"/>\n", n_in + i + 1));
    }
    out.push_str("    </InitialUnknowns>\n");
    out.push_str("  </ModelStructure>\n");
    out.push_str("</fmiModelDescription>\n");
    out
}

fn xml_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

/// Cheap non-crypto hash to derive a stable-ish GUID from the model name.
fn md5_fmi_guid(s: &str) -> u64 {
    let mut h: u64 = 0xcbf29ce484222325;
    for b in s.bytes() {
        h ^= b as u64;
        h = h.wrapping_mul(0x100000001b3);
    }
    h
}

/// Assemble the final `.fmu` zip archive.
fn assemble_fmu(
    out_path: &Path,
    xml: &str,
    lib_path: &Path,
    platform: &str,
    model_json: &str,
    io_json: &str,
) -> CliResult<()> {
    use std::io::Write;

    let file = fs::File::create(out_path)?;
    let mut zip = zip::ZipWriter::new(file);
    let opts = zip::write::FileOptions::default();

    zip.start_file("modelDescription.xml", opts)
        .map_err(|e| CliError::Simulation(format!("zip: {e}")))?;
    zip.write_all(xml.as_bytes())?;

    let ext = lib_path
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or("so");
    let lib_name = format!("binaries/{platform}/entropyk_fmi.{ext}");
    zip.start_file(&lib_name, opts)
        .map_err(|e| CliError::Simulation(format!("zip: {e}")))?;
    let mut lib_file = fs::File::open(lib_path)?;
    std::io::copy(&mut lib_file, &mut zip)?;

    zip.start_file("resources/config.json", opts)
        .map_err(|e| CliError::Simulation(format!("zip: {e}")))?;
    zip.write_all(model_json.as_bytes())?;
    zip.start_file("resources/fmu_io.json", opts)
        .map_err(|e| CliError::Simulation(format!("zip: {e}")))?;
    zip.write_all(io_json.as_bytes())?;

    zip.finish()
        .map_err(|e| CliError::Simulation(format!("zip: {e}")))?;
    Ok(())
}
