use anyhow::Context as _;
use clap::Parser;
use directories_next::BaseDirs;
use std::{
    ffi::OsString,
    path::{Path, PathBuf},
};
use tera::{Context, Tera};

fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt::init();
    let opts: Opts = Opts::parse();

    match opts {
        Opts::Install(opts) => run_install(opts),
    }
}

fn run_install(opts: InstallOpts) -> anyhow::Result<()> {
    let context = load_context()?;

    for entry in walkdir::WalkDir::new(&opts.root)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| e.file_name() == "install.toml")
    {
        let install_file_path = entry.path().canonicalize()?;
        let mut install_base = install_file_path.clone();
        install_base.pop();
        tracing::debug!("reading install file at {install_file_path:?}");

        let install_config: String =
            std::fs::read_to_string(entry.path()).context("reading install file")?;
        let install_config: InstallConfig = InstallConfig::render_from(&install_config, &context)?;
        install_config.install_links(&install_base, opts.overwrite, &context)?;
    }
    Ok(())
}

pub fn load_context() -> anyhow::Result<Context> {
    fn path_to_str(path: &Path) -> anyhow::Result<&str> {
        path.to_str()
            .ok_or_else(|| anyhow::anyhow!("paths must currently be UTF-8"))
    }

    let base_dirs: BaseDirs = match BaseDirs::new() {
        Some(dirs) => dirs,
        None => anyhow::bail!("couldn't get base directories"),
    };

    let hostname_os: OsString = gethostname::gethostname();
    let hostname = hostname_os
        .to_str()
        .ok_or_else(|| anyhow::anyhow!("this library currently requires UTF-8 hostnames"))?;

    let mut ctx = Context::new();

    ctx.insert("HOME", path_to_str(base_dirs.home_dir())?);
    ctx.insert("CACHE", path_to_str(base_dirs.cache_dir())?);
    ctx.insert("CONFIG", path_to_str(base_dirs.config_dir())?);
    ctx.insert("DATA", path_to_str(base_dirs.data_dir())?);
    ctx.insert("DATA_LOCAL", path_to_str(base_dirs.data_local_dir())?);
    ctx.insert("HOSTNAME", hostname);

    Ok(ctx)
}

#[derive(Clone, Debug, serde::Deserialize)]
struct InstallConfig {
    #[serde(rename = "link")]
    links: Vec<Link>,
}

impl InstallConfig {
    fn render_from(template: &str, context: &Context) -> anyhow::Result<Self> {
        let rendered_install: String = Tera::one_off(template, context, false)?;
        let install_config: InstallConfig = toml::from_str(&rendered_install)?;
        Ok(install_config)
    }

    fn install_links(
        &self,
        install_path: &Path,
        overwrite: bool,
        ctx: &Context,
    ) -> anyhow::Result<()> {
        anyhow::ensure!(
            install_path.is_dir(),
            "install path ({install_path:?}) must be a directory that exists"
        );
        for link in &self.links {
            // Normalize paths
            let file_path: PathBuf = path_rel_to_install(&link.file, install_path)?;
            let link_path: PathBuf = path_rel_to_install(&link.link, install_path)?;
            let template_path: Option<Result<PathBuf, _>> = link
                .template
                .as_ref()
                .map(|path| path_rel_to_install(path, install_path));

            // If we're given a template path, we render that template using the regular
            // `Context` and write the result out to `file_path`. This is so the user
            // can programmatically generate their dotfiles -- at least in a basic way.
            if let Some(template_path) = template_path {
                let template_path = template_path?;
                match generate_template(&template_path, &file_path, ctx) {
                    Ok(s) => s,
                    Err(e) => {
                        tracing::warn!(
                            "couldn't render Tera template {template_path:?}: {}\n\t\
                            (skipping this link)",
                            display_anyhow_err_chain(e)
                        );
                        continue;
                    }
                }
            }

            // If the link location has a file or link already, either skip
            // this iteration or delete the offending file/link.
            let link_loc_exists = !matches!(
                link_path.symlink_metadata().map_err(|e| e.kind()),
                Err(std::io::ErrorKind::NotFound)
            );

            if link_loc_exists {
                if overwrite {
                    tracing::info!("deleting file/link at {link_path:?} before writing new link");
                    std::fs::remove_file(&link_path)?;
                } else {
                    tracing::info!("skipping file/link at {link_path:?} because it already exists");
                    continue;
                }
            }

            let status_msg = format!("creating link: {file_path:?} -> {link_path:?}");

            tracing::info!("{status_msg}");

            #[cfg(unix)]
            std::os::unix::fs::symlink(&file_path, &link_path).context(status_msg)?;

            #[cfg(windows)]
            todo!("not really sure how windows works; patches welcome")
        }

        Ok(())
    }
}

fn generate_template(template_path: &Path, file_path: &Path, ctx: &Context) -> anyhow::Result<()> {
    let template_contents = std::fs::read_to_string(&template_path)?;
    let rendered: String = Tera::one_off(&template_contents, ctx, false)?;

    // Notify the user if we've generated a new version of the file.
    let old_rendered = std::fs::read_to_string(&file_path).unwrap_or_default();
    if rendered == old_rendered {
        tracing::debug!(
            "found template for {file_path:?} \
                        but rendered contents are unchanged: skipping."
        );
    } else {
        std::fs::write(&file_path, &rendered)?;
        tracing::info!(
            "found template for {file_path:?} \
                        and rendered contents were updated"
        );
    }

    Ok(())
}

fn display_anyhow_err_chain(top_level_err: anyhow::Error) -> String {
    let mut display_chain = String::new();
    for err in top_level_err.chain() {
        display_chain.push_str(&format!("\n\t{}", err));
    }
    display_chain
}

fn path_rel_to_install(path: &Path, install: &Path) -> anyhow::Result<PathBuf> {
    if path.is_absolute() {
        Ok(path.to_path_buf())
    } else {
        let mut canon_path = install.canonicalize()?;
        canon_path.push(&path);
        Ok(canon_path)
    }
}

#[derive(Clone, Debug, serde::Deserialize)]
struct Link {
    file: PathBuf,
    link: PathBuf,
    template: Option<PathBuf>,
}

#[derive(Clone, Debug, clap::Parser)]
enum Opts {
    Install(InstallOpts),
}

#[derive(Clone, Debug, clap::Parser)]
struct InstallOpts {
    #[clap(short, long)]
    root: PathBuf,

    /// if enabled, files and symlinks in our way will be terminated
    #[clap(long)]
    overwrite: bool,
}