use std::env;
use std::fmt::Write;
use std::fs;
use std::io::{self, Cursor};
use std::path::{Path, PathBuf};

const MAGIC: &[u8] = b"BEAMR_EMBED\0";
const VERSION: u8 = 1;
const HEADER_LEN: usize = MAGIC.len() + 1 + 4;
const ZSTD_LEVEL: i32 = 3;
const DEFAULT_WASM_BINDGEN_IMPORT: &str = "./beamr_wasm.js";

struct BeamModule {
    name: String,
    path: PathBuf,
    bytes: Vec<u8>,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    println!("cargo:rerun-if-env-changed=BEAMR_WASM_BUNDLE_DIR");
    println!("cargo:rerun-if-env-changed=BEAMR_EMBED_DIR");
    println!("cargo:rerun-if-env-changed=BEAMR_WASM_BINDGEN_IMPORT");

    let out_dir = env::var_os("OUT_DIR")
        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "OUT_DIR is not set"))?;
    let bundle_dir = Path::new(&out_dir).join("beamr-wasm-bundle");
    fs::create_dir_all(&bundle_dir)?;

    let modules = match bundle_source_dir() {
        Some(dir) => {
            println!("cargo:rerun-if-changed={}", dir.display());
            collect_modules(&dir)?
        }
        None => Vec::new(),
    };

    let archive = archive_pack(&modules)?;
    fs::write(bundle_dir.join("modules.bin"), archive)?;
    fs::write(bundle_dir.join("manifest.json"), manifest_json(&modules))?;
    fs::write(bundle_dir.join("bootstrap.js"), bootstrap_js(&modules)?)?;
    fs::write(bundle_dir.join("package-bundle.mjs"), package_script())?;

    Ok(())
}

fn bundle_source_dir() -> Option<PathBuf> {
    env::var_os("BEAMR_WASM_BUNDLE_DIR")
        .or_else(|| env::var_os("BEAMR_EMBED_DIR"))
        .map(PathBuf::from)
}

fn collect_modules(beam_dir: &Path) -> Result<Vec<BeamModule>, Box<dyn std::error::Error>> {
    let mut modules = Vec::new();
    for entry in fs::read_dir(beam_dir)? {
        let entry = entry?;
        let path = entry.path();
        if path.extension().and_then(|extension| extension.to_str()) != Some("beam") {
            continue;
        }
        let Some(name) = path.file_stem().and_then(|stem| stem.to_str()) else {
            continue;
        };
        println!("cargo:rerun-if-changed={}", path.display());
        let bytes = fs::read(&path)?;
        modules.push(BeamModule {
            name: name.to_owned(),
            path,
            bytes,
        });
    }
    modules.sort_by(|left, right| left.name.cmp(&right.name));
    Ok(modules)
}

fn archive_pack(modules: &[BeamModule]) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
    let mut packed = empty_archive_bytes();
    let count = u32::try_from(modules.len()).map_err(|_| {
        io::Error::new(
            io::ErrorKind::InvalidData,
            format!("too many embedded modules ({})", modules.len()),
        )
    })?;
    packed[MAGIC.len() + 1..HEADER_LEN].copy_from_slice(&count.to_le_bytes());

    for module in modules {
        let compressed = zstd::stream::encode_all(Cursor::new(&module.bytes), ZSTD_LEVEL)?;
        write_entry(&mut packed, &module.name, &compressed)?;
    }

    Ok(packed)
}

fn empty_archive_bytes() -> Vec<u8> {
    let mut bytes = Vec::with_capacity(HEADER_LEN);
    bytes.extend_from_slice(MAGIC);
    bytes.push(VERSION);
    bytes.extend_from_slice(&0_u32.to_le_bytes());
    bytes
}

fn write_entry(output: &mut Vec<u8>, name: &str, compressed: &[u8]) -> io::Result<()> {
    let name_len = u16::try_from(name.len()).map_err(|_| {
        io::Error::new(
            io::ErrorKind::InvalidData,
            format!("embedded module name {name:?} is too long"),
        )
    })?;
    let beam_len = u32::try_from(compressed.len()).map_err(|_| {
        io::Error::new(
            io::ErrorKind::InvalidData,
            format!("embedded module {name:?} compressed payload is too long"),
        )
    })?;
    output.extend_from_slice(&name_len.to_le_bytes());
    output.extend_from_slice(name.as_bytes());
    output.extend_from_slice(&beam_len.to_le_bytes());
    output.extend_from_slice(compressed);
    Ok(())
}

fn manifest_json(modules: &[BeamModule]) -> String {
    let mut output = String::from(
        "{\n  \"format\": \"beamr-wasm-bundle\",\n  \"version\": 1,\n  \"wasm\": \"beamr_wasm_bg.wasm\",\n  \"bootstrap\": \"bootstrap.js\",\n  \"archive\": \"modules.bin\",\n  \"modules\": [",
    );
    for (index, module) in modules.iter().enumerate() {
        if index == 0 {
            output.push('\n');
        } else {
            output.push_str(",\n");
        }
        output.push_str("    { \"name\": ");
        push_json_string(&mut output, &module.name);
        output.push_str(", \"file\": ");
        let file = module
            .path
            .file_name()
            .and_then(|name| name.to_str())
            .map_or("<unknown>", |name| name);
        push_json_string(&mut output, file);
        output.push_str(", \"bytes\": ");
        output.push_str(&module.bytes.len().to_string());
        output.push_str(" }");
    }
    output.push_str("\n  ]\n}\n");
    output
}

fn bootstrap_js(modules: &[BeamModule]) -> Result<String, Box<dyn std::error::Error>> {
    let wasm_import = env::var("BEAMR_WASM_BINDGEN_IMPORT")
        .unwrap_or_else(|_| DEFAULT_WASM_BINDGEN_IMPORT.to_owned());
    let mut output = String::new();
    writeln!(
        output,
        "// Generated by crates/beamr-wasm/build.rs; do not edit."
    )?;
    writeln!(
        output,
        "import init, {{ create_vm, WasmVm }} from {};",
        js_string(&wasm_import)
    )?;
    writeln!(output)?;
    writeln!(output, "const MODULES = [")?;
    for module in modules {
        writeln!(
            output,
            "  {{ name: {}, base64: {} }},",
            js_string(&module.name),
            js_string(&base64_encode(&module.bytes))
        )?;
    }
    writeln!(output, "];\n")?;
    output.push_str(BOOTSTRAP_RUNTIME);
    Ok(output)
}

fn js_string(value: &str) -> String {
    let mut output = String::from("\"");
    for character in value.chars() {
        match character {
            '\\' => output.push_str("\\\\"),
            '"' => output.push_str("\\\""),
            '\n' => output.push_str("\\n"),
            '\r' => output.push_str("\\r"),
            '\t' => output.push_str("\\t"),
            character if character.is_control() => {
                output.push_str("\\u");
                output.push_str(&format!("{:04x}", character as u32));
            }
            character => output.push(character),
        }
    }
    output.push('"');
    output
}

fn push_json_string(output: &mut String, value: &str) {
    output.push_str(&js_string(value));
}

fn base64_encode(bytes: &[u8]) -> String {
    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
    for chunk in bytes.chunks(3) {
        let b0 = chunk[0];
        let b1 = chunk.get(1).copied().unwrap_or(0);
        let b2 = chunk.get(2).copied().unwrap_or(0);
        output.push(TABLE[(b0 >> 2) as usize] as char);
        output.push(TABLE[(((b0 & 0b0000_0011) << 4) | (b1 >> 4)) as usize] as char);
        if chunk.len() > 1 {
            output.push(TABLE[(((b1 & 0b0000_1111) << 2) | (b2 >> 6)) as usize] as char);
        } else {
            output.push('=');
        }
        if chunk.len() > 2 {
            output.push(TABLE[(b2 & 0b0011_1111) as usize] as char);
        } else {
            output.push('=');
        }
    }
    output
}

fn package_script() -> &'static str {
    PACKAGE_SCRIPT
}

const BOOTSTRAP_RUNTIME: &str = r#"function decodeBase64(base64) {
  if (typeof atob === "function") {
    const binary = atob(base64);
    const bytes = new Uint8Array(binary.length);
    for (let index = 0; index < binary.length; index += 1) {
      bytes[index] = binary.charCodeAt(index);
    }
    return bytes;
  }
  if (typeof Buffer === "function") {
    return new Uint8Array(Buffer.from(base64, "base64"));
  }
  throw new Error("No base64 decoder is available in this JavaScript host");
}

function parseJsonResult(value) {
  if (typeof value === "string") {
    return JSON.parse(value);
  }
  return value;
}

export { WasmVm };

export function bundledModules() {
  return MODULES.map((module) => ({ name: module.name, bytes: decodeBase64(module.base64) }));
}

export async function initBeamr(wasmPathOrModule) {
  await init(wasmPathOrModule);
  return { createVm: createPreloadedVm, modules: MODULES.map((module) => module.name) };
}

export async function createPreloadedVm(wasmPathOrModule) {
  await init(wasmPathOrModule);
  const vm = create_vm();
  const loads = [];
  for (const module of MODULES) {
    const result = parseJsonResult(vm.load_module(decodeBase64(module.base64)));
    loads.push(result);
  }
  return { vm, loads };
}

export async function spawnPreloaded(module, functionName, args = [], options = {}) {
  const { vm, loads } = await createPreloadedVm(options.wasmPathOrModule);
  const pid = vm.spawn(module, functionName, JSON.stringify(args));
  return { vm, pid, loads };
}

export function runUntilExit(vm, pid, options = {}) {
  const maxSteps = options.maxSteps ?? 1024;
  for (let step = 0; step < maxSteps; step += 1) {
    const summary = parseJsonResult(vm.run_step());
    const result = summary.results.find((entry) => entry.pid === pid);
    if (result) {
      if (typeof vm.take_exit_result === "function") {
        result.value = parseJsonResult(vm.take_exit_result(pid));
      }
      return { summary, result };
    }
    if (summary.executed === 0 && summary.waiting === 0 && summary.yielded === 0) {
      return { summary, result: undefined };
    }
  }
  throw new Error(`BEAM process ${pid} did not exit within the ${maxSteps} step limit`);
}

const api = { WasmVm, initBeamr, createPreloadedVm, spawnPreloaded, runUntilExit, bundledModules };
if (typeof globalThis !== "undefined") {
  globalThis.BeamrBundle = api;
}

export default api;
"#;

const PACKAGE_SCRIPT: &str = r#"#!/usr/bin/env node
import { readFile, writeFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

function usage() {
  return "usage: node package-bundle.mjs <wasm-pack-pkg-dir> [output-file]";
}

function jsString(value) {
  return JSON.stringify(value).replace(/</g, "\\u003c");
}

async function main() {
  const pkgDir = process.argv[2];
  if (!pkgDir) {
    throw new Error(usage());
  }
  const output = process.argv[3] ?? join(pkgDir, "beamr.bundle.mjs");
  const bootstrapPath = join(dirname(fileURLToPath(import.meta.url)), "bootstrap.js");
  const bootstrap = await readFile(bootstrapPath, "utf8");
  const gluePath = join(pkgDir, "beamr_wasm.js");
  const wasmPath = join(pkgDir, "beamr_wasm_bg.wasm");
  const glue = await readFile(gluePath, "utf8");
  const wasm = await readFile(wasmPath);
  const wasmBase64 = wasm.toString("base64");
  const importLine = 'import init, { create_vm, WasmVm } from "./beamr_wasm.js";';
  if (!bootstrap.includes(importLine)) {
    throw new Error("bootstrap import line did not match expected wasm-bindgen import");
  }
  const rewrittenBootstrap = bootstrap.replace(
    importLine,
    'const { default: init, create_vm, WasmVm } = await importWasmBindgen();'
  );
  const bundle = `// Generated Beamr single-file WASM bundle.\nconst WASM_BASE64 = ${jsString(wasmBase64)};\nconst WASM_GLUE = ${jsString(glue)};\n\nfunction decodeEmbeddedBase64(base64) {\n  if (typeof atob === "function") {\n    const binary = atob(base64);\n    const bytes = new Uint8Array(binary.length);\n    for (let index = 0; index < binary.length; index += 1) {\n      bytes[index] = binary.charCodeAt(index);\n    }\n    return bytes;\n  }\n  if (typeof Buffer === "function") {\n    return new Uint8Array(Buffer.from(base64, "base64"));\n  }\n  throw new Error("No base64 decoder is available in this JavaScript host");\n}\n\nasync function importWasmBindgen() {\n  const source = WASM_GLUE.replace(/new URL\\(['\"]beamr_wasm_bg\\.wasm['\"], import\\.meta\\.url\\)/g, "decodeEmbeddedBase64(WASM_BASE64)");\n  const url = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));\n  try {\n    return await import(url);\n  } finally {\n    URL.revokeObjectURL(url);\n  }\n}\n\n${rewrittenBootstrap}\n`;
  await writeFile(output, bundle);
  console.log(`wrote ${basename(output)}`);
}

main().catch((error) => {
  console.error(error instanceof Error ? error.message : error);
  process.exitCode = 1;
});
"#;
