use std::path::{Path, PathBuf};

use git2::{Cred, FetchOptions, RemoteCallbacks, Repository};

use crate::error::AgentError;

/// Credentials for accessing a private repository
#[derive(Debug, Clone, Default)]
pub struct RepoCredentials {
    /// Path to the SSH private key (for SSH URLs)
    pub ssh_key_path: Option<String>,
    /// Auth token / password (for HTTPS URLs)
    pub auth_token: Option<String>,
    /// Username for HTTPS auth (defaults to "x-access-token")
    pub auth_username: Option<String>,
}

impl RepoCredentials {
    pub(crate) fn make_callbacks(&self) -> RemoteCallbacks<'_> {
        let mut callbacks = RemoteCallbacks::new();
        let ssh_key = self.ssh_key_path.clone();
        let token = self.auth_token.clone();
        let username = self.auth_username.clone();

        callbacks.credentials(move |_url, username_from_url, allowed_types| {
            // SSH key authentication
            if allowed_types.contains(git2::CredentialType::SSH_KEY) {
                if let Some(ref key_path) = ssh_key {
                    let key = Path::new(key_path);
                    if key.exists() {
                        let user = username_from_url.unwrap_or("git");
                        return Cred::ssh_key(user, None, key, None);
                    }
                }
            }

            // HTTPS userpass authentication
            if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
                if let Some(ref tok) = token {
                    let user = username.as_deref().unwrap_or("x-access-token");
                    return Cred::userpass_plaintext(user, tok);
                }
            }

            Cred::default()
        });

        callbacks
    }

    fn fetch_options(&self) -> FetchOptions<'_> {
        let mut fetch_opts = FetchOptions::new();
        if self.has_credentials() {
            fetch_opts.remote_callbacks(self.make_callbacks());
        }
        fetch_opts
    }

    fn has_credentials(&self) -> bool {
        self.ssh_key_path
            .as_ref()
            .map(|p| Path::new(p).exists())
            .unwrap_or(false)
            || self.auth_token.is_some()
    }
}

pub struct GitOps {
    base_path: PathBuf,
    credentials: RepoCredentials,
}

impl GitOps {
    pub fn new(base_path: &str, credentials: RepoCredentials) -> Self {
        Self {
            base_path: PathBuf::from(base_path),
            credentials,
        }
    }

    #[tracing::instrument(skip_all, fields(repo_name = %repo_name))]
    pub fn clone_or_fetch(&self, git_url: &str, repo_name: &str) -> Result<PathBuf, AgentError> {
        // Names can contain slashes or other path-hostile characters (a target
        // named after a repo path, say); collapse to one safe directory segment
        // so the clone path never nests or breaks.
        let repo_path = self.base_path.join(sanitize_repo_dir(repo_name));

        if repo_path.exists() {
            tracing::info!("fetching updates for existing repo");
            self.fetch(&repo_path)?;
        } else {
            std::fs::create_dir_all(&repo_path)?;
            self.clone_repo(git_url, &repo_path)?;
            tracing::info!("Cloned {git_url} to {}", repo_path.display());
        }

        Ok(repo_path)
    }

    #[tracing::instrument(skip_all)]
    fn clone_repo(&self, git_url: &str, repo_path: &Path) -> Result<(), AgentError> {
        tracing::info!("cloning repo from {}", git_url);
        let mut builder = git2::build::RepoBuilder::new();
        let fetch_opts = self.credentials.fetch_options();
        builder.fetch_options(fetch_opts);
        builder.clone(git_url, repo_path)?;
        Ok(())
    }

    fn fetch(&self, repo_path: &Path) -> Result<(), AgentError> {
        let repo = Repository::open(repo_path)?;
        let mut remote = repo.find_remote("origin")?;
        let mut fetch_opts = self.credentials.fetch_options();
        remote.fetch(&[] as &[&str], Some(&mut fetch_opts), None)?;

        // Fast-forward to origin/HEAD
        let fetch_head = repo.find_reference("FETCH_HEAD")?;
        let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
        let head_ref = repo.head()?;
        let head_name = head_ref.name().unwrap_or("HEAD");

        repo.reference(head_name, fetch_commit.id(), true, "fast-forward")?;
        repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?;

        tracing::info!("Fetched and fast-forwarded {}", repo_path.display());
        Ok(())
    }

    /// Test that we can access a remote repository (used during add validation)
    pub fn test_access(git_url: &str, credentials: &RepoCredentials) -> Result<(), AgentError> {
        let mut remote = git2::Remote::create_detached(git_url)?;
        let callbacks = credentials.make_callbacks();
        remote.connect_auth(git2::Direction::Fetch, Some(callbacks), None)?;
        remote.disconnect()?;
        Ok(())
    }

    /// Build credentials from agent config + per-repo overrides
    pub fn make_repo_credentials(
        config: &compliance_core::AgentConfig,
        repo: &crate::pipeline::repo_view::RepoView,
    ) -> RepoCredentials {
        RepoCredentials {
            ssh_key_path: Some(config.ssh_key_path.clone()),
            auth_token: repo.auth_token.clone(),
            auth_username: repo.auth_username.clone(),
        }
    }

    pub fn get_head_sha(repo_path: &Path) -> Result<String, AgentError> {
        let repo = Repository::open(repo_path)?;
        let head = repo.head()?;
        let commit = head.peel_to_commit()?;
        Ok(commit.id().to_string())
    }

    pub fn has_new_commits(repo_path: &Path, last_sha: Option<&str>) -> Result<bool, AgentError> {
        let current_sha = Self::get_head_sha(repo_path)?;
        match last_sha {
            Some(sha) if sha == current_sha => Ok(false),
            _ => Ok(true),
        }
    }

    /// Extract structured diff content between two commits
    pub fn get_diff_content(
        repo_path: &Path,
        old_sha: &str,
        new_sha: &str,
    ) -> Result<Vec<DiffFile>, AgentError> {
        let repo = Repository::open(repo_path)?;
        let old_commit = repo.find_commit(git2::Oid::from_str(old_sha)?)?;
        let new_commit = repo.find_commit(git2::Oid::from_str(new_sha)?)?;

        let old_tree = old_commit.tree()?;
        let new_tree = new_commit.tree()?;

        let diff = repo.diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)?;

        let mut diff_files: Vec<DiffFile> = Vec::new();

        diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| {
            let file_path = delta
                .new_file()
                .path()
                .map(|p| p.to_string_lossy().to_string())
                .unwrap_or_default();

            // Find or create the DiffFile entry
            let idx = if let Some(pos) = diff_files.iter().position(|f| f.path == file_path) {
                pos
            } else {
                diff_files.push(DiffFile {
                    path: file_path,
                    hunks: String::new(),
                });
                diff_files.len() - 1
            };
            let diff_file = &mut diff_files[idx];

            let prefix = match line.origin() {
                '+' => "+",
                '-' => "-",
                ' ' => " ",
                _ => "",
            };

            let content = std::str::from_utf8(line.content()).unwrap_or("");
            diff_file.hunks.push_str(prefix);
            diff_file.hunks.push_str(content);

            true
        })?;

        // Filter out binary files and very large diffs
        diff_files.retain(|f| !f.hunks.is_empty() && f.hunks.len() < 50_000);

        Ok(diff_files)
    }

    #[allow(dead_code)]
    pub fn get_changed_files(
        repo_path: &Path,
        old_sha: &str,
        new_sha: &str,
    ) -> Result<Vec<String>, AgentError> {
        let repo = Repository::open(repo_path)?;
        let old_commit = repo.find_commit(git2::Oid::from_str(old_sha)?)?;
        let new_commit = repo.find_commit(git2::Oid::from_str(new_sha)?)?;

        let old_tree = old_commit.tree()?;
        let new_tree = new_commit.tree()?;

        let diff = repo.diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None)?;

        let mut files = Vec::new();
        diff.foreach(
            &mut |delta, _| {
                if let Some(path) = delta.new_file().path() {
                    files.push(path.to_string_lossy().to_string());
                }
                true
            },
            None,
            None,
            None,
        )?;

        Ok(files)
    }
}

/// A file changed between two commits with its diff content
#[derive(Debug, Clone)]
pub struct DiffFile {
    pub path: String,
    pub hunks: String,
}

/// Collapse a repository name into a single filesystem-safe directory segment.
/// Names may carry slashes or other path-hostile characters (a target named
/// after a repo path, for instance); those would otherwise nest or break the
/// clone path, so map anything outside `[A-Za-z0-9._-]` to `_`.
fn sanitize_repo_dir(name: &str) -> String {
    let mapped: String = name
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
                c
            } else {
                '_'
            }
        })
        .collect();
    let trimmed = mapped.trim_matches(|c| c == '.' || c == '_');
    if trimmed.is_empty() {
        "repo".to_string()
    } else {
        trimmed.to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::sanitize_repo_dir;

    #[test]
    fn sanitizes_path_hostile_names() {
        assert_eq!(
            sanitize_repo_dir("zephyr-example-app"),
            "zephyr-example-app"
        );
        assert_eq!(
            sanitize_repo_dir("ChristianRinn/bare_metal_stm32f411xe"),
            "ChristianRinn_bare_metal_stm32f411xe"
        );
        assert_eq!(sanitize_repo_dir("../../etc/passwd"), "etc_passwd");
        assert_eq!(sanitize_repo_dir("a b:c"), "a_b_c");
        assert_eq!(sanitize_repo_dir("///"), "repo");
    }
}
