mod auth;
use crate::manifest::GenericManifestFile;
use crate::{
    manifest::{self, PackageManifestFile},
    source,
};
use anyhow::{anyhow, bail, Context, Result};
use forc_util::git_checkouts_directory;
use serde::{Deserialize, Serialize};
use std::{
    collections::hash_map,
    fmt, fs,
    path::{Path, PathBuf},
    str::FromStr,
};
use tracing::info;
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
pub struct Url {
    url: gix_url::Url,
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
pub struct Source {
    pub repo: Url,
    pub reference: Reference,
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)]
pub enum Reference {
    Branch(String),
    Tag(String),
    Rev(String),
    DefaultBranch,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)]
pub struct Pinned {
    pub source: Source,
    pub commit_hash: String,
}
#[derive(Clone, Debug)]
pub enum PinnedParseError {
    Prefix,
    Url,
    Reference,
    CommitHash,
}
type HeadWithTime = (String, i64);
const DEFAULT_REMOTE_NAME: &str = "origin";
#[derive(Serialize, Deserialize)]
pub struct SourceIndex {
    pub git_reference: Reference,
    pub head_with_time: HeadWithTime,
}
impl SourceIndex {
    pub fn new(time: i64, git_reference: Reference, commit_hash: String) -> SourceIndex {
        SourceIndex {
            git_reference,
            head_with_time: (commit_hash, time),
        }
    }
}
impl Reference {
    pub fn resolve(&self, repo: &git2::Repository) -> Result<git2::Oid> {
        fn resolve_tag(repo: &git2::Repository, tag: &str) -> Result<git2::Oid> {
            let refname = format!("refs/remotes/{DEFAULT_REMOTE_NAME}/tags/{tag}");
            let id = repo.refname_to_id(&refname)?;
            let obj = repo.find_object(id, None)?;
            let obj = obj.peel(git2::ObjectType::Commit)?;
            Ok(obj.id())
        }
        fn resolve_branch(repo: &git2::Repository, branch: &str) -> Result<git2::Oid> {
            let name = format!("{DEFAULT_REMOTE_NAME}/{branch}");
            let b = repo
                .find_branch(&name, git2::BranchType::Remote)
                .with_context(|| format!("failed to find branch `{branch}`"))?;
            b.get()
                .target()
                .ok_or_else(|| anyhow::format_err!("branch `{}` did not have a target", branch))
        }
        fn resolve_default_branch(repo: &git2::Repository) -> Result<git2::Oid> {
            let head_id =
                repo.refname_to_id(&format!("refs/remotes/{DEFAULT_REMOTE_NAME}/HEAD"))?;
            let head = repo.find_object(head_id, None)?;
            Ok(head.peel(git2::ObjectType::Commit)?.id())
        }
        fn resolve_rev(repo: &git2::Repository, rev: &str) -> Result<git2::Oid> {
            let obj = repo.revparse_single(rev)?;
            match obj.as_tag() {
                Some(tag) => Ok(tag.target_id()),
                None => Ok(obj.id()),
            }
        }
        match self {
            Reference::Tag(s) => {
                resolve_tag(repo, s).with_context(|| format!("failed to find tag `{s}`"))
            }
            Reference::Branch(s) => resolve_branch(repo, s),
            Reference::DefaultBranch => resolve_default_branch(repo),
            Reference::Rev(s) => resolve_rev(repo, s),
        }
    }
}
impl Pinned {
    pub const PREFIX: &'static str = "git";
}
impl source::Pin for Source {
    type Pinned = Pinned;
    fn pin(&self, ctx: source::PinCtx) -> Result<(Self::Pinned, PathBuf)> {
        let pinned = if ctx.offline() {
            let (_local_path, commit_hash) =
                search_source_locally(ctx.name(), self)?.ok_or_else(|| {
                    anyhow!(
                        "Unable to fetch pkg {:?} from  {:?} in offline mode",
                        ctx.name(),
                        self.repo
                    )
                })?;
            Pinned {
                source: self.clone(),
                commit_hash,
            }
        } else if let Reference::DefaultBranch | Reference::Branch(_) = self.reference {
            pin(ctx.fetch_id(), ctx.name(), self.clone())?
        } else {
            match search_source_locally(ctx.name(), self) {
                Ok(Some((_local_path, commit_hash))) => Pinned {
                    source: self.clone(),
                    commit_hash,
                },
                _ => {
                    pin(ctx.fetch_id(), ctx.name(), self.clone())?
                }
            }
        };
        let repo_path = commit_path(ctx.name(), &pinned.source.repo, &pinned.commit_hash);
        Ok((pinned, repo_path))
    }
}
impl source::Fetch for Pinned {
    fn fetch(&self, ctx: source::PinCtx, repo_path: &Path) -> Result<PackageManifestFile> {
        let mut lock = forc_util::path_lock(repo_path)?;
        {
            let _guard = lock.write()?;
            if !repo_path.exists() {
                info!(
                    "  {} {} {}",
                    ansi_term::Color::Green.bold().paint("Fetching"),
                    ansi_term::Style::new().bold().paint(ctx.name),
                    self
                );
                fetch(ctx.fetch_id(), ctx.name(), self)?;
            }
        }
        let path = {
            let _guard = lock.read()?;
            manifest::find_within(repo_path, ctx.name())
                .ok_or_else(|| anyhow!("failed to find package `{}` in {}", ctx.name(), self))?
        };
        PackageManifestFile::from_file(path)
    }
}
impl source::DepPath for Pinned {
    fn dep_path(&self, name: &str) -> anyhow::Result<source::DependencyPath> {
        let repo_path = commit_path(name, &self.source.repo, &self.commit_hash);
        let lock = forc_util::path_lock(&repo_path)?;
        let _guard = lock.read()?;
        let path = manifest::find_within(&repo_path, name)
            .ok_or_else(|| anyhow!("failed to find package `{}` in {}", name, self))?;
        Ok(source::DependencyPath::ManifestPath(path))
    }
}
impl fmt::Display for Url {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let url_string = self.url.to_bstring().to_string();
        write!(f, "{url_string}")
    }
}
impl fmt::Display for Pinned {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(
            f,
            "{}+{}?{}#{}",
            Self::PREFIX,
            self.source.repo,
            self.source.reference,
            self.commit_hash
        )
    }
}
impl fmt::Display for Reference {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Reference::Branch(ref s) => write!(f, "branch={s}"),
            Reference::Tag(ref s) => write!(f, "tag={s}"),
            Reference::Rev(ref _s) => write!(f, "rev"),
            Reference::DefaultBranch => write!(f, "default-branch"),
        }
    }
}
impl FromStr for Url {
    type Err = anyhow::Error;
    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
        let url = gix_url::Url::from_bytes(s.as_bytes().into()).map_err(|e| anyhow!("{}", e))?;
        Ok(Self { url })
    }
}
impl FromStr for Pinned {
    type Err = PinnedParseError;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let s = s.trim();
        let prefix_plus = format!("{}+", Self::PREFIX);
        if s.find(&prefix_plus) != Some(0) {
            return Err(PinnedParseError::Prefix);
        }
        let s = &s[prefix_plus.len()..];
        let repo_str = s.split('?').next().ok_or(PinnedParseError::Url)?;
        let repo = Url::from_str(repo_str).map_err(|_| PinnedParseError::Url)?;
        let s = &s[repo_str.len() + "?".len()..];
        let mut s_iter = s.split('#');
        let reference = s_iter.next().ok_or(PinnedParseError::Reference)?;
        let commit_hash = s_iter
            .next()
            .ok_or(PinnedParseError::CommitHash)?
            .to_string();
        validate_git_commit_hash(&commit_hash).map_err(|_| PinnedParseError::CommitHash)?;
        const BRANCH: &str = "branch=";
        const TAG: &str = "tag=";
        let reference = if reference.find(BRANCH) == Some(0) {
            Reference::Branch(reference[BRANCH.len()..].to_string())
        } else if reference.find(TAG) == Some(0) {
            Reference::Tag(reference[TAG.len()..].to_string())
        } else if reference == "rev" {
            Reference::Rev(commit_hash.to_string())
        } else if reference == "default-branch" {
            Reference::DefaultBranch
        } else {
            return Err(PinnedParseError::Reference);
        };
        let source = Source { repo, reference };
        Ok(Self {
            source,
            commit_hash,
        })
    }
}
impl Default for Reference {
    fn default() -> Self {
        Self::DefaultBranch
    }
}
impl From<Pinned> for source::Pinned {
    fn from(p: Pinned) -> Self {
        Self::Git(p)
    }
}
fn git_repo_dir_name(name: &str, repo: &Url) -> String {
    use std::hash::{Hash, Hasher};
    fn hash_url(url: &Url) -> u64 {
        let mut hasher = hash_map::DefaultHasher::new();
        url.hash(&mut hasher);
        hasher.finish()
    }
    let repo_url_hash = hash_url(repo);
    format!("{name}-{repo_url_hash:x}")
}
fn validate_git_commit_hash(commit_hash: &str) -> Result<()> {
    const LEN: usize = 40;
    if commit_hash.len() != LEN {
        bail!(
            "invalid hash length: expected {}, found {}",
            LEN,
            commit_hash.len()
        );
    }
    if !commit_hash.chars().all(|c| c.is_ascii_alphanumeric()) {
        bail!("hash contains one or more non-ascii-alphanumeric characters");
    }
    Ok(())
}
fn tmp_git_repo_dir(fetch_id: u64, name: &str, repo: &Url) -> PathBuf {
    let repo_dir_name = format!("{:x}-{}", fetch_id, git_repo_dir_name(name, repo));
    git_checkouts_directory().join("tmp").join(repo_dir_name)
}
fn git_ref_to_refspecs(reference: &Reference) -> (Vec<String>, bool) {
    let mut refspecs = vec![];
    let mut tags = false;
    match reference {
        Reference::Branch(s) => {
            refspecs.push(format!(
                "+refs/heads/{s}:refs/remotes/{DEFAULT_REMOTE_NAME}/{s}"
            ));
        }
        Reference::Tag(s) => {
            refspecs.push(format!(
                "+refs/tags/{s}:refs/remotes/{DEFAULT_REMOTE_NAME}/tags/{s}"
            ));
        }
        Reference::Rev(s) => {
            if s.starts_with("refs/") {
                refspecs.push(format!("+{s}:{s}"));
            } else {
                refspecs.push(format!(
                    "+refs/heads/*:refs/remotes/{DEFAULT_REMOTE_NAME}/*"
                ));
                refspecs.push(format!("+HEAD:refs/remotes/{DEFAULT_REMOTE_NAME}/HEAD"));
                tags = true;
            }
        }
        Reference::DefaultBranch => {
            refspecs.push(format!("+HEAD:refs/remotes/{DEFAULT_REMOTE_NAME}/HEAD"));
        }
    }
    (refspecs, tags)
}
fn with_tmp_git_repo<F, O>(fetch_id: u64, name: &str, source: &Source, f: F) -> Result<O>
where
    F: FnOnce(git2::Repository) -> Result<O>,
{
    let repo_dir = tmp_git_repo_dir(fetch_id, name, &source.repo);
    if repo_dir.exists() {
        let _ = std::fs::remove_dir_all(&repo_dir);
    }
    let config = git2::Config::open_default().unwrap();
    let mut auth_handler = auth::AuthHandler::default_with_config(config);
    let mut callback = git2::RemoteCallbacks::new();
    callback.credentials(move |url, username, allowed| {
        auth_handler.handle_callback(url, username, allowed)
    });
    let repo = git2::Repository::init(&repo_dir)
        .map_err(|e| anyhow!("failed to init repo at \"{}\": {}", repo_dir.display(), e))?;
    let (refspecs, tags) = git_ref_to_refspecs(&source.reference);
    let mut fetch_opts = git2::FetchOptions::new();
    fetch_opts.remote_callbacks(callback);
    if tags {
        fetch_opts.download_tags(git2::AutotagOption::All);
    }
    let repo_url_string = source.repo.to_string();
    repo.remote_anonymous(&repo_url_string)?
        .fetch(&refspecs, Some(&mut fetch_opts), None)
        .with_context(|| {
            format!(
                "failed to fetch `{}`. Check your connection or run in `--offline` mode",
                &repo_url_string
            )
        })?;
    let output = f(repo)?;
    let _ = std::fs::remove_dir_all(&repo_dir);
    Ok(output)
}
pub fn pin(fetch_id: u64, name: &str, source: Source) -> Result<Pinned> {
    let commit_hash = with_tmp_git_repo(fetch_id, name, &source, |repo| {
        let commit_id = source
            .reference
            .resolve(&repo)
            .with_context(|| "failed to resolve reference".to_string())?;
        Ok(format!("{commit_id}"))
    })?;
    Ok(Pinned {
        source,
        commit_hash,
    })
}
pub fn commit_path(name: &str, repo: &Url, commit_hash: &str) -> PathBuf {
    let repo_dir_name = git_repo_dir_name(name, repo);
    git_checkouts_directory()
        .join(repo_dir_name)
        .join(commit_hash)
}
pub fn fetch(fetch_id: u64, name: &str, pinned: &Pinned) -> Result<PathBuf> {
    let path = commit_path(name, &pinned.source.repo, &pinned.commit_hash);
    with_tmp_git_repo(fetch_id, name, &pinned.source, |repo| {
        let id = git2::Oid::from_str(&pinned.commit_hash)?;
        repo.set_head_detached(id)?;
        if path.exists() {
            let _ = fs::remove_dir_all(&path);
        }
        fs::create_dir_all(&path)?;
        let mut checkout = git2::build::CheckoutBuilder::new();
        checkout.force().target_dir(&path);
        repo.checkout_head(Some(&mut checkout))?;
        let current_head = repo.revparse_single("HEAD")?;
        let head_commit = current_head
            .as_commit()
            .ok_or_else(|| anyhow!("Cannot get commit from {}", current_head.id().to_string()))?;
        let head_time = head_commit.time().seconds();
        let source_index = SourceIndex::new(
            head_time,
            pinned.source.reference.clone(),
            pinned.commit_hash.clone(),
        );
        fs::write(
            path.join(".forc_index"),
            serde_json::to_string(&source_index)?,
        )?;
        Ok(())
    })?;
    Ok(path)
}
pub(crate) fn search_source_locally(
    name: &str,
    git_source: &Source,
) -> Result<Option<(PathBuf, String)>> {
    let checkouts_dir = git_checkouts_directory();
    match &git_source.reference {
        Reference::Branch(branch) => {
            let repos_from_branch = collect_local_repos_with_branch(checkouts_dir, name, branch)?;
            let newest_branch_repo = repos_from_branch
                .into_iter()
                .max_by_key(|&(_, (_, time))| time)
                .map(|(repo_path, (hash, _))| (repo_path, hash));
            Ok(newest_branch_repo)
        }
        _ => find_exact_local_repo_with_reference(checkouts_dir, name, &git_source.reference),
    }
}
fn collect_local_repos_with_branch(
    checkouts_dir: PathBuf,
    package_name: &str,
    branch_name: &str,
) -> Result<Vec<(PathBuf, HeadWithTime)>> {
    let mut list_of_repos = Vec::new();
    with_search_checkouts(checkouts_dir, package_name, |repo_index, repo_dir_path| {
        if let Reference::Branch(branch) = repo_index.git_reference {
            if branch == branch_name {
                list_of_repos.push((repo_dir_path, repo_index.head_with_time));
            }
        }
        Ok(())
    })?;
    Ok(list_of_repos)
}
fn find_exact_local_repo_with_reference(
    checkouts_dir: PathBuf,
    package_name: &str,
    git_reference: &Reference,
) -> Result<Option<(PathBuf, String)>> {
    let mut found_local_repo = None;
    if let Reference::Tag(tag) = git_reference {
        found_local_repo = find_repo_with_tag(tag, package_name, checkouts_dir)?;
    } else if let Reference::Rev(rev) = git_reference {
        found_local_repo = find_repo_with_rev(rev, package_name, checkouts_dir)?;
    }
    Ok(found_local_repo)
}
fn find_repo_with_tag(
    tag: &str,
    package_name: &str,
    checkouts_dir: PathBuf,
) -> Result<Option<(PathBuf, String)>> {
    let mut found_local_repo = None;
    with_search_checkouts(checkouts_dir, package_name, |repo_index, repo_dir_path| {
        let current_head = repo_index.head_with_time.0;
        if let Reference::Tag(curr_repo_tag) = repo_index.git_reference {
            if curr_repo_tag == tag {
                found_local_repo = Some((repo_dir_path, current_head));
            }
        }
        Ok(())
    })?;
    Ok(found_local_repo)
}
fn find_repo_with_rev(
    rev: &str,
    package_name: &str,
    checkouts_dir: PathBuf,
) -> Result<Option<(PathBuf, String)>> {
    let mut found_local_repo = None;
    with_search_checkouts(checkouts_dir, package_name, |repo_index, repo_dir_path| {
        let current_head = repo_index.head_with_time.0;
        if let Reference::Rev(curr_repo_rev) = repo_index.git_reference {
            if curr_repo_rev == rev {
                found_local_repo = Some((repo_dir_path, current_head));
            }
        }
        Ok(())
    })?;
    Ok(found_local_repo)
}
fn with_search_checkouts<F>(checkouts_dir: PathBuf, package_name: &str, mut f: F) -> Result<()>
where
    F: FnMut(SourceIndex, PathBuf) -> Result<()>,
{
    for entry in fs::read_dir(checkouts_dir)? {
        let entry = entry?;
        let folder_name = entry
            .file_name()
            .into_string()
            .map_err(|_| anyhow!("invalid folder name"))?;
        if folder_name.starts_with(package_name) {
            for repo_dir in fs::read_dir(entry.path())? {
                let repo_dir = repo_dir
                    .map_err(|e| anyhow!("Cannot find local repo at checkouts dir {}", e))?;
                if repo_dir.file_type()?.is_dir() {
                    let repo_dir_path = repo_dir.path();
                    if let Ok(index_file) = fs::read_to_string(repo_dir_path.join(".forc_index")) {
                        let index = serde_json::from_str(&index_file)?;
                        f(index, repo_dir_path)?;
                    }
                }
            }
        }
    }
    Ok(())
}
#[test]
fn test_source_git_pinned_parsing() {
    let strings = [
        "git+https://github.com/foo/bar?branch=baz#64092602dd6158f3e41d775ed889389440a2cd86",
        "git+https://github.com/fuellabs/sway-lib-std?tag=v0.1.0#0000000000000000000000000000000000000000",
        "git+https://github.com/fuellabs/sway-lib-core?tag=v0.0.1#0000000000000000000000000000000000000000",
        "git+https://some-git-host.com/owner/repo?rev#FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",
        "git+https://some-git-host.com/owner/repo?default-branch#AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
    ];
    let expected = [
        Pinned {
            source: Source {
                repo: Url::from_str("https://github.com/foo/bar").unwrap(),
                reference: Reference::Branch("baz".to_string()),
            },
            commit_hash: "64092602dd6158f3e41d775ed889389440a2cd86".to_string(),
        },
        Pinned {
            source: Source {
                repo: Url::from_str("https://github.com/fuellabs/sway-lib-std").unwrap(),
                reference: Reference::Tag("v0.1.0".to_string()),
            },
            commit_hash: "0000000000000000000000000000000000000000".to_string(),
        },
        Pinned {
            source: Source {
                repo: Url::from_str("https://github.com/fuellabs/sway-lib-core").unwrap(),
                reference: Reference::Tag("v0.0.1".to_string()),
            },
            commit_hash: "0000000000000000000000000000000000000000".to_string(),
        },
        Pinned {
            source: Source {
                repo: Url::from_str("https://some-git-host.com/owner/repo").unwrap(),
                reference: Reference::Rev("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF".to_string()),
            },
            commit_hash: "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF".to_string(),
        },
        Pinned {
            source: Source {
                repo: Url::from_str("https://some-git-host.com/owner/repo").unwrap(),
                reference: Reference::DefaultBranch,
            },
            commit_hash: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(),
        },
    ];
    for (&string, expected) in strings.iter().zip(&expected) {
        let parsed = Pinned::from_str(string).unwrap();
        assert_eq!(&parsed, expected);
        let serialized = expected.to_string();
        assert_eq!(&serialized, string);
    }
}