use std::{
    collections::{HashMap, HashSet},
    path::Path,
};

use anyhow::Context;
use memofs::{DirEntry, Vfs};

use crate::{
    snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot, InstigatingSource},
    syncback::{hash_instance, slugify_name, FsSnapshot, SyncbackReturn, SyncbackSnapshot},
};

use super::{meta_file::DirectoryMetadata, snapshot_from_vfs};

const EMPTY_DIR_KEEP_NAME: &str = ".gitkeep";

pub fn snapshot_dir(
    context: &InstanceContext,
    vfs: &Vfs,
    path: &Path,
    name: &str,
) -> anyhow::Result<Option<InstanceSnapshot>> {
    let mut snapshot = match snapshot_dir_no_meta(context, vfs, path, name)? {
        Some(snapshot) => snapshot,
        None => return Ok(None),
    };

    DirectoryMetadata::read_and_apply_all(vfs, path, &mut snapshot)?;

    Ok(Some(snapshot))
}

/// Snapshot a directory without applying meta files; useful for if the
/// directory's ClassName will change before metadata should be applied. For
/// example, this can happen if the directory contains an `init.client.lua`
/// file.
pub fn snapshot_dir_no_meta(
    context: &InstanceContext,
    vfs: &Vfs,
    path: &Path,
    name: &str,
) -> anyhow::Result<Option<InstanceSnapshot>> {
    let passes_filter_rules = |child: &DirEntry| {
        context
            .path_ignore_rules
            .iter()
            .all(|rule| rule.passes(child.path()))
    };

    let mut snapshot_children = Vec::new();

    for entry in vfs.read_dir(path)? {
        let entry = entry?;

        if !passes_filter_rules(&entry) {
            continue;
        }

        if let Some(child_snapshot) = snapshot_from_vfs(context, vfs, entry.path())? {
            snapshot_children.push(child_snapshot);
        }
    }

    let normalized_path = vfs.canonicalize(path)?;
    let relevant_paths = vec![
        normalized_path.clone(),
        // TODO: We shouldn't need to know about Lua existing in this
        // middleware. Should we figure out a way for that function to add
        // relevant paths to this middleware?
        normalized_path.join("init.lua"),
        normalized_path.join("init.luau"),
        normalized_path.join("init.server.lua"),
        normalized_path.join("init.server.luau"),
        normalized_path.join("init.client.lua"),
        normalized_path.join("init.client.luau"),
        normalized_path.join("init.csv"),
    ];

    let snapshot = InstanceSnapshot::new()
        .name(name)
        .class_name("Folder")
        .children(snapshot_children)
        .metadata(
            InstanceMetadata::new()
                .instigating_source(path)
                .relevant_paths(relevant_paths)
                .context(context),
        );

    Ok(Some(snapshot))
}

pub fn syncback_dir<'sync>(
    snapshot: &SyncbackSnapshot<'sync>,
) -> anyhow::Result<SyncbackReturn<'sync>> {
    let new_inst = snapshot.new_inst();

    let mut dir_syncback = syncback_dir_no_meta(snapshot)?;

    let mut meta = DirectoryMetadata::from_syncback_snapshot(snapshot, snapshot.path.clone())?;
    if let Some(meta) = &mut meta {
        if new_inst.class != "Folder" {
            meta.class_name = Some(new_inst.class);
        }

        if !meta.is_empty() {
            dir_syncback.fs_snapshot.add_file(
                snapshot.path.join("init.meta.json"),
                serde_json::to_vec_pretty(&meta)
                    .context("could not serialize new init.meta.json")?,
            );
        }
    }

    let metadata_empty = meta
        .as_ref()
        .map(DirectoryMetadata::is_empty)
        .unwrap_or_default();
    if new_inst.children().is_empty() && metadata_empty {
        dir_syncback
            .fs_snapshot
            .add_file(snapshot.path.join(EMPTY_DIR_KEEP_NAME), Vec::new())
    }

    Ok(dir_syncback)
}

pub fn syncback_dir_no_meta<'sync>(
    snapshot: &SyncbackSnapshot<'sync>,
) -> anyhow::Result<SyncbackReturn<'sync>> {
    let new_inst = snapshot.new_inst();

    let mut children = Vec::new();
    let mut removed_children = Vec::new();

    // Build the old child map early so it can be used for deduplication below.
    let mut old_child_map = HashMap::new();
    if let Some(old_inst) = snapshot.old_inst() {
        for child in old_inst.children() {
            let inst = snapshot.get_old_instance(*child).unwrap();
            old_child_map.insert(inst.name(), inst);
        }
    }

    // Enforce unique filesystem names. Uses actual on-disk names for existing
    // children and resolved names (with init-prefix) for new ones.
    let mut fs_child_names = HashSet::with_capacity(new_inst.children().len());
    let mut duplicate_set = HashSet::new();
    for child_ref in new_inst.children() {
        let child = snapshot.get_new_instance(*child_ref).unwrap();
        let fs_name = old_child_map
            .get(child.name.as_str())
            .and_then(|old| old.metadata().relevant_paths.first())
            .and_then(|p| p.file_name())
            .and_then(|n| n.to_str())
            .map(|s| s.to_lowercase())
            .unwrap_or_else(|| {
                let slug = slugify_name(&child.name);
                let slug_lower = slug.to_lowercase();
                // Mirror name_for_inst's init-prefix.
                if slug_lower == "init" {
                    format!("_{slug_lower}")
                } else {
                    slug_lower
                }
            });

        if !fs_child_names.insert(fs_name) {
            duplicate_set.insert(child.name.as_str());
        }
    }
    if !duplicate_set.is_empty() {
        if duplicate_set.len() <= 25 {
            anyhow::bail!(
                "Instance has children with duplicate name (case may not exactly match):\n {}",
                duplicate_set.into_iter().collect::<Vec<&str>>().join(", ")
            );
        }
        anyhow::bail!("Instance has more than 25 children with duplicate names");
    }

    if snapshot.old_inst().is_some() {
        for new_child_ref in new_inst.children() {
            let new_child = snapshot.get_new_instance(*new_child_ref).unwrap();
            if let Some(old_child) = old_child_map.remove(new_child.name.as_str()) {
                if old_child.metadata().relevant_paths.is_empty() {
                    log::debug!(
                        "Skipping instance {} because it doesn't exist on the disk",
                        old_child.name()
                    );
                    continue;
                } else if matches!(
                    old_child.metadata().instigating_source,
                    Some(InstigatingSource::ProjectNode { .. })
                ) {
                    log::debug!(
                        "Skipping instance {} because it originates in a project file",
                        old_child.name()
                    );
                    continue;
                }
                // This child exists in both doms. Pass it on.
                children.push(snapshot.with_joined_path(*new_child_ref, Some(old_child.id()))?);
            } else {
                // The child only exists in the the new dom
                children.push(snapshot.with_joined_path(*new_child_ref, None)?);
            }
        }
        // Any children that are in the old dom but not the new one are removed.
        removed_children.extend(old_child_map.into_values());
    } else {
        // There is no old instance. Just add every child.
        for new_child_ref in new_inst.children() {
            children.push(snapshot.with_joined_path(*new_child_ref, None)?);
        }
    }
    let mut fs_snapshot = FsSnapshot::new();

    if let Some(old_ref) = snapshot.old {
        let new_hash = hash_instance(snapshot.project(), snapshot.new_tree(), snapshot.new)
            .expect("new Instance should be hashable");
        let old_hash = hash_instance(snapshot.project(), snapshot.old_tree(), old_ref)
            .expect("old Instance should be hashable");

        if old_hash != new_hash {
            fs_snapshot.add_dir(&snapshot.path);
        } else {
            log::debug!(
                "Skipping reserializing directory {} because old and new tree hash the same",
                new_inst.name
            );
        }
    } else {
        fs_snapshot.add_dir(&snapshot.path);
    }

    Ok(SyncbackReturn {
        fs_snapshot,
        children,
        removed_children,
    })
}

#[cfg(test)]
mod test {
    use super::*;

    use std::path::PathBuf;

    use crate::{
        snapshot::{InstanceMetadata, InstanceSnapshot},
        Project, RojoTree, SyncbackData, SyncbackSnapshot,
    };
    use memofs::{InMemoryFs, VfsSnapshot};

    #[test]
    fn empty_folder() {
        let mut imfs = InMemoryFs::new();
        imfs.load_snapshot("/foo", VfsSnapshot::empty_dir())
            .unwrap();

        let vfs = Vfs::new(imfs);

        let instance_snapshot =
            snapshot_dir(&InstanceContext::default(), &vfs, Path::new("/foo"), "foo")
                .unwrap()
                .unwrap();

        insta::assert_yaml_snapshot!(instance_snapshot);
    }

    #[test]
    fn folder_in_folder() {
        let mut imfs = InMemoryFs::new();
        imfs.load_snapshot(
            "/foo",
            VfsSnapshot::dir([("Child", VfsSnapshot::empty_dir())]),
        )
        .unwrap();

        let vfs = Vfs::new(imfs);

        let instance_snapshot =
            snapshot_dir(&InstanceContext::default(), &vfs, Path::new("/foo"), "foo")
                .unwrap()
                .unwrap();

        insta::assert_yaml_snapshot!(instance_snapshot);
    }

    fn make_project() -> Project {
        serde_json::from_str(r#"{"tree": {"$className": "DataModel"}}"#).unwrap()
    }

    fn make_vfs() -> Vfs {
        let mut imfs = InMemoryFs::new();
        imfs.load_snapshot("/root", VfsSnapshot::empty_dir()).unwrap();
        Vfs::new(imfs)
    }

    /// Two children whose Roblox names are identical when lowercased ("Alpha"
    /// and "alpha") but live at different filesystem paths because of the
    /// `name` property ("Beta/" and "Alpha/" respectively). The dedup check
    /// must use the actual filesystem paths, not the raw Roblox names, to
    /// avoid a false-positive duplicate error.
    #[test]
    fn syncback_no_false_duplicate_with_name_prop() {
        use rbx_dom_weak::{InstanceBuilder, WeakDom};

        // Old child A: Roblox name "Alpha", on disk at "/root/Beta"
        //   (name property maps "Alpha" → "Beta" on the filesystem)
        let old_child_a = InstanceSnapshot::new()
            .name("Alpha")
            .class_name("Folder")
            .metadata(
                InstanceMetadata::new()
                    .instigating_source(PathBuf::from("/root/Beta"))
                    .relevant_paths(vec![PathBuf::from("/root/Beta")]),
            );
        // Old child B: Roblox name "alpha", on disk at "/root/Alpha"
        let old_child_b = InstanceSnapshot::new()
            .name("alpha")
            .class_name("Folder")
            .metadata(
                InstanceMetadata::new()
                    .instigating_source(PathBuf::from("/root/Alpha"))
                    .relevant_paths(vec![PathBuf::from("/root/Alpha")]),
            );
        let old_parent = InstanceSnapshot::new()
            .name("Parent")
            .class_name("Folder")
            .children(vec![old_child_a, old_child_b])
            .metadata(
                InstanceMetadata::new()
                    .instigating_source(PathBuf::from("/root"))
                    .relevant_paths(vec![PathBuf::from("/root")]),
            );
        let old_tree = RojoTree::new(old_parent);

        // New state: same two children in Roblox.
        let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT"));
        let new_parent = new_tree.insert(
            new_tree.root_ref(),
            InstanceBuilder::new("Folder").with_name("Parent"),
        );
        new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("Alpha"));
        new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("alpha"));

        let vfs = make_vfs();
        let project = make_project();
        let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project);
        let snapshot = SyncbackSnapshot {
            data,
            old: Some(old_tree.get_root_id()),
            new: new_parent,
            path: PathBuf::from("/root"),
            middleware: None,
        };

        let result = syncback_dir_no_meta(&snapshot);
        assert!(
            result.is_ok(),
            "should not error when two children have the same lowercased Roblox \
             name but map to distinct filesystem paths: {result:?}",
        );
    }

    /// Two completely new children with the same non-init name would produce
    /// the same filesystem entry and must be detected as a duplicate.
    #[test]
    fn syncback_detects_sibling_duplicate_names() {
        use rbx_dom_weak::{InstanceBuilder, WeakDom};

        let old_parent = InstanceSnapshot::new()
            .name("Parent")
            .class_name("Folder")
            .metadata(
                InstanceMetadata::new()
                    .instigating_source(PathBuf::from("/root"))
                    .relevant_paths(vec![PathBuf::from("/root")]),
            );
        let old_tree = RojoTree::new(old_parent);

        let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT"));
        let new_parent = new_tree.insert(
            new_tree.root_ref(),
            InstanceBuilder::new("Folder").with_name("Parent"),
        );
        // "Foo" is not a reserved name but two siblings named "Foo" still
        // collide on disk.
        new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("Foo"));
        new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("Foo"));

        let vfs = make_vfs();
        let project = make_project();
        let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project);
        let snapshot = SyncbackSnapshot {
            data,
            old: Some(old_tree.get_root_id()),
            new: new_parent,
            path: PathBuf::from("/root"),
            middleware: None,
        };

        let result = syncback_dir_no_meta(&snapshot);
        assert!(
            result.is_err(),
            "should error when two new children would produce the same filesystem name",
        );
    }

    /// A new child named "Init" (as a ModuleScript) would naively become
    /// "Init.luau", which case-insensitively matches the parent's reserved
    /// "init.luau". Syncback must resolve this automatically by prefixing the
    /// filesystem name with '_' (→ "_Init.luau") rather than erroring.
    #[test]
    fn syncback_resolves_init_name_conflict() {
        use rbx_dom_weak::{InstanceBuilder, WeakDom};

        let old_parent = InstanceSnapshot::new()
            .name("Parent")
            .class_name("Folder")
            .metadata(
                InstanceMetadata::new()
                    .instigating_source(PathBuf::from("/root"))
                    .relevant_paths(vec![PathBuf::from("/root")]),
            );
        let old_tree = RojoTree::new(old_parent);

        let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT"));
        let new_parent = new_tree.insert(
            new_tree.root_ref(),
            InstanceBuilder::new("Folder").with_name("Parent"),
        );
        new_tree.insert(
            new_parent,
            InstanceBuilder::new("ModuleScript").with_name("Init"),
        );

        let vfs = make_vfs();
        let project = make_project();
        let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project);
        let snapshot = SyncbackSnapshot {
            data,
            old: Some(old_tree.get_root_id()),
            new: new_parent,
            path: PathBuf::from("/root"),
            middleware: None,
        };

        let result = syncback_dir_no_meta(&snapshot);
        assert!(
            result.is_ok(),
            "should resolve init-name conflict by prefixing '_', not error: {result:?}",
        );
        // The child should have been placed at "_Init.luau", not "Init.luau".
        let child_file_name = result
            .unwrap()
            .children
            .into_iter()
            .next()
            .and_then(|c| c.path.file_name().map(|n| n.to_string_lossy().into_owned()))
            .unwrap_or_default();
        assert!(
            child_file_name.starts_with('_'),
            "child filesystem name should start with '_' to avoid init collision, \
             got: {child_file_name}",
        );
    }

    /// A child whose filesystem name is stored with a slugified prefix (e.g.
    /// "_Init") must NOT be blocked — only the bare "init" stem is reserved.
    #[test]
    fn syncback_allows_slugified_init_name() {
        use rbx_dom_weak::{InstanceBuilder, WeakDom};

        // Existing child: on disk as "_Init" (slugified from a name with an
        // illegal character), its stem is "_init" which is not reserved.
        let old_child = InstanceSnapshot::new()
            .name("Init")
            .class_name("Folder")
            .metadata(
                InstanceMetadata::new()
                    .instigating_source(PathBuf::from("/root/_Init"))
                    .relevant_paths(vec![PathBuf::from("/root/_Init")]),
            );
        let old_parent = InstanceSnapshot::new()
            .name("Parent")
            .class_name("Folder")
            .children(vec![old_child])
            .metadata(
                InstanceMetadata::new()
                    .instigating_source(PathBuf::from("/root"))
                    .relevant_paths(vec![PathBuf::from("/root")]),
            );
        let old_tree = RojoTree::new(old_parent);

        let mut new_tree = WeakDom::new(InstanceBuilder::new("ROOT"));
        let new_parent = new_tree.insert(
            new_tree.root_ref(),
            InstanceBuilder::new("Folder").with_name("Parent"),
        );
        new_tree.insert(new_parent, InstanceBuilder::new("Folder").with_name("Init"));

        let vfs = make_vfs();
        let project = make_project();
        let data = SyncbackData::for_test(&vfs, &old_tree, &new_tree, &project);
        let snapshot = SyncbackSnapshot {
            data,
            old: Some(old_tree.get_root_id()),
            new: new_parent,
            path: PathBuf::from("/root"),
            middleware: None,
        };

        let result = syncback_dir_no_meta(&snapshot);
        assert!(
            result.is_ok(),
            "should allow a child whose filesystem name is slugified away from \
             the reserved 'init' stem: {result:?}",
        );
    }
}
