gitorii 0.6.0

A human-first Git client with simplified commands, snapshots, multi-platform mirrors and built-in secret scanning
use serde::{Deserialize, Serialize};
use reqwest::blocking::Client;
use crate::config::ToriiConfig;
use crate::error::{Result, ToriiError};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Issue {
    pub number: u64,
    pub title: String,
    pub body: Option<String>,
    pub state: String,
    pub author: String,
    pub url: String,
    pub labels: Vec<String>,
    pub assignees: Vec<String>,
    pub created_at: String,
    pub comments: u64,
}

#[derive(Debug, Clone)]
pub struct CreateIssueOptions {
    pub title: String,
    pub body: Option<String>,
}

pub trait IssueClient: Send {
    fn list(&self, owner: &str, repo: &str, state: &str) -> Result<Vec<Issue>>;
    fn create(&self, owner: &str, repo: &str, opts: CreateIssueOptions) -> Result<Issue>;
    fn close(&self, owner: &str, repo: &str, number: u64) -> Result<()>;
    fn comment(&self, owner: &str, repo: &str, number: u64, body: &str) -> Result<()>;
}

// ── GitHub ────────────────────────────────────────────────────────────────────

pub struct GitHubIssueClient {
    token: String,
}

impl GitHubIssueClient {
    pub fn new() -> Result<Self> {
        let config = ToriiConfig::load_global().unwrap_or_default();
        let token = std::env::var("GITHUB_TOKEN").ok()
            .or_else(|| std::env::var("GH_TOKEN").ok())
            .or(config.auth.github_token)
            .ok_or_else(|| ToriiError::InvalidConfig(
                "GitHub token not found. Run: torii config set auth.github_token YOUR_TOKEN".to_string()
            ))?;
        Ok(Self { token })
    }

    fn client(&self) -> Client {
        Client::builder().user_agent("gitorii-cli").build().unwrap()
    }

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

impl IssueClient for GitHubIssueClient {
    fn list(&self, owner: &str, repo: &str, state: &str) -> Result<Vec<Issue>> {
        let url = format!(
            "https://api.github.com/repos/{}/{}/issues?state={}&per_page=50",
            owner, repo, state
        );
        let resp = self.client()
            .get(&url)
            .header("Authorization", self.auth())
            .header("Accept", "application/vnd.github.v3+json")
            .send()
            .map_err(|e| ToriiError::InvalidConfig(format!("GitHub API error: {}", e)))?;
        let json: serde_json::Value = resp.json()
            .map_err(|e| ToriiError::InvalidConfig(format!("GitHub API parse error: {}", e)))?;
        let arr = json.as_array()
            .ok_or_else(|| ToriiError::InvalidConfig("Unexpected GitHub response".to_string()))?;
        // filter out PRs (GitHub issues API returns PRs too)
        Ok(arr.iter().filter(|v| v["pull_request"].is_null()).filter_map(|v| parse_github_issue(v).ok()).collect())
    }

    fn create(&self, owner: &str, repo: &str, opts: CreateIssueOptions) -> Result<Issue> {
        let url = format!("https://api.github.com/repos/{}/{}/issues", owner, repo);
        let body = serde_json::json!({
            "title": opts.title,
            "body":  opts.body.unwrap_or_default(),
        });
        let resp = self.client()
            .post(&url)
            .header("Authorization", self.auth())
            .header("Accept", "application/vnd.github.v3+json")
            .json(&body)
            .send()
            .map_err(|e| ToriiError::InvalidConfig(format!("GitHub API error: {}", e)))?;
        let status = resp.status();
        let json: serde_json::Value = resp.json()
            .map_err(|e| ToriiError::InvalidConfig(format!("GitHub API parse error: {}", e)))?;
        if !status.is_success() {
            let msg = json["message"].as_str().unwrap_or("unknown error");
            return Err(ToriiError::InvalidConfig(format!("GitHub API {}: {}", status, msg)));
        }
        parse_github_issue(&json)
    }

    fn close(&self, owner: &str, repo: &str, number: u64) -> Result<()> {
        let url = format!("https://api.github.com/repos/{}/{}/issues/{}", owner, repo, number);
        let body = serde_json::json!({ "state": "closed" });
        let resp = self.client()
            .patch(&url)
            .header("Authorization", self.auth())
            .header("Accept", "application/vnd.github.v3+json")
            .json(&body)
            .send()
            .map_err(|e| ToriiError::InvalidConfig(format!("GitHub API error: {}", e)))?;
        if !resp.status().is_success() {
            let json: serde_json::Value = resp.json().unwrap_or_default();
            let msg = json["message"].as_str().unwrap_or("close failed");
            return Err(ToriiError::InvalidConfig(format!("Close failed: {}", msg)));
        }
        Ok(())
    }

    fn comment(&self, owner: &str, repo: &str, number: u64, body: &str) -> Result<()> {
        let url = format!("https://api.github.com/repos/{}/{}/issues/{}/comments", owner, repo, number);
        let payload = serde_json::json!({ "body": body });
        let resp = self.client()
            .post(&url)
            .header("Authorization", self.auth())
            .header("Accept", "application/vnd.github.v3+json")
            .json(&payload)
            .send()
            .map_err(|e| ToriiError::InvalidConfig(format!("GitHub API error: {}", e)))?;
        if !resp.status().is_success() {
            let json: serde_json::Value = resp.json().unwrap_or_default();
            let msg = json["message"].as_str().unwrap_or("comment failed");
            return Err(ToriiError::InvalidConfig(format!("Comment failed: {}", msg)));
        }
        Ok(())
    }
}

fn parse_github_issue(json: &serde_json::Value) -> Result<Issue> {
    Ok(Issue {
        number:     json["number"].as_u64().unwrap_or(0),
        title:      json["title"].as_str().unwrap_or("").to_string(),
        body:       json["body"].as_str().map(|s| s.to_string()),
        state:      json["state"].as_str().unwrap_or("").to_string(),
        author:     json["user"]["login"].as_str().unwrap_or("").to_string(),
        url:        json["html_url"].as_str().unwrap_or("").to_string(),
        labels:     json["labels"].as_array().map(|a| {
            a.iter().filter_map(|l| l["name"].as_str().map(|s| s.to_string())).collect()
        }).unwrap_or_default(),
        assignees:  json["assignees"].as_array().map(|a| {
            a.iter().filter_map(|u| u["login"].as_str().map(|s| s.to_string())).collect()
        }).unwrap_or_default(),
        created_at: json["created_at"].as_str().unwrap_or("").to_string(),
        comments:   json["comments"].as_u64().unwrap_or(0),
    })
}

// ── GitLab ────────────────────────────────────────────────────────────────────

pub struct GitLabIssueClient {
    token: String,
    base_url: String,
}

impl GitLabIssueClient {
    pub fn new() -> Result<Self> {
        let config = ToriiConfig::load_global().unwrap_or_default();
        let token = std::env::var("GITLAB_TOKEN").ok()
            .or(config.auth.gitlab_token)
            .ok_or_else(|| ToriiError::InvalidConfig(
                "GitLab token not found. Run: torii config set auth.gitlab_token YOUR_TOKEN".to_string()
            ))?;
        let base_url = std::env::var("GITLAB_URL")
            .unwrap_or_else(|_| "https://gitlab.com/api/v4".to_string());
        Ok(Self { token, base_url })
    }

    fn client(&self) -> Client {
        Client::builder().user_agent("gitorii-cli").build().unwrap()
    }

    fn project_path(owner: &str, repo: &str) -> String {
        crate::url::encode(&format!("{}/{}", owner, repo))
    }
}

impl IssueClient for GitLabIssueClient {
    fn list(&self, owner: &str, repo: &str, state: &str) -> Result<Vec<Issue>> {
        let gl_state = match state {
            "open"   => "opened",
            "closed" => "closed",
            other    => other,
        };
        let url = format!(
            "{}/projects/{}/issues?state={}&per_page=50",
            self.base_url, Self::project_path(owner, repo), gl_state
        );
        let resp = self.client()
            .get(&url)
            .header("PRIVATE-TOKEN", &self.token)
            .send()
            .map_err(|e| ToriiError::InvalidConfig(format!("GitLab API error: {}", e)))?;
        let json: serde_json::Value = resp.json()
            .map_err(|e| ToriiError::InvalidConfig(format!("GitLab API parse error: {}", e)))?;
        let arr = json.as_array()
            .ok_or_else(|| ToriiError::InvalidConfig("Unexpected GitLab response".to_string()))?;
        arr.iter().map(|v| parse_gitlab_issue(v)).collect()
    }

    fn create(&self, owner: &str, repo: &str, opts: CreateIssueOptions) -> Result<Issue> {
        let url = format!(
            "{}/projects/{}/issues",
            self.base_url, Self::project_path(owner, repo)
        );
        let body = serde_json::json!({
            "title":       opts.title,
            "description": opts.body.unwrap_or_default(),
        });
        let resp = self.client()
            .post(&url)
            .header("PRIVATE-TOKEN", &self.token)
            .json(&body)
            .send()
            .map_err(|e| ToriiError::InvalidConfig(format!("GitLab API error: {}", e)))?;
        let status = resp.status();
        let json: serde_json::Value = resp.json()
            .map_err(|e| ToriiError::InvalidConfig(format!("GitLab API parse error: {}", e)))?;
        if !status.is_success() {
            let msg = json["message"].as_str()
                .or_else(|| json["error"].as_str())
                .unwrap_or("unknown error");
            return Err(ToriiError::InvalidConfig(format!("GitLab API {}: {}", status, msg)));
        }
        parse_gitlab_issue(&json)
    }

    fn close(&self, owner: &str, repo: &str, number: u64) -> Result<()> {
        let url = format!(
            "{}/projects/{}/issues/{}",
            self.base_url, Self::project_path(owner, repo), number
        );
        let body = serde_json::json!({ "state_event": "close" });
        let resp = self.client()
            .put(&url)
            .header("PRIVATE-TOKEN", &self.token)
            .json(&body)
            .send()
            .map_err(|e| ToriiError::InvalidConfig(format!("GitLab API error: {}", e)))?;
        if !resp.status().is_success() {
            let json: serde_json::Value = resp.json().unwrap_or_default();
            let msg = json["message"].as_str().unwrap_or("close failed");
            return Err(ToriiError::InvalidConfig(format!("Close failed: {}", msg)));
        }
        Ok(())
    }

    fn comment(&self, owner: &str, repo: &str, number: u64, body: &str) -> Result<()> {
        let url = format!(
            "{}/projects/{}/issues/{}/notes",
            self.base_url, Self::project_path(owner, repo), number
        );
        let payload = serde_json::json!({ "body": body });
        let resp = self.client()
            .post(&url)
            .header("PRIVATE-TOKEN", &self.token)
            .json(&payload)
            .send()
            .map_err(|e| ToriiError::InvalidConfig(format!("GitLab API error: {}", e)))?;
        if !resp.status().is_success() {
            let json: serde_json::Value = resp.json().unwrap_or_default();
            let msg = json["message"].as_str().unwrap_or("comment failed");
            return Err(ToriiError::InvalidConfig(format!("Comment failed: {}", msg)));
        }
        Ok(())
    }
}

fn parse_gitlab_issue(json: &serde_json::Value) -> Result<Issue> {
    Ok(Issue {
        number:     json["iid"].as_u64().unwrap_or(0),
        title:      json["title"].as_str().unwrap_or("").to_string(),
        body:       json["description"].as_str().map(|s| s.to_string()),
        state:      json["state"].as_str().unwrap_or("").to_string(),
        author:     json["author"]["username"].as_str().unwrap_or("").to_string(),
        url:        json["web_url"].as_str().unwrap_or("").to_string(),
        labels:     json["labels"].as_array().map(|a| {
            a.iter().filter_map(|l| l.as_str().map(|s| s.to_string())).collect()
        }).unwrap_or_default(),
        assignees:  json["assignees"].as_array().map(|a| {
            a.iter().filter_map(|u| u["username"].as_str().map(|s| s.to_string())).collect()
        }).unwrap_or_default(),
        created_at: json["created_at"].as_str().unwrap_or("").to_string(),
        comments:   json["user_notes_count"].as_u64().unwrap_or(0),
    })
}

// ── Factory ───────────────────────────────────────────────────────────────────

pub fn get_issue_client(platform: &str) -> Result<Box<dyn IssueClient>> {
    match platform.to_lowercase().as_str() {
        "github" => Ok(Box::new(GitHubIssueClient::new()?)),
        "gitlab" => Ok(Box::new(GitLabIssueClient::new()?)),
        other => Err(ToriiError::InvalidConfig(
            format!("Unsupported platform: {}. Supported: github, gitlab", other)
        )),
    }
}