use std::io;
use gix::bstr::ByteSlice;
use gix::clone::PrepareFetch;
use gix::remote::fetch::Shallow;
use gix::{open, create, ThreadSafeRepository};
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::str::Utf8Error;
use std::string::FromUtf8Error;
use std::sync::atomic::AtomicBool;

/// Check `source()` of the `Error` for details
#[derive(thiserror::Error, Debug)]
pub enum Error {
    /// Can't clone the repo
    #[error("Can't clone {}", DEBCARGO_CONF_REPO_URL)]
    Clone(#[from] #[source] gix::clone::Error),

    /// Can't fetch the repo
    #[error("Can't fetch {}", DEBCARGO_CONF_REPO_URL)]
    Fetch(#[from] #[source]  gix::clone::fetch::Error),

    /// Can't open the disk repo
    #[error("Can't open git clone of debcargo repo")]
    Git(#[from] #[source] gix::open::Error),

    /// Can't find object in the repo
    #[error("Debcargo repo has incompatible structure (gix error)")]
    Find(#[from] #[source] gix::object::find::existing::Error),

    /// The repo has incompatible structure
    #[error("Debcargo repo has incompatible structure: {}", _0)]
    Repo(String),

    /// TOML parsing error
    #[error("Parse error: {}", _0)]
    Toml(String),

    /// TOML parsing error
    #[error("Parse error: {}", _0)]
    ChangeLog(String),

    /// See `Error::source()` for details
    #[error("Package {} can't be parsed", _0)]
    Package(String, #[source] Box<Error>),

    /// Invalid/non-packaged
    #[error("The package does not have a required changelog file")]
    NotFound,

    /// Invalid/non-packaged
    #[error("The package has been excluded by maintainers")]
    Blocked,
}

impl From<FromUtf8Error> for Error {
    fn from(e: FromUtf8Error) -> Self {
        Error::ChangeLog(e.to_string())
    }
}

impl From<Utf8Error> for Error {
    fn from(e: Utf8Error) -> Self {
        Error::ChangeLog(e.to_string())
    }
}

/// The data is taken from here
pub const DEBCARGO_CONF_REPO_URL: &str = "https://salsa.debian.org/rust-team/debcargo-conf.git";

/// **Start here**. Index of Debian's source packages for Rust/Cargo crates
pub struct Index {
    repo: ThreadSafeRepository,
    path: PathBuf,

    distros: HashMap<String, HashSet<String>>,
}

/// A crate in Debian
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Packaged {
    pub name: String,
    /// crates.io version
    pub version: String,
    pub maintainer_name: Option<String>,
    pub maintainer_email: Option<String>,
    pub uploaders: Vec<String>,
    /// Latest entry
    pub changelog: String,
    /// If true, Debian chose not to use binaries from the crate
    pub excludes_binaries: bool,

    pub build_depends: Vec<String>,
    pub depends: Vec<String>,

    // Matching data from [`Index::add_distro_source`]
    pub distros: Vec<String>,
}

impl Index {
    /// Specify path where to place repo checkout (e.g. `"/var/tmp"`)
    ///
    /// <div class="warning"><a href="https://github.com/Byron/gitoxide/issues/1026">
    /// This call may deadlock forever due to gix-lock.
    /// </a></div>
    #[inline]
    pub fn new(local_temp_dir: impl AsRef<Path>) -> Result<Self, Error> {
        let path = local_temp_dir.as_ref().join("debcargo.git");
        if !path.exists() {
            let stop = AtomicBool::new(false);
            Self::do_fetch(&path, &stop)?;
        }
        let repo = ThreadSafeRepository::open_opts(&path, open::Options::isolated())?;

        Ok(Self {
            repo, path,
            distros: HashMap::new(),
        })
    }

    #[inline(never)]
    #[cold]
    fn do_fetch(path: &Path, stop: &AtomicBool) -> Result<(), Error> {
        // fetching into an existing checkout is weirdly complicated in gix?
        // https://github.com/EmbarkStudios/cargo-deny/blob/f929379b25268b916ddf9ec960c9b6d353f3819f/src/advisories/helpers/db.rs#L217-L222
        if path.exists() {
            let fetched_ok = std::process::Command::new("git")
                .args(["fetch", "--depth=1", "origin"])
                .current_dir(&path)
                .status()
                .map_or(false, |s| s.success());
            if fetched_ok {
                return Ok(());
            }
        }
        let _ = std::fs::remove_dir_all(path);
        let mut pre_fetch = PrepareFetch::new(DEBCARGO_CONF_REPO_URL, path, create::Kind::Bare, create::Options::default(), open::Options::isolated())?
            .with_shallow(Shallow::DepthAtRemote(1.try_into().unwrap()));
        pre_fetch.fetch_only(gix::progress::Discard, stop)?;
        Ok(())
    }

    /// Update the repository
    ///
    /// You can flip `stop_if_true` to abort the fetch.
    ///
    /// <div class="warning"><a href="https://github.com/Byron/gitoxide/issues/1026">
    /// This call may deadlock forever due to gix-lock.
    /// </a></div>
    #[inline]
    pub fn update(&self, stop_if_true: Option<&AtomicBool>) -> Result<(), Error> {
        Self::do_fetch(&self.path, stop_if_true.unwrap_or(&AtomicBool::new(false)))
    }

    /// Parse one crate
    ///
    /// If you need moret than a few, it's cheaper to call `list_all`.
    pub fn get(&self, name: &str) -> Result<Packaged, Error> {
        let repo = self.repo.to_thread_local();
        let src_dir = Self::find_src(&repo)?;
        let item = src_dir.lookup_entry_by_path(name)?.ok_or(Error::NotFound)?;
        let obj = item.object()?;
        self.scan_crate_dir(obj, name)
    }

    /// Parse all crates to be packaged in Debian
    #[inline(never)]
    pub fn list_all(&self) -> Vec<Result<Packaged, Error>> {
        let repo = self.repo.to_thread_local();
        let src_dir = match Self::find_src(&repo) {
            Ok(dir) => dir,
            Err(err) => return vec![Err(err)],
        };
        src_dir.iter().filter_map(|entry| {
            let entry = match entry.map_err(r_err) {
                Ok(entry) => entry,
                Err(e) => return Some(Err(e)),
            };
            let name = entry.filename().to_str().ok()?;
            let res = entry.object().map_err(Error::from)
                .and_then(|obj| self.scan_crate_dir(obj, name));
            if let Err(Error::NotFound | Error::Blocked) = &res {
                return None;
            }
            Some(res.map_err(|e| Error::Package(name.into(), Box::new(e))))
        }).collect()
    }

    fn find_src<'r>(repo: &'r gix::Repository) -> Result<gix::Tree<'r>, Error> {
        let head = repo.head_commit().map_err(r_err)?;
        let src_dir = head.tree().map_err(r_err)?
            .lookup_entry_by_path("src")?.ok_or(Error::Repo("no src dir in the repo".into()))?
            .object()?.peel_to_tree().map_err(r_err)?;
        Ok(src_dir)
    }

    #[inline(never)]
    fn scan_crate_dir(&self, entry: gix::Object, dir_name: &str) -> Result<Packaged, Error> {
        let as_dir = entry.peel_to_tree().map_err(r_err)?
            .lookup_entry_by_path("debian")?.ok_or_else(|| Error::Repo(format!("no {dir_name}/debian dir")))?
            .object()?.peel_to_tree().map_err(r_err)?;

        if as_dir.lookup_entry_by_path("BLOCK")?.is_some() {
            return Err(Error::Blocked);
        }

        let debcargo: DebcargoInfo = match as_dir.lookup_entry_by_path("debcargo.toml")? {
            Some(debcargo) => {
                let debcargo = debcargo.object()?.peel_to_kind(gix::objs::Kind::Blob).map_err(r_err)?;
                let toml_str = std::str::from_utf8(&debcargo.data)?;
                match toml::from_str(toml_str) {
                    Ok(debcargo) => debcargo,
                    Err(e) => if toml_str.contains("Please don't package this") || toml_str.contains("avoid packaging it") {
                        return Err(Error::Blocked)
                    } else {
                        return Err(Error::Toml(format!("debcargo.toml: {e}")))
                    },
                }
            },
            None => DebcargoInfo::default(),
        };

        let changelog = as_dir.lookup_entry_by_path("changelog")?.ok_or(Error::NotFound)?;
        let changelog = changelog.object()?.peel_to_kind(gix::objs::Kind::Blob).map_err(r_err)?;
        let changelog_data = changelog.data.as_slice();
        let changelog = debian_changelog::ChangeLog::read_relaxed(changelog_data)
            .map_err(|e| Error::ChangeLog(e.to_string()))?;
        let changelog = changelog.iter().next().ok_or_else(|| {
            Error::ChangeLog(format!("broken changelog: {}", String::from_utf8_lossy(changelog_data)))
        })?;

        let name = if debcargo.semver_suffix {
            let (name, _) = dir_name.rsplit_once('-').ok_or_else(|| Error::Repo("semver-suffix invalid".into()))?;
            name
        } else {
            dir_name
        };
        let package_name = changelog.package().ok_or_else(|| Error::ChangeLog("changelog without package name".into()))?;
        let version = changelog.version().ok_or_else(|| Error::ChangeLog("versionless changelog".into()))?;
        let mut distros = self.matching_distros(&package_name, &version.upstream_version, version.debian_revision.as_deref())?;
        if let Some(d) = changelog.distributions() {
            if d.iter().any(|d| d.starts_with("UNRELEASED")) {
                return Err(Error::Blocked)
            }
            if distros.is_empty() {
                distros = d;
            }
        }

        Ok(Packaged {
            name: name.into(),
            distros,
            maintainer_name: changelog.maintainer(),
            maintainer_email: changelog.email(),
            version: version.upstream_version,
            uploaders: debcargo.uploaders,
            changelog: changelog.change_lines().collect::<Vec<_>>().join("\n"),
            excludes_binaries: debcargo.bin == Some(false),
            build_depends: debcargo.build_depends,
            depends: debcargo.depends,
        })
    }

    /// Scan decompressed Source(.gz) file from a distro, and remember which Rust packages it has
    pub fn add_distro_source<R: io::BufRead>(&mut self, name: &str, mut read: R) -> Result<(), Error> {
        let add_to = self.distros.entry(name.into()).or_default();
        let mut current_package = None;
        let mut line = String::with_capacity(128);
        loop {
            line.clear();
            let n = read.read_line(&mut line).map_err(|e| Error::Repo(format!("I/O error reading {name} source: {e}")))?;
            if n == 0 {
                break;
            }
            let line = line.trim();
            if line.is_empty() {
                current_package = None;
                continue;
            }
            if let Some(rest) = line.strip_prefix("Package:") {
                current_package = if let Some(package_name) = rest.trim_start().strip_prefix("rust-") {
                    Some(package_name.to_owned())
                } else {
                    None
                };
            } else if let Some(version) = line.strip_prefix("Version:") {
                if let Some(current_package) = current_package.take() {
                    // must match matching_distros
                    add_to.insert(format!("{current_package} {}", version.trim_start()));
                }
            }
        }
        Ok(())
    }

    fn matching_distros(&self, package_name: &str, upstream_version: &str, revision: Option<&str>) -> Result<Vec<String>, Error> {
        let mut out = vec![];
        if self.distros.is_empty() {
            return Ok(out);
        }
        let package_name = package_name.trim_start_matches("rust-");
        let key = if let Some(rev) = revision {
            format!("{package_name} {upstream_version}-{rev}")
        } else {
            format!("{package_name} {upstream_version}")
        };
        for (name, packages) in &self.distros {
            if packages.contains(&key) {
                out.push(name.to_owned());
            }
        }
        Ok(out)
    }
}

#[cold]
fn r_err(msg: impl std::fmt::Display) -> Error {
    Error::Repo(msg.to_string())
}

#[derive(Debug, Deserialize, Default)]
struct DebcargoInfo {
    #[serde(default)]
    uploaders: Vec<String>,
    #[serde(default)]
    semver_suffix: bool,
    bin: Option<bool>,

    #[serde(default)]
    build_depends: Vec<String>,
    #[serde(default)]
    depends: Vec<String>,
}

#[test]
fn poke_it() {
    let mut d = Index::new("/tmp/").unwrap();
    let mut num_bad = 0;
    let l = d.list_all().into_iter().filter_map(|r| match r {
        Ok(r) => Some(r),
        Err(e) => {
            eprintln!("{e}");
            num_bad += 1;
            None
        },
    })
    .collect::<Vec<_>>();
    assert!(num_bad <= 1);
    let cc1 = l.iter().find(|p| p.name == "cc").unwrap();

    let sources = format!("
Package rust-nothisone
Version: 1723

Package: rust-cc
Homepage: there
Version: {}-1

Package: nope
Unrelated: yes

", cc1.version);
    d.add_distro_source("fudge", sources.as_bytes()).unwrap();

    let cc2 = d.get("cc").unwrap();
    assert_eq!(cc2.distros[0], "fudge");
    assert_eq!(cc1.version, cc2.version);
    assert_eq!(cc1.name, "cc");
    assert_eq!(cc2.name, "cc");
    assert!(cc2.excludes_binaries);
    assert_eq!(cc1.maintainer_email, cc2.maintainer_email);
    drop(d);
    let d2 = Index::new("/tmp/").unwrap();
    d2.update(None).unwrap();
}
