Skip to content
use anyhow::{anyhow, Context, Result};
use reqwest::Client;
use serde::Deserialize;

use crate::cache::{Project, Repo, StatusOption};

pub struct GitHubClient {
    client: Client,
    token: String,
}

#[derive(Debug)]
pub struct CreatedIssue {
    pub number: u64,
    pub html_url: String,
    pub node_id: String,
}

pub struct StatusField {
    pub field_id: String,
    pub options: Vec<StatusOption>,
}

impl GitHubClient {
    pub fn new(token: &str) -> Self {
        let client = Client::builder()
            .user_agent("pike")
            .build()
            .expect("Failed to create HTTP client");
        Self {
            client,
            token: token.to_string(),
        }
    }

    fn auth_header(&self) -> String {
        format!("Bearer {}", self.token)
    }

    pub async fn list_org_repos(&self, org: &str) -> Result<Vec<Repo>> {
        let mut all_repos: Vec<Repo> = Vec::new();
        let mut page = 1u32;

        loop {
            let url = format!(
                "https://api.github.com/orgs/{org}/repos?per_page=100&page={page}"
            );
            let resp = self
                .client
                .get(&url)
                .header("Authorization", self.auth_header())
                .header("Accept", "application/vnd.github+json")
                .send()
                .await
                .context("Failed to fetch org repos")?;

            let status = resp.status();
            if !status.is_success() {
                let body = resp.text().await.unwrap_or_default();
                return Err(anyhow!("GitHub API error ({status}): {body}"));
            }

            let repos_raw: Vec<RepoResponse> = resp
                .json()
                .await
                .context("Failed to parse repo list response")?;

            let count = repos_raw.len();
            all_repos.extend(repos_raw.into_iter().map(|r| Repo {
                name: r.name,
                full_name: r.full_name,
            }));

            if count < 100 {
                break;
            }
            page += 1;
        }

        Ok(all_repos)
    }

    pub async fn create_issue(
        &self,
        owner_repo: &str,
        title: &str,
        body: &str,
    ) -> Result<CreatedIssue> {
        let url = format!("https://api.github.com/repos/{owner_repo}/issues");
        let body_json = serde_json::json!({
            "title": title,
            "body": body,
        });

        let resp = self
            .client
            .post(&url)
            .header("Authorization", self.auth_header())
            .header("Accept", "application/vnd.github+json")
            .json(&body_json)
            .send()
            .await
            .context("Failed to create issue")?;

        let status = resp.status();
        if !status.is_success() {
            let body = resp.text().await.unwrap_or_default();
            return Err(anyhow!("GitHub API error ({status}): {body}"));
        }

        let issue: CreateIssueResponse = resp
            .json()
            .await
            .context("Failed to parse create issue response")?;

        Ok(CreatedIssue {
            number: issue.number,
            html_url: issue.html_url,
            node_id: issue.node_id,
        })
    }

    pub async fn list_org_projects(&self, org: &str) -> Result<Vec<Project>> {
        let query = r#"
            query($login: String!) {
                organization(login: $login) {
                    projectsV2(first: 100) {
                        nodes {
                            id
                            title
                        }
                    }
                }
            }
        "#;

        let resp = self
            .graphql_request(query, serde_json::json!({ "login": org }))
            .await?;

        let org_data = resp
            .get("organization")
            .ok_or_else(|| anyhow!("No organization found in response"))?;
        let nodes = org_data
            .get("projectsV2")
            .and_then(|p| p.get("nodes"))
            .and_then(|n| n.as_array())
            .ok_or_else(|| anyhow!("No projects found in response"))?;

        let mut projects = Vec::new();
        for node in nodes {
            let id = node["id"].as_str().unwrap_or("").to_string();
            let title = node["title"].as_str().unwrap_or("").to_string();
            if !id.is_empty() {
                projects.push(Project {
                    id,
                    title,
                    status_field_id: String::new(),
                    status_options: Vec::new(),
                });
            }
        }

        for project in &mut projects {
            match self.get_project_status_field(&project.id).await {
                Ok(status) => {
                    project.status_field_id = status.field_id;
                    project.status_options = status.options;
                }
                Err(_) => continue,
            }
        }

        Ok(projects)
    }

    pub async fn get_project_status_field(&self, project_id: &str) -> Result<StatusField> {
        let query = r#"
            query($id: ID!) {
                node(id: $id) {
                    ... on ProjectV2 {
                        fields(first: 20) {
                            nodes {
                                ... on ProjectV2SingleSelectField {
                                    id
                                    name
                                    options {
                                        id
                                        name
                                    }
                                }
                            }
                        }
                    }
                }
            }
        "#;

        let resp = self
            .graphql_request(query, serde_json::json!({ "id": project_id }))
            .await?;

        let node = resp
            .get("node")
            .ok_or_else(|| anyhow!("No node found in response"))?;
        let fields = node
            .get("fields")
            .and_then(|f| f.get("nodes"))
            .and_then(|n| n.as_array())
            .ok_or_else(|| anyhow!("No fields found in project"))?;

        for field in fields {
            let name = field["name"].as_str().unwrap_or("");
            if name.eq_ignore_ascii_case("status") {
                let field_id = field["id"].as_str().unwrap_or("").to_string();
                let options = field
                    .get("options")
                    .and_then(|o| o.as_array())
                    .map(|arr| {
                        arr.iter()
                            .filter_map(|opt| {
                                let id = opt["id"].as_str()?;
                                let name = opt["name"].as_str()?;
                                Some(StatusOption {
                                    id: id.to_string(),
                                    name: name.to_string(),
                                })
                            })
                            .collect::<Vec<_>>()
                    })
                    .unwrap_or_default();

                return Ok(StatusField { field_id, options });
            }
        }

        Ok(StatusField {
            field_id: String::new(),
            options: Vec::new(),
        })
    }

    pub async fn add_issue_to_project(
        &self,
        project_id: &str,
        issue_node_id: &str,
    ) -> Result<String> {
        let query = r#"
            mutation($projectId: ID!, $contentId: ID!) {
                addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
                    item {
                        id
                    }
                }
            }
        "#;

        let resp = self
            .graphql_request(
                query,
                serde_json::json!({
                    "projectId": project_id,
                    "contentId": issue_node_id,
                }),
            )
            .await?;

        let item_id = resp
            .get("addProjectV2ItemById")
            .and_then(|r| r.get("item"))
            .and_then(|i| i.get("id"))
            .and_then(|id| id.as_str())
            .ok_or_else(|| anyhow!("Failed to get item ID from response"))?
            .to_string();

        Ok(item_id)
    }

    pub async fn set_issue_status(
        &self,
        project_id: &str,
        item_id: &str,
        field_id: &str,
        option_id: &str,
    ) -> Result<()> {
        let query = r#"
            mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
                updateProjectV2ItemFieldValue(
                    input: {
                        projectId: $projectId,
                        itemId: $itemId,
                        fieldId: $fieldId,
                        value: { singleSelectOptionId: $optionId }
                    }
                ) {
                    projectV2Item {
                        id
                    }
                }
            }
        "#;

        self.graphql_request(
            query,
            serde_json::json!({
                "projectId": project_id,
                "itemId": item_id,
                "fieldId": field_id,
                "optionId": option_id,
            }),
        )
        .await?;

        Ok(())
    }

    async fn graphql_request(
        &self,
        query: &str,
        variables: serde_json::Value,
    ) -> Result<serde_json::Value> {
        let body = serde_json::json!({
            "query": query,
            "variables": variables,
        });

        let resp = self
            .client
            .post("https://api.github.com/graphql")
            .header("Authorization", self.auth_header())
            .header("Content-Type", "application/json")
            .json(&body)
            .send()
            .await
            .context("Failed to send GraphQL request")?;

        let status = resp.status();
        if !status.is_success() {
            let text = resp.text().await.unwrap_or_default();
            return Err(anyhow!("GitHub GraphQL API error ({status}): {text}"));
        }

        let full: serde_json::Value = resp
            .json()
            .await
            .context("Failed to parse GraphQL response")?;

        if let Some(errors) = full.get("errors") {
            return Err(anyhow!("GitHub GraphQL errors: {errors}"));
        }

        full.get("data")
            .cloned()
            .ok_or_else(|| anyhow!("No data in GraphQL response"))
    }
}

#[derive(Deserialize)]
struct RepoResponse {
    name: String,
    full_name: String,
}

#[derive(Deserialize)]
struct CreateIssueResponse {
    number: u64,
    html_url: String,
    node_id: String,
}

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

    #[test]
    fn repo_response_deserialize() {
        let json = r#"{"name": "pike", "full_name": "anomalyco/pike", "private": false}"#;
        let repo: RepoResponse = serde_json::from_str(json).unwrap();
        assert_eq!(repo.name, "pike");
        assert_eq!(repo.full_name, "anomalyco/pike");
    }

    #[test]
    fn create_issue_response_deserialize() {
        let json = r#"{
            "number": 42,
            "html_url": "https://github.com/anomalyco/pike/issues/42",
            "node_id": "I_kwDO123456",
            "title": "Bug"
        }"#;
        let issue: CreateIssueResponse = serde_json::from_str(json).unwrap();
        assert_eq!(issue.number, 42);
        assert_eq!(issue.html_url, "https://github.com/anomalyco/pike/issues/42");
        assert_eq!(issue.node_id, "I_kwDO123456");
    }

    #[test]
    fn graphql_request_body_structure() {
        let query = "query { viewer { login } }";
        let variables = serde_json::json!({ "login": "test" });
        let body = serde_json::json!({
            "query": query,
            "variables": variables,
        });

        assert_eq!(body["query"], query);
        assert_eq!(body["variables"]["login"], "test");
    }

    #[test]
    fn parse_graphql_projects_response() {
        let data = serde_json::json!({
            "organization": {
                "projectsV2": {
                    "nodes": [
                        { "id": "PVT_1", "title": "Roadmap" },
                        { "id": "PVT_2", "title": "Backlog" }
                    ]
                }
            }
        });

        let nodes = data["organization"]["projectsV2"]["nodes"].as_array().unwrap();
        assert_eq!(nodes.len(), 2);
        assert_eq!(nodes[0]["id"], "PVT_1");
        assert_eq!(nodes[1]["title"], "Backlog");
    }

    #[test]
    fn parse_graphql_status_field_response() {
        let data = serde_json::json!({
            "node": {
                "fields": {
                    "nodes": [
                        {
                            "id": "PVTFL_status",
                            "name": "Status",
                            "options": [
                                { "id": "opt1", "name": "Todo" },
                                { "id": "opt2", "name": "Done" }
                            ]
                        },
                        {
                            "id": "PVTFL_priority",
                            "name": "Priority",
                            "options": [
                                { "id": "opt3", "name": "High" }
                            ]
                        }
                    ]
                }
            }
        });

        let fields = data["node"]["fields"]["nodes"].as_array().unwrap();
        let status_field = fields
            .iter()
            .find(|f| f["name"].as_str().unwrap_or("").eq_ignore_ascii_case("status"))
            .unwrap();

        assert_eq!(status_field["id"], "PVTFL_status");
        let options = status_field["options"].as_array().unwrap();
        assert_eq!(options.len(), 2);
        assert_eq!(options[0]["name"], "Todo");
    }

    #[test]
    fn created_issue_fields() {
        let issue = CreatedIssue {
            number: 7,
            html_url: "https://github.com/org/repo/issues/7".to_string(),
            node_id: "node_123".to_string(),
        };
        assert_eq!(issue.number, 7);
        assert_eq!(issue.html_url, "https://github.com/org/repo/issues/7");
        assert_eq!(issue.node_id, "node_123");
    }
}