use std::{borrow::Cow, collections::HashMap, fmt};

use anyhow::Result;
use derive_more::Deref;
use serde::Deserialize;

use crate::git::CommitSha;

pub const PR_REVIEW_LABEL: &str = "PR state:needs review";

#[derive(Debug, Clone)]
pub struct GithubUser {
    pub login: String,
}

#[derive(Debug, Clone)]
pub struct PullRequestData {
    pub number: u64,
    pub user: Option<GithubUser>,
    pub merged_by: Option<GithubUser>,
    pub labels: Option<Vec<String>>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewState {
    Approved,
    Other,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthorAssociation {
    Owner,
    Member,
    Collaborator,
    Contributor,
    FirstTimeContributor,
    FirstTimer,
    Mannequin,
    None,
}

impl AuthorAssociation {
    pub fn has_write_access(&self) -> bool {
        matches!(self, Self::Owner | Self::Member | Self::Collaborator)
    }
}

pub trait Approvable {
    fn author_login(&self) -> Option<&str>;
    fn review_state(&self) -> Option<ReviewState>;
    fn body(&self) -> Option<&str>;
    fn author_association(&self) -> Option<AuthorAssociation>;
}

#[derive(Debug, Clone)]
pub struct PullRequestReview {
    pub user: Option<GithubUser>,
    pub state: Option<ReviewState>,
    pub body: Option<String>,
    pub author_association: Option<AuthorAssociation>,
}

impl PullRequestReview {
    pub fn with_body(self, body: impl ToString) -> Self {
        Self {
            body: Some(body.to_string()),
            ..self
        }
    }
}

impl Approvable for PullRequestReview {
    fn author_login(&self) -> Option<&str> {
        self.user.as_ref().map(|user| user.login.as_str())
    }

    fn review_state(&self) -> Option<ReviewState> {
        self.state
    }

    fn body(&self) -> Option<&str> {
        self.body.as_deref()
    }

    fn author_association(&self) -> Option<AuthorAssociation> {
        self.author_association
    }
}

#[derive(Debug, Clone)]
pub struct PullRequestComment {
    pub user: GithubUser,
    pub body: Option<String>,
    pub author_association: Option<AuthorAssociation>,
}

impl Approvable for PullRequestComment {
    fn author_login(&self) -> Option<&str> {
        Some(&self.user.login)
    }

    fn review_state(&self) -> Option<ReviewState> {
        None
    }

    fn body(&self) -> Option<&str> {
        self.body.as_deref()
    }

    fn author_association(&self) -> Option<AuthorAssociation> {
        self.author_association
    }
}

#[derive(Debug, Deserialize, Clone, Deref, PartialEq, Eq)]
pub struct GithubLogin {
    login: String,
}

impl GithubLogin {
    pub fn new(login: String) -> Self {
        Self { login }
    }
}

impl fmt::Display for GithubLogin {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "@{}", self.login)
    }
}

#[derive(Debug, Deserialize, Clone)]
pub struct CommitAuthor {
    name: String,
    email: String,
    user: Option<GithubLogin>,
}

impl CommitAuthor {
    pub(crate) fn user(&self) -> Option<&GithubLogin> {
        self.user.as_ref()
    }
}

impl PartialEq for CommitAuthor {
    fn eq(&self, other: &Self) -> bool {
        self.user.as_ref().zip(other.user.as_ref()).map_or_else(
            || self.email == other.email || self.name == other.name,
            |(l, r)| l == r,
        )
    }
}

impl fmt::Display for CommitAuthor {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self.user.as_ref() {
            Some(user) => write!(formatter, "{} ({user})", self.name),
            None => write!(formatter, "{} ({})", self.name, self.email),
        }
    }
}

#[derive(Debug, Deserialize, Clone)]
pub struct CommitSignature {
    #[serde(rename = "isValid")]
    is_valid: bool,
    signer: Option<GithubLogin>,
}

impl CommitSignature {
    pub fn is_valid(&self) -> bool {
        self.is_valid
    }

    pub fn signer(&self) -> Option<&GithubLogin> {
        self.signer.as_ref()
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct CommitFileChange {
    pub filename: String,
}

#[derive(Debug, Deserialize)]
pub struct CommitMetadata {
    #[serde(rename = "author")]
    primary_author: CommitAuthor,
    #[serde(rename = "authors", deserialize_with = "graph_ql::deserialize_nodes")]
    co_authors: Vec<CommitAuthor>,
    #[serde(default)]
    signature: Option<CommitSignature>,
    #[serde(default)]
    additions: u64,
    #[serde(default)]
    deletions: u64,
}

impl CommitMetadata {
    pub fn co_authors(&self) -> Option<impl Iterator<Item = &CommitAuthor>> {
        let mut co_authors = self
            .co_authors
            .iter()
            .filter(|co_author| *co_author != &self.primary_author)
            .peekable();

        co_authors.peek().is_some().then_some(co_authors)
    }

    pub fn primary_author(&self) -> &CommitAuthor {
        &self.primary_author
    }

    pub fn signature(&self) -> Option<&CommitSignature> {
        self.signature.as_ref()
    }

    pub fn additions(&self) -> u64 {
        self.additions
    }

    pub fn deletions(&self) -> u64 {
        self.deletions
    }
}

#[derive(Debug, Deref)]
pub struct CommitMetadataBySha(HashMap<CommitSha, CommitMetadata>);

impl CommitMetadataBySha {
    const SHA_PREFIX: &'static str = "commit";
}

impl<'de> serde::Deserialize<'de> for CommitMetadataBySha {
    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let raw = HashMap::<String, CommitMetadata>::deserialize(deserializer)?;
        let map = raw
            .into_iter()
            .map(|(key, value)| {
                let sha = key
                    .strip_prefix(CommitMetadataBySha::SHA_PREFIX)
                    .unwrap_or(&key);
                (CommitSha::new(sha.to_owned()), value)
            })
            .collect();
        Ok(Self(map))
    }
}

#[derive(Clone)]
pub struct Repository<'a> {
    owner: Cow<'a, str>,
    name: Cow<'a, str>,
}

impl<'a> Repository<'a> {
    pub const ZED: Repository<'static> = Repository::new_static("zed-industries", "zed");

    pub fn new(owner: &'a str, name: &'a str) -> Self {
        Self {
            owner: Cow::Borrowed(owner),
            name: Cow::Borrowed(name),
        }
    }

    pub fn owner(&self) -> &str {
        &self.owner
    }

    pub fn name(&self) -> &str {
        &self.name
    }
}

impl Repository<'static> {
    pub const fn new_static(owner: &'static str, name: &'static str) -> Self {
        Self {
            owner: Cow::Borrowed(owner),
            name: Cow::Borrowed(name),
        }
    }
}

#[async_trait::async_trait(?Send)]
pub trait GithubApiClient {
    async fn get_pull_request(
        &self,
        repo: &Repository<'_>,
        pr_number: u64,
    ) -> Result<PullRequestData>;
    async fn get_pull_request_reviews(
        &self,
        repo: &Repository<'_>,
        pr_number: u64,
    ) -> Result<Vec<PullRequestReview>>;
    async fn get_pull_request_comments(
        &self,
        repo: &Repository<'_>,
        pr_number: u64,
    ) -> Result<Vec<PullRequestComment>>;
    async fn get_commit_metadata(
        &self,
        repo: &Repository<'_>,
        commit_shas: &[&CommitSha],
    ) -> Result<CommitMetadataBySha>;
    async fn get_commit_files(
        &self,
        repo: &Repository<'_>,
        sha: &CommitSha,
    ) -> Result<Vec<CommitFileChange>>;
    async fn check_repo_write_permission(
        &self,
        repo: &Repository<'_>,
        login: &GithubLogin,
    ) -> Result<bool>;
    async fn add_label_to_issue(
        &self,
        repo: &Repository<'_>,
        label: &str,
        issue_number: u64,
    ) -> Result<()>;
}

pub mod graph_ql {
    use anyhow::{Context as _, Result};
    use itertools::Itertools as _;
    use serde::Deserialize;

    use crate::git::CommitSha;

    use super::CommitMetadataBySha;

    #[derive(Debug, Deserialize)]
    pub struct GraphQLResponse<T> {
        pub data: Option<T>,
        pub errors: Option<Vec<GraphQLError>>,
    }

    impl<T> GraphQLResponse<T> {
        pub fn into_data(self) -> Result<T> {
            if let Some(errors) = &self.errors {
                if !errors.is_empty() {
                    let messages: String = errors.iter().map(|e| e.message.as_str()).join("; ");
                    anyhow::bail!("GraphQL error: {messages}");
                }
            }

            self.data.context("GraphQL response contained no data")
        }
    }

    #[derive(Debug, Deserialize)]
    pub struct GraphQLError {
        pub message: String,
    }

    #[derive(Debug, Deserialize)]
    pub struct CommitMetadataResponse {
        pub repository: CommitMetadataBySha,
    }

    pub fn deserialize_nodes<'de, T, D>(deserializer: D) -> std::result::Result<Vec<T>, D::Error>
    where
        T: Deserialize<'de>,
        D: serde::Deserializer<'de>,
    {
        #[derive(Deserialize)]
        struct Nodes<T> {
            nodes: Vec<T>,
        }
        Nodes::<T>::deserialize(deserializer).map(|wrapper| wrapper.nodes)
    }

    pub fn build_commit_metadata_query<'a>(
        org: &str,
        repo: &str,
        shas: impl IntoIterator<Item = &'a CommitSha>,
    ) -> String {
        const FRAGMENT: &str = r#"
            ... on Commit {
                author {
                    name
                    email
                    user { login }
                }
                authors(first: 10) {
                    nodes {
                        name
                        email
                        user { login }
                    }
                }
                signature {
                    isValid
                    signer { login }
                }
                additions
                deletions
            }
        "#;

        let objects = shas
            .into_iter()
            .map(|commit_sha| {
                format!(
                    "{sha_prefix}{sha}: object(oid: \"{sha}\") {{ {FRAGMENT} }}",
                    sha_prefix = CommitMetadataBySha::SHA_PREFIX,
                    sha = **commit_sha,
                )
            })
            .join("\n");

        format!("{{  repository(owner: \"{org}\", name: \"{repo}\") {{ {objects}  }} }}")
            .replace("\n", "")
    }
}

#[cfg(feature = "octo-client")]
mod octo_client {
    use anyhow::{Context, Result};
    use futures::TryStreamExt as _;
    use jsonwebtoken::EncodingKey;
    use octocrab::{
        Octocrab, Page,
        models::{
            AuthorAssociation as OctocrabAuthorAssociation,
            pulls::ReviewState as OctocrabReviewState,
        },
        service::middleware::cache::mem::InMemoryCache,
    };
    use serde::de::DeserializeOwned;
    use tokio::pin;

    use crate::{
        git::CommitSha,
        github::{Repository, graph_ql},
    };

    use super::{
        AuthorAssociation, CommitFileChange, CommitMetadataBySha, GithubApiClient, GithubLogin,
        GithubUser, PullRequestComment, PullRequestData, PullRequestReview, ReviewState,
    };

    fn convert_author_association(association: OctocrabAuthorAssociation) -> AuthorAssociation {
        match association {
            OctocrabAuthorAssociation::Owner => AuthorAssociation::Owner,
            OctocrabAuthorAssociation::Member => AuthorAssociation::Member,
            OctocrabAuthorAssociation::Collaborator => AuthorAssociation::Collaborator,
            OctocrabAuthorAssociation::Contributor => AuthorAssociation::Contributor,
            OctocrabAuthorAssociation::FirstTimeContributor => {
                AuthorAssociation::FirstTimeContributor
            }
            OctocrabAuthorAssociation::FirstTimer => AuthorAssociation::FirstTimer,
            OctocrabAuthorAssociation::Mannequin => AuthorAssociation::Mannequin,
            OctocrabAuthorAssociation::None => AuthorAssociation::None,
            _ => AuthorAssociation::None,
        }
    }

    const PAGE_SIZE: u8 = 100;

    pub struct OctocrabClient {
        client: Octocrab,
    }

    impl OctocrabClient {
        pub async fn new(app_id: u64, app_private_key: &str, org: &str) -> Result<Self> {
            let octocrab = Octocrab::builder()
                .cache(InMemoryCache::new())
                .app(
                    app_id.into(),
                    EncodingKey::from_rsa_pem(app_private_key.as_bytes())?,
                )
                .build()?;

            let installations = octocrab
                .apps()
                .installations()
                .send()
                .await
                .context("Failed to fetch installations")?
                .take_items();

            let installation_id = installations
                .into_iter()
                .find(|installation| installation.account.login == org)
                .context("Could not find Zed repository in installations")?
                .id;

            let client = octocrab.installation(installation_id)?;
            Ok(Self { client })
        }

        async fn graphql<R: DeserializeOwned>(&self, query: &serde_json::Value) -> Result<R> {
            let response: serde_json::Value = self.client.graphql(query).await?;
            let parsed: graph_ql::GraphQLResponse<R> = serde_json::from_value(response)
                .context("Failed to parse GraphQL response envelope")?;
            parsed.into_data()
        }

        async fn get_all<T: DeserializeOwned + 'static>(
            &self,
            page: Page<T>,
        ) -> octocrab::Result<Vec<T>> {
            self.get_filtered(page, |_| true).await
        }

        async fn get_filtered<T: DeserializeOwned + 'static>(
            &self,
            page: Page<T>,
            predicate: impl Fn(&T) -> bool,
        ) -> octocrab::Result<Vec<T>> {
            let stream = page.into_stream(&self.client);
            pin!(stream);

            let mut results = Vec::new();

            while let Some(item) = stream.try_next().await?
                && predicate(&item)
            {
                results.push(item);
            }

            Ok(results)
        }
    }

    #[async_trait::async_trait(?Send)]
    impl GithubApiClient for OctocrabClient {
        async fn get_pull_request(
            &self,
            repo: &Repository<'_>,
            pr_number: u64,
        ) -> Result<PullRequestData> {
            let pr = self
                .client
                .pulls(repo.owner.as_ref(), repo.name.as_ref())
                .get(pr_number)
                .await?;
            Ok(PullRequestData {
                number: pr.number,
                user: pr.user.map(|user| GithubUser { login: user.login }),
                merged_by: pr.merged_by.map(|user| GithubUser { login: user.login }),
                labels: pr
                    .labels
                    .map(|labels| labels.into_iter().map(|label| label.name).collect()),
            })
        }

        async fn get_pull_request_reviews(
            &self,
            repo: &Repository<'_>,
            pr_number: u64,
        ) -> Result<Vec<PullRequestReview>> {
            let page = self
                .client
                .pulls(repo.owner.as_ref(), repo.name.as_ref())
                .list_reviews(pr_number)
                .per_page(PAGE_SIZE)
                .send()
                .await?;

            let reviews = self.get_all(page).await?;

            Ok(reviews
                .into_iter()
                .map(|review| PullRequestReview {
                    user: review.user.map(|user| GithubUser { login: user.login }),
                    state: review.state.map(|state| match state {
                        OctocrabReviewState::Approved => ReviewState::Approved,
                        _ => ReviewState::Other,
                    }),
                    body: review.body,
                    author_association: review.author_association.map(convert_author_association),
                })
                .collect())
        }

        async fn get_pull_request_comments(
            &self,
            repo: &Repository<'_>,
            pr_number: u64,
        ) -> Result<Vec<PullRequestComment>> {
            let page = self
                .client
                .issues(repo.owner.as_ref(), repo.name.as_ref())
                .list_comments(pr_number)
                .per_page(PAGE_SIZE)
                .send()
                .await?;

            let comments = self.get_all(page).await?;

            Ok(comments
                .into_iter()
                .map(|comment| PullRequestComment {
                    user: GithubUser {
                        login: comment.user.login,
                    },
                    body: comment.body,
                    author_association: comment.author_association.map(convert_author_association),
                })
                .collect())
        }

        async fn get_commit_metadata(
            &self,
            repo: &Repository<'_>,
            commit_shas: &[&CommitSha],
        ) -> Result<CommitMetadataBySha> {
            let query = graph_ql::build_commit_metadata_query(
                repo.owner.as_ref(),
                repo.name.as_ref(),
                commit_shas.iter().copied(),
            );
            let query = serde_json::json!({ "query": query });
            self.graphql::<graph_ql::CommitMetadataResponse>(&query)
                .await
                .map(|response| response.repository)
        }

        async fn get_commit_files(
            &self,
            repo: &Repository<'_>,
            sha: &CommitSha,
        ) -> Result<Vec<CommitFileChange>> {
            let response = self
                .client
                .commits(repo.owner.as_ref(), repo.name.as_ref())
                .get(sha.as_str())
                .await?;

            Ok(response
                .files
                .into_iter()
                .flatten()
                .map(|file| CommitFileChange {
                    filename: file.filename,
                })
                .collect())
        }

        async fn check_repo_write_permission(
            &self,
            repo: &Repository<'_>,
            login: &GithubLogin,
        ) -> Result<bool> {
            // Check org membership first - we save ourselves a few request that way
            let page = self
                .client
                .orgs(repo.owner.as_ref())
                .list_members()
                .per_page(PAGE_SIZE)
                .send()
                .await?;

            let members = self.get_all(page).await?;

            if members
                .into_iter()
                .any(|member| member.login == login.as_str())
            {
                return Ok(true);
            }

            // TODO: octocrab fails to deserialize the permission response and
            // does not adhere to the scheme laid out at
            // https://docs.github.com/en/rest/collaborators/collaborators?apiVersion=2026-03-10#get-repository-permissions-for-a-user

            #[derive(serde::Deserialize)]
            #[serde(rename_all = "lowercase")]
            enum RepoPermission {
                Admin,
                Write,
                Read,
                #[serde(other)]
                Other,
            }

            #[derive(serde::Deserialize)]
            struct RepositoryPermissions {
                permission: RepoPermission,
            }

            self.client
                .get::<RepositoryPermissions, _, _>(
                    format!(
                        "/repos/{owner}/{repo}/collaborators/{user}/permission",
                        owner = repo.owner.as_ref(),
                        repo = repo.name.as_ref(),
                        user = login.as_str()
                    ),
                    None::<&()>,
                )
                .await
                .map(|response| {
                    matches!(
                        response.permission,
                        RepoPermission::Write | RepoPermission::Admin
                    )
                })
                .map_err(Into::into)
        }

        async fn add_label_to_issue(
            &self,
            repo: &Repository<'_>,
            label: &str,
            issue_number: u64,
        ) -> Result<()> {
            self.client
                .issues(repo.owner.as_ref(), repo.name.as_ref())
                .add_labels(issue_number, &[label.to_owned()])
                .await
                .map(|_| ())
                .map_err(Into::into)
        }
    }
}

#[cfg(feature = "octo-client")]
pub use octo_client::OctocrabClient;
