use serde::Serialize;
use std::fmt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Instant;
#[cfg(feature = "desktop")]
use tauri::State;

use crate::error_classification::calculate_backoff_delay;
use crate::state::AppState;

fn extract_graphql_name(query: &str) -> &str {
    // Extract name from "query FooBar {" or "mutation Baz(" patterns
    for keyword in &["query ", "mutation "] {
        if let Some(rest) = query.trim_start().strip_prefix(keyword) {
            let rest = rest.trim_start();
            let end = rest
                .find(|c: char| !c.is_alphanumeric() && c != '_')
                .unwrap_or(rest.len());
            if end > 0 {
                return &rest[..end];
            }
        }
    }
    "<inline>"
}

/// Resolve a GitHub API token from all available sources.
/// Delegates to `github_auth::resolve_token_with_source()` — single source of truth
/// for the priority chain: GH_TOKEN env → GITHUB_TOKEN env → keyring OAuth → gh_token crate → gh CLI.
#[cfg(test)]
pub(crate) fn resolve_github_token() -> Option<String> {
    crate::github_auth::resolve_token_with_source().0
}

/// Collect all non-empty GitHub token candidates in priority order.
/// Delegates to the single source of truth in `github_auth::resolve_all_candidates`.
fn resolve_github_token_candidates() -> Vec<(String, crate::github_auth::TokenSource)> {
    crate::github_auth::resolve_all_candidates()
}

/// Error type for GraphQL requests, distinguishing auth failures and rate limits from other errors.
#[derive(Debug)]
pub(crate) enum GqlError {
    /// 401 Unauthorized — token is invalid or expired
    Auth(String),
    /// Rate limited by GitHub (429, 403 with exhausted limits, or GraphQL RATE_LIMITED)
    RateLimit {
        /// Unix epoch from `x-ratelimit-reset` header (primary limit reset time)
        reset_at: Option<u64>,
        /// Seconds from `retry-after` header (secondary/abuse limits)
        retry_after: Option<u64>,
        message: String,
    },
    /// Any other error (network, parse, non-401 HTTP status, GraphQL errors)
    Other(String),
}

impl fmt::Display for GqlError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            GqlError::Auth(msg) => write!(f, "Auth error: {msg}"),
            GqlError::RateLimit { message, .. } => write!(f, "Rate limited: {message}"),
            GqlError::Other(msg) => write!(f, "{msg}"),
        }
    }
}

/// Circuit breaker for GitHub API calls.
/// Tracks consecutive failures and stops making requests after a threshold.
/// Rate limits are tracked separately so they don't inflate the failure count.
pub(crate) struct GitHubCircuitBreaker {
    failure_count: AtomicU32,
    open_until: parking_lot::RwLock<Option<Instant>>,
    /// Separate backoff for rate limits — does not affect failure_count
    rate_limit_until: parking_lot::RwLock<Option<Instant>>,
}

/// Consecutive failures before the circuit opens (tolerates occasional transient errors).
const CIRCUIT_BREAKER_THRESHOLD: u32 = 3;
/// Initial backoff when the circuit opens (5 seconds).
const CIRCUIT_BREAKER_BASE_MS: f64 = 5_000.0;
/// Maximum backoff cap so the circuit eventually retries (5 minutes).
const CIRCUIT_BREAKER_MAX_MS: f64 = 300_000.0;
/// Exponential backoff multiplier (doubles each failure beyond threshold).
const CIRCUIT_BREAKER_MULTIPLIER: f64 = 2.0;

impl GitHubCircuitBreaker {
    pub(crate) fn new() -> Self {
        Self {
            failure_count: AtomicU32::new(0),
            open_until: parking_lot::RwLock::new(None),
            rate_limit_until: parking_lot::RwLock::new(None),
        }
    }

    /// Check if the circuit is open (failure-based or rate-limited).
    /// Returns Ok(()) if closed (requests allowed), or Err with a message.
    pub(crate) fn check(&self) -> Result<(), String> {
        // Check rate limit backoff first (more specific message)
        let rl_guard = self.rate_limit_until.read();
        if let Some(until) = *rl_guard
            && Instant::now() < until
        {
            let remaining = until.duration_since(Instant::now());
            return Err(format!(
                "rate-limit: backing off for {:.0}s",
                remaining.as_secs_f64()
            ));
        }
        drop(rl_guard);

        // Check failure-based circuit breaker
        let guard = self.open_until.read();
        if let Some(until) = *guard
            && Instant::now() < until
        {
            let remaining = until.duration_since(Instant::now());
            return Err(format!(
                "GitHub API circuit breaker open — retrying in {:.0}s",
                remaining.as_secs_f64()
            ));
        }
        Ok(())
    }

    /// Record a successful API call. Resets failure count and closes the circuit.
    pub(crate) fn record_success(&self) {
        self.failure_count.store(0, Ordering::Relaxed);
        *self.open_until.write() = None;
    }

    /// Reset the circuit breaker entirely — clears failures, backoff, and rate limits.
    /// Used after a new token is obtained (e.g. OAuth login).
    pub(crate) fn reset(&self) {
        self.failure_count.store(0, Ordering::Relaxed);
        *self.open_until.write() = None;
        *self.rate_limit_until.write() = None;
    }

    /// Record a rate limit response. Sets a dedicated backoff timer
    /// without inflating the failure count.
    pub(crate) fn record_rate_limit(&self, wait_secs: u64) {
        let delay = std::time::Duration::from_secs(wait_secs);
        *self.rate_limit_until.write() = Some(Instant::now() + delay);
        tracing::warn!(
            source = "github",
            backoff_secs = wait_secs,
            "Rate limited — backing off"
        );
    }

    /// Record a failed API call. Opens the circuit after threshold failures.
    pub(crate) fn record_failure(&self) {
        let count = self.failure_count.fetch_add(1, Ordering::Relaxed) + 1;
        if count >= CIRCUIT_BREAKER_THRESHOLD {
            let delay_ms = calculate_backoff_delay(
                count - CIRCUIT_BREAKER_THRESHOLD,
                CIRCUIT_BREAKER_BASE_MS,
                CIRCUIT_BREAKER_MAX_MS,
                CIRCUIT_BREAKER_MULTIPLIER,
            );
            let delay = std::time::Duration::from_millis(delay_ms as u64);
            *self.open_until.write() = Some(Instant::now() + delay);
            tracing::warn!(
                source = "github",
                failures = count,
                backoff_secs = delay.as_secs_f64(),
                "Circuit breaker open"
            );
        }
    }
}

/// Parse a git remote URL into (owner, repo) for GitHub repos.
/// Supports HTTPS (github.com/owner/repo.git) and SSH (git@github.com:owner/repo.git).
pub(crate) fn parse_remote_url(url: &str) -> Option<(String, String)> {
    let url = url.trim();

    // SSH: git@github.com:owner/repo.git
    if let Some(path) = url.strip_prefix("git@github.com:") {
        let path = path.strip_suffix(".git").unwrap_or(path);
        let parts: Vec<&str> = path.splitn(2, '/').collect();
        if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
            return Some((parts[0].to_string(), parts[1].to_string()));
        }
    }

    // HTTPS: https://github.com/owner/repo.git
    if url.contains("github.com") {
        // Strip protocol and host
        let path = url
            .trim_start_matches("https://")
            .trim_start_matches("http://")
            .trim_start_matches("github.com/");
        let path = path.strip_suffix(".git").unwrap_or(path);
        let parts: Vec<&str> = path.splitn(3, '/').collect();
        if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
            return Some((parts[0].to_string(), parts[1].to_string()));
        }
    }

    None
}

/// Parse a header value as a u64, returning None if missing or unparseable.
fn header_as_u64(headers: &reqwest::header::HeaderMap, name: &str) -> Option<u64> {
    headers.get(name)?.to_str().ok()?.parse().ok()
}

/// Check a GraphQL JSON response for errors.
///
/// - Rate-limited errors always fail.
/// - Non-rate-limit errors fail only when `data` is absent (pure error).
///   When `data` is present alongside errors (partial success, e.g. one repo
///   not found in a batch query), the caller receives `Ok(())` so it can
///   process the valid portion.  The `get_all_pr_statuses_impl` loop already
///   handles null repos gracefully.
pub(crate) fn check_graphql_errors(
    json: &serde_json::Value,
    ratelimit_reset: Option<u64>,
    retry_after: Option<u64>,
) -> Result<(), GqlError> {
    let errors = match json["errors"].as_array() {
        Some(arr) if !arr.is_empty() => arr,
        _ => return Ok(()),
    };

    // Rate-limit errors always bubble up — partial data is stale anyway
    let has_rate_limit_error = errors
        .iter()
        .any(|e| e["type"].as_str() == Some("RATE_LIMITED"));
    if has_rate_limit_error {
        let msg = errors[0]["message"]
            .as_str()
            .unwrap_or("GraphQL rate limit");
        return Err(GqlError::RateLimit {
            reset_at: ratelimit_reset,
            retry_after,
            message: msg.to_string(),
        });
    }

    // If the response includes valid data alongside the errors, treat as
    // partial success — the caller will skip null repos individually.
    if json["data"].is_object() {
        return Ok(());
    }

    let msg = errors[0]["message"]
        .as_str()
        .unwrap_or("Unknown GraphQL error");
    Err(GqlError::Other(format!("GraphQL error: {msg}")))
}

/// Execute a GraphQL query against the GitHub API.
/// Returns the parsed JSON response or a typed error.
/// Detects rate limits from HTTP status codes, headers, and GraphQL error types.
pub(crate) async fn graphql_request(
    client: &reqwest::Client,
    token: &str,
    query: &str,
    variables: &serde_json::Value,
) -> Result<serde_json::Value, GqlError> {
    let body = serde_json::json!({
        "query": query,
        "variables": variables,
    });

    let response = client
        .post("https://api.github.com/graphql")
        .header("Authorization", format!("Bearer {token}"))
        .header("User-Agent", "tuicommander")
        .json(&body)
        .send()
        .await
        .map_err(|e| GqlError::Other(format!("GraphQL request failed: {e}")))?;

    let status = response.status();

    // Extract rate limit headers before consuming the response body
    let ratelimit_remaining = header_as_u64(response.headers(), "x-ratelimit-remaining");
    let ratelimit_reset = header_as_u64(response.headers(), "x-ratelimit-reset");
    let retry_after = header_as_u64(response.headers(), "retry-after");

    // 1. HTTP 429 → always a rate limit
    if status.as_u16() == 429 {
        return Err(GqlError::RateLimit {
            reset_at: ratelimit_reset,
            retry_after,
            message: "HTTP 429 Too Many Requests".to_string(),
        });
    }

    let json: serde_json::Value = response
        .json()
        .await
        .map_err(|e| GqlError::Other(format!("Failed to parse GraphQL response: {e}")))?;

    if !status.is_success() {
        let msg = json["message"].as_str().unwrap_or("Unknown error");
        let err_msg = format!("GitHub API error ({status}): {msg}");

        if status.as_u16() == 401 {
            return Err(GqlError::Auth(err_msg));
        }

        // 2. HTTP 403 + x-ratelimit-remaining: 0 → primary rate limit exhausted
        if status.as_u16() == 403 && ratelimit_remaining == Some(0) {
            return Err(GqlError::RateLimit {
                reset_at: ratelimit_reset,
                retry_after,
                message: format!("Primary rate limit exhausted: {msg}"),
            });
        }

        // 3. HTTP 403 + body mentions "secondary rate" → secondary/abuse rate limit
        if status.as_u16() == 403 {
            let msg_lower = msg.to_lowercase();
            if msg_lower.contains("secondary rate") || msg_lower.contains("abuse") {
                return Err(GqlError::RateLimit {
                    reset_at: ratelimit_reset,
                    retry_after,
                    message: format!("Secondary rate limit: {msg}"),
                });
            }
        }

        return Err(GqlError::Other(err_msg));
    }

    // 4. HTTP 200 + GraphQL errors
    check_graphql_errors(&json, ratelimit_reset, retry_after)?;

    Ok(json)
}

/// Calculate how long to wait for a rate limit, in seconds.
/// Prefers `retry-after` (secondary limits), falls back to `reset_at - now + 1`,
/// defaults to 60s if neither header is available.
fn rate_limit_wait_secs(reset_at: Option<u64>, retry_after: Option<u64>) -> u64 {
    if let Some(secs) = retry_after {
        return secs;
    }
    if let Some(reset) = reset_at {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        if reset > now {
            return reset - now + 1;
        }
    }
    60 // Default: wait 60 seconds
}

/// Execute a GraphQL query with token fallback and circuit breaker protection.
/// On 401, tries remaining token candidates and updates the stored token on success.
/// Rate limits are handled separately from failures — they don't inflate the failure count.
pub(crate) async fn graphql_with_retry(
    state: &AppState,
    query: &str,
    variables: serde_json::Value,
) -> Result<serde_json::Value, String> {
    // Check circuit breaker first
    state.github_circuit_breaker.check()?;

    if crate::github_debug::enabled() {
        let query_name = extract_graphql_name(query);
        tracing::info!(
            source = "github_api",
            method = "POST",
            url = "https://api.github.com/graphql",
            query = query_name,
            "GraphQL request"
        );
    }

    let mut current_token = state.github_token.read().clone();
    // Lazy resolution: boot skips keychain, resolve on first use.
    if current_token.is_none() {
        let (t, s) = tokio::task::spawn_blocking(crate::github_auth::resolve_token_with_source)
            .await
            .map_err(|e| format!("token resolve task panicked: {e}"))?;
        if t.is_some() {
            *state.github_token.write() = t.clone();
            *state.github_token_source.write() = s;
        }
        current_token = t;
    }
    let token = match current_token.as_deref() {
        Some(t) => t.to_string(),
        None => return Err("No GitHub token available".to_string()),
    };

    match graphql_request(&state.http_client, &token, query, &variables).await {
        Ok(response) => {
            state.github_circuit_breaker.record_success();
            Ok(response)
        }
        Err(GqlError::RateLimit {
            reset_at,
            retry_after,
            message,
        }) => {
            let wait = rate_limit_wait_secs(reset_at, retry_after);
            state.github_circuit_breaker.record_rate_limit(wait);
            Err(format!("rate-limit: {message}"))
        }
        Err(GqlError::Auth(msg)) => {
            tracing::warn!(
                source = "github",
                "401 with current token, trying fallback candidates"
            );
            // Try other candidates
            let candidates = resolve_github_token_candidates();
            for (candidate, candidate_source) in &candidates {
                if candidate == &token {
                    continue; // Skip the one that already failed
                }
                match graphql_request(&state.http_client, candidate, query, &variables).await {
                    Ok(response) => {
                        tracing::info!(source = "github", "Token fallback succeeded");
                        *state.github_token.write() = Some(candidate.clone());
                        *state.github_token_source.write() = *candidate_source;
                        state.github_circuit_breaker.record_success();
                        return Ok(response);
                    }
                    Err(GqlError::Auth(_)) => continue, // Try next candidate
                    Err(GqlError::RateLimit {
                        reset_at,
                        retry_after,
                        message,
                    }) => {
                        let wait = rate_limit_wait_secs(reset_at, retry_after);
                        state.github_circuit_breaker.record_rate_limit(wait);
                        return Err(format!("rate-limit: {message}"));
                    }
                    Err(GqlError::Other(e)) => {
                        state.github_circuit_breaker.record_failure();
                        return Err(e);
                    }
                }
            }
            // All candidates failed with 401
            state.github_circuit_breaker.record_failure();
            Err(msg)
        }
        Err(GqlError::Other(msg)) => {
            state.github_circuit_breaker.record_failure();
            Err(msg)
        }
    }
}

/// Git remote + branch status (no PR/CI — those come from githubStore via batch query)
#[derive(Clone, Serialize)]
pub(crate) struct GitHubStatus {
    pub(crate) has_remote: bool,
    pub(crate) current_branch: String,
    pub(crate) ahead: i32,
    pub(crate) behind: i32,
}

/// Summary of CI check states for a PR
#[derive(Clone, Debug, Serialize, PartialEq)]
pub(crate) struct CheckSummary {
    pub(crate) passed: u32,
    pub(crate) failed: u32,
    pub(crate) pending: u32,
    pub(crate) total: u32,
}

/// Individual CI check detail
#[derive(Clone, Debug, Serialize)]
pub(crate) struct CheckDetail {
    pub(crate) context: String,
    pub(crate) state: String,
}

/// Pre-computed merge/review state label for the UI
#[derive(Clone, Serialize, Debug, PartialEq)]
pub(crate) struct StateLabel {
    pub(crate) label: String,
    pub(crate) css_class: String,
}

/// Classify merge readiness from mergeable + merge_state_status fields
pub(crate) fn classify_merge_state(
    mergeable: Option<&str>,
    merge_state_status: Option<&str>,
) -> Option<StateLabel> {
    // CONFLICTING takes priority (merge would fail)
    if mergeable == Some("CONFLICTING") {
        return Some(StateLabel {
            label: "Conflicts".to_string(),
            css_class: "conflicting".to_string(),
        });
    }

    match merge_state_status {
        Some("CLEAN") => Some(StateLabel {
            label: "Ready to merge".to_string(),
            css_class: "clean".to_string(),
        }),
        Some("BEHIND") => Some(StateLabel {
            label: "Behind base".to_string(),
            css_class: "behind".to_string(),
        }),
        Some("BLOCKED") => Some(StateLabel {
            label: "Blocked".to_string(),
            css_class: "blocked".to_string(),
        }),
        Some("UNSTABLE") => Some(StateLabel {
            label: "Unstable".to_string(),
            css_class: "blocked".to_string(),
        }),
        Some("DRAFT") => Some(StateLabel {
            label: "Draft".to_string(),
            css_class: "behind".to_string(),
        }),
        Some("DIRTY") => Some(StateLabel {
            label: "Conflicts".to_string(),
            css_class: "conflicting".to_string(),
        }),
        _ => None, // UNKNOWN, HAS_HOOKS — don't show
    }
}

/// Classify review decision into display label
pub(crate) fn classify_review_state(review_decision: Option<&str>) -> Option<StateLabel> {
    match review_decision {
        Some("APPROVED") => Some(StateLabel {
            label: "Approved".to_string(),
            css_class: "approved".to_string(),
        }),
        Some("CHANGES_REQUESTED") => Some(StateLabel {
            label: "Changes requested".to_string(),
            css_class: "changes-requested".to_string(),
        }),
        Some("REVIEW_REQUIRED") => Some(StateLabel {
            label: "Review required".to_string(),
            css_class: "review-required".to_string(),
        }),
        _ => None,
    }
}

/// Parse r/g/b from a 6-char hex color string, returning (0,0,0) for invalid input
fn parse_hex_rgb(hex: &str) -> (u8, u8, u8) {
    if hex.len() < 6 {
        return (0, 0, 0);
    }
    let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
    let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
    let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
    (r, g, b)
}

/// Opacity used for GitHub label backgrounds in PR and issue display
const LABEL_BG_OPACITY: f64 = 0.7;

/// Convert a 6-char hex color to an rgba() CSS string with the given alpha
pub(crate) fn hex_to_rgba(hex: &str, alpha: f64) -> String {
    let (r, g, b) = parse_hex_rgb(hex);
    format!("rgba({r}, {g}, {b}, {alpha})")
}

/// Determine if a hex color is light (needs dark text) using BT.601 luma
pub(crate) fn is_light_color(hex: &str) -> bool {
    let (r, g, b) = parse_hex_rgb(hex);
    let (r, g, b) = (r as u32, g as u32, b as u32);
    (r * 299 + g * 587 + b * 114) / 1000 > 128
}

/// PR label with name, hex color, and pre-computed display colors
#[derive(Clone, Debug, Serialize)]
pub(crate) struct PrLabel {
    name: String,
    color: String,
    text_color: String,
    background_color: String,
}

/// PR status for a branch, returned by batch endpoint
#[derive(Clone, Debug, Serialize)]
pub(crate) struct BranchPrStatus {
    pub(crate) branch: String,
    pub(crate) number: i32,
    pub(crate) title: String,
    pub(crate) state: String,
    pub(crate) url: String,
    pub(crate) additions: i32,
    pub(crate) deletions: i32,
    pub(crate) checks: CheckSummary,
    pub(crate) check_details: Vec<CheckDetail>,
    pub(crate) author: String,
    pub(crate) commits: i32,
    pub(crate) mergeable: String,
    pub(crate) merge_state_status: String,
    pub(crate) review_decision: String,
    pub(crate) labels: Vec<PrLabel>,
    pub(crate) is_draft: bool,
    pub(crate) base_ref_name: String,
    pub(crate) head_ref_oid: String,
    pub(crate) created_at: String,
    pub(crate) updated_at: String,
    pub(crate) merge_state_label: Option<StateLabel>,
    pub(crate) review_state_label: Option<StateLabel>,
    /// Repo-level: merge commits allowed
    pub(crate) merge_commit_allowed: bool,
    /// Repo-level: squash merge allowed
    pub(crate) squash_merge_allowed: bool,
    /// Repo-level: rebase merge allowed
    pub(crate) rebase_merge_allowed: bool,
}

/// Shared logic for extracting fields from a single PR node.
fn parse_pr_node(v: &serde_json::Value) -> Option<BranchPrStatus> {
    let branch = v["headRefName"].as_str()?.to_string();
    let number = v["number"].as_i64()? as i32;
    let title = v["title"].as_str().unwrap_or("").to_string();
    let state = v["state"].as_str().unwrap_or("").to_string();
    let url = v["url"].as_str().unwrap_or("").to_string();
    let additions = v["additions"].as_i64().unwrap_or(0) as i32;
    let deletions = v["deletions"].as_i64().unwrap_or(0) as i32;
    let author = v["author"]["login"].as_str().unwrap_or("").to_string();
    let commits = v["commits"]["totalCount"].as_i64().unwrap_or(0) as i32;

    // Parse CI check summary from GraphQL statusCheckRollup
    let rollup_contexts = &v["commits"]["nodes"][0]["commit"]["statusCheckRollup"]["contexts"];
    let mut passed: u32 = 0;
    let mut failed: u32 = 0;
    let mut pending: u32 = 0;

    // checkRunCountsByState: [{state: "SUCCESS", count: 5}, ...]
    if let Some(counts) = rollup_contexts["checkRunCountsByState"].as_array() {
        for entry in counts {
            let count = entry["count"].as_u64().unwrap_or(0) as u32;
            match entry["state"].as_str().unwrap_or("") {
                "SUCCESS" | "NEUTRAL" | "SKIPPED" => passed += count,
                "FAILURE" | "ERROR" | "TIMED_OUT" | "CANCELLED" | "STARTUP_FAILURE" => {
                    failed += count
                }
                "ACTION_REQUIRED" | "STALE" | "QUEUED" | "IN_PROGRESS" | "WAITING" | "PENDING" => {
                    pending += count
                }
                _ => pending += count,
            }
        }
    }
    // statusContextCountsByState: same shape for commit statuses
    if let Some(counts) = rollup_contexts["statusContextCountsByState"].as_array() {
        for entry in counts {
            let count = entry["count"].as_u64().unwrap_or(0) as u32;
            match entry["state"].as_str().unwrap_or("") {
                "SUCCESS" => passed += count,
                "FAILURE" | "ERROR" => failed += count,
                _ => pending += count,
            }
        }
    }

    let total = passed + failed + pending;

    let mergeable = v["mergeable"].as_str().unwrap_or("UNKNOWN").to_string();
    let merge_state_status = v["mergeStateStatus"]
        .as_str()
        .unwrap_or("UNKNOWN")
        .to_string();
    let review_decision = v["reviewDecision"].as_str().unwrap_or("").to_string();
    let is_draft = v["isDraft"].as_bool().unwrap_or(false);

    let labels = v["labels"]["nodes"]
        .as_array()
        .map(|arr| {
            arr.iter()
                .filter_map(|l| {
                    let color = l["color"].as_str().unwrap_or("").to_string();
                    let (text_color, background_color) = if color.len() == 6 {
                        let text = if is_light_color(&color) {
                            "#1e1e1e"
                        } else {
                            "#e5e5e5"
                        };
                        (text.to_string(), hex_to_rgba(&color, LABEL_BG_OPACITY))
                    } else {
                        (String::new(), String::new())
                    };
                    Some(PrLabel {
                        name: l["name"].as_str()?.to_string(),
                        color,
                        text_color,
                        background_color,
                    })
                })
                .collect()
        })
        .unwrap_or_default();

    let base_ref_name = v["baseRefName"].as_str().unwrap_or("").to_string();
    let head_ref_oid = v["headRefOid"].as_str().unwrap_or("").to_string();
    let created_at = v["createdAt"].as_str().unwrap_or("").to_string();
    let updated_at = v["updatedAt"].as_str().unwrap_or("").to_string();

    let merge_state_label =
        classify_merge_state(Some(mergeable.as_str()), Some(merge_state_status.as_str()));
    let review_state_label = classify_review_state(if review_decision.is_empty() {
        None
    } else {
        Some(review_decision.as_str())
    });

    Some(BranchPrStatus {
        branch,
        number,
        title,
        state,
        url,
        additions,
        deletions,
        checks: CheckSummary {
            passed,
            failed,
            pending,
            total,
        },
        check_details: vec![], // Populated on-demand via per-PR query
        author,
        commits,
        mergeable,
        merge_state_status,
        review_decision,
        labels,
        is_draft,
        base_ref_name,
        head_ref_oid,
        created_at,
        updated_at,
        merge_state_label,
        review_state_label,
        // Defaults — stamped with real values from repo-level response after parsing
        merge_commit_allowed: true,
        squash_merge_allowed: true,
        rebase_merge_allowed: true,
    })
}

/// Stamp merge policy from a GraphQL repository object onto parsed PR nodes.
fn stamp_merge_policy(nodes: &mut [BranchPrStatus], repo_json: &serde_json::Value) {
    let merge = repo_json["mergeCommitAllowed"].as_bool().unwrap_or(true);
    let squash = repo_json["squashMergeAllowed"].as_bool().unwrap_or(true);
    let rebase = repo_json["rebaseMergeAllowed"].as_bool().unwrap_or(true);
    for pr in nodes.iter_mut() {
        pr.merge_commit_allowed = merge;
        pr.squash_merge_allowed = squash;
        pr.rebase_merge_allowed = rebase;
    }
}

/// Fetch the authenticated user's GitHub login via `query { viewer { login } }`.
/// Cached after first successful call for the session lifetime.
pub(crate) async fn get_viewer_login(state: &AppState) -> Result<String, String> {
    // Check cached value first
    if let Some(login) = state.github_viewer_login.read().as_ref() {
        return Ok(login.clone());
    }
    let response =
        graphql_with_retry(state, "query { viewer { login } }", serde_json::Value::Null).await?;
    let login = response["data"]["viewer"]["login"]
        .as_str()
        .ok_or_else(|| "Could not resolve viewer login".to_string())?
        .to_string();
    *state.github_viewer_login.write() = Some(login.clone());
    Ok(login)
}

// ── GitHub Issues ────────────────────────────────────────────────────────────

/// GitHub Issue status, analogous to BranchPrStatus for PRs.
#[derive(Clone, Debug, Serialize)]
pub(crate) struct GitHubIssue {
    pub(crate) number: i32,
    pub(crate) title: String,
    pub(crate) state: String, // OPEN, CLOSED
    pub(crate) url: String,
    pub(crate) author: String,
    pub(crate) labels: Vec<PrLabel>, // Reuse PrLabel — same GitHub schema
    pub(crate) assignees: Vec<String>,
    pub(crate) milestone: Option<String>,
    pub(crate) comments_count: i32,
    pub(crate) created_at: String,
    pub(crate) updated_at: String,
}

/// Parse a single issue node from GraphQL JSON.
fn parse_issue_node(v: &serde_json::Value) -> Option<GitHubIssue> {
    let number = v["number"].as_i64()? as i32;
    let title = v["title"].as_str().unwrap_or("").to_string();
    let state = v["state"].as_str().unwrap_or("").to_string();
    let url = v["url"].as_str().unwrap_or("").to_string();
    let author = v["author"]["login"].as_str().unwrap_or("").to_string();

    let labels = v["labels"]["nodes"]
        .as_array()
        .map(|arr| {
            arr.iter()
                .filter_map(|l| {
                    let color = l["color"].as_str().unwrap_or("").to_string();
                    let (text_color, background_color) = if color.len() == 6 {
                        let text = if is_light_color(&color) {
                            "#1e1e1e"
                        } else {
                            "#e5e5e5"
                        };
                        (text.to_string(), hex_to_rgba(&color, LABEL_BG_OPACITY))
                    } else {
                        (String::new(), String::new())
                    };
                    Some(PrLabel {
                        name: l["name"].as_str()?.to_string(),
                        color,
                        text_color,
                        background_color,
                    })
                })
                .collect()
        })
        .unwrap_or_default();

    let assignees = v["assignees"]["nodes"]
        .as_array()
        .map(|arr| {
            arr.iter()
                .filter_map(|a| a["login"].as_str().map(String::from))
                .collect()
        })
        .unwrap_or_default();

    let milestone = v["milestone"]["title"].as_str().map(String::from);
    let comments_count = v["comments"]["totalCount"].as_i64().unwrap_or(0) as i32;
    let created_at = v["createdAt"].as_str().unwrap_or("").to_string();
    let updated_at = v["updatedAt"].as_str().unwrap_or("").to_string();

    Some(GitHubIssue {
        number,
        title,
        state,
        url,
        author,
        labels,
        assignees,
        milestone,
        comments_count,
        created_at,
        updated_at,
    })
}

/// Build `filterBy` clause for `repository().issues()` based on filter mode.
fn issues_filter_clause(filter_mode: &str, viewer: &str) -> String {
    match filter_mode {
        "assigned" => format!(", filterBy: {{ assignee: \"{viewer}\" }}"),
        "created" => format!(", filterBy: {{ createdBy: \"{viewer}\" }}"),
        "mentioned" => format!(", filterBy: {{ mentioned: \"{viewer}\" }}"),
        _ => String::new(), // "all" — no user filter
    }
}

/// The issues sub-selection for embedding inside a repository alias.
fn issues_repo_section(filter_mode: &str, viewer: &str) -> String {
    let filter = issues_filter_clause(filter_mode, viewer);
    let node_fields = r#"number title state url createdAt updatedAt
        author { login }
        labels(first: 10) { nodes { name color } }
        assignees(first: 5) { nodes { login } }
        milestone { title }
        comments { totalCount }"#;
    format!(
        "    issues(first: 30, states: [OPEN]{filter}, orderBy: {{field: UPDATED_AT, direction: DESC}}) {{\n      nodes {{ {node_fields} }}\n    }}"
    )
}

/// Build a batched GraphQL query for issues only (on-demand Tauri command path).
/// Uses `repository().issues()` — cheaper than `search()` in GraphQL points.
fn build_multi_repo_issues_query(
    repos: &[(String, String, String)],
    viewer: &str,
    filter_mode: &str,
) -> (String, Vec<(String, String)>) {
    let mut aliases: Vec<(String, String)> = Vec::new();
    let mut parts = vec!["query BatchRepoIssues {".to_string()];

    for (i, (path, owner, name)) in repos.iter().enumerate() {
        let alias = format!("r{i}");
        parts.push(format!(
            "  {alias}: repository(owner: \"{owner}\", name: \"{name}\") {{\n{}\n  }}",
            issues_repo_section(filter_mode, viewer),
        ));
        aliases.push((alias, path.clone()));
    }
    parts.push("  rateLimit { cost remaining resetAt }".to_string());
    parts.push("}".to_string());

    (parts.join("\n"), aliases)
}

/// Fetch issues for all repos in a single batched GraphQL call.
pub(crate) async fn get_all_issues_impl(
    paths: &[String],
    filter_mode: &str,
    state: &AppState,
) -> Result<std::collections::HashMap<String, Vec<GitHubIssue>>, String> {
    if state.github_token.read().is_none() {
        return Ok(std::collections::HashMap::new());
    }

    let now = Instant::now();
    state
        .git_cache
        .github_repo_cooldown
        .retain(|_key, expiry| *expiry > now);

    let repos: Vec<(String, String, String)> = paths
        .iter()
        .filter_map(|path| {
            let repo_path = PathBuf::from(path);
            let url = get_github_remote_url(&repo_path)?;
            let (owner, name) = parse_remote_url(&url)?;
            let cooldown_key = format!("{owner}/{name}");
            if state
                .git_cache
                .github_repo_cooldown
                .contains_key(&cooldown_key)
            {
                return None;
            }
            Some((path.clone(), owner, name))
        })
        .collect();

    if repos.is_empty() {
        return Ok(std::collections::HashMap::new());
    }

    let viewer = if !matches!(filter_mode, "all") {
        get_viewer_login(state).await.unwrap_or_default()
    } else {
        String::new()
    };
    let (query, aliases) = build_multi_repo_issues_query(&repos, &viewer, filter_mode);

    let response = graphql_with_retry(state, &query, serde_json::Value::Null).await?;

    let mut results = std::collections::HashMap::new();
    for (alias, path) in &aliases {
        // Response is now repository.issues.nodes, not search.nodes
        let nodes = match response["data"][alias]["issues"]["nodes"].as_array() {
            Some(arr) => arr,
            None => continue,
        };
        let issues: Vec<GitHubIssue> = nodes.iter().filter_map(parse_issue_node).collect();
        results.insert(path.clone(), issues);
    }
    Ok(results)
}

// ── Unified batch query (PRs + Issues in one HTTP call) ──────────────────────

/// Build a batched GraphQL query fetching PRs and (optionally) Issues for all
/// repos in a single HTTP request.  When `filter_mode` is "disabled" the issues
/// section is omitted entirely, saving GraphQL points.
fn build_unified_batch_query(
    repos: &[(String, String, String)],
    include_merged: bool,
    filter_mode: &str,
    viewer: &str,
    hide_drafts: bool,
) -> (String, Vec<(String, String)>) {
    let states = if include_merged {
        "[OPEN, MERGED]"
    } else {
        "[OPEN]"
    };
    // Fetch more items when drafts are hidden so filtering leaves enough valid PRs.
    let pr_first = if hide_drafts { 40 } else { 20 };
    let pr_node_fields = r#"number title state url headRefName headRefOid baseRefName isDraft
        additions deletions mergeable mergeStateStatus reviewDecision
        createdAt updatedAt
        author { login }
        labels(first: 10) { nodes { name color } }
        commits(last: 1) {
          totalCount
          nodes {
            commit {
              statusCheckRollup {
                contexts {
                  checkRunCountsByState { state count }
                  statusContextCountsByState { state count }
                }
              }
            }
          }
        }"#;

    let include_issues = !matches!(filter_mode, "" | "disabled");

    let mut aliases: Vec<(String, String)> = Vec::new();
    let mut parts = vec!["query BatchPoll {".to_string()];

    for (i, (path, owner, name)) in repos.iter().enumerate() {
        let alias = format!("r{i}");
        let issues_section = if include_issues {
            format!("\n{}", issues_repo_section(filter_mode, viewer))
        } else {
            String::new()
        };
        parts.push(format!(
            "  {alias}: repository(owner: \"{owner}\", name: \"{name}\") {{\n    mergeCommitAllowed\n    squashMergeAllowed\n    rebaseMergeAllowed\n    pullRequests(first: {pr_first}, states: {states}, orderBy: {{field: UPDATED_AT, direction: DESC}}) {{\n      nodes {{ {pr_node_fields} }}\n    }}{issues_section}\n  }}"
        ));
        aliases.push((alias, path.clone()));
    }

    // Supplemental search for viewer's own open PRs across all queried repos.
    // Guarantees the current user's PRs appear even if outside the top-20 by activity.
    if !viewer.is_empty() {
        let repo_filters: String = repos
            .iter()
            .map(|(_, owner, name)| format!("repo:{owner}/{name}"))
            .collect::<Vec<_>>()
            .join(" ");
        let draft_filter = if hide_drafts { " -is:draft" } else { "" };
        let search_query = format!("is:pr is:open author:{viewer}{draft_filter} {repo_filters}");
        parts.push(format!(
            "  viewerPrs: search(query: \"{search_query}\", type: ISSUE, first: 30) {{\n    nodes {{\n      ... on PullRequest {{\n        repository {{ nameWithOwner }}\n        {pr_node_fields}\n      }}\n    }}\n  }}"
        ));
    }

    parts.push("  rateLimit { cost remaining resetAt }".to_string());
    parts.push("}".to_string());

    (parts.join("\n"), aliases)
}

/// Result of a unified batch poll.
pub(crate) struct BatchPollResult {
    pub(crate) prs: std::collections::HashMap<String, Vec<BranchPrStatus>>,
    /// Non-empty only when filter_mode != "disabled".
    pub(crate) issues: std::collections::HashMap<String, Vec<GitHubIssue>>,
}

/// Fetch PRs + Issues for all repos in a single batched GraphQL call.
/// Writes the remaining rate-limit budget to `state` for proactive throttling.
pub(crate) async fn get_all_batch_impl(
    paths: &[String],
    include_merged: bool,
    filter_mode: &str,
    pr_hide_drafts: bool,
    state: &AppState,
) -> Result<BatchPollResult, String> {
    if state.github_token.read().is_none() {
        return Ok(BatchPollResult {
            prs: Default::default(),
            issues: Default::default(),
        });
    }

    let now = Instant::now();
    state
        .git_cache
        .github_repo_cooldown
        .retain(|_key, expiry| *expiry > now);

    let repos: Vec<(String, String, String)> = paths
        .iter()
        .filter_map(|path| {
            let repo_path = PathBuf::from(path);
            let url = get_github_remote_url(&repo_path)?;
            let (owner, name) = parse_remote_url(&url)?;
            let cooldown_key = format!("{owner}/{name}");
            if state
                .git_cache
                .github_repo_cooldown
                .contains_key(&cooldown_key)
            {
                return None;
            }
            Some((path.clone(), owner, name))
        })
        .collect();

    if repos.is_empty() {
        return Ok(BatchPollResult {
            prs: Default::default(),
            issues: Default::default(),
        });
    }

    let include_issues = !matches!(filter_mode, "" | "disabled");
    // Always fetch viewer login — needed for both issue filtering and viewer PR search.
    let viewer = get_viewer_login(state).await.unwrap_or_default();

    let (query, aliases) =
        build_unified_batch_query(&repos, include_merged, filter_mode, &viewer, pr_hide_drafts);
    let response = graphql_with_retry(state, &query, serde_json::Value::Null).await?;

    // Store rate-limit budget for proactive throttling in the poller
    if let Some(remaining) = response["data"]["rateLimit"]["remaining"].as_u64() {
        state
            .github_rate_limit_remaining
            .store(remaining as u32, std::sync::atomic::Ordering::Relaxed);
    }

    let alias_repo_names: std::collections::HashMap<&str, (&str, &str)> = repos
        .iter()
        .enumerate()
        .map(|(i, (_path, owner, name))| (aliases[i].0.as_str(), (owner.as_str(), name.as_str())))
        .collect();

    let mut pr_results: std::collections::HashMap<String, Vec<BranchPrStatus>> = Default::default();
    let mut issue_results: std::collections::HashMap<String, Vec<GitHubIssue>> = Default::default();

    for (alias, path) in &aliases {
        let repo_json = &response["data"][alias];

        if repo_json.is_null() {
            if let Some((owner, name)) = alias_repo_names.get(alias.as_str()) {
                let cooldown_key = format!("{owner}/{name}");
                let was_known = state
                    .git_cache
                    .github_repo_cooldown
                    .contains_key(&cooldown_key);
                let expiry = Instant::now() + std::time::Duration::from_secs(24 * 3600);
                state
                    .git_cache
                    .github_repo_cooldown
                    .insert(cooldown_key, expiry);
                if !was_known {
                    let msg =
                        format!("Repository {owner}/{name} not found on GitHub — cooldown 24h");
                    let mut buf = state.log_buffer.lock();
                    buf.push("warn".into(), "github".into(), msg, None);
                }
            }
            continue;
        }

        if let Some(nodes) = repo_json["pullRequests"]["nodes"].as_array() {
            let mut statuses: Vec<BranchPrStatus> =
                nodes.iter().filter_map(parse_pr_node).collect();
            stamp_merge_policy(&mut statuses, repo_json);

            if include_merged && statuses.iter().any(|s| s.state == "MERGED") {
                let branch_tips = local_branch_tips(Path::new(path));
                statuses.retain(|s| {
                    if s.state != "MERGED" || s.head_ref_oid.is_empty() {
                        return true;
                    }
                    match branch_tips.get(&s.branch) {
                        Some(tip) => tip == &s.head_ref_oid,
                        None => true,
                    }
                });
            }

            state
                .git_cache
                .github_status
                .insert(path.clone(), Arc::new(statuses.clone()));
            pr_results.insert(path.clone(), statuses);
        }

        if include_issues && let Some(nodes) = repo_json["issues"]["nodes"].as_array() {
            let issues: Vec<GitHubIssue> = nodes.iter().filter_map(parse_issue_node).collect();
            issue_results.insert(path.clone(), issues);
        }
    }

    // Merge viewer's own PRs (from supplemental search) into pr_results.
    // These may be outside the top-20 by activity so would otherwise be missing.
    if !viewer.is_empty() {
        // Multiple local paths can map to the same owner/name (e.g. two worktrees
        // of the same repo imported as separate workspace entries).
        let mut name_to_paths: std::collections::HashMap<String, Vec<&str>> =
            std::collections::HashMap::new();
        for (path, owner, name) in &repos {
            name_to_paths
                .entry(format!("{owner}/{name}"))
                .or_default()
                .push(path.as_str());
        }

        if let Some(nodes) = response["data"]["viewerPrs"]["nodes"].as_array() {
            for node in nodes {
                let repo_name = node["repository"]["nameWithOwner"].as_str().unwrap_or("");
                let Some(paths) = name_to_paths.get(repo_name) else {
                    continue;
                };
                let Some(pr) = parse_pr_node(node) else {
                    continue;
                };
                for path in paths {
                    let entry = pr_results.entry(path.to_string()).or_default();
                    if !entry.iter().any(|existing| existing.branch == pr.branch) {
                        entry.push(pr.clone());
                    }
                }
            }
        }
    }

    Ok(BatchPollResult {
        prs: pr_results,
        issues: issue_results,
    })
}

/// Fetch issues for multiple repos (Tauri command).
#[cfg(feature = "desktop")]
#[tauri::command]
pub(crate) async fn get_all_issues(
    state: State<'_, Arc<AppState>>,
    paths: Vec<String>,
    filter_mode: String,
) -> Result<std::collections::HashMap<String, Vec<GitHubIssue>>, String> {
    let state = state.inner().clone();
    get_all_issues_impl(&paths, &filter_mode, &state).await
}

/// Close a GitHub issue via REST API.
pub(crate) async fn close_issue_impl(
    repo_path: &str,
    issue_number: i64,
    state: &AppState,
) -> Result<(), String> {
    let token = state
        .github_token
        .read()
        .clone()
        .ok_or_else(|| "No GitHub token available".to_string())?;
    let remote_url = get_github_remote_url(std::path::Path::new(repo_path))
        .ok_or_else(|| "No GitHub remote URL found".to_string())?;
    let (owner, repo) = parse_remote_url(&remote_url)
        .ok_or_else(|| format!("Failed to parse remote URL: {remote_url}"))?;

    let url = format!("https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}");
    crate::github_debug::log_api("PATCH", &url, "close_issue_impl");
    let body = serde_json::json!({ "state": "closed" });

    let response = state
        .http_client
        .patch(&url)
        .header("Authorization", format!("Bearer {token}"))
        .header("User-Agent", "tuicommander")
        .header("Accept", "application/vnd.github+json")
        .json(&body)
        .send()
        .await
        .map_err(|e| format!("GitHub API request failed: {e}"))?;

    let status = response.status().as_u16();
    if (200..300).contains(&status) {
        Ok(())
    } else {
        let json: serde_json::Value = response.json().await.unwrap_or_default();
        let msg = json["message"].as_str().unwrap_or("Unknown error");
        Err(format!("Failed to close issue ({status}): {msg}"))
    }
}

/// Close a GitHub issue (Tauri command).
#[cfg(feature = "desktop")]
#[tauri::command]
pub(crate) async fn close_issue(
    repo_path: String,
    issue_number: i64,
    state: State<'_, Arc<AppState>>,
) -> Result<(), String> {
    let state = state.inner().clone();
    close_issue_impl(&repo_path, issue_number, &state).await
}

/// Reopen a GitHub issue via REST API.
pub(crate) async fn reopen_issue_impl(
    repo_path: &str,
    issue_number: i64,
    state: &AppState,
) -> Result<(), String> {
    let token = state
        .github_token
        .read()
        .clone()
        .ok_or_else(|| "No GitHub token available".to_string())?;
    let remote_url = get_github_remote_url(std::path::Path::new(repo_path))
        .ok_or_else(|| "No GitHub remote URL found".to_string())?;
    let (owner, repo) = parse_remote_url(&remote_url)
        .ok_or_else(|| format!("Failed to parse remote URL: {remote_url}"))?;

    let url = format!("https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}");
    crate::github_debug::log_api("PATCH", &url, "reopen_issue_impl");
    let body = serde_json::json!({ "state": "open" });

    let response = state
        .http_client
        .patch(&url)
        .header("Authorization", format!("Bearer {token}"))
        .header("User-Agent", "tuicommander")
        .header("Accept", "application/vnd.github+json")
        .json(&body)
        .send()
        .await
        .map_err(|e| format!("GitHub API request failed: {e}"))?;

    let status = response.status().as_u16();
    if (200..300).contains(&status) {
        Ok(())
    } else {
        let json: serde_json::Value = response.json().await.unwrap_or_default();
        let msg = json["message"].as_str().unwrap_or("Unknown error");
        Err(format!("Failed to reopen issue ({status}): {msg}"))
    }
}

/// Reopen a GitHub issue (Tauri command).
#[cfg(feature = "desktop")]
#[tauri::command]
pub(crate) async fn reopen_issue(
    repo_path: String,
    issue_number: i64,
    state: State<'_, Arc<AppState>>,
) -> Result<(), String> {
    let state = state.inner().clone();
    reopen_issue_impl(&repo_path, issue_number, &state).await
}

// ── End GitHub Issues ────────────────────────────────────────────────────────

/// Parse a GraphQL batch PR response into BranchPrStatus entries.
/// Input: full GraphQL response JSON (with data.repository.pullRequests.nodes).
#[cfg(test)]
pub(crate) fn parse_graphql_prs(response: &serde_json::Value) -> Vec<BranchPrStatus> {
    let nodes = match response["data"]["repository"]["pullRequests"]["nodes"].as_array() {
        Some(arr) => arr,
        None => return vec![],
    };

    nodes.iter().filter_map(parse_pr_node).collect()
}

#[cfg(test)]
const BATCH_PR_QUERY: &str = r#"
query RepoPRs($owner: String!, $repo: String!, $first: Int!) {
  repository(owner: $owner, name: $repo) {
    pullRequests(first: $first, states: [OPEN, CLOSED, MERGED],
                 orderBy: {field: UPDATED_AT, direction: DESC}) {
      nodes {
        number title state url headRefName baseRefName isDraft
        additions deletions mergeable mergeStateStatus reviewDecision
        createdAt updatedAt
        author { login }
        labels(first: 10) { nodes { name color } }
        commits(last: 1) {
          totalCount
          nodes {
            commit {
              statusCheckRollup {
                contexts {
                  checkRunCountsByState { state count }
                  statusContextCountsByState { state count }
                }
              }
            }
          }
        }
      }
    }
  }
  rateLimit { cost remaining resetAt }
}
"#;

/// Get the remote URL for a repo, if it has a GitHub origin.
/// Reads directly from .git/config (no subprocess).
fn get_github_remote_url(repo_path: &Path) -> Option<String> {
    let url = crate::git::read_remote_url(repo_path)?;
    if url.contains("github.com") {
        Some(url)
    } else {
        None
    }
}

/// Core logic for fetching PR statuses via GitHub GraphQL API (no caching).
/// Returns Err for rate limits (prefixed with "rate-limit:") so callers can handle them.
pub(crate) async fn get_repo_pr_statuses_impl(
    path: &str,
    include_merged: bool,
    state: &AppState,
) -> Result<Vec<BranchPrStatus>, String> {
    let repo_path = PathBuf::from(path);

    if state.github_token.read().is_none() {
        return Ok(vec![]); // No token = no GitHub API access
    }

    let remote_url = match get_github_remote_url(&repo_path) {
        Some(url) => url,
        None => return Ok(vec![]),
    };

    let (owner, repo) = match parse_remote_url(&remote_url) {
        Some(pair) => pair,
        None => return Ok(vec![]),
    };

    // Reuse the multi-repo query builder for consistent state filtering
    let repos = vec![(path.to_string(), owner, repo)];
    let (query, aliases) = build_multi_repo_pr_query(&repos, include_merged);

    match graphql_with_retry(state, &query, serde_json::Value::Null).await {
        Ok(response) => {
            let alias = &aliases[0].0;
            let repo_json = &response["data"][alias];
            let mut nodes: Vec<BranchPrStatus> = repo_json["pullRequests"]["nodes"]
                .as_array()
                .map(|arr| arr.iter().filter_map(parse_pr_node).collect())
                .unwrap_or_default();

            stamp_merge_policy(&mut nodes, repo_json);

            // Filter stale merged PRs (same logic as batch endpoint)
            if include_merged && nodes.iter().any(|s| s.state == "MERGED") {
                let branch_tips = local_branch_tips(&repo_path);
                nodes.retain(|s| {
                    if s.state != "MERGED" || s.head_ref_oid.is_empty() {
                        return true;
                    }
                    match branch_tips.get(&s.branch) {
                        Some(tip) => tip == &s.head_ref_oid,
                        None => true,
                    }
                });
            }

            Ok(nodes)
        }
        Err(e) if e.starts_with("rate-limit:") => Err(e),
        Err(e) => {
            tracing::warn!(source = "github", %path, "GraphQL PR query failed: {e}");
            Ok(vec![])
        }
    }
}

/// Get PR statuses for a repository (cached, 30s TTL).
#[cfg(feature = "desktop")]
#[tauri::command]
pub(crate) async fn get_repo_pr_statuses(
    state: State<'_, Arc<AppState>>,
    path: String,
    include_merged: Option<bool>,
) -> Result<Vec<BranchPrStatus>, String> {
    let include_merged = include_merged.unwrap_or(false);
    let state = state.inner().clone();
    // Skip cache when include_merged is true (startup poll only). The loader is
    // async (network GraphQL), so this cache uses plain get/insert (TTL + bound)
    // rather than coalescing get_with.
    if !include_merged && let Some(cached) = state.git_cache.github_status.get(&path) {
        return Ok((*cached).clone());
    }

    let statuses = get_repo_pr_statuses_impl(&path, include_merged, &state).await?;
    state
        .git_cache
        .github_status
        .insert(path.clone(), Arc::new(statuses.clone()));
    Ok(statuses)
}

/// Read local branch tips (name → commit SHA) via `git for-each-ref`.
/// Returns an empty map on any error (no git, not a repo, etc.).
fn local_branch_tips(repo_path: &Path) -> std::collections::HashMap<String, String> {
    let mut tips = std::collections::HashMap::new();
    let output = crate::git_cli::git_cmd(repo_path)
        .args([
            "for-each-ref",
            "--format=%(refname:short)\t%(objectname)",
            "refs/heads/",
        ])
        .run_silent();
    if let Some(out) = output {
        for line in out.stdout.lines() {
            if let Some((name, sha)) = line.split_once('\t') {
                tips.insert(name.to_string(), sha.to_string());
            }
        }
    }
    tips
}

/// Build a single aliased GraphQL query that fetches PRs for multiple repos in one HTTP call.
/// Each repo gets an alias `r{i}` to avoid field name collisions.
/// Returns (query_string, Vec<(alias, repo_path)>) for result extraction.
fn build_multi_repo_pr_query(
    repos: &[(String, String, String)], // Vec<(path, owner, name)>
    include_merged: bool,
) -> (String, Vec<(String, String)>) {
    let states = if include_merged {
        "[OPEN, MERGED]"
    } else {
        "[OPEN]"
    };
    let node_fields = r#"number title state url headRefName headRefOid baseRefName isDraft
        additions deletions mergeable mergeStateStatus reviewDecision
        createdAt updatedAt
        author { login }
        labels(first: 10) { nodes { name color } }
        commits(last: 1) {
          totalCount
          nodes {
            commit {
              statusCheckRollup {
                contexts {
                  checkRunCountsByState { state count }
                  statusContextCountsByState { state count }
                }
              }
            }
          }
        }"#;

    let mut aliases: Vec<(String, String)> = Vec::new();
    let mut parts = vec!["query BatchRepoPRs {".to_string()];

    for (i, (path, owner, name)) in repos.iter().enumerate() {
        let alias = format!("r{i}");
        parts.push(format!(
            "  {alias}: repository(owner: \"{owner}\", name: \"{name}\") {{\n    mergeCommitAllowed\n    squashMergeAllowed\n    rebaseMergeAllowed\n    pullRequests(first: 20, states: {states}, orderBy: {{field: UPDATED_AT, direction: DESC}}) {{\n      nodes {{ {node_fields} }}\n    }}\n  }}"
        ));
        aliases.push((alias, path.clone()));
    }
    parts.push("  rateLimit { cost remaining resetAt }".to_string());
    parts.push("}".to_string());

    (parts.join("\n"), aliases)
}

/// Fetch PR statuses for all repos in a single batched GraphQL call.
/// Delegates to `get_all_batch_impl` with issues disabled (PR-only path for Tauri command).
pub(crate) async fn get_all_pr_statuses_impl(
    paths: &[String],
    include_merged: bool,
    state: &AppState,
) -> Result<std::collections::HashMap<String, Vec<BranchPrStatus>>, String> {
    let result = get_all_batch_impl(paths, include_merged, "disabled", false, state).await?;
    Ok(result.prs)
}

/// Fetch PR statuses for all repos in a single batched GraphQL call.
/// On failure, the frontend should retry with per-repo individual calls.
/// `include_merged` is true for the startup poll to detect offline transitions.
#[cfg(feature = "desktop")]
#[tauri::command]
pub(crate) async fn get_all_pr_statuses(
    state: State<'_, Arc<AppState>>,
    paths: Vec<String>,
    include_merged: bool,
) -> Result<std::collections::HashMap<String, Vec<BranchPrStatus>>, String> {
    let state = state.inner().clone();
    get_all_pr_statuses_impl(&paths, include_merged, &state).await
}

/// Get git remote + branch status for a repository (implementation).
/// PR and CI data now comes from the batch githubStore (GraphQL),
/// so this only returns has_remote, current_branch, ahead, and behind.
pub(crate) fn get_github_status_impl(path: &str) -> GitHubStatus {
    let repo_path = PathBuf::from(path);

    let has_remote = get_github_remote_url(&repo_path).is_some();

    // Read current branch from .git/HEAD (no subprocess)
    let current_branch = crate::git::read_branch_from_head(&repo_path).unwrap_or_default();

    if !has_remote {
        return GitHubStatus {
            has_remote: false,
            current_branch,
            ahead: 0,
            behind: 0,
        };
    }

    // Get ahead/behind counts
    let rev_range = format!("origin/{current_branch}...HEAD");
    let (ahead, behind) = crate::git_cli::git_cmd(&repo_path)
        .args(["rev-list", "--left-right", "--count", &rev_range])
        .run_silent()
        .and_then(|o| {
            let parts: Vec<&str> = o.stdout.split_whitespace().collect();
            if parts.len() == 2 {
                let behind = parts[0].parse::<i32>().unwrap_or(0);
                let ahead = parts[1].parse::<i32>().unwrap_or(0);
                Some((ahead, behind))
            } else {
                None
            }
        })
        .unwrap_or((0, 0));

    GitHubStatus {
        has_remote,
        current_branch,
        ahead,
        behind,
    }
}

/// Cached github status for synchronous callers (MCP handlers, etc.).
pub(crate) fn get_github_status_cached(state: &AppState, path: &str) -> GitHubStatus {
    let p = path.to_string();
    (*state
        .git_cache
        .git_status
        .get_with(path.to_string(), || Arc::new(get_github_status_impl(&p))))
    .clone()
}

/// Tauri command wrapper — cached with GIT_CACHE_TTL to avoid spawning git subprocesses every poll.
#[cfg(feature = "desktop")]
#[tauri::command]
pub(crate) async fn get_github_status(
    path: String,
    state: State<'_, Arc<AppState>>,
) -> Result<GitHubStatus, String> {
    let state = state.inner().clone();
    tokio::task::spawn_blocking(move || {
        let p = path.clone();
        (*state
            .git_cache
            .git_status
            .get_with(path, || Arc::new(get_github_status_impl(&p))))
        .clone()
    })
    .await
    .map_err(|e| format!("Task failed: {e}"))
}

/// Get the authenticated GitHub viewer's login (username).
/// Cached after first successful call.
#[cfg(feature = "desktop")]
#[tauri::command]
pub(crate) async fn get_github_viewer_login(
    state: State<'_, Arc<AppState>>,
) -> Result<String, String> {
    let state = state.inner().clone();
    get_viewer_login(&state).await
}

const PR_CHECKS_QUERY: &str = r#"
query PRChecks($owner: String!, $repo: String!, $number: Int!) {
  repository(owner: $owner, name: $repo) {
    pullRequest(number: $number) {
      commits(last: 1) {
        nodes {
          commit {
            statusCheckRollup {
              contexts(first: 50) {
                nodes {
                  __typename
                  ... on CheckRun { name status conclusion detailsUrl }
                  ... on StatusContext { context state targetUrl }
                }
              }
            }
          }
        }
      }
    }
  }
}
"#;

/// Parse GraphQL PR check contexts into frontend-compatible CiCheckDetail objects.
fn parse_pr_check_contexts(data: &serde_json::Value) -> Vec<serde_json::Value> {
    let nodes = &data["data"]["repository"]["pullRequest"]["commits"]["nodes"];
    let contexts = match nodes.as_array().and_then(|a| a.first()) {
        Some(node) => &node["commit"]["statusCheckRollup"]["contexts"]["nodes"],
        None => return vec![],
    };

    let context_nodes = match contexts.as_array() {
        Some(arr) => arr,
        None => return vec![],
    };

    context_nodes
        .iter()
        .map(|ctx| {
            let typename = ctx["__typename"].as_str().unwrap_or("");
            if typename == "CheckRun" {
                serde_json::json!({
                    "name": ctx["name"].as_str().unwrap_or(""),
                    "status": ctx["status"].as_str().unwrap_or("").to_lowercase(),
                    "conclusion": ctx["conclusion"].as_str().unwrap_or("").to_lowercase(),
                    "html_url": ctx["detailsUrl"].as_str().unwrap_or(""),
                })
            } else {
                // StatusContext
                let state = ctx["state"].as_str().unwrap_or("").to_lowercase();
                let conclusion = match state.as_str() {
                    "success" => "success",
                    "failure" | "error" => "failure",
                    "pending" | "expected" => "",
                    _ => "",
                };
                serde_json::json!({
                    "name": ctx["context"].as_str().unwrap_or(""),
                    "status": if conclusion.is_empty() { "in_progress" } else { "completed" },
                    "conclusion": conclusion,
                    "html_url": ctx["targetUrl"].as_str().unwrap_or(""),
                })
            }
        })
        .collect()
}

/// Core logic for fetching CI check details via GitHub GraphQL API (no caching).
pub(crate) async fn get_ci_checks_impl(
    path: &str,
    pr_number: i64,
    state: &AppState,
) -> Vec<serde_json::Value> {
    let repo_path = PathBuf::from(path);

    if state.github_token.read().is_none() {
        return vec![];
    }

    let remote_url = match get_github_remote_url(&repo_path) {
        Some(url) => url,
        None => return vec![],
    };

    let (owner, repo) = match parse_remote_url(&remote_url) {
        Some(pair) => pair,
        None => return vec![],
    };

    let variables = serde_json::json!({
        "owner": owner,
        "repo": repo,
        "number": pr_number,
    });

    match graphql_with_retry(state, PR_CHECKS_QUERY, variables).await {
        Ok(data) => parse_pr_check_contexts(&data),
        Err(e) => {
            tracing::warn!(source = "github", "GraphQL PR checks query failed: {e}");
            vec![]
        }
    }
}

/// Merge a PR via GitHub REST API using the specified merge method.
pub(crate) async fn merge_pr_github_impl(
    repo_path: &str,
    pr_number: i64,
    merge_method: &str,
    state: &AppState,
) -> Result<String, String> {
    let token = state
        .github_token
        .read()
        .clone()
        .ok_or_else(|| "No GitHub token available".to_string())?;

    let remote_url = get_github_remote_url(std::path::Path::new(repo_path))
        .ok_or_else(|| "No GitHub remote URL found for this repository".to_string())?;

    let (owner, repo) = parse_remote_url(&remote_url)
        .ok_or_else(|| format!("Failed to parse GitHub remote URL: {remote_url}"))?;

    let url = format!("https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/merge");
    crate::github_debug::log_api("PUT", &url, "merge_pr_github_impl");
    let body = serde_json::json!({ "merge_method": merge_method });

    let response = state
        .http_client
        .put(&url)
        .header("Authorization", format!("Bearer {token}"))
        .header("User-Agent", "tuicommander")
        .header("Accept", "application/vnd.github+json")
        .json(&body)
        .send()
        .await
        .map_err(|e| format!("GitHub API request failed: {e}"))?;

    let status = response.status().as_u16();
    let json: serde_json::Value = response
        .json()
        .await
        .map_err(|e| format!("Failed to parse GitHub API response: {e}"))?;

    if (200..300).contains(&status) {
        Ok(json["sha"].as_str().unwrap_or("").to_string())
    } else {
        let msg = json["message"]
            .as_str()
            .unwrap_or("Unknown error")
            .to_string();
        Err(format!("GitHub merge failed ({status}): {msg}"))
    }
}

/// Merge a PR via GitHub REST API (Tauri command).
/// Supports merge_method: "merge", "squash", "rebase".
#[cfg(feature = "desktop")]
#[tauri::command]
pub(crate) async fn merge_pr_via_github(
    repo_path: String,
    pr_number: i64,
    merge_method: String,
    state: State<'_, Arc<AppState>>,
) -> Result<String, String> {
    let state = state.inner().clone();
    merge_pr_github_impl(&repo_path, pr_number, &merge_method, &state).await
}

/// Get CI check details for a PR via GitHub GraphQL API (Story 060).
#[cfg(feature = "desktop")]
#[tauri::command]
pub(crate) async fn get_ci_checks(
    path: String,
    pr_number: i64,
    state: State<'_, Arc<AppState>>,
) -> Result<Vec<serde_json::Value>, String> {
    let state = state.inner().clone();
    Ok(get_ci_checks_impl(&path, pr_number, &state).await)
}

/// Approve a PR via GitHub REST API.
/// Creates a review with event=APPROVE.
pub(crate) async fn approve_pr_impl(
    repo_path: &str,
    pr_number: i64,
    state: &AppState,
) -> Result<(), String> {
    let token = state
        .github_token
        .read()
        .clone()
        .ok_or_else(|| "No GitHub token available".to_string())?;

    let remote_url = get_github_remote_url(std::path::Path::new(repo_path))
        .ok_or_else(|| "No GitHub remote URL found for this repository".to_string())?;

    let (owner, repo) = parse_remote_url(&remote_url)
        .ok_or_else(|| format!("Failed to parse GitHub remote URL: {remote_url}"))?;

    let url = format!("https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/reviews");
    crate::github_debug::log_api("POST", &url, "approve_pr_impl");
    let body = serde_json::json!({ "event": "APPROVE" });

    let response = state
        .http_client
        .post(&url)
        .header("Authorization", format!("Bearer {token}"))
        .header("User-Agent", "tuicommander")
        .header("Accept", "application/vnd.github+json")
        .json(&body)
        .send()
        .await
        .map_err(|e| format!("GitHub API request failed: {e}"))?;

    let status = response.status();
    if status.is_success() {
        Ok(())
    } else {
        let json: serde_json::Value = response
            .json()
            .await
            .unwrap_or_else(|_| serde_json::json!({"message": "Unknown error"}));
        let msg = json["message"].as_str().unwrap_or("Unknown error");
        Err(format!("GitHub approve failed ({status}): {msg}"))
    }
}

/// Approve a PR via GitHub REST API (Tauri command).
#[cfg(feature = "desktop")]
#[tauri::command]
pub(crate) async fn approve_pr(
    repo_path: String,
    pr_number: i64,
    state: State<'_, Arc<AppState>>,
) -> Result<(), String> {
    let state = state.inner().clone();
    approve_pr_impl(&repo_path, pr_number, &state).await
}

/// Fetch the unified diff for a PR via GitHub REST API.
/// Uses Accept: application/vnd.github.diff to get raw diff text.
pub(crate) async fn get_pr_diff_impl(
    repo_path: &str,
    pr_number: i64,
    state: &AppState,
) -> Result<String, String> {
    let token = state
        .github_token
        .read()
        .clone()
        .ok_or_else(|| "No GitHub token available".to_string())?;

    let remote_url = get_github_remote_url(std::path::Path::new(repo_path))
        .ok_or_else(|| "No GitHub remote URL found for this repository".to_string())?;

    let (owner, repo) = parse_remote_url(&remote_url)
        .ok_or_else(|| format!("Failed to parse GitHub remote URL: {remote_url}"))?;

    let url = format!("https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}");
    crate::github_debug::log_api("GET", &url, "get_pr_diff_impl");

    let response = state
        .http_client
        .get(&url)
        .header("Authorization", format!("Bearer {token}"))
        .header("User-Agent", "tuicommander")
        .header("Accept", "application/vnd.github.diff")
        .send()
        .await
        .map_err(|e| format!("GitHub API request failed: {e}"))?;

    let status = response.status();
    if status.is_success() {
        response
            .text()
            .await
            .map_err(|e| format!("Failed to read diff body: {e}"))
    } else {
        let body = response.text().await.unwrap_or_default();
        Err(format!("GitHub diff request failed ({status}): {body}"))
    }
}

/// Maximum characters returned from CI failure logs to avoid overwhelming
/// the agent's context window.
const CI_LOG_MAX_CHARS: usize = 4000;

/// Truncate log text to the last [`CI_LOG_MAX_CHARS`] characters, splitting
/// at a newline boundary to avoid cutting mid-line.
fn truncate_ci_logs(logs: &str) -> String {
    let logs = logs.trim();
    if logs.len() <= CI_LOG_MAX_CHARS {
        return logs.to_string();
    }
    // Keep the tail — the most relevant failures are usually at the end
    let truncated = &logs[logs.len() - CI_LOG_MAX_CHARS..];
    let start = truncated.find('\n').map(|i| i + 1).unwrap_or(0);
    format!(
        "[… truncated to last ~{CI_LOG_MAX_CHARS} chars …]\n{}",
        &truncated[start..]
    )
}

/// Find the most recent failed workflow run for a branch, then fetch its logs.
/// Uses `gh run list` to find the run ID, then `gh run view --log-failed`.
/// Resolves the GitHub repo slug from the local repo path.
fn fetch_ci_failure_logs_impl(repo_path: &str, branch: &str) -> Result<String, String> {
    let repo_path_buf = PathBuf::from(repo_path);
    let remote_url = get_github_remote_url(&repo_path_buf)
        .ok_or_else(|| "No GitHub remote found for this repo".to_string())?;
    let (owner, repo) = parse_remote_url(&remote_url)
        .ok_or_else(|| format!("Cannot parse GitHub owner/repo from remote URL: {remote_url}"))?;
    let repo_slug = format!("{owner}/{repo}");
    let gh = crate::agent::resolve_cli("gh");

    // Step 1: find the latest failed run for the branch
    crate::github_debug::log_api(
        "CLI",
        &format!("gh run list --repo {repo_slug} --branch {branch}"),
        "fetch_ci_failure_logs_impl",
    );
    let mut list_cmd = Command::new(&gh);
    list_cmd.args([
        "run",
        "list",
        "--repo",
        &repo_slug,
        "--branch",
        branch,
        "--status",
        "failure",
        "--limit",
        "1",
        "--json",
        "databaseId",
    ]);
    crate::cli::apply_no_window(&mut list_cmd);

    let list_output = list_cmd
        .output()
        .map_err(|e| format!("Failed to run gh: {e}"))?;
    if !list_output.status.success() {
        let stderr = String::from_utf8_lossy(&list_output.stderr);
        return Err(format!("gh run list failed: {stderr}"));
    }

    let list_json: serde_json::Value = serde_json::from_slice(&list_output.stdout)
        .map_err(|e| format!("Failed to parse gh run list output: {e}"))?;
    let run_id = list_json
        .as_array()
        .and_then(|arr| arr.first())
        .and_then(|obj| obj.get("databaseId"))
        .and_then(|v| v.as_u64())
        .ok_or_else(|| "No failed workflow runs found for this branch".to_string())?;

    // Step 2: fetch failure logs for that run
    crate::github_debug::log_api(
        "CLI",
        &format!("gh run view {run_id} --repo {repo_slug} --log-failed"),
        "fetch_ci_failure_logs_impl",
    );
    let mut view_cmd = Command::new(&gh);
    view_cmd.args([
        "run",
        "view",
        &run_id.to_string(),
        "--repo",
        &repo_slug,
        "--log-failed",
    ]);
    crate::cli::apply_no_window(&mut view_cmd);

    let view_output = view_cmd
        .output()
        .map_err(|e| format!("Failed to run gh: {e}"))?;
    if !view_output.status.success() {
        let stderr = String::from_utf8_lossy(&view_output.stderr);
        return Err(format!("gh run view failed: {stderr}"));
    }

    let logs = String::from_utf8_lossy(&view_output.stdout);
    Ok(truncate_ci_logs(&logs))
}

/// Tauri command: fetch CI failure logs for the latest failed run on a branch.
#[cfg_attr(feature = "desktop", tauri::command)]
pub(crate) async fn fetch_ci_failure_logs(
    repo_path: String,
    branch: String,
) -> Result<String, String> {
    tokio::task::spawn_blocking(move || fetch_ci_failure_logs_impl(&repo_path, &branch))
        .await
        .map_err(|e| format!("Task failed: {e}"))
        .and_then(|r| r)
}

/// Fetch PR diff via GitHub REST API (Tauri command).
#[cfg(feature = "desktop")]
#[tauri::command]
pub(crate) async fn get_pr_diff(
    repo_path: String,
    pr_number: i64,
    state: State<'_, Arc<AppState>>,
) -> Result<String, String> {
    let state = state.inner().clone();
    get_pr_diff_impl(&repo_path, pr_number, &state).await
}

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

    // --- hex_to_rgba tests ---

    #[test]
    fn test_hex_to_rgba_red_label() {
        assert_eq!(hex_to_rgba("d73a4a", 0.3), "rgba(215, 58, 74, 0.3)");
    }

    #[test]
    fn test_hex_to_rgba_light_blue_label() {
        assert_eq!(hex_to_rgba("a2eeef", 0.3), "rgba(162, 238, 239, 0.3)");
    }

    #[test]
    fn test_hex_to_rgba_black() {
        assert_eq!(hex_to_rgba("000000", 0.3), "rgba(0, 0, 0, 0.3)");
    }

    #[test]
    fn test_hex_to_rgba_white() {
        assert_eq!(hex_to_rgba("ffffff", 0.3), "rgba(255, 255, 255, 0.3)");
    }

    #[test]
    fn test_hex_to_rgba_full_opacity() {
        assert_eq!(hex_to_rgba("ff0000", 1.0), "rgba(255, 0, 0, 1)");
    }

    // --- is_light_color tests ---

    #[test]
    fn test_is_light_color_dark_red() {
        // d73a4a: (215*299+58*587+74*114)/1000 = 106.767 < 128
        assert!(!is_light_color("d73a4a"));
    }

    #[test]
    fn test_is_light_color_light_blue() {
        // a2eeef: (162*299+238*587+239*114)/1000 = 215.39 > 128
        assert!(is_light_color("a2eeef"));
    }

    #[test]
    fn test_is_light_color_black() {
        assert!(!is_light_color("000000"));
    }

    #[test]
    fn test_is_light_color_white() {
        assert!(is_light_color("ffffff"));
    }

    #[test]
    fn test_is_light_color_mid_gray() {
        // 808080: (128*299+128*587+128*114)/1000 = 128.0, NOT > 128 => dark
        assert!(!is_light_color("808080"));
    }

    #[test]
    fn test_is_light_color_just_above_threshold() {
        // 818181: (129*299+129*587+129*114)/1000 = 129.0 > 128
        assert!(is_light_color("818181"));
    }

    // --- classify_merge_state tests ---

    #[test]
    fn test_classify_merge_state_conflicting_overrides_status() {
        let result = classify_merge_state(Some("CONFLICTING"), Some("CLEAN"));
        assert_eq!(
            result,
            Some(StateLabel {
                label: "Conflicts".to_string(),
                css_class: "conflicting".to_string()
            })
        );
    }

    #[test]
    fn test_classify_merge_state_clean() {
        let result = classify_merge_state(Some("MERGEABLE"), Some("CLEAN"));
        assert_eq!(
            result,
            Some(StateLabel {
                label: "Ready to merge".to_string(),
                css_class: "clean".to_string()
            })
        );
    }

    #[test]
    fn test_classify_merge_state_behind() {
        let result = classify_merge_state(Some("MERGEABLE"), Some("BEHIND"));
        assert_eq!(
            result,
            Some(StateLabel {
                label: "Behind base".to_string(),
                css_class: "behind".to_string()
            })
        );
    }

    #[test]
    fn test_classify_merge_state_blocked() {
        let result = classify_merge_state(Some("MERGEABLE"), Some("BLOCKED"));
        assert_eq!(
            result,
            Some(StateLabel {
                label: "Blocked".to_string(),
                css_class: "blocked".to_string()
            })
        );
    }

    #[test]
    fn test_classify_merge_state_unstable() {
        let result = classify_merge_state(Some("MERGEABLE"), Some("UNSTABLE"));
        assert_eq!(
            result,
            Some(StateLabel {
                label: "Unstable".to_string(),
                css_class: "blocked".to_string()
            })
        );
    }

    #[test]
    fn test_classify_merge_state_draft() {
        let result = classify_merge_state(Some("MERGEABLE"), Some("DRAFT"));
        assert_eq!(
            result,
            Some(StateLabel {
                label: "Draft".to_string(),
                css_class: "behind".to_string()
            })
        );
    }

    #[test]
    fn test_classify_merge_state_dirty() {
        let result = classify_merge_state(Some("MERGEABLE"), Some("DIRTY"));
        assert_eq!(
            result,
            Some(StateLabel {
                label: "Conflicts".to_string(),
                css_class: "conflicting".to_string()
            })
        );
    }

    #[test]
    fn test_classify_merge_state_unknown_returns_none() {
        assert!(classify_merge_state(Some("MERGEABLE"), Some("UNKNOWN")).is_none());
    }

    #[test]
    fn test_classify_merge_state_has_hooks_returns_none() {
        assert!(classify_merge_state(Some("MERGEABLE"), Some("HAS_HOOKS")).is_none());
    }

    #[test]
    fn test_classify_merge_state_none_none_returns_none() {
        assert!(classify_merge_state(None, None).is_none());
    }

    // --- classify_review_state tests ---

    #[test]
    fn test_classify_review_state_approved() {
        let result = classify_review_state(Some("APPROVED"));
        assert_eq!(
            result,
            Some(StateLabel {
                label: "Approved".to_string(),
                css_class: "approved".to_string()
            })
        );
    }

    #[test]
    fn test_classify_review_state_changes_requested() {
        let result = classify_review_state(Some("CHANGES_REQUESTED"));
        assert_eq!(
            result,
            Some(StateLabel {
                label: "Changes requested".to_string(),
                css_class: "changes-requested".to_string()
            })
        );
    }

    #[test]
    fn test_classify_review_state_review_required() {
        let result = classify_review_state(Some("REVIEW_REQUIRED"));
        assert_eq!(
            result,
            Some(StateLabel {
                label: "Review required".to_string(),
                css_class: "review-required".to_string()
            })
        );
    }

    #[test]
    fn test_classify_review_state_none_returns_none() {
        assert!(classify_review_state(None).is_none());
    }

    #[test]
    fn test_classify_review_state_empty_returns_none() {
        assert!(classify_review_state(Some("")).is_none());
    }

    // --- parse_graphql_prs tests ---

    /// Helper to build a GraphQL PR node for testing
    #[expect(clippy::too_many_arguments)]
    fn graphql_pr_node(
        number: i64,
        title: &str,
        state: &str,
        branch: &str,
        additions: i64,
        deletions: i64,
        author: &str,
        commits_count: i64,
        check_run_counts: &[(&str, u64)],
        status_context_counts: &[(&str, u64)],
        mergeable: &str,
        merge_state_status: &str,
        review_decision: Option<&str>,
        is_draft: bool,
        labels: &[(&str, &str)],
        base_ref_name: &str,
    ) -> serde_json::Value {
        let check_run_counts_json: Vec<serde_json::Value> = check_run_counts
            .iter()
            .map(|(s, c)| serde_json::json!({"state": s, "count": c}))
            .collect();
        let status_context_counts_json: Vec<serde_json::Value> = status_context_counts
            .iter()
            .map(|(s, c)| serde_json::json!({"state": s, "count": c}))
            .collect();
        let labels_json: Vec<serde_json::Value> = labels
            .iter()
            .map(|(name, color)| serde_json::json!({"name": name, "color": color}))
            .collect();

        serde_json::json!({
            "number": number,
            "title": title,
            "state": state,
            "url": format!("https://github.com/org/repo/pull/{number}"),
            "headRefName": branch,
            "headRefOid": format!("abc{number:04}"),
            "baseRefName": base_ref_name,
            "isDraft": is_draft,
            "additions": additions,
            "deletions": deletions,
            "mergeable": mergeable,
            "mergeStateStatus": merge_state_status,
            "reviewDecision": review_decision,
            "createdAt": "2025-01-01T00:00:00Z",
            "updatedAt": "2025-01-02T00:00:00Z",
            "author": {"login": author},
            "labels": {"nodes": labels_json},
            "commits": {
                "totalCount": commits_count,
                "nodes": [{
                    "commit": {
                        "statusCheckRollup": {
                            "contexts": {
                                "checkRunCountsByState": check_run_counts_json,
                                "statusContextCountsByState": status_context_counts_json,
                            }
                        }
                    }
                }]
            }
        })
    }

    /// Wrap PR nodes into a full GraphQL response
    fn graphql_response(nodes: Vec<serde_json::Value>) -> serde_json::Value {
        serde_json::json!({
            "data": {
                "repository": {
                    "pullRequests": {
                        "nodes": nodes
                    }
                }
            },
            "rateLimit": {"cost": 1, "remaining": 4999, "resetAt": "2025-01-01T01:00:00Z"}
        })
    }

    #[test]
    fn test_parse_graphql_prs_basic() {
        let response = graphql_response(vec![
            graphql_pr_node(
                42,
                "Add feature X",
                "OPEN",
                "feature/x",
                150,
                30,
                "alice",
                5,
                &[("SUCCESS", 2), ("FAILURE", 1)],
                &[("PENDING", 1)],
                "MERGEABLE",
                "BLOCKED",
                Some("CHANGES_REQUESTED"),
                false,
                &[],
                "main",
            ),
            graphql_pr_node(
                43,
                "Fix bug Y",
                "OPEN",
                "fix/y",
                10,
                5,
                "bob",
                1,
                &[("SUCCESS", 2)],
                &[],
                "MERGEABLE",
                "CLEAN",
                Some("APPROVED"),
                false,
                &[],
                "main",
            ),
        ]);

        let result = parse_graphql_prs(&response);
        assert_eq!(result.len(), 2);

        let pr1 = &result[0];
        assert_eq!(pr1.branch, "feature/x");
        assert_eq!(pr1.number, 42);
        assert_eq!(pr1.title, "Add feature X");
        assert_eq!(pr1.state, "OPEN");
        assert_eq!(pr1.additions, 150);
        assert_eq!(pr1.deletions, 30);
        assert_eq!(pr1.author, "alice");
        assert_eq!(pr1.commits, 5);
        assert_eq!(pr1.checks.passed, 2);
        assert_eq!(pr1.checks.failed, 1);
        assert_eq!(pr1.checks.pending, 1);
        assert_eq!(pr1.checks.total, 4);
        assert!(pr1.check_details.is_empty()); // Empty for batch query

        let pr2 = &result[1];
        assert_eq!(pr2.branch, "fix/y");
        assert_eq!(pr2.number, 43);
        assert_eq!(pr2.checks.passed, 2);
        assert_eq!(pr2.checks.failed, 0);
        assert_eq!(pr2.checks.pending, 0);
        assert_eq!(pr2.checks.total, 2);
    }

    #[test]
    fn test_parse_graphql_prs_empty_nodes() {
        let response = graphql_response(vec![]);
        let result = parse_graphql_prs(&response);
        assert!(result.is_empty());
    }

    #[test]
    fn test_parse_graphql_prs_no_data() {
        let response = serde_json::json!({"errors": [{"message": "something went wrong"}]});
        let result = parse_graphql_prs(&response);
        assert!(result.is_empty());
    }

    #[test]
    fn test_parse_graphql_prs_missing_branch_skips() {
        let mut node = graphql_pr_node(
            1,
            "No branch",
            "OPEN",
            "test",
            0,
            0,
            "alice",
            1,
            &[],
            &[],
            "UNKNOWN",
            "UNKNOWN",
            None,
            false,
            &[],
            "main",
        );
        // Remove headRefName
        node.as_object_mut().unwrap().remove("headRefName");
        let response = graphql_response(vec![node]);
        let result = parse_graphql_prs(&response);
        assert!(result.is_empty());
    }

    #[test]
    fn test_parse_graphql_prs_no_checks() {
        let response = graphql_response(vec![graphql_pr_node(
            10,
            "Draft PR",
            "OPEN",
            "draft/feature",
            0,
            0,
            "carol",
            1,
            &[],
            &[],
            "UNKNOWN",
            "DRAFT",
            None,
            true,
            &[],
            "main",
        )]);

        let result = parse_graphql_prs(&response);
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].checks.total, 0);
        assert!(result[0].is_draft);
    }

    #[test]
    fn test_parse_graphql_prs_labels_with_colors() {
        let response = graphql_response(vec![graphql_pr_node(
            1,
            "Labels PR",
            "OPEN",
            "label-branch",
            0,
            0,
            "alice",
            1,
            &[],
            &[],
            "UNKNOWN",
            "UNKNOWN",
            None,
            false,
            &[("bug", "d73a4a"), ("enhancement", "a2eeef")],
            "main",
        )]);

        let result = parse_graphql_prs(&response);
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].labels.len(), 2);

        let bug = &result[0].labels[0];
        assert_eq!(bug.name, "bug");
        assert_eq!(bug.color, "d73a4a");
        assert_eq!(bug.background_color, "rgba(215, 58, 74, 0.7)");
        assert_eq!(bug.text_color, "#e5e5e5"); // dark label => light text

        let enh = &result[0].labels[1];
        assert_eq!(enh.name, "enhancement");
        assert_eq!(enh.text_color, "#1e1e1e"); // light label => dark text
    }

    #[test]
    fn test_parse_graphql_prs_merge_and_review_labels() {
        let response = graphql_response(vec![
            graphql_pr_node(
                1,
                "Clean PR",
                "OPEN",
                "clean-branch",
                0,
                0,
                "alice",
                1,
                &[],
                &[],
                "MERGEABLE",
                "CLEAN",
                Some("APPROVED"),
                false,
                &[],
                "main",
            ),
            graphql_pr_node(
                2,
                "Conflicting PR",
                "OPEN",
                "conflict-branch",
                0,
                0,
                "bob",
                1,
                &[],
                &[],
                "CONFLICTING",
                "DIRTY",
                Some("CHANGES_REQUESTED"),
                false,
                &[],
                "main",
            ),
        ]);

        let result = parse_graphql_prs(&response);
        assert_eq!(result.len(), 2);

        assert_eq!(
            result[0].merge_state_label,
            Some(StateLabel {
                label: "Ready to merge".to_string(),
                css_class: "clean".to_string()
            })
        );
        assert_eq!(
            result[0].review_state_label,
            Some(StateLabel {
                label: "Approved".to_string(),
                css_class: "approved".to_string()
            })
        );

        assert_eq!(
            result[1].merge_state_label,
            Some(StateLabel {
                label: "Conflicts".to_string(),
                css_class: "conflicting".to_string()
            })
        );
    }

    #[test]
    fn test_parse_graphql_prs_error_check_states() {
        let response = graphql_response(vec![graphql_pr_node(
            99,
            "Error checks",
            "OPEN",
            "error-branch",
            0,
            0,
            "eve",
            1,
            &[("ERROR", 1), ("TIMED_OUT", 1), ("CANCELLED", 1)],
            &[("ERROR", 1)],
            "UNKNOWN",
            "UNKNOWN",
            None,
            false,
            &[],
            "main",
        )]);

        let result = parse_graphql_prs(&response);
        assert_eq!(result.len(), 1);
        assert_eq!(result[0].checks.failed, 4); // ERROR + TIMED_OUT + CANCELLED + status ERROR
    }

    #[test]
    fn test_parse_graphql_prs_merged_and_closed() {
        let response = graphql_response(vec![
            graphql_pr_node(
                10,
                "Merged feature",
                "MERGED",
                "feature/merged",
                0,
                0,
                "alice",
                3,
                &[],
                &[],
                "UNKNOWN",
                "UNKNOWN",
                None,
                false,
                &[],
                "main",
            ),
            graphql_pr_node(
                11,
                "Closed PR",
                "CLOSED",
                "feature/closed",
                0,
                0,
                "bob",
                1,
                &[],
                &[],
                "UNKNOWN",
                "UNKNOWN",
                None,
                false,
                &[],
                "main",
            ),
        ]);

        let result = parse_graphql_prs(&response);
        assert_eq!(result.len(), 2);
        assert_eq!(result[0].state, "MERGED");
        assert_eq!(result[1].state, "CLOSED");
    }

    #[test]
    fn test_parse_graphql_prs_head_ref_oid() {
        let response = graphql_response(vec![graphql_pr_node(
            42,
            "With OID",
            "OPEN",
            "feature/oid",
            0,
            0,
            "alice",
            1,
            &[],
            &[],
            "UNKNOWN",
            "UNKNOWN",
            None,
            false,
            &[],
            "main",
        )]);
        let result = parse_graphql_prs(&response);
        assert_eq!(result.len(), 1);
        assert_eq!(
            result[0].head_ref_oid, "abc0042",
            "headRefOid must be parsed"
        );
    }

    // --- parse_remote_url tests ---

    #[test]
    fn test_parse_remote_url_https() {
        let result = parse_remote_url("https://github.com/owner/repo.git");
        assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
    }

    #[test]
    fn test_parse_remote_url_https_no_git_suffix() {
        let result = parse_remote_url("https://github.com/owner/repo");
        assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
    }

    #[test]
    fn test_parse_remote_url_ssh() {
        let result = parse_remote_url("git@github.com:owner/repo.git");
        assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
    }

    #[test]
    fn test_parse_remote_url_ssh_no_git_suffix() {
        let result = parse_remote_url("git@github.com:owner/repo");
        assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
    }

    #[test]
    fn test_parse_remote_url_with_trailing_newline() {
        let result = parse_remote_url("https://github.com/owner/repo.git\n");
        assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
    }

    #[test]
    fn test_parse_remote_url_not_github() {
        let result = parse_remote_url("https://gitlab.com/owner/repo.git");
        assert_eq!(result, None);
    }

    #[test]
    fn test_parse_remote_url_empty() {
        assert_eq!(parse_remote_url(""), None);
    }

    #[test]
    fn test_parse_remote_url_malformed() {
        assert_eq!(parse_remote_url("not-a-url"), None);
    }

    // --- resolve_github_token tests ---
    // Must run serially: env vars are process-global state and gh_token crate
    // also reads them internally, causing races when tests run in parallel.

    #[test]
    #[serial_test::serial]
    fn test_resolve_github_token_env_priority() {
        // Scenario 1: GH_TOKEN takes priority
        unsafe {
            std::env::set_var("GH_TOKEN", "gh-wins");
            std::env::set_var("GITHUB_TOKEN", "github-loses");
        }
        assert_eq!(resolve_github_token(), Some("gh-wins".to_string()));

        // Scenario 2: Falls back to GITHUB_TOKEN when GH_TOKEN absent
        unsafe {
            std::env::remove_var("GH_TOKEN");
            std::env::set_var("GITHUB_TOKEN", "github-token-456");
        }
        assert_eq!(resolve_github_token(), Some("github-token-456".to_string()));

        // Scenario 3: Empty GH_TOKEN is skipped, falls back to GITHUB_TOKEN
        unsafe {
            std::env::set_var("GH_TOKEN", "");
            std::env::set_var("GITHUB_TOKEN", "fallback");
        }
        assert_eq!(resolve_github_token(), Some("fallback".to_string()));

        // Cleanup
        unsafe {
            std::env::remove_var("GH_TOKEN");
            std::env::remove_var("GITHUB_TOKEN");
        }
    }

    // --- parse_pr_check_contexts tests ---

    #[test]
    fn test_parse_pr_check_contexts_check_runs() {
        let data = serde_json::json!({
            "data": {
                "repository": {
                    "pullRequest": {
                        "commits": {
                            "nodes": [{
                                "commit": {
                                    "statusCheckRollup": {
                                        "contexts": {
                                            "nodes": [
                                                {
                                                    "__typename": "CheckRun",
                                                    "name": "build",
                                                    "status": "COMPLETED",
                                                    "conclusion": "SUCCESS",
                                                    "detailsUrl": "https://github.com/runs/1"
                                                },
                                                {
                                                    "__typename": "CheckRun",
                                                    "name": "test",
                                                    "status": "COMPLETED",
                                                    "conclusion": "FAILURE",
                                                    "detailsUrl": "https://github.com/runs/2"
                                                }
                                            ]
                                        }
                                    }
                                }
                            }]
                        }
                    }
                }
            }
        });

        let result = parse_pr_check_contexts(&data);
        assert_eq!(result.len(), 2);
        assert_eq!(result[0]["name"], "build");
        assert_eq!(result[0]["conclusion"], "success");
        assert_eq!(result[0]["html_url"], "https://github.com/runs/1");
        assert_eq!(result[1]["name"], "test");
        assert_eq!(result[1]["conclusion"], "failure");
    }

    #[test]
    fn test_parse_pr_check_contexts_status_contexts() {
        let data = serde_json::json!({
            "data": {
                "repository": {
                    "pullRequest": {
                        "commits": {
                            "nodes": [{
                                "commit": {
                                    "statusCheckRollup": {
                                        "contexts": {
                                            "nodes": [
                                                {
                                                    "__typename": "StatusContext",
                                                    "context": "ci/circleci",
                                                    "state": "SUCCESS",
                                                    "targetUrl": "https://circleci.com/build/1"
                                                },
                                                {
                                                    "__typename": "StatusContext",
                                                    "context": "ci/jenkins",
                                                    "state": "PENDING",
                                                    "targetUrl": "https://jenkins.io/build/2"
                                                }
                                            ]
                                        }
                                    }
                                }
                            }]
                        }
                    }
                }
            }
        });

        let result = parse_pr_check_contexts(&data);
        assert_eq!(result.len(), 2);
        assert_eq!(result[0]["name"], "ci/circleci");
        assert_eq!(result[0]["conclusion"], "success");
        assert_eq!(result[0]["status"], "completed");
        assert_eq!(result[0]["html_url"], "https://circleci.com/build/1");
        assert_eq!(result[1]["name"], "ci/jenkins");
        assert_eq!(result[1]["conclusion"], "");
        assert_eq!(result[1]["status"], "in_progress");
    }

    #[test]
    fn test_parse_pr_check_contexts_empty() {
        let data = serde_json::json!({
            "data": {
                "repository": {
                    "pullRequest": {
                        "commits": { "nodes": [] }
                    }
                }
            }
        });
        assert_eq!(parse_pr_check_contexts(&data).len(), 0);
    }

    #[test]
    fn test_parse_pr_check_contexts_no_data() {
        let data = serde_json::json!({});
        assert_eq!(parse_pr_check_contexts(&data).len(), 0);
    }

    // --- Integration tests: GraphQL API vs gh CLI (requires network + token) ---
    // Run with: cargo test --lib -- --ignored --test-threads=1

    /// Test that our GraphQL batch PR query returns the same data as `gh pr list`.
    /// Compares owner/repo extraction, token resolution, API call, and parsed results
    /// against the gh CLI output on this repository.
    #[tokio::test]
    #[ignore] // Requires network + GitHub token
    async fn test_graphql_pr_query_matches_gh_cli() {
        // 1. Resolve token (same path our production code uses)
        let token = resolve_github_token()
            .expect("No GitHub token found — set GH_TOKEN or run `gh auth login`");

        // 2. Get repo info from local .git (same as production code)
        let manifest_dir = env!("CARGO_MANIFEST_DIR");
        let repo_root = PathBuf::from(manifest_dir).parent().unwrap().to_path_buf();
        let remote_url = crate::git::read_remote_url(&repo_root).expect("No origin remote found");
        let (owner, repo) =
            parse_remote_url(&remote_url).expect("Failed to parse remote URL into owner/repo");

        println!("Testing against {owner}/{repo}");

        // 3. Call GraphQL API
        let client = reqwest::Client::new();
        let variables = serde_json::json!({
            "owner": owner,
            "repo": repo,
            "first": 50,
        });
        let graphql_result = graphql_request(&client, &token, BATCH_PR_QUERY, &variables).await;
        assert!(
            graphql_result.is_ok(),
            "GraphQL request failed: {:?}",
            graphql_result.err()
        );

        let data = graphql_result.unwrap();

        // 4. Verify response structure
        assert!(
            data["data"]["repository"].is_object(),
            "Response should have data.repository: {}",
            serde_json::to_string_pretty(&data).unwrap()
        );
        assert!(
            data["data"]["repository"]["pullRequests"]["nodes"].is_array(),
            "Response should have pullRequests.nodes array"
        );
        assert!(
            data["data"]["rateLimit"]["remaining"].is_number(),
            "Response should include rateLimit info"
        );

        let remaining = data["data"]["rateLimit"]["remaining"].as_i64().unwrap();
        println!("GraphQL rate limit remaining: {remaining}");

        // 5. Parse into BranchPrStatus (same as production code)
        let graphql_prs = parse_graphql_prs(&data);
        println!("GraphQL returned {} PRs", graphql_prs.len());

        // 6. Compare with gh CLI (if available)
        let gh_output = Command::new(crate::agent::resolve_cli("gh"))
            .current_dir(&repo_root)
            .args([
                "pr",
                "list",
                "--state",
                "all",
                "--limit",
                "50",
                "--json",
                "number,title,state,headRefName,additions,deletions,isDraft",
            ])
            .output()
            .ok();

        if let Some(output) = gh_output {
            if output.status.success() {
                let gh_json: Vec<serde_json::Value> =
                    serde_json::from_slice(&output.stdout).unwrap_or_default();
                println!("gh CLI returned {} PRs", gh_json.len());

                // PR counts should match
                assert_eq!(
                    graphql_prs.len(),
                    gh_json.len(),
                    "GraphQL and gh CLI should return the same number of PRs"
                );

                // For each PR, verify key fields match
                for gh_pr in &gh_json {
                    let number = gh_pr["number"].as_i64().unwrap() as i32;
                    let branch = gh_pr["headRefName"].as_str().unwrap();

                    let gql_pr = graphql_prs.iter().find(|p| p.number == number);
                    assert!(
                        gql_pr.is_some(),
                        "PR #{number} ({branch}) found in gh CLI but not in GraphQL"
                    );

                    let gql_pr = gql_pr.unwrap();
                    assert_eq!(gql_pr.branch, branch, "PR #{number}: branch mismatch");
                    assert_eq!(
                        gql_pr.title,
                        gh_pr["title"].as_str().unwrap(),
                        "PR #{number}: title mismatch"
                    );
                    assert_eq!(
                        gql_pr.state,
                        gh_pr["state"].as_str().unwrap(),
                        "PR #{number}: state mismatch"
                    );
                    assert_eq!(
                        gql_pr.is_draft,
                        gh_pr["isDraft"].as_bool().unwrap_or(false),
                        "PR #{number}: isDraft mismatch"
                    );
                    assert_eq!(
                        gql_pr.additions,
                        gh_pr["additions"].as_i64().unwrap_or(0) as i32,
                        "PR #{number}: additions mismatch"
                    );
                    assert_eq!(
                        gql_pr.deletions,
                        gh_pr["deletions"].as_i64().unwrap_or(0) as i32,
                        "PR #{number}: deletions mismatch"
                    );
                }

                println!("All {} PRs match between GraphQL and gh CLI", gh_json.len());
            } else {
                println!(
                    "gh CLI not available — skipping comparison, GraphQL-only validation passed"
                );
            }
        } else {
            println!("gh CLI not installed — skipping comparison, GraphQL-only validation passed");
        }
    }

    /// Test that GraphQL token resolution works and can authenticate.
    #[tokio::test]
    #[ignore] // Requires network + GitHub token
    async fn test_graphql_auth_and_rate_limit() {
        let token = resolve_github_token().expect("No GitHub token found");

        let client = reqwest::Client::new();
        // Minimal query just to verify auth works
        let result = graphql_request(
            &client,
            &token,
            "query { viewer { login } rateLimit { remaining resetAt } }",
            &serde_json::json!({}),
        )
        .await;

        assert!(result.is_ok(), "Auth failed: {:?}", result.err());
        let data = result.unwrap();

        let login = data["data"]["viewer"]["login"].as_str();
        assert!(login.is_some(), "Should return authenticated user login");
        println!("Authenticated as: {}", login.unwrap());

        let remaining = data["data"]["rateLimit"]["remaining"].as_i64().unwrap();
        println!("Rate limit remaining: {remaining}/5000");
        assert!(remaining > 0, "Should have rate limit remaining");
    }

    // --- resolve_github_token_candidates tests ---

    #[test]
    #[serial_test::serial]
    fn test_resolve_github_token_candidates() {
        // Set both env vars to known values
        unsafe {
            std::env::set_var("GH_TOKEN", "gh-token-1");
            std::env::set_var("GITHUB_TOKEN", "github-token-2");
        }

        let candidates = resolve_github_token_candidates();
        let tokens: Vec<&str> = candidates.iter().map(|(t, _)| t.as_str()).collect();
        assert!(tokens.len() >= 2, "Should have at least 2 candidates");
        assert_eq!(tokens[0], "gh-token-1");
        assert_eq!(tokens[1], "github-token-2");

        // Cleanup
        unsafe {
            std::env::remove_var("GH_TOKEN");
            std::env::remove_var("GITHUB_TOKEN");
        }
    }

    // --- Empty token filtering tests ---
    // Env vars are process-global state, so all env-var scenarios run in a single
    // test to avoid parallel race conditions.

    #[test]
    fn test_resolve_github_token_filters_empty_from_gh_token_crate() {
        // Simulate Tauri GUI process: GITHUB_TOKEN="" (set but empty).
        // gh_token crate's get() uses env::var_os() which returns Some("") for
        // empty env vars without checking emptiness. Our resolve_github_token()
        // must filter this and not return Some("").
        unsafe {
            std::env::set_var("GITHUB_TOKEN", "");
            std::env::remove_var("GH_TOKEN");
        }

        let result = resolve_github_token();
        // Result should be either None (no gh CLI) or a non-empty token from CLI
        if let Some(ref token) = result {
            assert!(
                !token.is_empty(),
                "resolve_github_token must never return an empty string"
            );
        }

        // Cleanup
        unsafe {
            std::env::remove_var("GITHUB_TOKEN");
        }
    }

    #[test]
    #[serial_test::serial]
    fn test_resolve_github_token_candidates_filters_empty() {
        unsafe {
            std::env::set_var("GH_TOKEN", "");
            std::env::set_var("GITHUB_TOKEN", "");
        }

        let candidates = resolve_github_token_candidates();
        for (token, _source) in &candidates {
            assert!(
                !token.is_empty(),
                "Candidates must never contain empty strings"
            );
        }

        // Cleanup
        unsafe {
            std::env::remove_var("GH_TOKEN");
            std::env::remove_var("GITHUB_TOKEN");
        }
    }

    // --- Integration test: token resolution with gh CLI fallback ---
    // Run with: cargo test --lib -- --ignored --test-threads=1

    /// Verify that resolve_github_token works even when env vars are empty,
    /// by falling through to `gh auth token` CLI.
    /// This catches the exact bug where GITHUB_TOKEN="" in Tauri GUI processes
    /// caused gh_token crate to return an empty string → 401 Bad credentials.
    #[tokio::test]
    #[ignore] // Requires gh CLI authenticated
    async fn test_resolve_token_with_empty_env_falls_through_to_cli() {
        // Save and clear env vars to simulate GUI context
        let saved_gh = std::env::var("GH_TOKEN").ok();
        let saved_github = std::env::var("GITHUB_TOKEN").ok();
        unsafe {
            std::env::set_var("GITHUB_TOKEN", "");
            std::env::set_var("GH_TOKEN", "");
        }

        let token = resolve_github_token();
        assert!(
            token.is_some(),
            "Should resolve token via gh CLI when env vars are empty"
        );
        let token = token.unwrap();
        assert!(!token.is_empty(), "Token from CLI should not be empty");

        // Verify the token actually works against GitHub API
        let client = reqwest::Client::new();
        let result = graphql_request(
            &client,
            &token,
            "query { viewer { login } }",
            &serde_json::json!({}),
        )
        .await;
        assert!(
            result.is_ok(),
            "Token from gh CLI should authenticate successfully: {:?}",
            result.err()
        );

        let data = result.unwrap();
        let login = data["data"]["viewer"]["login"].as_str();
        assert!(login.is_some(), "Should return authenticated user login");
        println!("Authenticated via CLI fallback as: {}", login.unwrap());

        // Restore env vars
        unsafe {
            match saved_gh {
                Some(v) => std::env::set_var("GH_TOKEN", v),
                None => std::env::remove_var("GH_TOKEN"),
            }
            match saved_github {
                Some(v) => std::env::set_var("GITHUB_TOKEN", v),
                None => std::env::remove_var("GITHUB_TOKEN"),
            }
        }
    }

    // --- GqlError display tests ---

    #[test]
    fn test_gql_error_display_auth() {
        let err = GqlError::Auth("401 Unauthorized".to_string());
        assert_eq!(format!("{err}"), "Auth error: 401 Unauthorized");
    }

    #[test]
    fn test_gql_error_display_other() {
        let err = GqlError::Other("network timeout".to_string());
        assert_eq!(format!("{err}"), "network timeout");
    }

    // --- GitHubCircuitBreaker tests ---

    #[test]
    fn test_circuit_breaker_stays_closed_on_success() {
        let cb = GitHubCircuitBreaker::new();
        cb.record_success();
        cb.record_success();
        assert!(cb.check().is_ok());
    }

    #[test]
    fn test_circuit_breaker_opens_after_threshold() {
        let cb = GitHubCircuitBreaker::new();
        cb.record_failure();
        cb.record_failure();
        assert!(
            cb.check().is_ok(),
            "Should still be closed after 2 failures"
        );
        cb.record_failure();
        assert!(cb.check().is_err(), "Should be open after 3 failures");
    }

    #[test]
    fn test_circuit_breaker_resets_on_success() {
        let cb = GitHubCircuitBreaker::new();
        cb.record_failure();
        cb.record_failure();
        cb.record_success(); // Reset before threshold
        cb.record_failure();
        cb.record_failure();
        assert!(
            cb.check().is_ok(),
            "Should be closed — success reset the count"
        );
    }

    #[test]
    fn test_circuit_breaker_respects_open_until() {
        let cb = GitHubCircuitBreaker::new();
        // Force circuit open with a future instant
        *cb.open_until.write() = Some(Instant::now() + std::time::Duration::from_secs(60));
        let result = cb.check();
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("circuit breaker open"));
    }

    // --- Rate limit circuit breaker tests ---

    #[test]
    fn test_circuit_breaker_rate_limit_blocks_requests() {
        let cb = GitHubCircuitBreaker::new();
        cb.record_rate_limit(60);
        let result = cb.check();
        assert!(result.is_err());
        assert!(
            result.unwrap_err().starts_with("rate-limit:"),
            "Rate limit check should return error starting with 'rate-limit:'"
        );
    }

    #[test]
    fn test_circuit_breaker_rate_limit_does_not_increment_failure_count() {
        let cb = GitHubCircuitBreaker::new();
        // Record 2 failures (below threshold)
        cb.record_failure();
        cb.record_failure();
        // Record a rate limit — this should NOT push us over the failure threshold
        cb.record_rate_limit(1);
        // Wait for rate limit to expire
        std::thread::sleep(std::time::Duration::from_millis(1100));
        // Should still be open for requests (only 2 failures, threshold is 3)
        assert!(
            cb.check().is_ok(),
            "Rate limit should not inflate failure count"
        );
    }

    #[test]
    fn test_circuit_breaker_rate_limit_expires() {
        let cb = GitHubCircuitBreaker::new();
        // Set rate limit that expires in 1 second
        *cb.rate_limit_until.write() = Some(Instant::now() + std::time::Duration::from_millis(50));
        assert!(cb.check().is_err(), "Should be rate limited initially");
        std::thread::sleep(std::time::Duration::from_millis(60));
        assert!(
            cb.check().is_ok(),
            "Should be open after rate limit expires"
        );
    }

    #[test]
    fn test_circuit_breaker_rate_limit_takes_priority_over_open() {
        let cb = GitHubCircuitBreaker::new();
        // Set both circuit breaker open and rate limited
        *cb.open_until.write() = Some(Instant::now() + std::time::Duration::from_secs(60));
        *cb.rate_limit_until.write() = Some(Instant::now() + std::time::Duration::from_secs(60));
        let result = cb.check();
        assert!(result.is_err());
        // Rate limit message should take priority
        assert!(
            result.unwrap_err().starts_with("rate-limit:"),
            "Rate limit should take priority in error message"
        );
    }

    // --- GqlError display tests for RateLimit ---

    #[test]
    fn test_gql_error_display_rate_limit() {
        let err = GqlError::RateLimit {
            reset_at: Some(1700000000),
            retry_after: Some(60),
            message: "API rate limit exceeded".to_string(),
        };
        assert_eq!(format!("{err}"), "Rate limited: API rate limit exceeded");
    }

    // --- rate_limit_wait_secs tests ---

    #[test]
    fn test_rate_limit_wait_secs_prefers_retry_after() {
        // retry-after takes priority over reset_at
        assert_eq!(rate_limit_wait_secs(Some(9999999999), Some(42)), 42);
    }

    #[test]
    fn test_rate_limit_wait_secs_falls_back_to_reset_at() {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_secs();
        let reset = now + 30;
        let wait = rate_limit_wait_secs(Some(reset), None);
        // Should be approximately 31 (30 + 1 safety margin)
        assert!((30..=32).contains(&wait), "Expected ~31, got {wait}");
    }

    #[test]
    fn test_rate_limit_wait_secs_defaults_to_60() {
        assert_eq!(rate_limit_wait_secs(None, None), 60);
    }

    #[test]
    fn test_rate_limit_wait_secs_reset_in_past() {
        // If reset_at is in the past and no retry-after, default to 60
        assert_eq!(rate_limit_wait_secs(Some(1000), None), 60);
    }

    // --- check_graphql_errors tests ---

    #[test]
    fn test_check_graphql_errors_no_errors_returns_ok() {
        let json = serde_json::json!({
            "data": { "r0": { "pullRequests": { "nodes": [] } } }
        });
        assert!(check_graphql_errors(&json, None, None).is_ok());
    }

    #[test]
    fn test_check_graphql_errors_empty_errors_array_returns_ok() {
        let json = serde_json::json!({
            "data": { "r0": null },
            "errors": []
        });
        assert!(check_graphql_errors(&json, None, None).is_ok());
    }

    #[test]
    fn test_check_graphql_errors_rate_limited_always_fails() {
        let json = serde_json::json!({
            "data": { "r0": { "pullRequests": { "nodes": [] } } },
            "errors": [{ "type": "RATE_LIMITED", "message": "rate limited" }]
        });
        let err = check_graphql_errors(&json, Some(9999), None).unwrap_err();
        assert!(matches!(err, GqlError::RateLimit { .. }));
    }

    #[test]
    fn test_check_graphql_errors_partial_data_returns_ok() {
        // Errors + data present → partial success, should return Ok
        let json = serde_json::json!({
            "data": {
                "r0": { "pullRequests": { "nodes": [] } },
                "r1": null
            },
            "errors": [{
                "type": "NOT_FOUND",
                "message": "Could not resolve to a Repository with the name 'foo/bar'."
            }]
        });
        assert!(check_graphql_errors(&json, None, None).is_ok());
    }

    #[test]
    fn test_check_graphql_errors_no_data_returns_err() {
        // Errors without data → pure error
        let json = serde_json::json!({
            "errors": [{ "message": "Something went wrong" }]
        });
        let err = check_graphql_errors(&json, None, None).unwrap_err();
        match err {
            GqlError::Other(msg) => assert!(msg.contains("Something went wrong")),
            _ => panic!("Expected GqlError::Other, got {err:?}"),
        }
    }

    #[test]
    fn test_check_graphql_errors_data_null_returns_err() {
        // data: null is not a valid object — treat as pure error
        let json = serde_json::json!({
            "data": null,
            "errors": [{ "message": "Bad query" }]
        });
        let err = check_graphql_errors(&json, None, None).unwrap_err();
        assert!(matches!(err, GqlError::Other(_)));
    }

    // --- github_repo_cooldown tests ---

    #[test]
    fn test_cooldown_evicts_expired_entries() {
        let cache = crate::state::GitCacheState::new();
        // Insert an already-expired entry
        cache.github_repo_cooldown.insert(
            "owner/expired".to_string(),
            Instant::now() - std::time::Duration::from_secs(1),
        );
        // Insert a still-valid entry
        cache.github_repo_cooldown.insert(
            "owner/active".to_string(),
            Instant::now() + std::time::Duration::from_secs(3600),
        );
        // Evict expired
        let now = Instant::now();
        cache
            .github_repo_cooldown
            .retain(|_k, expiry| *expiry > now);

        assert!(!cache.github_repo_cooldown.contains_key("owner/expired"));
        assert!(cache.github_repo_cooldown.contains_key("owner/active"));
    }

    // --- truncate_ci_logs tests ---

    #[test]
    fn test_ci_log_short_output_unchanged() {
        let short = "Error: test failed\nassert_eq failed";
        let result = truncate_ci_logs(short);
        assert_eq!(result, short);
    }

    #[test]
    fn test_ci_log_truncation_keeps_tail() {
        let mut logs = String::new();
        for i in 0..500 {
            logs.push_str(&format!("line {i}: some log output here\n"));
        }
        let result = truncate_ci_logs(&logs);

        assert!(result.starts_with("[… truncated"));
        assert!(result.contains("line 499"));
        assert!(!result.contains("line 0:"));
        // Result length should be manageable
        assert!(result.len() <= CI_LOG_MAX_CHARS + 100); // header adds a bit
    }

    #[test]
    fn test_ci_log_empty_input() {
        assert_eq!(truncate_ci_logs(""), "");
        assert_eq!(truncate_ci_logs("  \n  "), "");
    }

    #[test]
    fn test_cooldown_survives_clear_all() {
        let cache = crate::state::GitCacheState::new();
        cache.github_repo_cooldown.insert(
            "owner/repo".to_string(),
            Instant::now() + std::time::Duration::from_secs(3600),
        );
        cache.clear_all();
        // Cooldowns must survive cache invalidation — only explicit user actions clear them
        assert!(!cache.github_repo_cooldown.is_empty());
    }

    #[test]
    fn test_parse_issue_node_full() {
        let json = serde_json::json!({
            "number": 42,
            "title": "Bug: widget crashes",
            "state": "OPEN",
            "url": "https://github.com/owner/repo/issues/42",
            "createdAt": "2026-01-15T10:00:00Z",
            "updatedAt": "2026-04-10T12:00:00Z",
            "author": { "login": "octocat" },
            "labels": { "nodes": [
                { "name": "bug", "color": "d73a49" },
                { "name": "P1", "color": "ffffff" }
            ]},
            "assignees": { "nodes": [
                { "login": "alice" },
                { "login": "bob" }
            ]},
            "milestone": { "title": "v2.0" },
            "comments": { "totalCount": 5 }
        });
        let issue = parse_issue_node(&json).expect("should parse");
        assert_eq!(issue.number, 42);
        assert_eq!(issue.title, "Bug: widget crashes");
        assert_eq!(issue.state, "OPEN");
        assert_eq!(issue.author, "octocat");
        assert_eq!(issue.labels.len(), 2);
        assert_eq!(issue.labels[0].name, "bug");
        assert_eq!(issue.labels[0].background_color, "rgba(215, 58, 73, 0.7)");
        assert_eq!(issue.labels[0].text_color, "#e5e5e5"); // dark label => light text
        assert_eq!(issue.labels[1].name, "P1");
        assert_eq!(issue.labels[1].text_color, "#1e1e1e"); // light label => dark text
        assert_eq!(issue.assignees, vec!["alice", "bob"]);
        assert_eq!(issue.milestone, Some("v2.0".to_string()));
        assert_eq!(issue.comments_count, 5);
    }

    #[test]
    fn test_parse_issue_node_minimal() {
        let json = serde_json::json!({
            "number": 1,
            "title": "",
            "state": "CLOSED",
            "url": "",
            "createdAt": "",
            "updatedAt": "",
            "author": { "login": "" },
            "labels": { "nodes": [] },
            "assignees": { "nodes": [] },
            "milestone": null,
            "comments": { "totalCount": 0 }
        });
        let issue = parse_issue_node(&json).expect("should parse");
        assert_eq!(issue.number, 1);
        assert_eq!(issue.milestone, None);
        assert!(issue.labels.is_empty());
        assert!(issue.assignees.is_empty());
    }

    #[test]
    fn test_parse_issue_node_missing_number() {
        let json = serde_json::json!({ "title": "no number" });
        assert!(parse_issue_node(&json).is_none());
    }

    #[test]
    fn test_build_multi_repo_issues_query_assigned() {
        let repos = vec![("path1".to_string(), "owner".to_string(), "repo".to_string())];
        let (query, aliases) = build_multi_repo_issues_query(&repos, "octocat", "assigned");
        // New format uses repository().issues(filterBy:) instead of search()
        assert!(query.contains("filterBy: { assignee: \"octocat\" }"));
        assert!(query.contains("issues("));
        assert!(query.contains("states: [OPEN]"));
        assert_eq!(aliases.len(), 1);
        assert_eq!(aliases[0].0, "r0");
    }

    #[test]
    fn test_build_multi_repo_issues_query_all() {
        let repos = vec![("p".to_string(), "o".to_string(), "r".to_string())];
        let (query, _) = build_multi_repo_issues_query(&repos, "viewer", "all");
        // "all" mode should NOT include filterBy qualifier
        assert!(!query.contains("filterBy"));
        assert!(query.contains("issues("));
    }

    // --- build_unified_batch_query: hide_drafts tests ---

    #[test]
    fn test_build_unified_batch_query_hide_drafts_adds_filter() {
        let repos = vec![("/path".to_string(), "owner".to_string(), "repo".to_string())];
        let (query, _) = build_unified_batch_query(&repos, false, "disabled", "alice", true);
        assert!(
            query.contains("-is:draft"),
            "draft filter should appear in viewer search"
        );
    }

    #[test]
    fn test_build_unified_batch_query_show_drafts_no_filter() {
        let repos = vec![("/path".to_string(), "owner".to_string(), "repo".to_string())];
        let (query, _) = build_unified_batch_query(&repos, false, "disabled", "alice", false);
        assert!(
            !query.contains("-is:draft"),
            "draft filter should not appear when hide_drafts is false"
        );
    }

    #[test]
    fn test_build_unified_batch_query_hide_drafts_fetches_more() {
        let repos = vec![("/path".to_string(), "owner".to_string(), "repo".to_string())];
        let (query_hide, _) = build_unified_batch_query(&repos, false, "disabled", "", true);
        let (query_show, _) = build_unified_batch_query(&repos, false, "disabled", "", false);
        assert!(
            query_hide.contains("first: 40"),
            "hide_drafts should fetch 40 PRs"
        );
        assert!(
            query_show.contains("first: 20"),
            "show_drafts should fetch 20 PRs"
        );
    }

    #[test]
    fn test_build_unified_batch_query_no_viewer_prs_section_without_viewer() {
        let repos = vec![("/path".to_string(), "owner".to_string(), "repo".to_string())];
        let (query, _) = build_unified_batch_query(&repos, false, "disabled", "", true);
        assert!(
            !query.contains("viewerPrs"),
            "no viewerPrs section when viewer is empty"
        );
    }

    #[test]
    fn test_build_unified_batch_query_viewer_prs_section_with_viewer() {
        let repos = vec![("/path".to_string(), "owner".to_string(), "repo".to_string())];
        let (query, _) = build_unified_batch_query(&repos, false, "disabled", "alice", false);
        assert!(
            query.contains("viewerPrs"),
            "viewerPrs section present when viewer is set"
        );
        assert!(
            query.contains("author:alice"),
            "viewer search targets alice"
        );
    }

    // --- extract_graphql_name tests ---

    #[test]
    fn test_extract_named_query() {
        assert_eq!(
            extract_graphql_name("query BatchPoll { viewer { login } }"),
            "BatchPoll"
        );
    }

    #[test]
    fn test_extract_anonymous_query() {
        assert_eq!(extract_graphql_name("{ viewer { login } }"), "<inline>");
    }

    #[test]
    fn test_extract_mutation() {
        assert_eq!(
            extract_graphql_name("mutation ClosePR($id: ID!) { ... }"),
            "ClosePR"
        );
    }

    #[test]
    fn test_extract_inline_query_keyword() {
        assert_eq!(
            extract_graphql_name("query { viewer { login } }"),
            "<inline>"
        );
    }
}
