//! Contains code for reading and writing [local registries](https://doc.rust-lang.org/cargo/reference/source-replacement.html#local-registry-sources)

use super::FileLock;
use crate::{Error, IndexKrate, KrateName, Path, PathBuf};
use smol_str::SmolStr;

#[cfg(feature = "local-builder")]
pub mod builder;

/// An error that can occur when validating or creating a [`LocalRegistry`]
#[derive(Debug, thiserror::Error)]
pub enum LocalRegistryError {
    /// A .crate file has a version that isnot in the index
    #[error("missing version {version} for crate {name}")]
    MissingVersion {
        /// The name of the crate
        name: String,
        /// The specific crate version
        version: SmolStr,
    },
    /// A .crate file's checksum did not match the checksum in the index for that version
    #[error("checksum mismatch for {name}-{version}.crate")]
    ChecksumMismatch {
        /// The name of the crate
        name: String,
        /// The specific crate version
        version: SmolStr,
    },
}

/// A [local registry](https://doc.rust-lang.org/cargo/reference/source-replacement.html#local-registry-sources)
/// implementation
pub struct LocalRegistry {
    path: PathBuf,
}

impl LocalRegistry {
    /// Opens an existing local registry, optionally validating it
    pub fn open(path: PathBuf, validate: bool) -> Result<Self, Error> {
        if validate {
            Self::validate(&path)?;
        }

        Ok(Self { path })
    }

    /// Validates the specified path contains a local registry
    ///
    /// Validation ensures every crate file matches the expected according to
    /// the index entry for the crate
    pub fn validate(path: &Path) -> Result<(), Error> {
        let rd = std::fs::read_dir(path).map_err(|err| Error::IoPath(err, path.to_owned()))?;

        // There _should_ only be one directory in the root path, `index`
        let index_root = path.join("index");
        if !index_root.exists() {
            return Err(Error::IoPath(
                std::io::Error::new(
                    std::io::ErrorKind::NotFound,
                    "unable to find index directory",
                ),
                index_root,
            ));
        }

        // Don't bother deserializing multiple times if there are multiple versions
        // of the same crate
        let mut indexed = std::collections::BTreeMap::new();

        for entry in rd {
            let Ok(entry) = entry else {
                continue;
            };
            if entry.file_type().map_or(true, |ft| !ft.is_file()) {
                continue;
            }
            let Ok(path) = PathBuf::from_path_buf(entry.path()) else {
                continue;
            };

            let Some(fname) = path.file_name() else {
                continue;
            };
            let Some((crate_name, version)) = crate_file_components(fname) else {
                continue;
            };

            let index_entry = if let Some(ie) = indexed.get(crate_name) {
                ie
            } else {
                let krate_name: crate::KrateName<'_> = crate_name.try_into()?;
                let path = index_root.join(krate_name.relative_path(None));

                let index_contents =
                    std::fs::read(&path).map_err(|err| Error::IoPath(err, path.clone()))?;
                let ik = IndexKrate::from_slice(&index_contents)?;

                indexed.insert(crate_name.to_owned(), ik);

                indexed.get(crate_name).unwrap()
            };

            let index_vers = index_entry
                .versions
                .iter()
                .find(|kv| kv.version == version)
                .ok_or_else(|| LocalRegistryError::MissingVersion {
                    name: crate_name.to_owned(),
                    version: version.into(),
                })?;

            // Read the crate file from disk and verify its checksum matches
            let file =
                std::fs::File::open(&path).map_err(|err| Error::IoPath(err, path.clone()))?;
            if !validate_checksum::<{ 8 * 1024 }>(&file, &index_vers.checksum)
                .map_err(|err| Error::IoPath(err, path.clone()))?
            {
                return Err(LocalRegistryError::ChecksumMismatch {
                    name: crate_name.to_owned(),
                    version: version.into(),
                }
                .into());
            }
        }

        Ok(())
    }

    /// Gets the index information for the crate
    ///
    /// Note this naming is just to be consistent with [`crate::SparseIndex`] and
    /// [`crate::GitIndex`], local registries do not have a .cache in the index
    #[inline]
    pub fn cached_krate(
        &self,
        name: KrateName<'_>,
        _lock: &FileLock,
    ) -> Result<Option<IndexKrate>, Error> {
        let index_path = self.krate_path(name);

        let buf = match std::fs::read(&index_path) {
            Ok(buf) => buf,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(err) => return Err(Error::IoPath(err, index_path)),
        };

        Ok(Some(IndexKrate::from_slice(&buf)?))
    }

    /// Gets the path to the index entry for the krate.
    ///
    /// Note that unlike .cache entries for git and sparse indices, these are not
    /// binary files, they are just the JSON line format
    #[inline]
    pub fn krate_path(&self, name: KrateName<'_>) -> PathBuf {
        make_path(&self.path, name)
    }
}

/// Allows the building of a local registry from a [`RemoteSparseIndex`]
pub struct LocalRegistryBuilder {
    path: PathBuf,
}

impl LocalRegistryBuilder {
    /// Creates a builder for the specified directory.
    ///
    /// The directory is required to be empty, but it will
    /// be created if it doesn't exist
    pub fn create(path: PathBuf) -> Result<Self, Error> {
        if path.exists() {
            let count = std::fs::read_dir(&path)?.count();
            if count != 0 {
                return Err(Error::IoPath(
                    std::io::Error::new(
                        std::io::ErrorKind::AlreadyExists,
                        format!("{count} entries already exist at the specified path"),
                    ),
                    path,
                ));
            }
        } else {
            std::fs::create_dir_all(&path)?;
        }

        std::fs::create_dir_all(path.join("index"))?;

        Ok(Self { path })
    }

    /// Inserts the specified crate index entry and one or more crates files
    /// into the registry
    ///
    /// This will fail if the specified crate is already located in the index, it
    /// is your responsibility to insert the crate and all the versions you want
    /// only once
    pub fn insert(&self, krate: &IndexKrate, krates: &[ValidKrate<'_>]) -> Result<u64, Error> {
        let index_path = make_path(&self.path, krate.name().try_into()?);

        if index_path.exists() {
            return Err(Error::IoPath(
                std::io::Error::new(
                    std::io::ErrorKind::AlreadyExists,
                    "crate has already been inserted",
                ),
                index_path,
            ));
        }

        let mut written = {
            if let Err(err) = std::fs::create_dir_all(index_path.parent().unwrap()) {
                return Err(Error::IoPath(err, index_path));
            }

            let mut index_entry =
                std::fs::File::create(&index_path).map_err(|err| Error::IoPath(err, index_path))?;
            krate.write_json_lines(&mut index_entry)?;
            // This _should_ never fail, but even if it does, just ignore it
            use std::io::Seek;
            index_entry.stream_position().unwrap_or_default()
        };

        for krate in krates {
            let krate_fname = format!("{}-{}.crate", krate.iv.name, krate.iv.version);
            let krate_path = self.path.join(krate_fname);

            std::fs::write(&krate_path, &krate.buff)
                .map_err(|err| Error::IoPath(err, krate_path))?;

            written += krate.buff.len() as u64;
        }

        Ok(written)
    }

    /// Consumes the builder and opens a [`LocalRegistry`]
    #[inline]
    pub fn finalize(self, validate: bool) -> Result<LocalRegistry, Error> {
        LocalRegistry::open(self.path, validate)
    }
}

/// A wrapper around the raw byte buffer for a .crate response from a remote
/// index
pub struct ValidKrate<'iv> {
    buff: bytes::Bytes,
    iv: &'iv crate::IndexVersion,
}

impl<'iv> ValidKrate<'iv> {
    /// Given a buffer, validates its checksum matches the specified version
    pub fn validate(
        buff: impl Into<bytes::Bytes>,
        expected: &'iv crate::IndexVersion,
    ) -> Result<Self, Error> {
        let buff = buff.into();

        let computed = {
            use sha2::{Digest, Sha256};
            let mut hasher = Sha256::new();
            hasher.update(&buff);
            hasher.finalize()
        };

        if computed.as_slice() != expected.checksum.0 {
            return Err(LocalRegistryError::ChecksumMismatch {
                name: expected.name.to_string(),
                version: expected.version.clone(),
            }
            .into());
        }

        Ok(Self { buff, iv: expected })
    }
}

/// Ensures the specified stream's sha-256 matches the specified checksum
#[inline]
pub fn validate_checksum<const N: usize>(
    mut stream: impl std::io::Read,
    chksum: &crate::krate::Chksum,
) -> Result<bool, std::io::Error> {
    use sha2::{Digest, Sha256};

    let mut buffer = [0u8; N];
    let mut hasher = Sha256::new();

    loop {
        let read = stream.read(&mut buffer)?;
        if read == 0 {
            break;
        }

        hasher.update(&buffer[..read]);
    }

    let computed = hasher.finalize();

    Ok(computed.as_slice() == chksum.0)
}

/// Splits a crate package name into its component parts
///
/// `<crate-name>-<semver>.crate`
///
/// The naming is a bit annoying for these since the separator between
/// the crate name and version is a `-` which can be present in both
/// the crate name as well as the semver, so we have to take those into account
#[inline]
pub fn crate_file_components(name: &str) -> Option<(&str, &str)> {
    let name = name.strip_suffix(".crate")?;

    // The first `.` should be after the major version
    let dot = name.find('.')?;
    let dash_sep = name[..dot].rfind('-')?;

    Some((&name[..dash_sep], &name[dash_sep + 1..]))
}

#[inline]
fn make_path(root: &Path, name: KrateName<'_>) -> PathBuf {
    let rel_path = name.relative_path(None);

    let mut index_path = PathBuf::with_capacity(root.as_str().len() + 7 + rel_path.len());
    index_path.push(root);
    index_path.push("index");
    index_path.push(rel_path);

    index_path
}

#[cfg(test)]
mod test {
    #[test]
    fn gets_components() {
        use super::crate_file_components as cfc;

        assert_eq!(cfc("cc-1.0.75.crate").unwrap(), ("cc", "1.0.75"));
        assert_eq!(
            cfc("cfg-expr-0.1.0-dev+1234.crate").unwrap(),
            ("cfg-expr", "0.1.0-dev+1234")
        );
        assert_eq!(
            cfc("android_system_properties-0.1.5.crate").unwrap(),
            ("android_system_properties", "0.1.5")
        );
    }
}
