use std::{
    collections::HashMap,
    io::Write,
    path::PathBuf,
    process::{Command, exit},
};

use clap::Parser;
use clingwrap::runner::CommandRunner;

use radicle::{
    cob::patch::{RevisionId, cache::Patches},
    git::Oid,
    prelude::*,
    storage::git::Repository,
};

fn main() {
    if let Err(err) = fallible_main() {
        eprintln!("ERROR: {err}");
        let mut err = err.source();
        while let Some(underlying) = err {
            eprintln!("caused by: {underlying}");
            err = underlying.source();
        }
        exit(1);
    }
}

fn fallible_main() -> Result<(), Box<dyn std::error::Error>> {
    let args = Args::parse();

    match &args.cmd {
        Cmd::Id(x) => x.run()?,
        Cmd::Delegates(x) => x.run()?,
        Cmd::Lint(x) => x.run()?,
        Cmd::RangeDiff(x) => x.run()?,
        Cmd::Vacuum(x) => x.run()?,
    }

    Ok(())
}

/// Utility sub-commands for rad.
#[derive(Parser)]
struct Args {
    #[clap(subcommand)]
    cmd: Cmd,
}

#[derive(Parser)]
enum Cmd {
    Id(IdCmd),
    Delegates(DelegateCmd),
    Lint(LintCmd),
    RangeDiff(RangeDiffCmd),
    Vacuum(VacuumCmd),
}

/// Look up the Radicle repository ID from the name of the repository.
///
/// The repository must be on the local node.
#[derive(Parser)]
struct IdCmd {
    /// Names of repositories to look ID for.
    names: Vec<String>,

    /// Output only the repository ID, without the name.
    #[clap(long, short)]
    short: bool,
}

impl IdCmd {
    fn run(&self) -> Result<(), Box<dyn std::error::Error>> {
        let profile = Profile::load()?;
        let repos = profile.storage.repositories()?;
        let mut map: HashMap<RepoId, String> = HashMap::new();
        for repo in repos {
            map.insert(repo.rid, repo.doc.project()?.name().into());
        }

        let mut ok = true;
        for wanted in self.names.iter() {
            let found = find_repositories(&map, wanted);
            match &found[..] {
                [] => {
                    eprintln!("no repository called {wanted}");
                    ok = false;
                }
                [(name, rid)] => {
                    if self.short {
                        println!("{rid}");
                    } else {
                        println!("{rid} {name}");
                    }
                }
                [_, _, ..] => {
                    eprintln!("more than one repository named {wanted}");
                    ok = false;
                }
            }
        }

        if ok { Ok(()) } else { Err("failed".into()) }
    }
}

/// Look up the delegates for a Radicle repository.
#[derive(Parser)]
struct DelegateCmd {
    /// Id of repository.
    rid: RepoId,
}

impl DelegateCmd {
    fn run(&self) -> Result<(), Box<dyn std::error::Error>> {
        let profile = Profile::load()?;
        let repos = profile.storage.repositories()?;
        let mut map: HashMap<RepoId, String> = HashMap::new();
        for repo in repos {
            map.insert(repo.rid, repo.doc.project()?.name().into());
        }

        let repo = profile.storage.repository(self.rid).unwrap();
        for d in repo.delegates().iter().flatten() {
            println!("{}", d.to_human());
        }

        Ok(())
    }
}

/// Look at the Radicle repository in the current working directory
/// and look for possible problems.
#[derive(Parser)]
struct LintCmd {
    repos: Vec<RepoId>,
}

impl LintCmd {
    fn run(&self) -> Result<(), Box<dyn std::error::Error>> {
        let profile = Profile::load()?;
        if self.repos.is_empty() {
            let (_, repoid) = radicle::rad::cwd()?;
            Self::lint_delegate_count(&profile, repoid)?;
        } else {
            for repoid in self.repos.iter() {
                Self::lint_delegate_count(&profile, *repoid)?;
            }
        }
        Ok(())
    }

    fn lint_delegate_count(
        profile: &Profile,
        repoid: RepoId,
    ) -> Result<(), Box<dyn std::error::Error>> {
        let doc = profile.storage.get(repoid)?.unwrap();
        print!("repository {} ({}): ", repoid, doc.project()?.name());
        match doc.delegates().len() {
            0 => println!("has no delegates, this should not be possible"),
            1 => println!(
                "has only one delegate, this may be OK, but at least three would be better"
            ),
            2 => {
                println!("has exactly two delegates, this is risky, three or more would be better")
            }
            _ => println!("there are more than two delegates, good"),
        }
        Ok(())
    }
}

/// Run `git range-diff` between to patch revisions.
#[derive(Parser)]
struct RangeDiffCmd {
    /// Use this repository. Default is the one for the current
    /// working directory.
    #[clap(short, long)]
    repository: Option<RepoId>,

    /// The first revision.
    rev1: String,

    /// The second revision.
    rev2: Option<String>,
}

impl RangeDiffCmd {
    fn run(&self) -> Result<(), Box<dyn std::error::Error>> {
        let profile = Profile::load()?;
        let repo = self.repository(&profile)?;
        let patches = profile.home.patches(&repo)?;

        let git_repo = &repo.backend;
        let rev1 = rev(git_repo, &self.rev1)?;
        let (rev1, rev2) = if let Some(rev2) = &self.rev2 {
            // Two revisions were given, use them both.
            let rev2 = rev(git_repo, rev2)?;
            (rev1, rev2)
        } else {
            // One revision was given, use it as the second one. Use
            // the revision id as the first one.
            let by_rev = patches.find_by_revision(&RevisionId::from(rev1))?.unwrap();
            let rev0: git2::Oid = *by_rev.id.as_ref();
            let rev0 = Oid::from(rev0);
            (rev0, rev1)
        };

        let rev1 = RevisionId::from(rev1);
        let rev2 = RevisionId::from(rev2);

        let by_rev1 = patches.find_by_revision(&rev1)?.unwrap();
        let by_rev2 = patches.find_by_revision(&rev2)?.unwrap();

        let base = by_rev1.revision.base();
        let base2 = by_rev2.revision.base();
        if base != base2 {
            Err(RadUtilError::Base)?;
        }

        let head1 = by_rev1.revision.head();
        let head2 = by_rev2.revision.head();

        let output = range_diff(*base, head1, head2)?;
        std::io::stdout().write_all(&output)?;

        Ok(())
    }

    fn repository(&self, profile: &Profile) -> Result<Repository, Box<dyn std::error::Error>> {
        let repo = if let Some(repo_id) = &self.repository {
            profile.storage.repository(*repo_id)?
        } else {
            let (_, repo_id) = radicle::rad::cwd()?;
            profile.storage.repository(repo_id)?
        };
        Ok(repo)
    }
}

fn find_repositories<'a>(map: &'a HashMap<RepoId, String>, wanted: &str) -> Vec<(&'a str, RepoId)> {
    map.iter()
        .filter_map(|(id, name)| {
            if name == wanted {
                Some((name.as_str(), *id))
            } else {
                None
            }
        })
        .collect()
}

fn rev(git_repo: &git2::Repository, rev: &str) -> Result<Oid, Box<dyn std::error::Error>> {
    let object = git_repo.revparse_single(rev)?;
    Ok(Oid::from(object.id()))
}

fn range_diff(base: Oid, head1: Oid, head2: Oid) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
    let mut cmd = Command::new("git");
    cmd.arg("range-diff");
    cmd.arg("--color=always");
    cmd.arg(base.to_string());
    cmd.arg(head1.to_string());
    cmd.arg(head2.to_string());

    let mut runner = CommandRunner::new(cmd);
    runner.capture_stdout();
    runner.capture_stderr();
    let output = runner.execute()?;

    Ok(output.stdout)
}

/// Run the sqlite sql statement VACUUM to remove deleted db entries.
#[derive(Parser)]
struct VacuumCmd {
    /// Use this sqlite path. Normally using $HOME/.radicle/node/node.db
    db_path: Option<PathBuf>,
}

impl VacuumCmd {
    fn run(&self) -> Result<(), Box<dyn std::error::Error>> {
        let db_path = if let Some(path) = &self.db_path {
            path.clone()
        } else {
            let home = std::env::home_dir().ok_or("Unable to determine home directory")?;
            let node_db = home.join(".radicle").join("node").join("node.db");

            if !node_db.exists() {
                return Err(format!("Node database not found at: {}", node_db.display()).into());
            }
            node_db
        };

        let connection = sqlite::open(&db_path)?;

        println!("Vacuuming database at: {}", db_path.display());

        let query = "VACUUM;";
        connection.execute(query)?;

        println!("Database vacuumed successfully");

        Ok(())
    }
}

#[derive(Debug, thiserror::Error)]
enum RadUtilError {
    #[error("range-diff: revisions have different base commits")]
    Base,
}
