//! GitHub Releases API client.
//!
//! `GithubClient` fetches release metadata and streams asset downloads
//! via sync ureq + rustls with platform certificate verification. Supports
//! `GITHUB_TOKEN` / `GH_TOKEN` auth with pinned precedence (GITHUB_TOKEN wins).
//!
//! The client is configured to strip the `Authorization` header on any
//! cross-host redirect (e.g. `api.github.com` → `objects.githubusercontent.com`)
//! while preserving it on same-host redirects.

// All public types and methods in this module are consumed by command
// handlers landing in Units 8b and 9. Suppress dead-code warnings until
// those call sites exist.
#![allow(dead_code)]

use std::io::Read;
use std::time::{Duration, SystemTime};

use serde::Deserialize;
use thiserror::Error;
use ureq::{http, Agent, Body};

/// Errors returned by GitHub API operations.
///
/// Each variant is self-contained — no references to ureq internals or
/// request headers leak through `Display`. The `Http` variant is sanitized:
/// it carries only `{ method, url, status }`, never headers or body content.
#[derive(Debug, Error)]
pub enum Error {
    /// HTTP 403 with `x-ratelimit-remaining: 0`.
    /// `reset_at` is parsed from the `x-ratelimit-reset` epoch-seconds header.
    #[error("{}", format_rate_limit_message(reset_at))]
    RateLimited { reset_at: Option<SystemTime> },

    /// HTTP 403 without rate-limit headers — likely a permissions issue.
    #[error("GitHub API returned 403 Forbidden — check your GITHUB_TOKEN permissions")]
    AuthFailed,

    /// No releases found for the given org/repo (empty list or 404).
    #[error("no releases found for {org}/{repo}")]
    NoReleaseFound { org: String, repo: String },

    /// The release exists but has no `.tar.gz` asset.
    #[error("no .tar.gz asset found for {org}/{repo} tag {tag}")]
    NoTarballAsset {
        org: String,
        repo: String,
        tag: String,
    },

    /// Sanitized HTTP error — carries only method, URL, and status code.
    /// Never includes request headers, request body, or response body.
    #[error("HTTP error: {method} {url} → {}", status.map_or("connection failed".to_string(), |s| format!("{s}")))]
    Http {
        method: String,
        url: String,
        status: Option<u16>,
    },
}

fn format_rate_limit_message(reset_at: &Option<SystemTime>) -> String {
    match reset_at {
        Some(t) => {
            let now = SystemTime::now();
            let remaining = t.duration_since(now).unwrap_or(Duration::ZERO);
            let mins = remaining.as_secs() / 60;
            format!(
                "GitHub API rate limit exceeded; resets in {mins} minute(s). \
                 Set GITHUB_TOKEN to raise the limit to 5000/hour"
            )
        }
        None => "GitHub API rate limit exceeded. \
                 Set GITHUB_TOKEN to raise the limit to 5000/hour"
            .to_string(),
    }
}

/// A release entry from the GitHub Releases API.
#[derive(Debug, Deserialize)]
struct Release {
    tag_name: String,
    draft: bool,
    assets: Vec<Asset>,
}

/// A single asset within a release.
#[derive(Debug, Deserialize)]
struct Asset {
    name: String,
    browser_download_url: String,
    size: u64,
}

/// Sync GitHub Releases API client.
///
/// Constructed with an explicit base URL (for test injection via mockito)
/// and an optional bearer token. Does **not** read environment variables
/// in the constructor — use `from_env()` for that.
pub struct GithubClient {
    agent: Agent,
    base_url: String,
    user_agent: String,
    token: Option<String>,
}

impl GithubClient {
    /// Create a client with an explicit token (or `None` for unauthenticated).
    ///
    /// Tests use this constructor directly to avoid touching process-global
    /// env state.
    pub fn new(base_url: &str, user_agent: &str, token: Option<String>) -> Self {
        use ureq::config::RedirectAuthHeaders;
        use ureq::tls::{RootCerts, TlsConfig};

        let agent = Agent::config_builder()
            .tls_config(
                TlsConfig::builder()
                    .root_certs(RootCerts::PlatformVerifier)
                    .build(),
            )
            .redirect_auth_headers(RedirectAuthHeaders::SameHost)
            .http_status_as_error(false)
            .build()
            .new_agent();

        Self {
            agent,
            base_url: base_url.trim_end_matches('/').to_string(),
            user_agent: user_agent.to_string(),
            token,
        }
    }

    /// Create a client reading auth from the environment.
    ///
    /// Precedence: `GITHUB_TOKEN` wins when both are set, matching `gh` CLI's
    /// default non-enterprise behavior.
    pub fn from_env(base_url: &str, user_agent: &str) -> Self {
        let token = std::env::var("GITHUB_TOKEN")
            .ok()
            .or_else(|| std::env::var("GH_TOKEN").ok());
        Self::new(base_url, user_agent, token)
    }

    /// Fetch the latest (most recently published, non-draft) release tag.
    ///
    /// Uses the list endpoint (`/repos/{org}/{repo}/releases`) rather than
    /// `/releases/latest` so that pre-release tags are visible.
    pub fn latest_release_tag(&self, org: &str, repo: &str) -> Result<String, Error> {
        let url = format!("{}/repos/{}/{}/releases", self.base_url, org, repo);
        let mut response = self.get(&url)?;
        let status = response.status().as_u16();

        if status == 403 {
            return Err(classify_forbidden(&response));
        }
        if status == 404 || status == 410 {
            return Err(Error::NoReleaseFound {
                org: org.to_string(),
                repo: repo.to_string(),
            });
        }
        if status == 429 {
            return Err(Error::RateLimited {
                reset_at: parse_reset_header(&response),
            });
        }
        if !is_success(status) {
            return Err(Error::Http {
                method: "GET".to_string(),
                url,
                status: Some(status),
            });
        }

        let releases: Vec<Release> = response.body_mut().read_json().map_err(|_| Error::Http {
            method: "GET".to_string(),
            url: url.clone(),
            status: None,
        })?;

        releases
            .into_iter()
            .find(|r| !r.draft)
            .map(|r| r.tag_name)
            .ok_or_else(|| Error::NoReleaseFound {
                org: org.to_string(),
                repo: repo.to_string(),
            })
    }

    /// Stream the first `.tar.gz` asset for a given release tag.
    ///
    /// Returns `(content_length, reader)`. The reader is the raw HTTP body;
    /// the caller wraps it in a progress bar and hands it to `tarball::extract_safe`.
    pub fn release_asset_body(
        &self,
        org: &str,
        repo: &str,
        tag: &str,
    ) -> Result<(u64, Box<dyn Read + Send>), Error> {
        let url = format!("{}/repos/{}/{}/releases", self.base_url, org, repo);
        let mut response = self.get(&url)?;
        let status = response.status().as_u16();

        if status == 403 {
            return Err(classify_forbidden(&response));
        }
        if status == 404 || status == 410 {
            return Err(Error::NoReleaseFound {
                org: org.to_string(),
                repo: repo.to_string(),
            });
        }
        if status == 429 {
            return Err(Error::RateLimited {
                reset_at: parse_reset_header(&response),
            });
        }
        if !is_success(status) {
            return Err(Error::Http {
                method: "GET".to_string(),
                url,
                status: Some(status),
            });
        }

        let releases: Vec<Release> = response.body_mut().read_json().map_err(|_| Error::Http {
            method: "GET".to_string(),
            url: url.clone(),
            status: None,
        })?;

        let release = releases
            .into_iter()
            .find(|r| r.tag_name == tag)
            .ok_or_else(|| Error::NoReleaseFound {
                org: org.to_string(),
                repo: repo.to_string(),
            })?;

        let asset = release
            .assets
            .into_iter()
            .find(|a| a.name.ends_with(".tar.gz"))
            .ok_or_else(|| Error::NoTarballAsset {
                org: org.to_string(),
                repo: repo.to_string(),
                tag: tag.to_string(),
            })?;

        let content_length = asset.size;

        // Download the asset — follows redirects (api.github.com → objects.githubusercontent.com).
        // The SameHost redirect policy strips Authorization on the cross-host hop.
        let asset_response = self.get(&asset.browser_download_url)?;
        let asset_status = asset_response.status().as_u16();

        if !is_success(asset_status) {
            return Err(Error::Http {
                method: "GET".to_string(),
                url: asset.browser_download_url,
                status: Some(asset_status),
            });
        }

        let reader = asset_response.into_body().into_reader();
        Ok((content_length, Box::new(reader)))
    }

    /// Build and send a GET request with standard GitHub API headers.
    fn get(&self, url: &str) -> Result<http::Response<Body>, Error> {
        let mut req = self
            .agent
            .get(url)
            .header("Accept", "application/vnd.github+json")
            .header("User-Agent", &self.user_agent)
            .header("X-GitHub-Api-Version", "2022-11-28");

        if let Some(ref token) = self.token {
            req = req.header("Authorization", &format!("Bearer {token}"));
        }

        req.call().map_err(|e| sanitize_ureq_error("GET", url, e))
    }
}

/// Classify a 403 response as rate-limit or auth failure.
fn classify_forbidden(response: &http::Response<Body>) -> Error {
    let remaining = response
        .headers()
        .get("x-ratelimit-remaining")
        .and_then(|v| v.to_str().ok());
    match remaining {
        Some("0") => Error::RateLimited {
            reset_at: parse_reset_header(response),
        },
        _ => Error::AuthFailed,
    }
}

/// Parse the `x-ratelimit-reset` header into a `SystemTime`.
fn parse_reset_header(response: &http::Response<Body>) -> Option<SystemTime> {
    response
        .headers()
        .get("x-ratelimit-reset")
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.parse::<u64>().ok())
        .map(|secs| SystemTime::UNIX_EPOCH + Duration::from_secs(secs))
}

/// Strip ureq error internals down to method + URL + optional status code.
/// Never leaks request headers, request body, or response body.
fn sanitize_ureq_error(method: &str, url: &str, _err: ureq::Error) -> Error {
    Error::Http {
        method: method.to_string(),
        url: url.to_string(),
        status: None,
    }
}

fn is_success(status: u16) -> bool {
    (200..300).contains(&status)
}

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

    // ── Error Display tests ──────────────────────────────────────────

    #[test]
    fn token_not_leaked_in_http_error_display() {
        let sentinel = "sentinel_value_please_do_not_leak";
        let err = Error::Http {
            method: "GET".to_string(),
            url: "https://api.github.com/repos/org/repo/releases".to_string(),
            status: None,
        };
        let display = format!("{err}");
        assert!(
            !display.contains(sentinel),
            "Error Display must never contain the token value"
        );
    }

    #[test]
    fn rate_limited_error_mentions_github_token() {
        let err = Error::RateLimited { reset_at: None };
        let display = format!("{err}");
        assert!(
            display.contains("GITHUB_TOKEN"),
            "Rate limit error should suggest GITHUB_TOKEN"
        );
    }

    #[test]
    fn rate_limited_with_reset_time_formats_minutes() {
        let reset_at = SystemTime::now() + Duration::from_secs(300);
        let err = Error::RateLimited {
            reset_at: Some(reset_at),
        };
        let display = format!("{err}");
        assert!(
            display.contains("minute"),
            "Should mention minutes: {display}"
        );
        assert!(
            display.contains("GITHUB_TOKEN"),
            "Should suggest GITHUB_TOKEN"
        );
    }

    #[test]
    fn auth_failed_error_mentions_permissions() {
        let err = Error::AuthFailed;
        let display = format!("{err}");
        assert!(display.contains("403"), "Should mention 403 status");
    }

    #[test]
    fn no_release_found_names_org_and_repo() {
        let err = Error::NoReleaseFound {
            org: "myorg".to_string(),
            repo: "myrepo".to_string(),
        };
        let display = format!("{err}");
        assert!(display.contains("myorg/myrepo"));
    }

    #[test]
    fn no_tarball_asset_names_tag() {
        let err = Error::NoTarballAsset {
            org: "o".to_string(),
            repo: "r".to_string(),
            tag: "v1.0.0".to_string(),
        };
        let display = format!("{err}");
        assert!(display.contains("v1.0.0"));
    }

    // ── Mockito-based client tests ───────────────────────────────────

    /// Standard fake releases JSON for two releases (one published, one draft).
    fn fake_releases_json(server_url: &str) -> String {
        serde_json::json!([
            {
                "tag_name": "v0.2.0",
                "draft": false,
                "assets": [{
                    "name": "omne-v0.2.0-x86_64-unknown-linux-gnu.tar.gz",
                    "browser_download_url": format!("{server_url}/download/v0.2.0/omne.tar.gz"),
                    "size": 1024
                }]
            },
            {
                "tag_name": "v0.1.0",
                "draft": false,
                "assets": [{
                    "name": "omne-v0.1.0-x86_64-unknown-linux-gnu.tar.gz",
                    "browser_download_url": format!("{server_url}/download/v0.1.0/omne.tar.gz"),
                    "size": 512
                }]
            }
        ])
        .to_string()
    }

    // ── latest_release_tag ───────────────────────────────────────────

    #[test]
    fn latest_release_tag_returns_first_non_draft() {
        let mut server = mockito::Server::new();
        let body = fake_releases_json(&server.url());

        let _m = server
            .mock("GET", "/repos/omne-org/omne/releases")
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(&body)
            .create();

        let client = GithubClient::new(&server.url(), "test/1.0", None);
        let tag = client.latest_release_tag("omne-org", "omne").unwrap();
        assert_eq!(tag, "v0.2.0");
    }

    #[test]
    fn latest_release_tag_skips_drafts() {
        let mut server = mockito::Server::new();
        let body = serde_json::json!([
            { "tag_name": "v0.3.0-draft", "draft": true, "assets": [] },
            { "tag_name": "v0.2.0", "draft": false, "assets": [] }
        ])
        .to_string();

        let _m = server
            .mock("GET", "/repos/org/repo/releases")
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(&body)
            .create();

        let client = GithubClient::new(&server.url(), "test/1.0", None);
        let tag = client.latest_release_tag("org", "repo").unwrap();
        assert_eq!(tag, "v0.2.0");
    }

    #[test]
    fn latest_release_tag_empty_list_returns_no_release_found() {
        let mut server = mockito::Server::new();
        let _m = server
            .mock("GET", "/repos/org/repo/releases")
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body("[]")
            .create();

        let client = GithubClient::new(&server.url(), "test/1.0", None);
        let err = client.latest_release_tag("org", "repo").unwrap_err();
        assert!(
            matches!(err, Error::NoReleaseFound { ref org, ref repo } if org == "org" && repo == "repo"),
            "Expected NoReleaseFound, got: {err:?}"
        );
    }

    #[test]
    fn latest_release_tag_404_returns_no_release_found() {
        let mut server = mockito::Server::new();
        let _m = server
            .mock("GET", "/repos/org/missing/releases")
            .with_status(404)
            .with_body("{\"message\": \"Not Found\"}")
            .create();

        let client = GithubClient::new(&server.url(), "test/1.0", None);
        let err = client.latest_release_tag("org", "missing").unwrap_err();
        assert!(matches!(err, Error::NoReleaseFound { .. }));
    }

    // ── Rate limiting ────────────────────────────────────────────────

    #[test]
    fn rate_limit_403_with_headers_returns_rate_limited() {
        let mut server = mockito::Server::new();
        let reset_epoch = SystemTime::now()
            .duration_since(SystemTime::UNIX_EPOCH)
            .unwrap()
            .as_secs()
            + 600;

        let _m = server
            .mock("GET", "/repos/org/repo/releases")
            .with_status(403)
            .with_header("x-ratelimit-remaining", "0")
            .with_header("x-ratelimit-reset", &reset_epoch.to_string())
            .with_body("{\"message\": \"API rate limit exceeded\"}")
            .create();

        let client = GithubClient::new(&server.url(), "test/1.0", None);
        let err = client.latest_release_tag("org", "repo").unwrap_err();
        match err {
            Error::RateLimited { reset_at } => {
                assert!(reset_at.is_some(), "Should parse reset_at from header");
                let display = format!("{err}");
                assert!(display.contains("GITHUB_TOKEN"));
                assert!(display.contains("minute"));
            }
            other => panic!("Expected RateLimited, got: {other:?}"),
        }
    }

    #[test]
    fn auth_failure_403_without_rate_limit_headers() {
        let mut server = mockito::Server::new();
        let _m = server
            .mock("GET", "/repos/org/repo/releases")
            .with_status(403)
            .with_body("{\"message\": \"Bad credentials\"}")
            .create();

        let client = GithubClient::new(&server.url(), "test/1.0", Some("bad-token".into()));
        let err = client.latest_release_tag("org", "repo").unwrap_err();
        assert!(
            matches!(err, Error::AuthFailed),
            "Expected AuthFailed, got: {err:?}"
        );
    }

    #[test]
    fn rate_limit_429_returns_rate_limited() {
        let mut server = mockito::Server::new();
        let _m = server
            .mock("GET", "/repos/org/repo/releases")
            .with_status(429)
            .with_header("retry-after", "60")
            .with_body("{\"message\": \"Too Many Requests\"}")
            .create();

        let client = GithubClient::new(&server.url(), "test/1.0", None);
        let err = client.latest_release_tag("org", "repo").unwrap_err();
        assert!(
            matches!(err, Error::RateLimited { .. }),
            "Expected RateLimited, got: {err:?}"
        );
    }

    // ── Token handling ───────────────────────────────────────────────

    #[test]
    fn client_with_token_sends_authorization_header() {
        let mut server = mockito::Server::new();
        let body = fake_releases_json(&server.url());

        let _m = server
            .mock("GET", "/repos/org/repo/releases")
            .match_header("Authorization", "Bearer test-token-123")
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(&body)
            .expect(1)
            .create();

        let client = GithubClient::new(&server.url(), "test/1.0", Some("test-token-123".into()));
        let tag = client.latest_release_tag("org", "repo").unwrap();
        assert_eq!(tag, "v0.2.0");
        // mockito's expect(1) asserts the mock was hit exactly once with the
        // matched Authorization header.
    }

    #[test]
    fn client_without_token_sends_no_authorization_header() {
        let mut server = mockito::Server::new();
        let body = fake_releases_json(&server.url());

        let _m = server
            .mock("GET", "/repos/org/repo/releases")
            .match_header("Authorization", mockito::Matcher::Missing)
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(&body)
            .expect(1)
            .create();

        let client = GithubClient::new(&server.url(), "test/1.0", None);
        let tag = client.latest_release_tag("org", "repo").unwrap();
        assert_eq!(tag, "v0.2.0");
    }

    // ── Token sanitization ───────────────────────────────────────────

    #[test]
    fn token_not_leaked_on_network_error() {
        // Point at a port that nothing is listening on.
        let client = GithubClient::new(
            "http://127.0.0.1:1",
            "test/1.0",
            Some("sentinel_value_please_do_not_leak".into()),
        );
        let err = client.latest_release_tag("org", "repo").unwrap_err();
        let display = format!("{err}");
        assert!(
            !display.contains("sentinel_value_please_do_not_leak"),
            "Error must not leak token: {display}"
        );
    }

    // ── release_asset_body ───────────────────────────────────────────

    #[test]
    fn release_asset_body_returns_reader_and_size() {
        let mut server = mockito::Server::new();
        let tarball_bytes = b"fake-tarball-content";
        let body = serde_json::json!([{
            "tag_name": "v1.0.0",
            "draft": false,
            "assets": [{
                "name": "omne-v1.0.0.tar.gz",
                "browser_download_url": format!("{}/download/v1.0.0/omne.tar.gz", server.url()),
                "size": tarball_bytes.len()
            }]
        }])
        .to_string();

        let _m_releases = server
            .mock("GET", "/repos/org/repo/releases")
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(&body)
            .create();

        let _m_download = server
            .mock("GET", "/download/v1.0.0/omne.tar.gz")
            .with_status(200)
            .with_body(tarball_bytes)
            .create();

        let client = GithubClient::new(&server.url(), "test/1.0", None);
        let (size, mut reader) = client.release_asset_body("org", "repo", "v1.0.0").unwrap();

        assert_eq!(size, tarball_bytes.len() as u64);

        let mut buf = Vec::new();
        reader.read_to_end(&mut buf).unwrap();
        assert_eq!(buf, tarball_bytes);
    }

    #[test]
    fn release_asset_body_no_tarball_asset() {
        let mut server = mockito::Server::new();
        let body = serde_json::json!([{
            "tag_name": "v1.0.0",
            "draft": false,
            "assets": [{
                "name": "omne-v1.0.0.zip",
                "browser_download_url": format!("{}/download/omne.zip", server.url()),
                "size": 100
            }]
        }])
        .to_string();

        let _m = server
            .mock("GET", "/repos/org/repo/releases")
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(&body)
            .create();

        let client = GithubClient::new(&server.url(), "test/1.0", None);
        let result = client.release_asset_body("org", "repo", "v1.0.0");
        let err = result.err().expect("expected error");
        assert!(
            matches!(err, Error::NoTarballAsset { ref tag, .. } if tag == "v1.0.0"),
            "Expected NoTarballAsset, got: {err:?}"
        );
    }

    #[test]
    fn release_asset_body_tag_not_found() {
        let mut server = mockito::Server::new();
        let body = serde_json::json!([{
            "tag_name": "v2.0.0",
            "draft": false,
            "assets": []
        }])
        .to_string();

        let _m = server
            .mock("GET", "/repos/org/repo/releases")
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(&body)
            .create();

        let client = GithubClient::new(&server.url(), "test/1.0", None);
        let result = client.release_asset_body("org", "repo", "v1.0.0");
        let err = result.err().expect("expected error");
        assert!(
            matches!(err, Error::NoReleaseFound { .. }),
            "Expected NoReleaseFound, got: {err:?}"
        );
    }

    #[test]
    fn release_asset_body_404_returns_no_release_found() {
        let mut server = mockito::Server::new();

        let _m = server
            .mock("GET", "/repos/org/repo/releases")
            .with_status(404)
            .create();

        let client = GithubClient::new(&server.url(), "test/1.0", None);
        let result = client.release_asset_body("org", "repo", "v1.0.0");
        let err = result.err().expect("expected error");
        assert!(
            matches!(err, Error::NoReleaseFound { .. }),
            "Expected NoReleaseFound on 404, got: {err:?}"
        );
    }

    #[test]
    fn release_asset_body_429_returns_rate_limited() {
        let mut server = mockito::Server::new();

        let _m = server
            .mock("GET", "/repos/org/repo/releases")
            .with_status(429)
            .with_header("retry-after", "60")
            .create();

        let client = GithubClient::new(&server.url(), "test/1.0", None);
        let result = client.release_asset_body("org", "repo", "v1.0.0");
        let err = result.err().expect("expected error");
        assert!(
            matches!(err, Error::RateLimited { .. }),
            "Expected RateLimited on 429, got: {err:?}"
        );
    }

    // ── GitHub API headers ───────────────────────────────────────────

    #[test]
    fn client_sends_required_github_api_headers() {
        let mut server = mockito::Server::new();
        let body = fake_releases_json(&server.url());

        let _m = server
            .mock("GET", "/repos/org/repo/releases")
            .match_header("Accept", "application/vnd.github+json")
            .match_header("X-GitHub-Api-Version", "2022-11-28")
            .match_header("User-Agent", "omne-cli/test")
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(&body)
            .expect(1)
            .create();

        let client = GithubClient::new(&server.url(), "omne-cli/test", None);
        client.latest_release_tag("org", "repo").unwrap();
    }

    // ── from_env constructor ─────────────────────────────────────────

    #[test]
    fn from_env_prefers_github_token_over_gh_token() {
        // This test validates the from_env logic by testing the constructor
        // with explicit tokens (avoiding process-global env mutation).
        // The precedence logic is: GITHUB_TOKEN wins when both are set.
        //
        // We verify this structurally: from_env reads GITHUB_TOKEN first
        // via ok().or_else(GH_TOKEN). We test the actual header sent
        // using the explicit new() constructor.
        let mut server = mockito::Server::new();
        let body = fake_releases_json(&server.url());

        // Simulate the precedence: GITHUB_TOKEN="X" wins over GH_TOKEN="Y"
        let _m = server
            .mock("GET", "/repos/org/repo/releases")
            .match_header("Authorization", "Bearer X")
            .with_status(200)
            .with_header("content-type", "application/json")
            .with_body(&body)
            .expect(1)
            .create();

        // This mirrors what from_env does when GITHUB_TOKEN=X and GH_TOKEN=Y
        let client = GithubClient::new(&server.url(), "test/1.0", Some("X".into()));
        client.latest_release_tag("org", "repo").unwrap();
    }

    // ── Live smoke test (ignored by default) ─────────────────────────

    #[test]
    #[ignore = "hits real api.github.com — run manually with `cargo test -- --ignored`"]
    fn live_smoke_test_latest_release() {
        let client = GithubClient::from_env("https://api.github.com", "omne-cli/test");
        let tag = client.latest_release_tag("omne-org", "omne").unwrap();
        assert!(
            tag.starts_with('v'),
            "Expected tag starting with 'v', got: {tag}"
        );
    }
}
