use std::{
    env::{self, VarError},
    fs::{self, File},
    io::{Read, Write},
    path::{Path, PathBuf},
    process::{Command, Stdio},
};

use anyhow::{Context, Result};
use clap::Parser;

const SHIM_FILE: &str = include_str!("./js/shim.js");
const SHIM_UNWIND_FILE: &str = include_str!("./js/shim-unwind.js");

pub(crate) mod binary;
mod build;
mod build_lock;
mod emoji;
mod lockfile;
mod main_legacy;
mod producers;
mod versions;

use build::{Build, BuildOptions};
use build_lock::BuildLock;

use crate::{
    binary::{Esbuild, GetBinary},
    build::Target,
};

const VERSION: &str = env!("CARGO_PKG_VERSION");

fn fix_wasm_import(out_dir: &Path) -> Result<()> {
    let index_path = output_path(out_dir, "index.js");
    let content = fs::read_to_string(&index_path)
        .with_context(|| format!("Failed to read {}", index_path.display()))?;
    let updated_content = content.replace("import source ", "import ");
    fs::write(&index_path, updated_content)
        .with_context(|| format!("Failed to write {}", index_path.display()))?;
    Ok(())
}

fn update_package_json(out_dir: &Path) -> Result<()> {
    let package_json_path = output_path(out_dir, "package.json");

    let original_content = fs::read_to_string(&package_json_path)
        .with_context(|| format!("Failed to read {}", package_json_path.display()))?;
    let mut package_json: serde_json::Value = serde_json::from_str(&original_content)?;

    package_json["files"] = serde_json::json!(["index_bg.wasm", "index.js", "index.d.ts"]);
    package_json["main"] = serde_json::Value::String("index.js".to_string());
    package_json["sideEffects"] = serde_json::json!(["./index.js"]);

    let updated_content = serde_json::to_string_pretty(&package_json)?;
    fs::write(&package_json_path, updated_content)
        .with_context(|| format!("Failed to write {}", package_json_path.display()))?;
    Ok(())
}

pub fn main() -> Result<()> {
    env_logger::init();

    let args: Vec<_> = env::args().collect();
    if args.len() > 1 && (args[1].as_str() == "--version" || args[1].as_str() == "-v") {
        println!("{VERSION}");
        return Ok(());
    }
    let no_panic_recovery = args.iter().any(|a| a == "--no-panic-recovery");

    let wasm_pack_opts = parse_wasm_pack_opts(env::args().skip(1))?;
    let mut builder = Build::try_from_opts(wasm_pack_opts)?;

    // IMPORTANT: Build output is always relative to the crate root discovered by
    // `Build::try_from_opts`, not the process current working directory.
    let out_dir = builder.out_dir.clone();

    // Acquire the build lock: waits for any concurrent build to finish,
    // then creates a fresh .tmp staging directory with a heartbeat thread.
    let lock = BuildLock::acquire(&out_dir)?;
    let staging_dir = lock.staging_dir().to_path_buf();

    // Point the builder at the staging directory
    builder.out_dir = staging_dir.clone();

    builder.init()?;

    let module_target = !no_panic_recovery && env::var("CUSTOM_SHIM").is_err();
    if module_target {
        builder.extra_args.extend_from_slice(&[
            "--experimental-reset-state-function".into(),
            "--force-enable-abort-handler".into(),
        ]);
        builder.run()?;
    } else {
        builder.target = Target::Bundler;
        builder.run()?;
    }

    let with_coredump = env::var("COREDUMP").is_ok();
    if with_coredump {
        println!("Adding wasm coredump");
        wasm_coredump(&staging_dir)?;
    }

    producers::inject_workers_rs_sdk_metadata(&staging_dir, VERSION)?;

    if module_target {
        let shim = if builder.panic_unwind {
            SHIM_UNWIND_FILE
        } else {
            SHIM_FILE
        }
        .replace("$HANDLERS", &generate_handlers(&staging_dir)?);
        let shim_path = output_path(&staging_dir, "shim.js");
        fs::write(&shim_path, shim)
            .with_context(|| format!("Failed to write {}", shim_path.display()))?;

        add_export_wrappers(&staging_dir)?;

        update_package_json(&staging_dir)?;

        let esbuild_path = Esbuild.get_binary(None)?.0;
        bundle(&staging_dir, &esbuild_path)?;

        fix_wasm_import(&staging_dir)?;

        remove_unused_files(&staging_dir)?;

        create_wrapper_alias(&staging_dir, false)?;
    } else {
        main_legacy::process(&staging_dir)?;
        create_wrapper_alias(&staging_dir, true)?;
    }

    // Swap staging entries into the real output directory and clean up.
    lock.finish()?;

    Ok(())
}

fn generate_handlers(out_dir: &Path) -> Result<String> {
    let index_path = output_path(out_dir, "index.js");
    let content = fs::read_to_string(&index_path)
        .with_context(|| format!("Failed to read {}", index_path.display()))?;

    // Extract ESM function exports from the wasm-bindgen generated output.
    // This code is specialized to what wasm-bindgen outputs for ESM and is therefore
    // brittle to upstream changes. It is comprehensive to current output patterns though.
    // TODO: Convert this to Wasm binary exports analysis for entry point detection instead.
    let mut func_names = Vec::new();
    for line in content.lines() {
        if let Some(rest) = line.strip_prefix("export function") {
            if let Some(bracket_pos) = rest.find("(") {
                let func_name = rest[..bracket_pos].trim();
                // strip the exported function (we re-wrap all handlers)
                if !SYSTEM_FNS.contains(&func_name) {
                    func_names.push(func_name);
                }
            }
        } else if let Some(rest) = line.strip_prefix("export {") {
            if let Some(as_pos) = rest.find(" as ") {
                let rest = &rest[as_pos + 4..];
                if let Some(brace_pos) = rest.find("}") {
                    let func_name = rest[..brace_pos].trim();
                    if !SYSTEM_FNS.contains(&func_name) {
                        func_names.push(func_name);
                    }
                }
            }
        }
    }

    let mut handlers = String::new();
    for func_name in func_names {
        if func_name == "fetch" && env::var("RUN_TO_COMPLETION").is_ok() {
            handlers += "Entrypoint.prototype.fetch = async function fetch(request) {
  let response = exports.fetch(request, this.env, this.ctx);
  this.ctx.waitUntil(response);
  return response;
}
";
        } else if func_name == "fetch"
            || func_name == "queue"
            || func_name == "scheduled"
            || func_name == "email"
        {
            // TODO: Switch these over to https://github.com/wasm-bindgen/wasm-bindgen/pull/4757
            // once that lands.
            handlers += &format!(
                "Entrypoint.prototype.{func_name} = function {func_name} (arg) {{
  return exports.{func_name}.call(this, arg, this.env, this.ctx);
}}
"
            );
        } else {
            handlers += &format!("Entrypoint.prototype.{func_name} = exports.{func_name};\n");
        }
    }

    Ok(handlers)
}

static SYSTEM_FNS: &[&str] = &["__wbg_reset_state", "__worker_init_state"];

fn add_export_wrappers(out_dir: &Path) -> Result<()> {
    let index_path = output_path(out_dir, "index.js");
    let content = fs::read_to_string(&index_path)
        .with_context(|| format!("Failed to read {}", index_path.display()))?;

    let mut class_names = Vec::new();
    for line in content.lines() {
        if let Some(rest) = line.strip_prefix("export class ") {
            if let Some(brace_pos) = rest.find("{") {
                let class_name = rest[..brace_pos].trim();
                class_names.push(class_name.to_string());
            }
        }
    }

    let shim_path = output_path(out_dir, "shim.js");
    let mut output = fs::read_to_string(&shim_path)
        .with_context(|| format!("Failed to read {}", shim_path.display()))?;
    for class_name in class_names {
        output.push_str(&format!(
            "export const {class_name} = new Proxy(exports.{class_name}, classProxyHooks);\n"
        ));
    }
    fs::write(&shim_path, output)
        .with_context(|| format!("Failed to write {}", shim_path.display()))?;
    Ok(())
}

const INSTALL_HELP: &str = "In case you are missing the binary, you can install it using: `cargo install wasm-coredump-rewriter`";

fn wasm_coredump(out_dir: &Path) -> Result<()> {
    let coredump_flags = env::var("COREDUMP_FLAGS");
    let coredump_flags: Vec<&str> = if let Ok(flags) = &coredump_flags {
        flags.split(' ').collect()
    } else {
        vec![]
    };

    let mut child = Command::new("wasm-coredump-rewriter")
        .args(coredump_flags)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .spawn()
        .map_err(|err| {
            anyhow::anyhow!("failed to spawn wasm-coredump-rewriter: {err}\n\n{INSTALL_HELP}.")
        })?;

    let input_filename = output_path(out_dir, "index.wasm");

    let input_bytes = {
        let mut input = File::open(input_filename.clone())
            .map_err(|err| anyhow::anyhow!("failed to open input file: {err}"))?;

        let mut input_bytes = Vec::new();
        input
            .read_to_end(&mut input_bytes)
            .map_err(|err| anyhow::anyhow!("failed to open input file: {err}"))?;

        input_bytes
    };

    {
        let child_stdin = child.stdin.as_mut().unwrap();
        child_stdin
            .write_all(&input_bytes)
            .map_err(|err| anyhow::anyhow!("failed to write input file to rewriter: {err}"))?;
        // Close stdin to finish and avoid indefinite blocking
    }

    let output = child
        .wait_with_output()
        .map_err(|err| anyhow::anyhow!("failed to get rewriter's status: {err}"))?;

    if output.status.success() {
        // Open the input file again with truncate to write the output
        let mut f = fs::OpenOptions::new()
            .truncate(true)
            .write(true)
            .open(input_filename)
            .map_err(|err| anyhow::anyhow!("failed to open output file: {err}"))?;
        f.write_all(&output.stdout)
            .map_err(|err| anyhow::anyhow!("failed to write output file: {err}"))?;

        Ok(())
    } else {
        let stdout = String::from_utf8_lossy(&output.stdout);
        let stderr = String::from_utf8_lossy(&output.stderr);
        Err(anyhow::anyhow!(format!(
            "failed to run Wasm coredump rewriter: {stdout}\n{stderr}"
        )))
    }
}

fn create_wrapper_alias(out_dir: &Path, legacy: bool) -> Result<()> {
    let msg = if !legacy {
        "// Use index.js directly, this file provided for backwards compat
// with former shim.mjs only.
"
    } else {
        ""
    };
    let path = if !legacy {
        "../index.js"
    } else {
        "./worker/shim.mjs"
    };
    let shim_content = format!(
        "{msg}export * from '{path}';
export {{ default }} from '{path}';
"
    );

    if !legacy {
        let worker_dir = output_path(out_dir, "worker");
        fs::create_dir_all(&worker_dir)
            .with_context(|| format!("Failed to create directory {}", worker_dir.display()))?;
        let shim_path = output_path(out_dir, "worker/shim.mjs");
        fs::write(&shim_path, shim_content)
            .with_context(|| format!("Failed to write {}", shim_path.display()))?;
    } else {
        let index_path = output_path(out_dir, "index.js");
        fs::write(&index_path, shim_content)
            .with_context(|| format!("Failed to write {}", index_path.display()))?;
    }
    Ok(())
}

#[derive(Parser)]
struct BuildArgs {
    #[clap(flatten)]
    pub build_options: BuildOptions,
}

fn parse_wasm_pack_opts<I>(args: I) -> Result<BuildOptions>
where
    I: IntoIterator<Item = String>,
{
    // This is done instead of explicitly constructing
    // BuildOptions to preserve the behavior of appending
    // arbitrary arguments in `args`.
    let mut build_args = vec![
        env!("CARGO_BIN_NAME").to_owned(),
        "--no-typescript".to_owned(),
        "--target".to_owned(),
        "module".to_owned(),
        "--out-name".to_owned(),
        "index".to_owned(),
    ];

    build_args.extend(args);

    let command = BuildArgs::try_parse_from(build_args)?;
    Ok(command.build_options)
}

// Bundles the snippets and worker-related code into a single file.
fn bundle(out_dir: &Path, esbuild_path: &Path) -> Result<()> {
    let no_minify = !matches!(env::var("NO_MINIFY"), Err(VarError::NotPresent));
    let path = out_dir
        .canonicalize()
        .with_context(|| format!("Failed to resolve output directory {}", out_dir.display()))?;
    let esbuild_path = esbuild_path
        .canonicalize()
        .with_context(|| format!("Failed to resolve esbuild path {}", esbuild_path.display()))?;
    let mut command = Command::new(esbuild_path);
    command.args([
        "--external:./index_bg.wasm",
        "--external:cloudflare:email",
        "--external:cloudflare:sockets",
        "--external:cloudflare:workers",
        "--format=esm",
        "--bundle",
        "./shim.js",
        "--outfile=index.js",
        "--allow-overwrite",
    ]);

    if !no_minify {
        command.arg("--minify");
    }

    let exit_status = command.current_dir(path).spawn()?.wait()?;

    match exit_status.success() {
        true => Ok(()),
        false => anyhow::bail!("esbuild exited with status {exit_status}"),
    }
}

fn remove_unused_files(out_dir: &Path) -> Result<()> {
    let shim_path = output_path(out_dir, "shim.js");
    std::fs::remove_file(&shim_path)
        .with_context(|| format!("Failed to remove {}", shim_path.display()))?;
    let snippets_path = output_path(out_dir, "snippets");
    if snippets_path.exists() {
        std::fs::remove_dir_all(&snippets_path)
            .with_context(|| format!("Failed to remove {}", snippets_path.display()))?;
    }
    Ok(())
}

pub fn output_path(out_dir: &Path, name: impl AsRef<str>) -> PathBuf {
    out_dir.join(name.as_ref())
}

#[cfg(test)]
mod test {
    use super::parse_wasm_pack_opts;
    #[test]
    fn test_wasm_pack_args_build_arg() {
        let args = vec!["--release".to_owned()];
        let result = parse_wasm_pack_opts(args);
        assert!(result.is_ok());
    }

    #[test]
    fn test_out_dir_default() {
        let args: Vec<String> = vec![];
        let result = parse_wasm_pack_opts(args).unwrap();
        assert_eq!(result.out_dir, "build");
    }

    #[test]
    fn test_out_dir_override() {
        let args = vec!["--out-dir".to_owned(), "dist/worker".to_owned()];
        let result = parse_wasm_pack_opts(args).unwrap();
        assert_eq!(result.out_dir, "dist/worker");
    }
}
