// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2020 The Gleam contributors

use crate::{
    Result,
    build::{
        ErlangAppCodegenConfiguration, Module, module_erlang_name, package_compiler::StdlibPackage,
    },
    config::PackageConfig,
    erlang,
    io::FileSystemWriter,
    javascript::{self, ModuleConfig},
    line_numbers::LineNumbers,
};
use ecow::EcoString;
use erlang::escape_atom_string;
use itertools::Itertools;
use std::fmt::Debug;

use camino::Utf8Path;

/// A code generator that creates a .erl Erlang module and record header files
/// for each Gleam module in the package.
#[derive(Debug)]
pub struct Erlang<'a> {
    build_directory: &'a Utf8Path,
    include_directory: &'a Utf8Path,
}

impl<'a> Erlang<'a> {
    pub fn new(build_directory: &'a Utf8Path, include_directory: &'a Utf8Path) -> Self {
        Self {
            build_directory,
            include_directory,
        }
    }

    pub fn render<Writer: FileSystemWriter>(
        &self,
        writer: Writer,
        modules: &[Module],
        root: &Utf8Path,
    ) -> Result<()> {
        for module in modules {
            let erl_name = module.erlang_name();
            self.erlang_module(&writer, module, &erl_name, root)?;
            self.erlang_record_headers(&writer, module, &erl_name)?;
        }
        Ok(())
    }

    fn erlang_module<Writer: FileSystemWriter>(
        &self,
        writer: &Writer,
        module: &Module,
        erl_name: &str,
        root: &Utf8Path,
    ) -> Result<()> {
        let name = format!("{erl_name}.erl");
        let path = self.build_directory.join(&name);
        let line_numbers = LineNumbers::new(&module.code);
        let output = erlang::module(&module.ast, &line_numbers, root);
        tracing::debug!(name = ?name, "Generated Erlang module");
        writer.write(&path, &output)
    }

    fn erlang_record_headers<Writer: FileSystemWriter>(
        &self,
        writer: &Writer,
        module: &Module,
        erl_name: &str,
    ) -> Result<()> {
        for (name, text) in erlang::records(&module.ast) {
            let name = format!("{erl_name}_{name}.hrl");
            tracing::debug!(name = ?name, "Generated Erlang header");
            writer.write(&self.include_directory.join(name), &text)?;
        }
        Ok(())
    }
}

/// A code generator that creates a .app Erlang application file for the package
#[derive(Debug)]
pub struct ErlangApp<'a> {
    output_directory: &'a Utf8Path,
    config: &'a ErlangAppCodegenConfiguration,
}

impl<'a> ErlangApp<'a> {
    pub fn new(output_directory: &'a Utf8Path, config: &'a ErlangAppCodegenConfiguration) -> Self {
        Self {
            output_directory,
            config,
        }
    }

    pub fn render<Writer: FileSystemWriter>(
        &self,
        writer: Writer,
        config: &PackageConfig,
        modules: &[Module],
        cached_module_names: &[EcoString],
        native_modules: Vec<EcoString>,
    ) -> Result<()> {
        fn tuple(key: &str, value: &str) -> String {
            format!("    {{{key}, {value}}},\n")
        }

        let path = self.output_directory.join(format!("{}.app", config.name));

        let start_module = match config.erlang.application_start_module.as_ref() {
            None => "".into(),
            Some(module) => {
                let module = module_erlang_name(module);
                let argument = match config.erlang.application_start_argument.as_ref() {
                    Some(argument) => argument.as_str(),
                    None => "[]",
                };
                tuple("mod", &format!("{{'{module}', {argument}}}"))
            }
        };

        // Include the cached modules that were not recompiled this build, so a
        // warm rebuild doesn't shrink the `modules` list (see #5834).
        let modules = modules
            .iter()
            .map(|m| m.erlang_name())
            .chain(cached_module_names.iter().map(module_erlang_name))
            .chain(native_modules)
            .unique()
            .sorted()
            .map(escape_atom_string)
            .join(",\n               ");

        // TODO: When precompiling for production (i.e. as a precompiled hex
        // package) we will need to exclude the dev deps.
        let applications = config
            .dependencies
            .keys()
            .chain(
                config
                    .dev_dependencies
                    .keys()
                    .take_while(|_| self.config.include_dev_deps),
            )
            // TODO: test this!
            .map(|name| self.config.package_name_overrides.get(name).unwrap_or(name))
            .chain(config.erlang.extra_applications.iter())
            .sorted()
            .join(",\n                    ");

        let text = format!(
            r#"{{application, {package}, [
{start_module}    {{vsn, "{version}"}},
    {{applications, [{applications}]}},
    {{description, "{description}"}},
    {{modules, [{modules}]}},
    {{registered, []}}
]}}.
"#,
            applications = applications,
            description = config.description,
            modules = modules,
            package = config.name,
            start_module = start_module,
            version = config.version,
        );

        writer.write(&path, &text)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TypeScriptDeclarations {
    None,
    Emit,
}

#[derive(Debug)]
pub struct JavaScript<'a> {
    output_directory: &'a Utf8Path,
    prelude_location: &'a Utf8Path,
    project_root: &'a Utf8Path,
    typescript: TypeScriptDeclarations,
    source_map: bool,
}

impl<'a> JavaScript<'a> {
    pub fn new(
        output_directory: &'a Utf8Path,
        typescript: TypeScriptDeclarations,
        source_map: bool,
        prelude_location: &'a Utf8Path,
        project_root: &'a Utf8Path,
    ) -> Self {
        Self {
            prelude_location,
            output_directory,
            project_root,
            typescript,
            source_map,
        }
    }

    pub fn render(
        &self,
        writer: &impl FileSystemWriter,
        modules: &[Module],
        stdlib_package: StdlibPackage,
    ) -> Result<()> {
        for module in modules {
            let js_name = module.name.clone();
            if self.typescript == TypeScriptDeclarations::Emit {
                self.ts_declaration(writer, module, &js_name)?;
            }
            self.js_module(writer, module, &js_name, stdlib_package)?;
        }
        self.write_prelude(writer)?;
        Ok(())
    }

    fn write_prelude(&self, writer: &impl FileSystemWriter) -> Result<()> {
        let rexport = format!("export * from \"{}\";\n", self.prelude_location);
        let prelude_path = &self.output_directory.join("gleam.mjs");

        // This check skips unnecessary `gleam.mjs` writes which confuse
        // watchers and HMR build tools
        if !writer.exists(prelude_path) {
            writer.write(prelude_path, &rexport)?;
        }

        if self.typescript == TypeScriptDeclarations::Emit {
            let rexport = format!(
                "export * from \"{}\";\nexport type * from \"{}\";\n",
                self.prelude_location,
                self.prelude_location.as_str().replace(".mjs", ".d.mts")
            );
            let prelude_declaration_path = &self.output_directory.join("gleam.d.mts");

            // Type declaration may trigger badly configured watchers
            if !writer.exists(prelude_declaration_path) {
                writer.write(prelude_declaration_path, &rexport)?;
            }
        }

        Ok(())
    }

    fn ts_declaration(
        &self,
        writer: &impl FileSystemWriter,
        module: &Module,
        js_name: &str,
    ) -> Result<()> {
        let name = format!("{js_name}.d.mts");
        let path = self.output_directory.join(name);
        let output = javascript::ts_declaration(&module.ast);
        tracing::debug!(name = ?js_name, "Generated TS declaration");
        writer.write(&path, &output)
    }

    fn js_module(
        &self,
        writer: &impl FileSystemWriter,
        module: &Module,
        js_name: &str,
        stdlib_package: StdlibPackage,
    ) -> Result<()> {
        let name = format!("{js_name}.mjs");
        let path = self.output_directory.join(name);
        let line_numbers = LineNumbers::new(&module.code);
        let (output, source_map) = javascript::module(ModuleConfig {
            module: &module.ast,
            line_numbers: &line_numbers,
            path: &module.input_path,
            project_root: self.project_root,
            src: &module.code,
            typescript: self.typescript,
            source_map: self.source_map,
            stdlib_package,
        });
        tracing::debug!(name = ?js_name, "Generated js module");
        writer.write(&path, &output)?;

        if let Some(source_map) = source_map {
            let mut output = Vec::new();
            // We first write to a vector then build a string, hoping that
            // the `sourcemap` crate generated a valid sourcemap. If it
            // did not, it is a bug that should be reported.
            //
            // SourceMap currently does not support being written directly
            // to a string.
            source_map
                .to_writer(&mut output)
                .expect("Failed to write sourcemap to memory.");
            let content =
                String::from_utf8(output).expect("Sourcemap did not generate valid UTF-8.");
            let source_map_path = self.output_directory.join(format!("{js_name}.mjs.map"));
            tracing::debug!(path = ?source_map_path, name = ?js_name, "Emitting sourcemap for module");
            writer.write(&source_map_path, &content)?;
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::io::{FileSystemReader, memory::InMemoryFileSystem};
    use std::collections::HashMap;

    #[test]
    fn app_file_includes_cached_modules() {
        // A warm rebuild recompiles nothing, so every module arrives as a cached
        // name rather than in `modules`; they must still be listed in the .app
        // (https://github.com/gleam-lang/gleam/issues/5834).
        let fs = InMemoryFileSystem::new();
        let mut config = PackageConfig::default();
        config.name = "my_app".into();
        let codegen_config = ErlangAppCodegenConfiguration {
            include_dev_deps: false,
            package_name_overrides: HashMap::new(),
        };

        ErlangApp::new(Utf8Path::new("/ebin"), &codegen_config)
            .render(
                fs.clone(),
                &config,
                &[],
                &["my_app/one".into(), "my_app/two".into()],
                vec![],
            )
            .expect("render .app");

        let app = fs
            .read(Utf8Path::new("/ebin/my_app.app"))
            .expect("read .app");
        insta::assert_snapshot!(app);
    }
}
