use std::{
    borrow::Cow,
    io::{BufWriter, Write},
    mem::forget,
    path::{self, Path, PathBuf},
};

use anyhow::Context;
use clap::Parser;
use fs_err::File;
use memofs::Vfs;
use rayon::prelude::*;
use rbx_dom_weak::{types::Ref, Ustr};
use serde::{Deserialize, Serialize};
use tokio::runtime::Runtime;

use crate::{
    serve_session::ServeSession,
    snapshot::{AppliedPatchSet, InstanceWithMeta, RojoTree},
};

use super::resolve_path;

const PATH_STRIP_FAILED_ERR: &str = "Failed to create relative paths for project file!";
const ABSOLUTE_PATH_FAILED_ERR: &str = "Failed to turn relative path into absolute path!";

/// Representation of a node in the generated sourcemap tree.
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SourcemapNode<'a> {
    name: &'a str,
    class_name: Ustr,

    #[serde(
        default,
        skip_serializing_if = "Vec::is_empty",
        serialize_with = "crate::path_serializer::serialize_vec_absolute"
    )]
    file_paths: Vec<Cow<'a, Path>>,

    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    children: Vec<SourcemapNode<'a>>,
}

/// Generates a sourcemap file from the Rojo project.
#[derive(Debug, Parser)]
pub struct SourcemapCommand {
    /// Path to the project to use for the sourcemap. Defaults to the current
    /// directory.
    #[clap(default_value = "")]
    pub project: PathBuf,

    /// Where to output the sourcemap. Omit this to use stdout instead of
    /// writing to a file.
    ///
    /// Should end in .json.
    #[clap(long, short)]
    pub output: Option<PathBuf>,

    /// If non-script files should be included or not. Defaults to false.
    #[clap(long)]
    pub include_non_scripts: bool,

    /// Whether to automatically recreate a snapshot when any input files change.
    #[clap(long)]
    pub watch: bool,

    /// Whether the sourcemap should use absolute paths instead of relative paths.
    #[clap(long)]
    pub absolute: bool,
}

impl SourcemapCommand {
    pub fn run(self) -> anyhow::Result<()> {
        let project_path = fs_err::canonicalize(resolve_path(&self.project)?)?;

        log::trace!("Constructing filesystem with StdBackend");
        let vfs = Vfs::new_default()?;
        vfs.set_watch_enabled(self.watch);

        log::trace!("Setting up session for sourcemap generation");
        let session = ServeSession::new(vfs, project_path)?;
        let mut cursor = session.message_queue().cursor();

        let filter = if self.include_non_scripts {
            filter_nothing
        } else {
            filter_non_scripts
        };

        // Pre-build a rayon threadpool with a low number of threads to avoid
        // dynamic creation overhead on systems with a high number of cpus.
        log::trace!("Setting rayon global threadpool");
        rayon::ThreadPoolBuilder::new()
            .num_threads(num_cpus::get().min(6))
            .build_global()
            .ok();

        log::trace!("Writing initial sourcemap");
        write_sourcemap(&session, self.output.as_deref(), filter, self.absolute)?;

        if self.watch {
            log::trace!("Setting up runtime for watch mode");
            let rt = Runtime::new().context("Failed to start the async runtime for watch mode")?;

            loop {
                let receiver = session.message_queue().subscribe(cursor);
                let (new_cursor, patch_set) = match rt.block_on(receiver) {
                    Ok(message) => message,
                    // The message queue was dropped, so there is nothing left
                    // to watch. Stop watching gracefully.
                    Err(_) => break,
                };
                cursor = new_cursor;

                if patch_set_affects_sourcemap(&session, &patch_set, filter) {
                    write_sourcemap(&session, self.output.as_deref(), filter, self.absolute)?;
                }
            }
        }

        // Avoid dropping ServeSession: it's potentially VERY expensive to drop
        // and we're about to exit anyways.
        forget(session);

        Ok(())
    }
}

fn filter_nothing(_instance: &InstanceWithMeta) -> bool {
    true
}

fn filter_non_scripts(instance: &InstanceWithMeta) -> bool {
    matches!(
        instance.class_name().as_str(),
        "Script" | "LocalScript" | "ModuleScript"
    )
}

fn patch_set_affects_sourcemap(
    session: &ServeSession,
    patch_set: &[AppliedPatchSet],
    filter: fn(&InstanceWithMeta) -> bool,
) -> bool {
    let tree = session.tree();

    // A sourcemap has probably changed when:
    patch_set.par_iter().any(|set| {
        // 1. An instance was removed, in which case it will no
        // longer exist in the tree and we cant check the filter
        !set.removed.is_empty()
            // 2. A newly added instance passes the filter
            || set.added.iter().any(|referent| {
                let instance = tree
                    .get_instance(*referent)
                    .expect("instance did not exist when updating sourcemap");
                filter(&instance)
            })
            // 3. An existing instance has its class name, name,
            // or file paths changed, and passes the filter
            || set.updated.iter().any(|updated| {
                let changed = updated.changed_class_name.is_some()
                    || updated.changed_name.is_some()
                    || updated.changed_metadata.is_some();
                if changed {
                    let instance = tree
                        .get_instance(updated.id)
                        .expect("instance did not exist when updating sourcemap");
                    filter(&instance)
                } else {
                    false
                }
            })
    })
}

fn recurse_create_node<'a>(
    tree: &'a RojoTree,
    referent: Ref,
    project_dir: &Path,
    filter: fn(&InstanceWithMeta) -> bool,
    use_absolute_paths: bool,
) -> Option<SourcemapNode<'a>> {
    let instance = tree.get_instance(referent).expect("instance did not exist");

    let children: Vec<_> = instance
        .children()
        .par_iter()
        .filter_map(|&child_id| {
            recurse_create_node(tree, child_id, project_dir, filter, use_absolute_paths)
        })
        .collect();

    // If this object has no children and doesn't pass the filter, it doesn't
    // contain any information we're looking for.
    if children.is_empty() && !filter(&instance) {
        return None;
    }

    let file_paths = instance
        .metadata()
        .relevant_paths
        .iter()
        // Not all paths listed as relevant are guaranteed to exist.
        .filter(|path| path.is_file())
        .map(|path| path.as_path());

    let mut output_file_paths: Vec<Cow<'a, Path>> =
        Vec::with_capacity(instance.metadata().relevant_paths.len());

    if use_absolute_paths {
        // It's somewhat important to note here that `path::absolute` takes in a Path and returns a PathBuf
        for val in file_paths {
            output_file_paths.push(Cow::Owned(
                path::absolute(val).expect(ABSOLUTE_PATH_FAILED_ERR),
            ));
        }
    } else {
        for val in file_paths {
            output_file_paths.push(Cow::from(
                pathdiff::diff_paths(val, project_dir).expect(PATH_STRIP_FAILED_ERR),
            ));
        }
    };

    Some(SourcemapNode {
        name: instance.name(),
        class_name: instance.class_name(),
        file_paths: output_file_paths,
        children,
    })
}

fn write_sourcemap(
    session: &ServeSession,
    output: Option<&Path>,
    filter: fn(&InstanceWithMeta) -> bool,
    use_absolute_paths: bool,
) -> anyhow::Result<()> {
    let tree = session.tree();

    let root_node = recurse_create_node(
        &tree,
        tree.get_root_id(),
        session.root_dir(),
        filter,
        use_absolute_paths,
    );

    if let Some(output_path) = output {
        let mut file = BufWriter::new(File::create(output_path)?);
        serde_json::to_writer(&mut file, &root_node)?;
        file.flush()?;

        println!("Created sourcemap at {}", output_path.display());
    } else {
        let output = serde_json::to_string(&root_node)?;
        println!("{}", output);
    }

    Ok(())
}

#[cfg(test)]
mod test {
    use crate::cli::sourcemap::SourcemapNode;
    use crate::cli::SourcemapCommand;
    use insta::internals::Content;
    use std::path::Path;

    #[test]
    fn maps_relative_paths() {
        let sourcemap_dir = tempfile::tempdir().unwrap();
        let sourcemap_output = sourcemap_dir.path().join("sourcemap.json");
        let project_path = fs_err::canonicalize(
            Path::new(env!("CARGO_MANIFEST_DIR"))
                .join("test-projects")
                .join("relative_paths")
                .join("project"),
        )
        .unwrap();
        let sourcemap_command = SourcemapCommand {
            project: project_path,
            output: Some(sourcemap_output.clone()),
            include_non_scripts: false,
            watch: false,
            absolute: false,
        };
        assert!(sourcemap_command.run().is_ok());

        let raw_sourcemap_contents = fs_err::read_to_string(sourcemap_output.as_path()).unwrap();
        let sourcemap_contents =
            serde_json::from_str::<SourcemapNode>(&raw_sourcemap_contents).unwrap();
        insta::assert_json_snapshot!(sourcemap_contents);
    }

    #[test]
    fn maps_absolute_paths() {
        let sourcemap_dir = tempfile::tempdir().unwrap();
        let sourcemap_output = sourcemap_dir.path().join("sourcemap.json");
        let project_path = fs_err::canonicalize(
            Path::new(env!("CARGO_MANIFEST_DIR"))
                .join("test-projects")
                .join("relative_paths")
                .join("project"),
        )
        .unwrap();
        let sourcemap_command = SourcemapCommand {
            project: project_path,
            output: Some(sourcemap_output.clone()),
            include_non_scripts: false,
            watch: false,
            absolute: true,
        };
        assert!(sourcemap_command.run().is_ok());

        let raw_sourcemap_contents = fs_err::read_to_string(sourcemap_output.as_path()).unwrap();
        let sourcemap_contents =
            serde_json::from_str::<SourcemapNode>(&raw_sourcemap_contents).unwrap();
        insta::assert_json_snapshot!(sourcemap_contents, {
            ".**.filePaths" => insta::dynamic_redaction(|mut value, _path| {
                let mut paths_count = 0;

                match value {
                    Content::Seq(ref mut vec) => {
                        for path in vec.iter().map(|i| i.as_str().unwrap()) {
                            assert_eq!(fs_err::canonicalize(path).is_ok(), true, "path was not valid");
                            assert_eq!(Path::new(path).is_absolute(), true, "path was not absolute");

                            paths_count += 1;
                        }
                    }
                    _ => panic!("Expected filePaths to be a sequence"),
                }
                format!("[...{} path{} omitted...]", paths_count, if paths_count != 1 { "s" } else { "" } )
            })
        });
    }
}
