//! Manages dotfile symlinking
//!
//! Dotfiles are managed by the SymlinkHandler, its fields contain the following runtime information:
//! - dotfiles_dir: the location of the dotfiles directory
//! - symlinked: all files that have been symlinked
//! - not_symlinked: files that haven't been symlinked yet
//! - not_owned: files that have the same name on dotfiles/Configs but that do not belong to us,
//!   therefore they are in conflict
//!
//! This information is retrieved by walking through dotfiles/Configs and checking whether their
//! $TUCKR_TARGET equivalents are pointing to them and categorizing them accordingly.

use crate::Context;
use crate::dotfiles::{self, Dotfile, DotfileType, ReturnCode};
use crate::fileops;
use enumflags2::BitFlags;
use owo_colors::OwoColorize;
use rust_i18n::t;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use tabled::{Table, Tabled};

fn symlink_file(dry_run: bool, f: PathBuf) {
    match Dotfile::try_from(f.clone()) {
        Ok(group) => {
            let target_path = match group.to_target_path() {
                Ok(t) => t,
                Err(err) => {
                    eprintln!("{err}");
                    return;
                }
            };

            if target_path.exists() {
                if dry_run {
                    eprintln!(
                        "{}",
                        t!(
                            "dry-run.ignoring_x_already_exists",
                            x = target_path.display()
                        )
                        .yellow()
                    );
                }
                return;
            }

            if dry_run {
                eprintln!(
                    "{}",
                    t!(
                        "dry-run.symlinking_x_to_y",
                        x = f.display(),
                        y = target_path.display()
                    )
                );
                return;
            }

            let result = {
                #[cfg(target_family = "unix")]
                {
                    std::os::unix::fs::symlink(f, target_path)
                }

                #[cfg(target_family = "windows")]
                {
                    if f.is_dir() {
                        std::os::windows::fs::symlink_dir(f, target_path)
                    } else {
                        std::os::windows::fs::symlink_file(f, target_path)
                    }
                }
            };

            if let Err(err) = result {
                eprintln!(
                    "{}",
                    t!(
                        "errors.failed_to_symlink_x",
                        groupname = group.group_name,
                        err_msg = err.red()
                    )
                );
            }
        }

        Err(err) => {
            eprintln!("{err}");
            eprintln!(
                "{}",
                t!("errors.failed_to_link_file", file = f.to_str().unwrap())
            );
        }
    }
}

fn remove_symlink(dry_run: bool, file: &Path) {
    let dotfile = Dotfile::try_from(file.to_path_buf()).unwrap();
    let target_dotfile = dotfile.to_target_path().unwrap();
    let Ok(linked) = fs::read_link(&target_dotfile) else {
        return;
    };

    if dotfile.path != linked {
        return;
    }

    if dry_run {
        eprintln!(
            "{}",
            t!("dry-run.removing_x", x = target_dotfile.display()).red()
        );
        return;
    }

    if target_dotfile.is_dir() {
        fs::remove_dir_all(&target_dotfile).unwrap();
    } else {
        fs::remove_file(&target_dotfile)
            .map_err(|err| format!("error with path `{}`: {err}", target_dotfile.display()))
            .unwrap();
    }
}

#[enumflags2::bitflags]
#[repr(u8)]
#[derive(Copy, Clone, PartialEq, Debug)]
enum SymlinkType {
    Symlinked = 0b001,
    NotSymlinked = 0b010,
    NotOwned = 0b100,
}

type HashCache = HashMap<String, HashSet<Dotfile>>;

/// Handles dotfile symlinking and their current status
struct SymlinkHandler<'a> {
    ctx: &'a Context,
    dotfiles_dir: PathBuf,    // path to the dotfiles directory
    symlinked: HashCache,     // dotfiles that have been symlinked from Dotfiles/Configs
    not_symlinked: HashCache, // dotfiles that haven't been symlinked to $TUCKR_TARGET yet
    not_owned: HashCache, // dotfiles that are symlinks but points somewhere outside of their respective Dotfiles/Configs's group dir
}

impl<'a> SymlinkHandler<'a> {
    /// Initializes SymlinkHandler and fills it dotfiles' status information
    fn try_new(ctx: &'a Context) -> Result<Self, ExitCode> {
        let dotfiles_dir = match dotfiles::get_dotfiles_path(ctx.profile.clone()) {
            Ok(dir) => dir,
            Err(e) => {
                eprintln!("{e}");
                return Err(ReturnCode::CouldntFindDotfiles.into());
            }
        };

        if let Err(err) = dotfiles::get_dotfiles_target_dir_path() {
            eprintln!("{err}");
            return Err(ReturnCode::NoSuchFileOrDir.into());
        }

        let mut symlinker = SymlinkHandler {
            ctx,
            dotfiles_dir,
            symlinked: HashCache::new(),
            not_symlinked: HashCache::new(),
            not_owned: HashCache::new(),
        };

        // this fills the symlinker with dotfile status information
        match symlinker.validate() {
            true => Ok(symlinker),
            false => Err(ReturnCode::CouldntFindDotfiles.into()),
        }
    }

    /// Validates all dotfiles and populates `Self` with the information gathered.
    ///
    /// Returns false if it failed to retrieve dotfiles and validate them
    fn validate(&mut self) -> bool {
        self.symlinked.clear();
        self.not_symlinked.clear();
        self.not_owned.clear();

        let configs_dir = self.dotfiles_dir.join("Configs");

        if !configs_dir.is_dir() {
            eprintln!(
                "{}",
                t!(
                    "errors.no_configs_dir_in_dotfiles",
                    dotfiles = configs_dir.display()
                )
            );
            return false;
        }

        // iterates over every file inside dotfiles/Config and determines their symlink status
        for f in fileops::DirWalk::new(configs_dir) {
            // skip group directories otherwise it would try to link dotfiles/Configs/Groups to the users home
            let f = Dotfile::try_from(f).unwrap();
            if f.path == f.group_path {
                continue;
            }

            let target = match f.to_target_path() {
                Ok(target) => target,
                Err(err) => {
                    eprintln!("{}", err.red());
                    continue;
                }
            };

            if target.is_symlink() {
                let link = match fs::read_link(target) {
                    Ok(link) => link,
                    Err(err) => {
                        eprintln!("{err}");
                        continue;
                    }
                };

                if link == f.path {
                    self.symlinked.entry(f.group_name.clone()).or_default();

                    let group = self.symlinked.get_mut(&f.group_name).unwrap();
                    group.insert(f);
                } else {
                    self.not_owned.entry(f.group_name.clone()).or_default();

                    let group = self.not_owned.get_mut(&f.group_name).unwrap();
                    group.insert(f);
                }
            } else {
                if target.is_dir() && f.path.is_dir() {
                    continue;
                }

                self.not_symlinked.entry(f.group_name.clone()).or_default();

                let group = self.not_symlinked.get_mut(&f.group_name).unwrap();
                group.insert(f);
            }
        }

        // removes entries for paths that are subpaths of another entry (canonicalization).
        // this procedure makes so that symlinks are shallow.
        //
        // shallow symlinking: only symlinking files/directories that don't exist already
        fn canonicalize_groups(groups: &mut HashCache) {
            for files in groups.values_mut() {
                let files_copy = files.clone();

                for file in &files_copy {
                    for file2 in &files_copy {
                        if file2.path != file.path && file2.path.starts_with(&file.path) {
                            files.remove(file2);
                        }
                    }
                }
            }
        }

        // canonicalizes not_symlinked based on symlinked cache
        //
        // this is necessary because if a directory is canonicalized and symlinked,
        // files inside it won't be symlinked and thus marked as `not_symlinked` wrongly.
        for (group, files) in &self.symlinked {
            let Some(unsymlinked_group) = self.not_symlinked.get_mut(group) else {
                continue;
            };

            let unsymlinked_group_copy = unsymlinked_group.clone();

            for file1 in files {
                for file2 in unsymlinked_group_copy.iter() {
                    if file2.path.starts_with(&file1.path) {
                        unsymlinked_group.remove(file2);
                    }
                }
            }
        }

        canonicalize_groups(&mut self.symlinked);
        canonicalize_groups(&mut self.not_symlinked);
        canonicalize_groups(&mut self.not_owned);

        fn remove_empty_groups(group_type: &mut HashCache) {
            *group_type = group_type
                .iter()
                .filter(|(_, v)| !v.is_empty())
                .map(|(k, v)| (k.to_owned(), v.to_owned()))
                .collect()
        }

        remove_empty_groups(&mut self.symlinked);
        remove_empty_groups(&mut self.not_symlinked);
        remove_empty_groups(&mut self.not_owned);
        true
    }

    fn is_empty(&self) -> bool {
        self.symlinked.is_empty() && self.not_symlinked.is_empty() && self.not_owned.is_empty()
    }

    /// Returns target_group and all of its conditional groups that are valid in the current platform
    ///
    /// target_group: a group used as the base for all conditional groups
    /// symtype: a bitflag of the possible symlink statuses
    /// include_invalid: if groups invalid in the current platform should be included in the returned vec
    fn get_related_conditional_groups(
        &self,
        target_group: &str,
        symtype: BitFlags<SymlinkType>,
        include_invalid: bool,
    ) -> Option<Vec<String>> {
        fn __get_related_cond_groups(target_group: &str, cache: &HashCache) -> Option<Vec<String>> {
            if dotfiles::get_group_target(target_group).is_some() {
                return match cache.contains_key(target_group) {
                    true => Some(vec![target_group.to_string()]),
                    false => None,
                };
            }

            let cond_groups: Vec<String> = cache
                .iter()
                .filter(|(group, _)| dotfiles::group_without_target(group) == target_group)
                .map(|(group, _)| group.clone())
                .collect();

            match cond_groups.is_empty() {
                true => None,
                false => Some(cond_groups),
            }
        }

        let symlinked = if symtype.contains(SymlinkType::Symlinked) {
            __get_related_cond_groups(target_group, &self.symlinked)
        } else {
            None
        };

        let not_symlinked = if symtype.contains(SymlinkType::NotSymlinked) {
            __get_related_cond_groups(target_group, &self.not_symlinked)
        } else {
            None
        };

        let not_owned = if symtype.contains(SymlinkType::NotOwned) {
            __get_related_cond_groups(target_group, &self.not_owned)
        } else {
            None
        };

        let cond_groups: Vec<_> = symlinked
            .iter()
            .chain(not_symlinked.iter())
            .chain(not_owned.iter())
            .flatten()
            .map(|group| group.to_owned())
            .filter(|group| {
                dotfiles::group_is_valid_target(group, &self.ctx.custom_targets) || include_invalid
            })
            .collect();

        if cond_groups.is_empty() {
            None
        } else {
            Some(cond_groups)
        }
    }

    /// returns a cache with files in dotfiles that already exist in $TUCKR_TARGET
    fn get_conflicts_in_cache(&self) -> HashCache {
        let mut conflicts = HashCache::new();

        // mark group as conflicting if at least one value already exists in $TUCKR_TARGET
        for files in self.not_symlinked.values() {
            for file in files {
                if file.to_target_path().unwrap().exists()
                    && file.is_valid_target(&self.ctx.custom_targets)
                {
                    conflicts.entry(file.group_name.clone()).or_default();
                    let curr_entry = conflicts.get_mut(&file.group_name).unwrap();
                    curr_entry.insert(file.clone());
                }
            }
        }

        // doesn't mark not owned dotfiles as conflicts if a higher priority dotfile
        // with the same file is already symlinked. this allows dotfile fallbacks to
        // work properly instead of falsely flagged as conflicts
        for files in self.not_owned.values() {
            for file in files {
                conflicts.entry(file.group_name.clone()).or_default();
                let curr_entry = conflicts.get_mut(&file.group_name).unwrap();

                let dotfile_source = file.to_target_path().unwrap().read_link().unwrap();
                let Ok(dotfile) = Dotfile::try_from(dotfile_source) else {
                    curr_entry.insert(file.clone());
                    continue;
                };

                let target_has_higher_priority = dotfiles::get_group_priority(&dotfile.group_name)
                    > dotfiles::get_group_priority(&file.group_name);
                let not_same_base_group = dotfiles::group_without_target(&file.group_name)
                    != dotfiles::group_without_target(&dotfile.group_name);

                if dotfile.path != file.path && target_has_higher_priority && not_same_base_group {
                    curr_entry.insert(file.clone());
                }
            }
        }

        conflicts.into_iter().filter(|g| !g.1.is_empty()).collect()
    }

    /// Symlinks all the files of a group to the user's $TUCKR_TARGET
    fn add(&mut self, dry_run: bool, only_files: bool, group: &str) {
        let Some(mut groups) =
            self.get_related_conditional_groups(group, SymlinkType::NotSymlinked.into(), false)
        else {
            return;
        };

        let mut removed_files = HashSet::new();
        let mut added_files = HashSet::new();

        // Context: if a directory is a symlink of a group and then another group tries to symlink to that same directory
        // we would end up adding a symlink inside another symlink, borking the user's dotfiles.
        // So the solution is to detect that the directory should be shared by multiple groups or even multiple dotfiles profiles,
        // If that happens we should remove all the groups that will share those directories, create the shared directories
        // and then add everything back in

        while let Some(idx) = dotfiles::get_highest_priority_target_idx(&groups) {
            let group = &groups[idx];
            let group = Dotfile::try_from(self.dotfiles_dir.join("Configs").join(group)).unwrap();
            if group.path.exists() {
                for f in group.try_iter().unwrap() {
                    let f_target = f.to_target_path().unwrap();

                    // if there's a symlink in this path or its parents, we take it and put it in the "to be removed" bucket
                    // it will later be readded after the parents dirs are created,
                    // so that the groups are all contained in the same parent directory
                    if let Some(dotfile) = dotfiles::get_dotfile_from_path(&f_target) {
                        removed_files.insert(dotfile);
                    }
                    added_files.insert(f);
                }
            } else {
                eprintln!(
                    "{}",
                    t!("errors.no_dotfiles_for_group", group = group.group_name).red()
                );
            }

            groups.remove(idx);
        }

        let group_only_added_files = added_files.clone();

        for file in &removed_files {
            remove_symlink(dry_run, &file.path);

            let target_path = file.to_target_path().unwrap();
            fs::create_dir_all(if file.path.is_file() {
                target_path.parent().unwrap()
            } else {
                &target_path
            })
            .unwrap();

            for file in fileops::DirWalk::new(&file.group_path) {
                let Ok(dotfile) = Dotfile::try_from(file) else {
                    continue;
                };
                added_files.insert(dotfile);
            }
        }

        for file in added_files {
            if only_files {
                if file.path.is_dir() {
                    continue;
                }

                // we need to ensure that the target dotfile's parent exists otherwise symlink will fail
                let f_target = file.to_target_path().unwrap();
                let target_parent = f_target.parent().unwrap();

                if !target_parent.exists() {
                    fs::create_dir_all(target_parent).unwrap();
                }
            }

            symlink_file(dry_run, file.path);
        }

        // we have to update the cache to reflect the new state
        self.symlinked
            .insert(group.into(), group_only_added_files.into_iter().collect());
        self.not_symlinked.remove(group);
    }

    /// Deletes symlinks from $TUCKR_TARGET if they're owned by dotfiles dir
    fn remove(&self, dry_run: bool, group: &str) {
        let Some(groups) =
            self.get_related_conditional_groups(group, SymlinkType::Symlinked.into(), false)
        else {
            return;
        };

        for group in groups {
            let group = Dotfile::try_from(self.dotfiles_dir.join("Configs").join(&group)).unwrap();

            if !group.path.exists() {
                eprintln!("{}", t!("errors.no_group", group = group.group_name).red());
                continue;
            }

            group
                .try_iter()
                .unwrap()
                .for_each(|f| remove_symlink(dry_run, &f.path));
        }
    }
}

fn get_nonexistent_groups_with_no_related_groups(
    sym: &SymlinkHandler,
    groups: &[String],
) -> Option<Vec<String>> {
    let mut nonexistent_groups = Vec::new();
    for group in groups {
        if sym
            .get_related_conditional_groups(
                group,
                SymlinkType::Symlinked | SymlinkType::NotSymlinked | SymlinkType::NotOwned,
                false,
            )
            .is_none()
        {
            nonexistent_groups.push(group.clone());
        }
    }

    if nonexistent_groups.is_empty() {
        return None;
    }

    Some(nonexistent_groups)
}

/// Adds symlinks
#[allow(clippy::too_many_arguments)]
pub fn add_cmd(
    ctx: &Context,
    only_files: bool,
    groups: &[String],
    exclude: &[String],
    force: bool,
    adopt: bool,
    assume_yes: bool,
) -> Result<(), ExitCode> {
    if !assume_yes && (force || adopt) {
        let confirmed = fileops::get_user_confirmation(if force {
            "Are you sure you want to override conflicts?"
        } else {
            "Are you sure you want to adopt conflicts?"
        });

        if !confirmed {
            return Err(ExitCode::FAILURE);
        }
    }

    let mut sym = SymlinkHandler::try_new(ctx)?;

    fn add_group(
        ctx: &Context,
        sym: &mut SymlinkHandler,
        group: &String,
        exclude: &[String],
        only_files: bool,
        force: bool,
        adopt: bool,
    ) {
        if exclude.contains(group) {
            return;
        }

        fn remove_files_and_decide_if_adopt(
            group: &str,
            status_group: &HashCache,
            adopt: bool,
            dry_run: bool,
        ) {
            let group = status_group.get(group);
            let Some(group_files) = group else { return };

            for file in group_files {
                let target_file = file.to_target_path().unwrap();

                let deleted_file = if adopt { &file.path } else { &target_file };

                if dry_run {
                    eprintln!(
                        "{}",
                        t!("dry-run.removing_x", x = deleted_file.display()).red()
                    );
                } else if deleted_file.is_dir() {
                    fs::remove_dir_all(deleted_file).unwrap();
                } else if deleted_file.is_file() {
                    fs::remove_file(deleted_file).unwrap();
                }

                if adopt {
                    if dry_run {
                        eprintln!(
                            "{}",
                            t!(
                                "dry-run.moving_x_to_y",
                                x = target_file.display(),
                                y = file.path.display()
                            )
                            .yellow()
                        );
                    } else {
                        fs::rename(target_file, &file.path).unwrap();
                    }
                }
            }
        }

        // Symlink dotfile by force
        if force {
            remove_files_and_decide_if_adopt(group, &sym.not_owned, false, ctx.dry_run);
            remove_files_and_decide_if_adopt(group, &sym.not_symlinked, false, ctx.dry_run);
        }

        // Discard dotfile and adopt the conflicting dotfile
        if adopt {
            remove_files_and_decide_if_adopt(group, &sym.not_owned, true, ctx.dry_run);
            remove_files_and_decide_if_adopt(group, &sym.not_symlinked, true, ctx.dry_run);
        }

        sym.add(ctx.dry_run, only_files, group)
    }

    if groups.contains(&"*".to_string()) {
        let not_symlinked_groups: Vec<_> = sym.not_symlinked.clone().into_keys().collect();
        for group in not_symlinked_groups {
            if dotfiles::group_is_valid_target(&group, &ctx.custom_targets) {
                add_group(ctx, &mut sym, &group, exclude, only_files, force, adopt);
            }
        }
    } else {
        if let Some(nonexistent_groups) =
            get_nonexistent_groups_with_no_related_groups(&sym, groups)
        {
            for group in nonexistent_groups {
                eprintln!("{}", t!("errors.x_doesnt_exist", x = group).red());
            }

            return Err(ReturnCode::NoSetupFolder.into());
        };

        let groups = {
            let mut related_groups = Vec::new();

            for group in groups {
                let Some(mut related) = sym.get_related_conditional_groups(
                    group,
                    SymlinkType::NotSymlinked | SymlinkType::NotOwned,
                    false,
                ) else {
                    continue;
                };

                related_groups.append(&mut related);
            }

            related_groups
        };

        for group in groups {
            add_group(ctx, &mut sym, &group, exclude, only_files, force, adopt);
        }
    }

    sym.validate();
    let potential_conflicts = sym.get_conflicts_in_cache();

    if !potential_conflicts.is_empty() {
        if groups.iter().any(|g| g == "*") {
            println!(
                "{} {}",
                t!("info.conflicts_detected").yellow(),
                t!("info.learn_more_about_conflicts", cmd = "tuckr status").yellow(),
            );
        }

        if groups.iter().any(|g| potential_conflicts.contains_key(g)) {
            println!(
                "{} {}",
                t!("info.conflicts_detected").yellow(),
                t!(
                    "info.conflicting_groups_not_added_until_resolved",
                    cmd = "tuckr status"
                )
                .yellow(),
            );
            return print_groups_status(ctx, &sym, groups.into(), false);
        }
    }
    Ok(())
}

/// Removes symlinks
pub fn remove_cmd(ctx: &Context, groups: &[String], exclude: &[String]) -> Result<(), ExitCode> {
    let sym = SymlinkHandler::try_new(ctx)?;

    // remove command should not care about whether a group is a valid target
    // if it's been added, removing it should always be possible
    if groups.contains(&"*".to_string()) {
        for group in sym.symlinked.keys() {
            if exclude.contains(group) {
                continue;
            }
            sym.remove(ctx.dry_run, group);
        }
        return Ok(());
    }

    if let Some(nonexistent_groups) = get_nonexistent_groups_with_no_related_groups(&sym, groups) {
        for group in nonexistent_groups {
            eprintln!("{}", t!("errors.x_doesnt_exist", x = group).red());
        }

        return Err(ReturnCode::NoSetupFolder.into());
    }

    let related_groups: Vec<_> = sym
        .symlinked
        .keys()
        .filter(|g| {
            groups.contains(g)
                || groups
                    .iter()
                    .any(|group| dotfiles::group_without_target(g) == group)
        })
        .collect();

    for group in related_groups {
        if exclude.contains(group) {
            continue;
        }
        sym.remove(ctx.dry_run, group);
    }

    Ok(())
}

#[derive(Serialize)]
struct JsonGlobalStatus<'a> {
    symlinked: Vec<&'a str>,
    not_symlinked: Vec<&'a str>,
    conflicts: HashSet<&'a str>,
}

#[derive(Serialize)]
struct JsonGroupStatus {
    symlinked: Vec<String>,
    not_symlinked: Vec<String>,
    unsupported: Vec<String>,
    conflicts: HashMap<String, Vec<ConflictDetail>>,
    nonexistent: Vec<String>,
}

#[derive(Serialize)]
struct ConflictDetail {
    source_path: String,
    target_path: String,
    reason: String,
}

fn print_global_status(sym: &SymlinkHandler, json: bool) -> Result<(), ExitCode> {
    #[derive(Tabled, Debug)]
    struct SymlinkRow<'a> {
        #[tabled(rename = "Symlinked")]
        symlinked: &'a str,

        #[tabled(rename = "Not Symlinked")]
        not_symlinked: &'a str,
    }

    // --- process status from symlink ---
    // groups that are both in symlinked and not_symlinked
    // will be marked as not_symlinked only

    let (symlinked, not_symlinked) = {
        let mut not_symlinked: Vec<_> = sym
            .not_symlinked
            .keys()
            .map(|group| group.as_str())
            .collect();

        let mut symlinked: Vec<_> = sym
            .symlinked
            .keys()
            .filter_map(|group| {
                if sym
                    .get_related_conditional_groups(group, SymlinkType::NotSymlinked.into(), false)
                    .is_none()
                {
                    Some(dotfiles::group_without_target(group))
                } else {
                    not_symlinked.push(group);
                    None
                }
            })
            .collect();

        let mut not_symlinked: Vec<_> = not_symlinked
            .iter()
            .filter_map(|group| {
                if dotfiles::group_is_valid_target(group, &sym.ctx.custom_targets) {
                    Some(dotfiles::group_without_target(group))
                } else {
                    None
                }
            })
            .collect();

        symlinked.sort();
        symlinked.dedup();

        not_symlinked.sort();
        not_symlinked.dedup();

        (symlinked, not_symlinked)
    };

    let status_rows: Vec<SymlinkRow> = {
        let (longest, shortest, symlinked_is_longest) = if symlinked.len() >= not_symlinked.len() {
            (&symlinked, &not_symlinked, true)
        } else {
            (&not_symlinked, &symlinked, false)
        };

        longest
            .iter()
            .zip(shortest.iter().chain(std::iter::repeat(&"")))
            .map(|(longest, shortest)| SymlinkRow {
                symlinked: if symlinked_is_longest {
                    longest
                } else {
                    shortest
                },

                not_symlinked: if symlinked_is_longest {
                    shortest
                } else {
                    longest
                },
            })
            .collect()
    };

    // --- detect conflicts ---
    let conflicts = sym.get_conflicts_in_cache();
    let conflicts: HashSet<_> = conflicts.keys().map(|s| s.as_str()).collect();

    // --- Creates status tables or json depending on user's preference ---
    if json {
        println!(
            "{}",
            serde_json::to_string_pretty(&JsonGlobalStatus {
                symlinked: symlinked.clone(),
                not_symlinked: not_symlinked.clone(),
                conflicts: conflicts.clone(),
            })
            .unwrap()
        );
    } else {
        use tabled::col;
        use tabled::settings::{
            Alignment, Margin, Modify, Style, format::Format, object::Columns, object::Rows,
        };

        let mut sym_table = Table::new(status_rows);
        sym_table
            .with(Style::rounded())
            .with(
                Modify::new(Rows::first()).with(Format::content(|s| s.default_color().to_string())),
            )
            .with(Modify::new(Columns::one(0)).with(Format::content(|s| s.green().to_string())))
            .with(Modify::new(Columns::one(1)).with(Format::content(|s| s.red().to_string())));

        let conflict_builder = tabled::Table::builder(&conflicts)
            .index()
            .column(0)
            .name(Some("Conflicting Dotfiles".yellow().to_string()));
        let mut conflict_table = conflict_builder.build();
        conflict_table
            .with(Style::empty())
            .with(Alignment::center());

        // Creates a table with sym_table and conflict_table
        let mut final_table = if conflicts.is_empty() {
            col![sym_table]
        } else {
            col![sym_table, conflict_table]
        };

        final_table
            .with(Style::empty())
            .with(Alignment::center())
            .with(Margin::new(4, 4, 1, 1));
        println!("{final_table}");

        if !conflicts.is_empty() {
            println!(
                "{}",
                t!(
                    "info.learn_more_about_conflicts",
                    cmd = "tuckr status <group...>"
                )
            );
        }
    }

    // Determines exit code for the command based on the dotfiles' status
    if !symlinked.is_empty() && not_symlinked.is_empty() && conflicts.is_empty() {
        Ok(())
    } else {
        Err(ExitCode::FAILURE)
    }
}

fn print_groups_status(
    ctx: &Context,
    sym: &SymlinkHandler,
    groups: Vec<String>,
    json: bool,
) -> Result<(), ExitCode> {
    let not_symlinked: Vec<_> = groups
        .iter()
        .filter_map(|g| {
            sym.get_related_conditional_groups(
                g,
                enumflags2::make_bitflags!(SymlinkType::{NotSymlinked | NotOwned}),
                false,
            )
        })
        .flatten()
        .collect();

    let symlinked: Vec<_> = groups
        .iter()
        .filter_map(|g| sym.get_related_conditional_groups(g, SymlinkType::Symlinked.into(), false))
        .flatten()
        .collect();

    let file_conflicts: HashCache = sym
        .get_conflicts_in_cache()
        .into_iter()
        .filter(|(g, _)| {
            groups.contains(g) || groups.contains(&dotfiles::group_without_target(g).to_string())
        })
        .collect();

    let nonexistent_groups =
        dotfiles::get_nonexistent_groups(ctx.profile.clone(), DotfileType::Configs, &groups);

    let unsupported = {
        let mut unsupported = Vec::new();

        for group in &groups {
            if let Some(related_groups) =
                sym.get_related_conditional_groups(group, SymlinkType::NotSymlinked.into(), true)
            {
                for group in related_groups {
                    if !dotfiles::group_is_valid_target(group.as_str(), &ctx.custom_targets) {
                        unsupported.push(group);
                    }
                }
            }
        }

        unsupported.sort();
        unsupported.dedup();
        unsupported
    };

    if json {
        let conflicts: HashMap<_, _> = file_conflicts
            .into_iter()
            .map(|(group, files)| {
                let details: Vec<ConflictDetail> = files
                    .iter()
                    .map(|f| {
                        let source_path = f.path.to_str().unwrap().to_string();
                        let target_path = f.to_target_path().unwrap();

                        // NOTE: DO NOT I18N THESE STRINGS.
                        // They are part of the JSON API. Changing them would break scripts.
                        let reason = if !target_path.is_symlink() {
                            "already exists".to_string()
                        } else {
                            let linked_path = fs::read_link(&target_path).unwrap();
                            if Dotfile::try_from(linked_path).is_err() {
                                "symlinks elsewhere".to_string()
                            } else {
                                // Symlink points to another dotfile within the repo
                                "already exists".to_string()
                            }
                        };
                        ConflictDetail {
                            source_path,
                            target_path: target_path.to_str().unwrap().to_string(),
                            reason,
                        }
                    })
                    .collect();
                (group, details)
            })
            .collect();

        let status = JsonGroupStatus {
            symlinked,
            not_symlinked,
            unsupported,
            conflicts,
            nonexistent: match nonexistent_groups {
                Some(ref groups) => groups.clone(),
                None => Default::default(),
            },
        };

        println!("{}", serde_json::to_string_pretty(&status).unwrap());
    } else {
        if !file_conflicts.is_empty() || !not_symlinked.is_empty() {
            fn print_conflicts(conflicts_cache: &HashCache, group: &str) {
                let Some(conflicts) = conflicts_cache.get(group) else {
                    return;
                };

                for file in conflicts {
                    let conflict = file.to_target_path().unwrap();
                    let msg = if !conflict.is_symlink() {
                        t!("errors.already_exists")
                    } else {
                        let conflict_dotfile = Dotfile::try_from(conflict.read_link().unwrap());

                        match conflict_dotfile {
                            Ok(conflict) => {
                                if file.path != conflict.path {
                                    t!("errors.already_exists")
                                } else {
                                    unreachable!();
                                }
                            }
                            Err(_) => t!("errors.symlinks_elsewhere"),
                        }
                    };

                    println!("\t -> {} ({})", conflict.display(), msg,);
                }
            }

            println!("{}:", t!("table-column.not_symlinked"));
            for group in not_symlinked {
                if file_conflicts.contains_key(&group) {
                    continue;
                }
                println!("\t{}", group.red());
            }

            for group in file_conflicts.keys() {
                print!("\t{}", group.red());
                print_conflicts(&file_conflicts, group);
            }

            println!();
        }

        if !symlinked.is_empty() {
            println!("{}:", t!("table-column.symlinked"));
            for group in symlinked {
                println!("\t{}", group.green());
            }
            println!();
        }

        if !unsupported.is_empty() {
            println!("{}:", t!("errors.not_supported_on_this_platform"));
            for group in unsupported {
                println!("\t{}", group.yellow());
            }
            println!();
        }

        if let Some(ref nonexistent_groups) = nonexistent_groups {
            eprintln!("{}:", t!("errors.following_groups_dont_exist"));
            for group in nonexistent_groups {
                eprintln!("\t{}", group.red());
            }
            println!();
        }

        if !file_conflicts.is_empty() {
            println!(
                "{}",
                t!("info.learn_how_to_fix_symlinks", cmd = "tuckr help add")
            );
        }
    }

    if nonexistent_groups.is_none() {
        return Err(ReturnCode::NoSetupFolder.into());
    }

    Ok(())
}

/// Prints symlinking status
pub fn status_cmd(ctx: &Context, groups: Option<Vec<String>>, json: bool) -> Result<(), ExitCode> {
    let sym = SymlinkHandler::try_new(ctx)?;

    if sym.is_empty() {
        println!("{}", t!("errors.no_x_setup_yet", x = "dotfiles").yellow());
        println!(
            "{}",
            t!(
                "info.how_to_get_started",
                dotfiles_config_dir = sym.dotfiles_dir.join("Configs").display()
            )
        );
        return Err(ReturnCode::NoSetupFolder.into());
    }

    match groups {
        Some(groups) => {
            let mut invalid_group_errs = Vec::new();

            let groups: Vec<_> = groups
                .into_iter()
                .filter_map(|g| match dotfiles::is_valid_groupname(&g) {
                    Ok(()) => Some(g),
                    Err(err) => {
                        invalid_group_errs.push(err);
                        None
                    }
                })
                .collect();

            let ret = print_groups_status(ctx, &sym, groups, json);

            if !invalid_group_errs.is_empty() {
                for err in invalid_group_errs {
                    eprintln!("{}", err.red());
                }
            }

            ret
        }

        None => print_global_status(&sym, json),
    }
}

#[cfg(test)]
mod tests {
    use std::{
        fs::{self, File},
        io::Write,
        path,
    };

    use owo_colors::OwoColorize;

    use super::SymlinkHandler;
    use crate::Context;
    use crate::dotfiles::{self, Dotfile};

    /// this ensures that the tests never
    // use crate::Context; fail with weird random panics
    #[must_use = "must be initialized before every test"]
    struct Test {
        files_used: Vec<path::PathBuf>,
    }

    impl Test {
        fn start() -> Self {
            let target_dir = dotfiles::get_dotfiles_target_dir_path().unwrap();
            fs::create_dir_all(&target_dir).unwrap();

            crate::fileops::init_cmd(&Context::default()).unwrap();
            let dotfiles_dir = dotfiles::get_dotfiles_path(None).unwrap();
            let configs_dir = dotfiles_dir.join("Configs");
            let group_dir = dotfiles_dir.join("Configs").join("Group1");
            let new_config_dir = group_dir.join(".config");
            fs::create_dir_all(&new_config_dir).unwrap();

            let filepaths = [
                new_config_dir.join("group_file"),
                group_dir.join("group_file_0"),
                configs_dir
                    .join("shared_group1")
                    .join("shared_dir")
                    .join("file1"),
                configs_dir
                    .join("shared_group2")
                    .join("shared_dir")
                    .join("file2"),
            ];

            for filepath in &filepaths {
                fs::create_dir_all(filepath.parent().unwrap()).unwrap();

                let mut file = File::create(filepath).unwrap();
                _ = file
                    .write("Some random content on file".as_bytes())
                    .unwrap();
            }

            Self {
                files_used: filepaths.to_vec(),
            }
        }
    }

    impl Drop for Test {
        fn drop(&mut self) {
            let Ok(dotfiles_dir) = dotfiles::get_dotfiles_path(None) else {
                eprintln!("{}", "Failed to clean up test.".red());
                return;
            };

            for file in &self.files_used {
                // delete everything to ensure everything starts from a blank slate
                if file.exists() {
                    let dotfile_path = Dotfile::try_from(file.clone()).unwrap();
                    _ = fs::remove_file(dotfile_path.to_target_path().unwrap());
                }
            }

            if dotfiles_dir.exists() {
                _ = super::remove_cmd(&Context::default(), &["*".to_string()], &[]);
                fs::remove_dir_all(dotfiles_dir).unwrap();
            }
        }
    }

    fn test_adding_symlink() {
        let _test = Test::start();

        let mut ctx = Context::default();
        ctx.profile = None;

        let mut sym = SymlinkHandler::try_new(&ctx).unwrap();
        assert!(
            !sym.not_symlinked.is_empty() || !sym.symlinked.is_empty() || !sym.not_owned.is_empty()
        );

        assert!(!sym.symlinked.contains_key("Group1"));
        super::add_cmd(
            &Context::default(),
            false,
            &["Group1".to_string()],
            &[],
            false,
            false,
            false,
        )
        .unwrap();

        sym.validate();
        assert!(sym.symlinked.contains_key("Group1"));
    }

    fn test_removing_symlink() {
        let _test = Test::start();

        super::add_cmd(
            &Context::default(),
            false,
            &["Group1".to_string()],
            &[],
            false,
            false,
            false,
        )
        .unwrap();

        let ctx = Context::default();

        let mut sym = SymlinkHandler::try_new(&ctx).unwrap();
        assert!(
            !sym.not_symlinked.is_empty() || !sym.symlinked.is_empty() || !sym.not_owned.is_empty()
        );

        assert!(!sym.not_symlinked.contains_key("Group1"));

        super::remove_cmd(&Context::default(), &["Group1".to_string()], &[]).unwrap();
        sym.validate();
        assert!(sym.not_symlinked.contains_key("Group1"));
    }

    #[test]
    fn add_and_remove_symlink() {
        test_adding_symlink();
        test_removing_symlink();
    }

    #[test]
    fn resolve_shared_directories() {
        let ctx = Context::default();
        let target_dir = dotfiles::get_dotfiles_target_dir_path().unwrap();
        let shared_dir_target = target_dir.join("shared_dir");
        _ = fs::remove_dir_all(&shared_dir_target);

        let _test = Test::start();

        assert!(!shared_dir_target.exists());

        _ = super::add_cmd(
            &ctx,
            false,
            &["shared_group1".into()],
            &[],
            false,
            false,
            true,
        );

        let file1_target = shared_dir_target.join("file1");
        let file2_target = shared_dir_target.join("file2");

        assert!(shared_dir_target.is_symlink());
        assert!(!file1_target.is_symlink() && file1_target.is_file());

        _ = super::add_cmd(
            &ctx,
            false,
            &["shared_group2".into()],
            &[],
            false,
            false,
            true,
        );

        assert!(file1_target.is_symlink() && file1_target.is_file());
        assert!(file2_target.is_symlink() && file2_target.is_file());

        _ = fs::remove_dir_all(shared_dir_target);
    }
}
