use anyhow::{Context as _, anyhow, bail};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use chrono::{DateTime, Utc};
use fabro_redact::DisplaySafeUrl;
use fabro_static::EnvVars;
use fabro_types::PullRequestGithubDetail;
use fabro_types::settings::run::MergeStrategy;
use serde::Deserialize;
use tokio::process::Command;

pub mod access;
pub mod token_source;

#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
#[cfg(test)]
pub(crate) mod tests_mock;

pub use access::GitHubRepositoryAccess;

pub const GITHUB_API_BASE_URL: &str = "https://api.github.com";

/// Git config key that routes github.com HTTPS credentials through
/// [`GITHUB_CREDENTIAL_HELPER`].
pub const GITHUB_CREDENTIAL_HELPER_KEY: &str = "credential.https://github.com.helper";

/// Secret-free git credential helper: reads `$GITHUB_TOKEN` from the
/// invoking git process's environment at invocation time, so the token never
/// lands in git configuration, argv, or rendered errors. Non-`get`
/// operations (`store`, `erase`) are ignored. The one definition shared by
/// the runtime git bridge, server preflight probes, and the live contract
/// test, so the probes always exercise exactly what the bridge configures.
pub const GITHUB_CREDENTIAL_HELPER: &str = r#"!f() { if [ "$1" = get ]; then echo username=x-access-token; echo "password=$GITHUB_TOKEN"; fi; }; f"#;

/// Configure a `git` invocation to authenticate to github.com through
/// [`GITHUB_CREDENTIAL_HELPER`] with `token`, isolated from user/system git
/// configuration and terminal prompts. The token is passed only through the
/// child process environment.
pub fn apply_probe_git_env(command: &mut Command, token: &str) {
    command
        .env("GIT_TERMINAL_PROMPT", "0")
        .env("GIT_CONFIG_NOSYSTEM", "1")
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env(EnvVars::GITHUB_TOKEN, token)
        .env("GIT_CONFIG_COUNT", "1")
        .env("GIT_CONFIG_KEY_0", GITHUB_CREDENTIAL_HELPER_KEY)
        .env("GIT_CONFIG_VALUE_0", GITHUB_CREDENTIAL_HELPER);
}

/// Returns the GitHub API base URL, allowing override via `GITHUB_BASE_URL` env
/// var.
#[expect(
    clippy::disallowed_methods,
    reason = "GitHub API client exposes a documented process-env base URL override."
)]
pub fn github_api_base_url() -> String {
    std::env::var(EnvVars::GITHUB_BASE_URL).unwrap_or_else(|_| GITHUB_API_BASE_URL.to_string())
}

/// Bundle of GitHub credentials and the API base URL, threaded through every
/// authenticated GitHub call. Lets call sites pass one parameter instead of
/// two, and keeps the auth/endpoint pair from drifting apart.
#[derive(Debug, Clone)]
pub struct GitHubContext<'a> {
    creds:       &'a GitHubCredentials,
    base_url:    &'a str,
    http_client: Option<fabro_http::HttpClient>,
}

impl<'a> GitHubContext<'a> {
    pub fn new(creds: &'a GitHubCredentials, base_url: &'a str) -> Self {
        Self {
            creds,
            base_url,
            http_client: None,
        }
    }

    pub fn with_http_client(
        creds: &'a GitHubCredentials,
        base_url: &'a str,
        http_client: fabro_http::HttpClient,
    ) -> Self {
        Self {
            creds,
            base_url,
            http_client: Some(http_client),
        }
    }

    fn http_client(&self) -> anyhow::Result<fabro_http::HttpClient> {
        self.http_client.clone().map_or_else(http_client, Ok)
    }
}

/// Errors returned by pull-request endpoints. Callers branch on `NotFound` to
/// distinguish a missing PR from any other failure.
#[derive(Debug, thiserror::Error)]
pub enum PullRequestApiError {
    #[error("Pull request #{number} not found in {owner}/{repo}")]
    NotFound {
        owner:  String,
        repo:   String,
        number: u64,
    },
    #[error(transparent)]
    Other(#[from] anyhow::Error),
}

fn http_client() -> anyhow::Result<fabro_http::HttpClient> {
    fabro_http::http_client().map_err(Into::into)
}

/// Owner information for a GitHub App.
#[derive(Debug, Clone, Deserialize)]
pub struct AppOwner {
    pub login: String,
}

/// Information about a GitHub App from the authenticated `/app` endpoint.
#[derive(Debug, Clone, Deserialize)]
pub struct AppInfo {
    pub slug:  String,
    pub owner: AppOwner,
}

/// Credentials for authenticating as a GitHub App.
#[derive(Clone, Debug)]
pub struct GitHubAppCredentials {
    pub app_id:          String,
    pub private_key_pem: String,
    pub slug:            Option<String>,
}

impl GitHubAppCredentials {
    #[expect(
        clippy::disallowed_methods,
        reason = "GitHub App credentials support a documented private-key env source."
    )]
    pub fn private_key_from_env() -> Result<Option<String>, String> {
        let Ok(raw) = std::env::var(EnvVars::GITHUB_APP_PRIVATE_KEY) else {
            return Ok(None);
        };
        decode_pem_env(EnvVars::GITHUB_APP_PRIVATE_KEY, &raw).map(Some)
    }

    pub fn from_env(app_id: Option<&str>) -> Result<Option<Self>, String> {
        Self::from_env_with_slug(app_id, None)
    }

    pub fn from_env_with_slug(
        app_id: Option<&str>,
        slug: Option<&str>,
    ) -> Result<Option<Self>, String> {
        let Some(app_id) = app_id else {
            return Ok(None);
        };
        let Some(private_key_pem) = Self::private_key_from_env()? else {
            return Ok(None);
        };
        Ok(Some(Self {
            app_id: app_id.to_string(),
            private_key_pem,
            slug: slug
                .map(str::trim)
                .filter(|slug| !slug.is_empty())
                .map(str::to_string),
        }))
    }

    pub fn installation_url(&self, owner: &str) -> Option<String> {
        self.slug.as_ref().map(|slug| {
            format!("https://github.com/organizations/{owner}/settings/apps/{slug}/installations")
        })
    }

    pub async fn mint_installation_token(
        &self,
        client: &impl HttpClient,
        owner: &str,
        repo: &str,
        base_url: &str,
        permissions: serde_json::Value,
        install_url: Option<&str>,
    ) -> anyhow::Result<InstallationToken> {
        self.mint_installation_token_for_repositories(
            client,
            owner,
            &[repo.to_string()],
            base_url,
            permissions,
            install_url,
        )
        .await
    }

    /// Mint one installation token scoped to every repository in
    /// `repository_names` (names within `owner`'s installation, primary
    /// first) with the shared `permissions`.
    pub async fn mint_installation_token_for_repositories(
        &self,
        client: &impl HttpClient,
        owner: &str,
        repository_names: &[String],
        base_url: &str,
        permissions: serde_json::Value,
        install_url: Option<&str>,
    ) -> anyhow::Result<InstallationToken> {
        let jwt = sign_app_jwt(&self.app_id, &self.private_key_pem)?;
        let default_install_url = self.installation_url(owner);
        let install_url = install_url.or(default_install_url.as_deref());
        mint_installation_token_with_jwt(
            client,
            &jwt,
            owner,
            repository_names,
            base_url,
            permissions,
            install_url,
        )
        .await
    }
}

#[derive(Clone, Debug)]
pub struct InstallationToken {
    pub token:      String,
    pub expires_at: DateTime<Utc>,
}

impl InstallationToken {
    pub fn near_expiry(&self, threshold: std::time::Duration) -> bool {
        let threshold = chrono::Duration::from_std(threshold).unwrap_or(chrono::Duration::MAX);
        self.expires_at <= Utc::now() + threshold
    }

    pub fn valid_token(&self) -> anyhow::Result<&str> {
        if self.expires_at <= Utc::now() {
            bail!(
                "GitHub installation access token expired at {}",
                self.expires_at
            );
        }
        Ok(&self.token)
    }
}

#[derive(Clone, Debug)]
pub enum GitHubCredentials {
    App(GitHubAppCredentials),
    Pat(String),
    Installation(InstallationToken),
}

impl GitHubCredentials {
    /// Whether resolving these credentials mints a new installation token.
    #[must_use]
    pub fn mints_installation_token(&self) -> bool {
        matches!(self, Self::App(_))
    }

    pub fn from_env(app_id: Option<&str>) -> Result<Option<Self>, String> {
        Ok(GitHubAppCredentials::from_env(app_id)?.map(Self::App))
    }

    pub fn from_env_with_slug(
        app_id: Option<&str>,
        slug: Option<&str>,
    ) -> Result<Option<Self>, String> {
        Ok(GitHubAppCredentials::from_env_with_slug(app_id, slug)?.map(Self::App))
    }

    pub async fn resolve_bearer_token(
        &self,
        client: &impl HttpClient,
        owner: &str,
        repo: &str,
        base_url: &str,
        permissions: serde_json::Value,
    ) -> anyhow::Result<String> {
        match self {
            Self::App(creds) => {
                let install_url = creds.installation_url(owner);
                creds
                    .mint_installation_token(
                        client,
                        owner,
                        repo,
                        base_url,
                        permissions,
                        install_url.as_deref(),
                    )
                    .await
                    .map(|token| token.token)
            }
            Self::Pat(token) => Ok(token.clone()),
            Self::Installation(token) => token.valid_token().map(str::to_owned),
        }
    }
}

pub fn validate_static_github_token(token: &str) -> anyhow::Result<()> {
    if token.starts_with("ghs_") {
        bail!(
            "GitHub installation access token (ghs_*) cannot be configured as a static token \
             because it expires quickly; use a PAT or GitHub App credentials instead"
        );
    }
    Ok(())
}

pub async fn gh_auth_token() -> anyhow::Result<String> {
    let output = Command::new("gh")
        .args(["auth", "token"])
        .output()
        .await
        .context("Failed to run `gh auth token`")?;
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
        let message = if stderr.is_empty() {
            format!("`gh auth token` exited with status {}", output.status)
        } else {
            stderr
        };
        bail!("Failed to get GitHub CLI token: {message}");
    }

    let token =
        String::from_utf8(output.stdout).context("`gh auth token` returned invalid UTF-8")?;
    let token = token.trim().to_string();
    if token.is_empty() {
        bail!("`gh auth token` returned an empty token");
    }
    Ok(token)
}

fn decode_pem_env(name: &str, raw: &str) -> Result<String, String> {
    if raw.starts_with("-----") {
        return Ok(raw.to_string());
    }
    let pem_bytes = STANDARD
        .decode(raw)
        .map_err(|err| format!("{name} is not valid PEM or base64: {err}"))?;
    String::from_utf8(pem_bytes)
        .map_err(|err| format!("{name} base64 decoded to invalid UTF-8: {err}"))
}

/// HTTP method used in GitHub API calls.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpMethod {
    Get,
    Post,
    Put,
    Patch,
}

/// A minimal HTTP response for testability.
pub struct HttpResponse {
    pub status: u16,
    body:       String,
}

impl HttpResponse {
    pub fn new(status: u16, body: String) -> Self {
        Self { status, body }
    }

    pub fn json<T: for<'de> Deserialize<'de>>(&self) -> anyhow::Result<T> {
        serde_json::from_str(&self.body).context("Failed to parse response")
    }

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

/// Abstract HTTP client for GitHub API calls.
///
/// Implemented for `fabro_http::HttpClient` in production; tests use a mock
/// to avoid TCP/process overhead.
pub trait HttpClient: Send + Sync {
    fn request(
        &self,
        method: HttpMethod,
        url: &str,
        headers: &[(&str, &str)],
        body: Option<&serde_json::Value>,
    ) -> impl std::future::Future<Output = anyhow::Result<HttpResponse>> + Send;
}

impl HttpClient for fabro_http::HttpClient {
    async fn request(
        &self,
        method: HttpMethod,
        url: &str,
        headers: &[(&str, &str)],
        body: Option<&serde_json::Value>,
    ) -> anyhow::Result<HttpResponse> {
        let mut builder = match method {
            HttpMethod::Get => self.get(url),
            HttpMethod::Post => self.post(url),
            HttpMethod::Put => self.put(url),
            HttpMethod::Patch => self.patch(url),
        };
        for &(key, value) in headers {
            builder = builder.header(key, value);
        }
        if let Some(json_body) = body {
            builder = builder.json(json_body);
        }
        let resp = builder.send().await.map_err(anyhow::Error::new)?;
        let status = resp.status().as_u16();
        let text = resp.text().await.map_err(anyhow::Error::new)?;
        Ok(HttpResponse::new(status, text))
    }
}

/// Parse `owner` and `repo` from a GitHub HTTPS URL.
///
/// Accepts URLs like:
/// - `https://github.com/owner/repo.git`
/// - `https://github.com/owner/repo`
/// - `https://github.com/owner/repo/`
/// - `https://x-access-token:TOKEN@github.com/owner/repo.git`
pub fn parse_github_owner_repo(url: &str) -> anyhow::Result<(String, String)> {
    // Strip credentials from URLs like https://x-access-token:TOKEN@github.com/...
    let stripped = url.strip_prefix("https://").and_then(|rest| {
        rest.split_once('@')
            .map(|(_, after)| format!("https://{after}"))
    });
    let url = stripped.as_deref().unwrap_or(url);
    let display_url = redacted_url_for_error(url);
    let path = url
        .strip_prefix("https://github.com/")
        .ok_or_else(|| anyhow!("Not a GitHub HTTPS URL: {display_url}"))?;

    let path = path.trim_end_matches('/');
    let path = path.strip_suffix(".git").unwrap_or(path);

    let mut parts = path.split('/');
    let owner = parts
        .next()
        .filter(|s| !s.is_empty())
        .ok_or_else(|| anyhow!("Missing owner in GitHub URL: {display_url}"))?;
    let repo = parts
        .next()
        .filter(|s| !s.is_empty())
        .ok_or_else(|| anyhow!("Missing repo in GitHub URL: {display_url}"))?;
    if parts.next().is_some() {
        bail!("GitHub URL must identify one repository: {display_url}");
    }

    Ok((owner.to_string(), repo.to_string()))
}

fn redacted_url_for_error(url: &str) -> String {
    DisplaySafeUrl::parse(url)
        .map_or_else(|_| "<invalid url>".to_string(), |url| url.redacted_string())
}

/// Create a signed JWT for GitHub App authentication (RS256).
///
/// The JWT is valid for 10 minutes with a 60-second clock skew allowance.
pub fn sign_app_jwt(app_id: &str, private_key_pem: &str) -> anyhow::Result<String> {
    use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
    use serde::Serialize;

    #[derive(Serialize)]
    struct Claims {
        iss: String,
        iat: i64,
        exp: i64,
    }

    let now = chrono::Utc::now().timestamp();
    let claims = Claims {
        iss: app_id.to_string(),
        iat: now - 60,
        exp: now + 600,
    };

    let key =
        EncodingKey::from_rsa_pem(private_key_pem.as_bytes()).context("Invalid RSA private key")?;

    let jwt =
        encode(&Header::new(Algorithm::RS256), &claims, &key).context("Failed to sign JWT")?;
    Ok(jwt)
}

/// Standard GitHub API headers for authenticated requests.
fn github_headers(auth: &str) -> [(&str, &str); 3] {
    [
        ("Authorization", auth),
        ("Accept", "application/vnd.github+json"),
        ("User-Agent", "fabro"),
    ]
}

/// Request a scoped Installation Access Token for a specific repository.
///
/// Uses the App JWT to find the installation for `owner/repo`, then requests
/// a token scoped to the given `permissions` on that single repository.
pub async fn create_installation_access_token_with_permissions(
    client: &impl HttpClient,
    jwt: &str,
    owner: &str,
    repo: &str,
    base_url: &str,
    permissions: serde_json::Value,
) -> anyhow::Result<String> {
    create_installation_access_token_with_permissions_and_install_url(
        client,
        jwt,
        owner,
        repo,
        base_url,
        permissions,
        None,
    )
    .await
}

pub async fn create_installation_access_token_with_permissions_and_install_url(
    client: &impl HttpClient,
    jwt: &str,
    owner: &str,
    repo: &str,
    base_url: &str,
    permissions: serde_json::Value,
    install_url: Option<&str>,
) -> anyhow::Result<String> {
    mint_installation_token_with_jwt(
        client,
        jwt,
        owner,
        &[repo.to_string()],
        base_url,
        permissions,
        install_url,
    )
    .await
    .map(|token| token.token)
}

/// Outcome of one GitHub App installation lookup
/// (`GET /repos/{owner}/{repo}/installation`).
///
/// Status interpretation stays with callers because their user guidance
/// differs: the mint path speaks about the owner's App installation, the
/// multi-repository resolution names the specific repository.
pub(crate) enum InstallationLookup {
    Found(u64),
    /// 404: the App cannot see the repository (or is not installed at all).
    NotFound,
    /// Any other non-200 status.
    Failed(u16),
}

/// Look up the App installation covering `owner/repo` and parse its id.
/// Transport and parse failures carry no caller-specific context; callers
/// attach their own.
pub(crate) async fn lookup_installation(
    client: &impl HttpClient,
    jwt: &str,
    base_url: &str,
    owner: &str,
    repo: &str,
) -> anyhow::Result<InstallationLookup> {
    #[derive(Deserialize)]
    struct Installation {
        id: u64,
    }

    let endpoint = format!("{base_url}/repos/{owner}/{repo}/installation");
    let auth = format!("Bearer {jwt}");
    let resp = client
        .request(HttpMethod::Get, &endpoint, &github_headers(&auth), None)
        .await?;
    match resp.status {
        200 => {
            let installation: Installation = resp
                .json()
                .context("Failed to parse installation response")?;
            Ok(InstallationLookup::Found(installation.id))
        }
        404 => Ok(InstallationLookup::NotFound),
        status => Ok(InstallationLookup::Failed(status)),
    }
}

async fn mint_installation_token_with_jwt(
    client: &impl HttpClient,
    jwt: &str,
    owner: &str,
    repos: &[String],
    base_url: &str,
    permissions: serde_json::Value,
    install_url: Option<&str>,
) -> anyhow::Result<InstallationToken> {
    let Some(primary_repo) = repos.first() else {
        bail!("installation token mint requires at least one repository");
    };

    // Step 1: Find the installation via the primary repository. Multi-
    // repository callers resolve every repository's installation up front
    // (`GitHubRepositoryAccess::resolve_shared_installation`), so the
    // primary stands for the whole set here.
    let installation_id = match lookup_installation(client, jwt, base_url, owner, primary_repo)
        .await
        .context("Failed to look up GitHub App installation")?
    {
        InstallationLookup::Found(id) => id,
        InstallationLookup::NotFound => {
            let install_url = install_url.map_or_else(
                || format!("https://github.com/organizations/{owner}/settings/installations"),
                str::to_string,
            );
            bail!(
                "GitHub App is not installed for {owner}, or its installation does not \
                 include {owner}/{primary_repo}. Check repository access at {install_url}"
            );
        }
        InstallationLookup::Failed(403) => {
            bail!(
                "GitHub App installation is suspended. \
                 Re-enable it in your organization's GitHub App settings."
            );
        }
        InstallationLookup::Failed(401) => {
            bail!(
                "GitHub App authentication failed. \
                 Check that app_id and GITHUB_APP_PRIVATE_KEY are correct."
            );
        }
        InstallationLookup::Failed(status) => {
            bail!("Unexpected status {status} looking up GitHub App installation");
        }
    };

    mint_installation_token_for_id_with_jwt(
        client,
        jwt,
        installation_id,
        repos,
        base_url,
        permissions,
    )
    .await
}

/// Create a repository-scoped token for an installation already resolved by
/// the caller.
pub(crate) async fn mint_installation_token_for_id_with_jwt(
    client: &impl HttpClient,
    jwt: &str,
    installation_id: u64,
    repos: &[String],
    base_url: &str,
    permissions: serde_json::Value,
) -> anyhow::Result<InstallationToken> {
    #[derive(Deserialize)]
    struct AccessToken {
        token:      String,
        expires_at: DateTime<Utc>,
    }

    let auth = format!("Bearer {jwt}");
    let token_url = format!("{base_url}/app/installations/{installation_id}/access_tokens");
    let body = serde_json::json!({
        "repositories": repos,
        "permissions": permissions,
    });

    let token_resp = client
        .request(
            HttpMethod::Post,
            &token_url,
            &github_headers(&auth),
            Some(&body),
        )
        .await
        .context("Failed to create installation access token")?;

    match token_resp.status {
        201 => {}
        422 => {
            bail!(
                "GitHub App does not have access to every requested repository ({}). \
                 Update the installation's repository permissions to include them.",
                repos.join(", ")
            );
        }
        401 => {
            bail!(
                "GitHub App authentication failed. \
                 Check that app_id and GITHUB_APP_PRIVATE_KEY are correct."
            );
        }
        _ => {
            bail!(
                "Unexpected status {} creating installation access token",
                token_resp.status
            );
        }
    }

    let access_token: AccessToken = token_resp
        .json()
        .context("Failed to parse access token response")?;

    Ok(InstallationToken {
        token:      access_token.token,
        expires_at: access_token.expires_at,
    })
}

/// Request a scoped Installation Access Token with `contents: write`.
pub async fn create_installation_access_token(
    client: &impl HttpClient,
    jwt: &str,
    owner: &str,
    repo: &str,
    base_url: &str,
) -> anyhow::Result<String> {
    create_installation_access_token_with_permissions(
        client,
        jwt,
        owner,
        repo,
        base_url,
        serde_json::json!({ "contents": "write" }),
    )
    .await
}

/// Request a scoped Installation Access Token with `contents: write`
/// and `pull_requests: write`. Used for creating pull requests.
pub async fn create_installation_access_token_for_pr(
    client: &impl HttpClient,
    jwt: &str,
    owner: &str,
    repo: &str,
    base_url: &str,
) -> anyhow::Result<String> {
    create_installation_access_token_with_permissions(
        client,
        jwt,
        owner,
        repo,
        base_url,
        serde_json::json!({ "contents": "write", "pull_requests": "write" }),
    )
    .await
}

/// Pull request created on, or reconciled from, GitHub.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreatedPullRequest {
    pub html_url: String,
    pub number:   u64,
    pub node_id:  String,
    pub title:    String,
}

/// Find an open pull request that already carries the expected head commit.
///
/// This supports recovery when GitHub created a pull request but the caller
/// stopped before it could persist the result locally.
pub async fn find_open_pull_request(
    ctx: &GitHubContext<'_>,
    owner: &str,
    repo: &str,
    base: &str,
    head: &str,
    expected_head_sha: &str,
) -> anyhow::Result<Option<CreatedPullRequest>> {
    let client = ctx.http_client()?;
    find_open_pull_request_with_client(&client, ctx, owner, repo, base, head, expected_head_sha)
        .await
}

#[allow(
    clippy::too_many_arguments,
    reason = "Pull request reconciliation needs explicit repo, branch, and commit coordinates."
)]
pub async fn find_open_pull_request_with_client(
    client: &impl HttpClient,
    ctx: &GitHubContext<'_>,
    owner: &str,
    repo: &str,
    base: &str,
    head: &str,
    expected_head_sha: &str,
) -> anyhow::Result<Option<CreatedPullRequest>> {
    #[derive(Deserialize)]
    struct PullRequestHead {
        sha: String,
    }

    #[derive(Deserialize)]
    struct PullRequestListItem {
        html_url: String,
        number:   u64,
        node_id:  String,
        title:    String,
        head:     PullRequestHead,
    }

    let token = ctx
        .creds
        .resolve_bearer_token(
            client,
            owner,
            repo,
            ctx.base_url,
            serde_json::json!({ "contents": "write", "pull_requests": "write" }),
        )
        .await?;
    let mut url = DisplaySafeUrl::parse(&format!("{}/repos/{owner}/{repo}/pulls", ctx.base_url))
        .context("Failed to build pull request reconciliation URL")?;
    url.query_pairs_mut()
        .append_pair("state", "open")
        .append_pair("base", base)
        .append_pair("head", &format!("{owner}:{head}"));
    let auth = format!("Bearer {token}");
    let resp = client
        .request(
            HttpMethod::Get,
            &url.raw_string(),
            &github_headers(&auth),
            None,
        )
        .await
        .context("Failed to find an existing pull request")?;
    if resp.status != 200 {
        bail!(
            "Unexpected status {} finding an existing pull request: {}",
            resp.status,
            resp.text()
        );
    }

    let pull_requests = resp
        .json::<Vec<PullRequestListItem>>()
        .context("Failed to parse existing pull request response")?;
    Ok(pull_requests
        .into_iter()
        .find(|pull_request| pull_request.head.sha == expected_head_sha)
        .map(|pull_request| CreatedPullRequest {
            html_url: pull_request.html_url,
            number:   pull_request.number,
            node_id:  pull_request.node_id,
            title:    pull_request.title,
        }))
}

/// Create a pull request on GitHub.
///
/// Signs a JWT, obtains a PR-scoped installation token, and POSTs to the
/// GitHub pulls API.
#[allow(
    clippy::too_many_arguments,
    reason = "Creating a pull request needs explicit repo, branch, and body fields."
)]
pub async fn create_pull_request(
    ctx: &GitHubContext<'_>,
    owner: &str,
    repo: &str,
    base: &str,
    head: &str,
    title: &str,
    body: &str,
    draft: bool,
) -> anyhow::Result<CreatedPullRequest> {
    let client = ctx.http_client()?;
    create_pull_request_with_client(&client, ctx, owner, repo, base, head, title, body, draft).await
}

#[allow(
    clippy::too_many_arguments,
    reason = "Creating a pull request needs explicit repo, branch, and body fields."
)]
pub async fn create_pull_request_with_client(
    client: &impl HttpClient,
    ctx: &GitHubContext<'_>,
    owner: &str,
    repo: &str,
    base: &str,
    head: &str,
    title: &str,
    body: &str,
    draft: bool,
) -> anyhow::Result<CreatedPullRequest> {
    #[derive(Deserialize)]
    struct PullRequestResponse {
        html_url: String,
        number:   u64,
        node_id:  String,
    }

    let token = ctx
        .creds
        .resolve_bearer_token(
            client,
            owner,
            repo,
            ctx.base_url,
            serde_json::json!({ "contents": "write", "pull_requests": "write" }),
        )
        .await?;

    tracing::info!(title = %title, head = %head, base = %base, draft, "Creating pull request");

    let pr_body = serde_json::json!({
        "title": title,
        "head": head,
        "base": base,
        "body": body,
        "draft": draft,
    });

    let url = format!("{}/repos/{owner}/{repo}/pulls", ctx.base_url);
    let auth = format!("Bearer {token}");
    let resp = HttpClient::request(
        client,
        HttpMethod::Post,
        &url,
        &github_headers(&auth),
        Some(&pr_body),
    )
    .await
    .context("Failed to create pull request")?;

    match resp.status {
        201 => {}
        422 => {
            bail!("Pull request could not be created (422): {}", resp.text());
        }
        401 | 403 => {
            bail!(
                "Authentication failed creating pull request ({})",
                resp.status
            );
        }
        _ => {
            bail!(
                "Unexpected status {} creating pull request: {}",
                resp.status,
                resp.text()
            );
        }
    }

    let pr: PullRequestResponse = resp
        .json()
        .context("Failed to parse pull request response")?;

    Ok(CreatedPullRequest {
        html_url: pr.html_url,
        number:   pr.number,
        node_id:  pr.node_id,
        title:    title.to_string(),
    })
}

fn merge_method_as_graphql_value(method: MergeStrategy) -> &'static str {
    match method {
        MergeStrategy::Merge => "MERGE",
        MergeStrategy::Squash => "SQUASH",
        MergeStrategy::Rebase => "REBASE",
    }
}

/// Enable auto-merge on a pull request via GitHub's GraphQL API.
///
/// Requires the PR's `node_id` (from the REST API response) and a merge method.
/// The repository must have auto-merge enabled in its settings.
pub async fn enable_auto_merge(
    ctx: &GitHubContext<'_>,
    owner: &str,
    repo: &str,
    pr_node_id: &str,
    merge_method: MergeStrategy,
) -> anyhow::Result<()> {
    let client = ctx.http_client()?;
    enable_auto_merge_with_client(&client, ctx, owner, repo, pr_node_id, merge_method).await
}

pub async fn enable_auto_merge_with_client(
    client: &impl HttpClient,
    ctx: &GitHubContext<'_>,
    owner: &str,
    repo: &str,
    pr_node_id: &str,
    merge_method: MergeStrategy,
) -> anyhow::Result<()> {
    let token = ctx
        .creds
        .resolve_bearer_token(
            client,
            owner,
            repo,
            ctx.base_url,
            serde_json::json!({ "contents": "write", "pull_requests": "write" }),
        )
        .await?;

    let graphql_value = merge_method_as_graphql_value(merge_method);
    let query = format!(
        r#"mutation {{
  enablePullRequestAutoMerge(input: {{pullRequestId: "{pr_node_id}", mergeMethod: {graphql_value}}}) {{
    pullRequest {{
      autoMergeRequest {{
        enabledAt
        mergeMethod
      }}
    }}
  }}
}}"#,
    );

    tracing::debug!(
        pr_node_id,
        merge_method = graphql_value,
        "Enabling auto-merge"
    );

    let graphql_url = format!("{}/graphql", ctx.base_url);
    let auth = format!("Bearer {token}");
    let graphql_body = serde_json::json!({ "query": query });
    let resp = HttpClient::request(
        client,
        HttpMethod::Post,
        &graphql_url,
        &[("Authorization", auth.as_str()), ("User-Agent", "fabro")],
        Some(&graphql_body),
    )
    .await
    .context("Failed to enable auto-merge")?;

    let status = resp.status;
    let body: serde_json::Value = resp.json().context("Failed to parse auto-merge response")?;

    if !(200..300).contains(&status) {
        bail!("Auto-merge request failed ({status}): {body}");
    }

    if let Some(errors) = body.get("errors") {
        bail!("Auto-merge GraphQL error: {errors}");
    }

    tracing::info!(pr_node_id, "Auto-merge enabled");
    Ok(())
}

/// Convert a Git SSH URL to HTTPS format for token-based authentication.
///
/// SSH URLs like `git@github.com:owner/repo.git` become
/// `https://github.com/owner/repo.git`. URLs that are already HTTPS
/// (or any other non-SSH format) are returned unchanged.
pub fn ssh_url_to_https(url: &str) -> String {
    // Match `git@<host>:<path>` (standard SSH URL format)
    if let Some(rest) = url.strip_prefix("git@") {
        if let Some((host, path)) = rest.split_once(':') {
            return format!("https://{host}/{path}");
        }
    }
    // Match `ssh://git@<host>/<path>`
    if let Some(rest) = url.strip_prefix("ssh://git@") {
        return format!("https://{rest}");
    }
    url.to_string()
}

pub fn normalize_repo_origin_url(url: &str) -> String {
    let https = ssh_url_to_https(url.trim());
    let without_credentials = strip_https_credentials(&https);
    let normalized = normalize_https_host_path(&without_credentials);
    let normalized = normalized.trim_end_matches('/');
    normalized
        .strip_suffix(".git")
        .unwrap_or(normalized)
        .to_string()
}

fn strip_https_credentials(url: &str) -> String {
    let Some(rest) = url.strip_prefix("https://") else {
        return url.to_string();
    };

    match rest.split_once('@') {
        Some((before, after)) if !before.contains('/') => format!("https://{after}"),
        _ => url.to_string(),
    }
}

fn normalize_https_host_path(url: &str) -> String {
    let Some(rest) = url.strip_prefix("https://") else {
        return url.to_string();
    };

    match rest.split_once(':') {
        Some((host, path)) if !host.contains('/') && !path.starts_with('/') => {
            format!("https://{host}/{path}")
        }
        _ => url.to_string(),
    }
}

/// Return the commit SHA at the head of a GitHub branch.
///
/// Returns `None` when the branch does not exist.
pub async fn branch_head_sha(
    ctx: &GitHubContext<'_>,
    owner: &str,
    repo: &str,
    branch: &str,
) -> anyhow::Result<Option<String>> {
    let client = ctx.http_client()?;
    branch_head_sha_with_client(&client, ctx, owner, repo, branch).await
}

async fn branch_head_sha_with_client(
    client: &impl HttpClient,
    ctx: &GitHubContext<'_>,
    owner: &str,
    repo: &str,
    branch: &str,
) -> anyhow::Result<Option<String>> {
    #[derive(Deserialize)]
    struct BranchResponse {
        commit: BranchCommit,
    }

    #[derive(Deserialize)]
    struct BranchCommit {
        sha: String,
    }

    let token = ctx
        .creds
        .resolve_bearer_token(
            client,
            owner,
            repo,
            ctx.base_url,
            serde_json::json!({ "contents": "read" }),
        )
        .await?;

    let url = format!("{}/repos/{owner}/{repo}/branches/{branch}", ctx.base_url);
    let auth = format!("Bearer {token}");
    let resp = client
        .request(HttpMethod::Get, &url, &github_headers(&auth), None)
        .await
        .context("Failed to read remote branch head")?;

    match resp.status {
        200 => {
            let branch: BranchResponse = resp
                .json()
                .context("Failed to parse remote branch response")?;
            Ok(Some(branch.commit.sha))
        }
        404 => Ok(None),
        status => bail!("Unexpected status {status} reading branch '{branch}'"),
    }
}

/// Check whether a GitHub App is installed for a specific repository.
///
/// Uses the App JWT to query `GET /repos/{owner}/{repo}/installation`.
/// Returns `Ok(true)` on 200, `Ok(false)` on 404.
pub async fn check_app_installed(
    client: &impl HttpClient,
    jwt: &str,
    owner: &str,
    repo: &str,
    base_url: &str,
) -> anyhow::Result<bool> {
    let lookup = lookup_installation(client, jwt, base_url, owner, repo)
        .await
        .context("Failed to check GitHub App installation")?;

    match lookup {
        InstallationLookup::Found(_) => Ok(true),
        InstallationLookup::NotFound => Ok(false),
        InstallationLookup::Failed(401) => bail!(
            "GitHub App authentication failed. \
             Check that app_id and GITHUB_APP_PRIVATE_KEY are correct."
        ),
        InstallationLookup::Failed(403) => bail!(
            "GitHub App installation is suspended. \
             Re-enable it in your organization's GitHub App settings."
        ),
        InstallationLookup::Failed(status) => {
            bail!("Unexpected status {status} checking GitHub App installation")
        }
    }
}

/// Fetch information about the authenticated GitHub App.
///
/// Uses the App JWT to call `GET /app` and returns the app's slug and owner.
pub async fn get_authenticated_app(
    client: &impl HttpClient,
    jwt: &str,
    base_url: &str,
) -> anyhow::Result<AppInfo> {
    let url = format!("{base_url}/app");
    let auth = format!("Bearer {jwt}");
    let resp = client
        .request(HttpMethod::Get, &url, &github_headers(&auth), None)
        .await
        .context("Failed to fetch GitHub App info")?;

    match resp.status {
        200 => {}
        401 => {
            bail!(
                "GitHub App authentication failed. \
                 Check that app_id and GITHUB_APP_PRIVATE_KEY are correct."
            );
        }
        status => {
            bail!("Unexpected status {status} fetching GitHub App info");
        }
    }

    resp.json::<AppInfo>()
        .context("Failed to parse GitHub App info")
}

/// Update a GitHub App's webhook URL via `PATCH /app/hook/config`.
///
/// Signs an App JWT and sets the webhook endpoint and content type.
pub async fn update_app_webhook_config(
    app_id: &str,
    private_key_pem: &str,
    webhook_url: &str,
) -> anyhow::Result<()> {
    let jwt = sign_app_jwt(app_id, private_key_pem)?;
    let client = http_client()?;
    let url = format!("{}/app/hook/config", github_api_base_url());
    let auth = format!("Bearer {jwt}");
    let body = serde_json::json!({
        "url": webhook_url,
        "content_type": "json",
    });

    let resp = HttpClient::request(
        &client,
        HttpMethod::Patch,
        &url,
        &github_headers(&auth),
        Some(&body),
    )
    .await
    .context("Failed to update GitHub App webhook")?;

    if !(200..300).contains(&resp.status) {
        bail!("GitHub API returned {}: {}", resp.status, resp.text());
    }

    Ok(())
}

/// Resolve git clone credentials for a GitHub repository.
///
/// Returns `(username, password)` for authenticated cloning and pushing.
/// Always generates a token regardless of repo visibility, since the token
/// is needed for pushing from the sandbox.
pub async fn resolve_clone_credentials(
    ctx: &GitHubContext<'_>,
    owner: &str,
    repo: &str,
) -> anyhow::Result<(Option<String>, Option<String>)> {
    let token = match ctx.creds {
        GitHubCredentials::Pat(token) => token.clone(),
        GitHubCredentials::Installation(token) => token.valid_token()?.to_string(),
        GitHubCredentials::App(_) => {
            let client = ctx.http_client()?;
            mint_git_contents_write_token(&client, ctx, owner, repo).await?
        }
    };
    Ok((Some("x-access-token".to_string()), Some(token)))
}

/// Mint an installation token scoped to repository contents writes.
async fn mint_git_contents_write_token(
    client: &impl HttpClient,
    ctx: &GitHubContext<'_>,
    owner: &str,
    repo: &str,
) -> anyhow::Result<String> {
    ctx.creds
        .resolve_bearer_token(
            client,
            owner,
            repo,
            ctx.base_url,
            serde_json::json!({ "contents": "write" }),
        )
        .await
}

/// Embed a token into an HTTPS URL for authenticated git operations.
///
/// Converts `https://github.com/owner/repo` to
/// `https://x-access-token:<token>@github.com/owner/repo`.
pub fn embed_token_in_url(url: &str, token: &str) -> anyhow::Result<DisplaySafeUrl> {
    let mut url = DisplaySafeUrl::parse(url).context("Failed to parse GitHub HTTPS URL")?;
    if url.scheme() != "https" {
        bail!("GitHub clone URL must use HTTPS: {}", url.redacted_string());
    }
    url.set_username("x-access-token")
        .map_err(|()| anyhow!("Failed to set GitHub token username"))?;
    url.set_password(Some(token))
        .map_err(|()| anyhow!("Failed to set GitHub token password"))?;
    Ok(url)
}

/// Resolve an authenticated HTTPS URL for a GitHub repository.
///
/// Parses owner/repo from the URL, obtains a fresh installation access token,
/// and returns the URL with embedded credentials.
pub async fn resolve_authenticated_url(
    ctx: &GitHubContext<'_>,
    url: &str,
) -> anyhow::Result<DisplaySafeUrl> {
    let (owner, repo) = parse_github_owner_repo(url)?;
    let (_username, password) = resolve_clone_credentials(ctx, &owner, &repo).await?;
    match password {
        Some(token) => embed_token_in_url(url, &token),
        None => DisplaySafeUrl::parse(url).context("Failed to parse GitHub HTTPS URL"),
    }
}

/// Fetch detailed information about a pull request.
pub async fn get_pull_request(
    ctx: &GitHubContext<'_>,
    owner: &str,
    repo: &str,
    number: u64,
) -> Result<PullRequestGithubDetail, PullRequestApiError> {
    let client = ctx.http_client()?;
    get_pull_request_with_client(&client, ctx, owner, repo, number).await
}

pub async fn get_pull_request_with_client(
    client: &impl HttpClient,
    ctx: &GitHubContext<'_>,
    owner: &str,
    repo: &str,
    number: u64,
) -> Result<PullRequestGithubDetail, PullRequestApiError> {
    tracing::debug!(owner, repo, number, "Fetching pull request");

    let token = ctx
        .creds
        .resolve_bearer_token(
            client,
            owner,
            repo,
            ctx.base_url,
            serde_json::json!({ "contents": "write", "pull_requests": "write" }),
        )
        .await?;

    let url = format!("{}/repos/{owner}/{repo}/pulls/{number}", ctx.base_url);
    let auth = format!("Bearer {token}");
    let resp = client
        .request(HttpMethod::Get, &url, &github_headers(&auth), None)
        .await
        .context("Failed to fetch pull request")?;

    match resp.status {
        200 => {}
        404 => {
            return Err(PullRequestApiError::NotFound {
                owner: owner.to_string(),
                repo: repo.to_string(),
                number,
            });
        }
        401 | 403 => {
            return Err(anyhow!(
                "Authentication failed fetching pull request ({})",
                resp.status
            )
            .into());
        }
        status => {
            return Err(anyhow!(
                "Unexpected status {status} fetching pull request: {}",
                resp.text()
            )
            .into());
        }
    }

    Ok(resp
        .json::<PullRequestGithubDetail>()
        .context("Failed to parse pull request response")?)
}

/// Merge a pull request.
pub async fn merge_pull_request(
    ctx: &GitHubContext<'_>,
    owner: &str,
    repo: &str,
    number: u64,
    method: MergeStrategy,
) -> Result<(), PullRequestApiError> {
    let client = ctx.http_client()?;
    merge_pull_request_with_client(&client, ctx, owner, repo, number, method).await
}

pub async fn merge_pull_request_with_client(
    client: &impl HttpClient,
    ctx: &GitHubContext<'_>,
    owner: &str,
    repo: &str,
    number: u64,
    method: MergeStrategy,
) -> Result<(), PullRequestApiError> {
    tracing::debug!(owner, repo, number, method = %method, "Merging pull request");

    let token = ctx
        .creds
        .resolve_bearer_token(
            client,
            owner,
            repo,
            ctx.base_url,
            serde_json::json!({ "contents": "write", "pull_requests": "write" }),
        )
        .await?;

    let url = format!("{}/repos/{owner}/{repo}/pulls/{number}/merge", ctx.base_url);
    let body = serde_json::json!({ "merge_method": method });
    let auth = format!("Bearer {token}");

    let resp = client
        .request(HttpMethod::Put, &url, &github_headers(&auth), Some(&body))
        .await
        .context("Failed to merge pull request")?;

    match resp.status {
        200 => Ok(()),
        405 => Err(
            anyhow!("Pull request #{number} is not mergeable (method may not be allowed)").into(),
        ),
        409 => Err(anyhow!("Pull request #{number} has a merge conflict").into()),
        404 => Err(PullRequestApiError::NotFound {
            owner: owner.to_string(),
            repo: repo.to_string(),
            number,
        }),
        401 | 403 => Err(anyhow!(
            "Authentication failed merging pull request ({})",
            resp.status
        )
        .into()),
        status => Err(anyhow!(
            "Unexpected status {status} merging pull request: {}",
            resp.text()
        )
        .into()),
    }
}

/// Close a pull request.
pub async fn close_pull_request(
    ctx: &GitHubContext<'_>,
    owner: &str,
    repo: &str,
    number: u64,
) -> Result<(), PullRequestApiError> {
    let client = ctx.http_client()?;
    close_pull_request_with_client(&client, ctx, owner, repo, number).await
}

pub async fn close_pull_request_with_client(
    client: &impl HttpClient,
    ctx: &GitHubContext<'_>,
    owner: &str,
    repo: &str,
    number: u64,
) -> Result<(), PullRequestApiError> {
    tracing::debug!(owner, repo, number, "Closing pull request");

    let token = ctx
        .creds
        .resolve_bearer_token(
            client,
            owner,
            repo,
            ctx.base_url,
            serde_json::json!({ "contents": "write", "pull_requests": "write" }),
        )
        .await?;

    let url = format!("{}/repos/{owner}/{repo}/pulls/{number}", ctx.base_url);
    let body = serde_json::json!({ "state": "closed" });
    let auth = format!("Bearer {token}");

    let resp = client
        .request(HttpMethod::Patch, &url, &github_headers(&auth), Some(&body))
        .await
        .context("Failed to close pull request")?;

    match resp.status {
        200 => Ok(()),
        404 => Err(PullRequestApiError::NotFound {
            owner: owner.to_string(),
            repo: repo.to_string(),
            number,
        }),
        401 | 403 => Err(anyhow!(
            "Authentication failed closing pull request ({})",
            resp.status
        )
        .into()),
        status => Err(anyhow!(
            "Unexpected status {status} closing pull request: {}",
            resp.text()
        )
        .into()),
    }
}

/// Request a scoped Installation Access Token with `issues: write`
/// and `organization_projects: write`. Used for GitHub Projects V2.
pub async fn create_installation_access_token_for_projects(
    client: &impl HttpClient,
    jwt: &str,
    owner: &str,
    repo: &str,
    base_url: &str,
) -> anyhow::Result<String> {
    create_installation_access_token_with_permissions(
        client,
        jwt,
        owner,
        repo,
        base_url,
        serde_json::json!({ "issues": "write", "organization_projects": "write" }),
    )
    .await
}

#[cfg(test)]
mod tests {
    use std::io;
    use std::sync::{Arc, Mutex};

    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
    use tracing::{debug, subscriber};
    use tracing_subscriber::fmt::{self as tracing_fmt, MakeWriter};
    use tracing_subscriber::prelude::*;
    use tracing_subscriber::registry;

    use super::*;

    #[test]
    fn decode_pem_env_accepts_raw_pem() {
        let pem = "-----BEGIN TEST KEY-----\nabc\n-----END TEST KEY-----";
        assert_eq!(decode_pem_env("GITHUB_APP_PRIVATE_KEY", pem).unwrap(), pem);
    }

    #[test]
    fn pull_request_api_error_preserves_other_source_chain() {
        let original = anyhow::Error::new(std::io::Error::other("leaf failure"))
            .context("middle context")
            .context("outer context");
        let original_chain = original
            .chain()
            .map(ToString::to_string)
            .collect::<Vec<_>>();

        let err = PullRequestApiError::from(original);
        let wrapped = anyhow::Error::new(err);
        let wrapped_chain = wrapped.chain().map(ToString::to_string).collect::<Vec<_>>();

        assert_eq!(wrapped_chain.len(), original_chain.len());
        for original_cause in original_chain {
            assert!(
                wrapped_chain.iter().any(|cause| cause == &original_cause),
                "missing original cause {original_cause:?} in {wrapped_chain:#?}"
            );
        }
    }

    #[test]
    fn decode_pem_env_accepts_base64_pem() {
        let pem = "-----BEGIN TEST KEY-----\nabc\n-----END TEST KEY-----";
        let encoded = STANDARD.encode(pem);
        assert_eq!(
            decode_pem_env("GITHUB_APP_PRIVATE_KEY", &encoded).unwrap(),
            pem
        );
    }

    #[test]
    fn decode_pem_env_rejects_invalid_base64() {
        let err = decode_pem_env("GITHUB_APP_PRIVATE_KEY", "%%%not-base64%%%").unwrap_err();
        assert!(err.contains("GITHUB_APP_PRIVATE_KEY is not valid PEM or base64"));
    }

    // -----------------------------------------------------------------------
    // parse_github_owner_repo
    // -----------------------------------------------------------------------

    #[test]
    fn parse_https_with_git_suffix() {
        let (owner, repo) = parse_github_owner_repo("https://github.com/owner/repo.git").unwrap();
        assert_eq!(owner, "owner");
        assert_eq!(repo, "repo");
    }

    #[test]
    fn parse_https_without_git_suffix() {
        let (owner, repo) = parse_github_owner_repo("https://github.com/owner/repo").unwrap();
        assert_eq!(owner, "owner");
        assert_eq!(repo, "repo");
    }

    #[test]
    fn parse_https_with_trailing_slash() {
        let (owner, repo) = parse_github_owner_repo("https://github.com/owner/repo/").unwrap();
        assert_eq!(owner, "owner");
        assert_eq!(repo, "repo");
    }

    #[test]
    fn parse_rejects_extra_path_components() {
        let error = parse_github_owner_repo("https://github.com/owner/repo/issues").unwrap_err();

        assert!(
            error.to_string().contains("must identify one repository"),
            "got: {error}"
        );
    }

    // -----------------------------------------------------------------------
    // ssh_url_to_https
    // -----------------------------------------------------------------------

    #[test]
    fn ssh_url_to_https_converts_git_at_syntax() {
        assert_eq!(
            ssh_url_to_https("git@github.com:brynary/arc.git"),
            "https://github.com/brynary/arc.git"
        );
    }

    #[test]
    fn ssh_url_to_https_converts_ssh_protocol() {
        assert_eq!(
            ssh_url_to_https("ssh://git@github.com/brynary/arc.git"),
            "https://github.com/brynary/arc.git"
        );
    }

    #[test]
    fn ssh_url_to_https_passes_through_https() {
        assert_eq!(
            ssh_url_to_https("https://github.com/brynary/arc.git"),
            "https://github.com/brynary/arc.git"
        );
    }

    #[test]
    fn normalize_repo_origin_url_converts_ssh_and_trims_git_suffix() {
        assert_eq!(
            normalize_repo_origin_url("git@github.com:brynary/arc.git"),
            "https://github.com/brynary/arc"
        );
    }

    #[test]
    fn normalize_repo_origin_url_strips_credentials_and_trailing_slash() {
        assert_eq!(
            normalize_repo_origin_url("https://token@github.com/acme/widgets.git/"),
            "https://github.com/acme/widgets"
        );
    }

    #[test]
    fn normalize_repo_origin_url_handles_sanitized_git_at_shape() {
        assert_eq!(
            normalize_repo_origin_url("https://***@github.com:acme/widgets.git"),
            "https://github.com/acme/widgets"
        );
    }

    #[test]
    fn parse_github_url_with_credentials() {
        let (owner, repo) = parse_github_owner_repo(
            "https://x-access-token:ghs_abc123@github.com/acme/widgets.git",
        )
        .unwrap();
        assert_eq!(owner, "acme");
        assert_eq!(repo, "widgets");
    }

    #[test]
    fn parse_github_url_with_credentials_no_password() {
        let (owner, repo) =
            parse_github_owner_repo("https://token@github.com/acme/widgets.git").unwrap();
        assert_eq!(owner, "acme");
        assert_eq!(repo, "widgets");
    }

    #[test]
    fn parse_credentials_non_github_still_errors() {
        let result = parse_github_owner_repo("https://user:pass@gitlab.com/owner/repo");
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("Not a GitHub HTTPS URL")
        );
    }

    #[test]
    fn embed_token_in_url_redacts_display_and_keeps_raw_access() {
        let url = embed_token_in_url("https://github.com/acme/widgets.git", "ghs_abc123").unwrap();

        assert_eq!(
            url.redacted_string(),
            "https://x-access-token:****@github.com/acme/widgets.git"
        );
        assert_eq!(
            url.raw_string(),
            "https://x-access-token:ghs_abc123@github.com/acme/widgets.git"
        );
    }

    #[test]
    fn logging_embedded_token_url_does_not_emit_token() {
        let output = CapturedTrace::default();
        let subscriber = registry().with(
            tracing_fmt::layer()
                .with_writer(output.clone())
                .without_time()
                .with_target(false),
        );

        subscriber::with_default(subscriber, || {
            let url =
                embed_token_in_url("https://github.com/acme/widgets.git", "ghs_abc123").unwrap();
            debug!(?url, %url, "resolved authenticated GitHub URL");
        });

        let formatted = output.captured_output();
        assert!(formatted.contains("x-access-token:****@"));
        assert!(!formatted.contains("ghs_abc123"));
    }

    #[derive(Clone, Default)]
    struct CapturedTrace {
        buffer: Arc<Mutex<Vec<u8>>>,
    }

    impl CapturedTrace {
        fn captured_output(&self) -> String {
            let buffer = self.buffer.lock().unwrap();
            String::from_utf8(buffer.clone()).unwrap()
        }
    }

    impl<'writer> MakeWriter<'writer> for CapturedTrace {
        type Writer = CapturedTraceWriter;

        fn make_writer(&'writer self) -> Self::Writer {
            CapturedTraceWriter {
                buffer: Arc::clone(&self.buffer),
            }
        }
    }

    struct CapturedTraceWriter {
        buffer: Arc<Mutex<Vec<u8>>>,
    }

    impl io::Write for CapturedTraceWriter {
        fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
            self.buffer.lock().unwrap().extend_from_slice(bytes);
            Ok(bytes.len())
        }

        fn flush(&mut self) -> io::Result<()> {
            Ok(())
        }
    }

    #[test]
    fn parse_non_github_url_errors() {
        let result = parse_github_owner_repo("https://gitlab.com/owner/repo");
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("Not a GitHub HTTPS URL")
        );
    }

    #[test]
    fn parse_missing_repo_errors() {
        let result = parse_github_owner_repo("https://github.com/owner");
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("Missing repo"));
    }

    #[test]
    fn parse_empty_string_errors() {
        let result = parse_github_owner_repo("");
        assert!(result.is_err());
    }

    // -----------------------------------------------------------------------
    // sign_app_jwt
    // -----------------------------------------------------------------------

    fn test_rsa_key() -> &'static str {
        tests_mock::test_rsa_key()
    }

    #[test]
    fn jwt_is_three_part_string() {
        let pem = test_rsa_key();
        let jwt = sign_app_jwt("12345", pem).unwrap();
        assert_eq!(jwt.split('.').count(), 3);
    }

    #[test]
    fn jwt_has_rs256_header() {
        let pem = test_rsa_key();
        let jwt = sign_app_jwt("12345", pem).unwrap();
        let header_b64 = jwt.split('.').next().unwrap();
        let header_json = URL_SAFE_NO_PAD.decode(header_b64).unwrap();
        let header: serde_json::Value = serde_json::from_slice(&header_json).unwrap();
        assert_eq!(header["alg"], "RS256");
    }

    #[test]
    fn jwt_has_correct_claims() {
        let pem = test_rsa_key();
        let jwt = sign_app_jwt("99999", pem).unwrap();
        let payload_b64 = jwt.split('.').nth(1).unwrap();
        let payload_json = URL_SAFE_NO_PAD.decode(payload_b64).unwrap();
        let claims: serde_json::Value = serde_json::from_slice(&payload_json).unwrap();
        assert_eq!(claims["iss"], "99999");

        let now = chrono::Utc::now().timestamp();
        let iat = claims["iat"].as_i64().unwrap();
        let exp = claims["exp"].as_i64().unwrap();
        // iat should be ~60s before now
        assert!((now - 60 - iat).abs() < 5);
        // exp should be ~10min after now
        assert!((now + 600 - exp).abs() < 5);
    }

    #[test]
    fn jwt_invalid_pem_errors() {
        let result = sign_app_jwt("12345", "not-a-pem");
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("Invalid RSA private key")
        );
    }

    // -----------------------------------------------------------------------
    // MockHttpClient
    // -----------------------------------------------------------------------

    use crate::tests_mock::{self, MockHttpClient};

    // -----------------------------------------------------------------------
    // create_installation_access_token — success
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn app_credentials_mint_installation_token_preserves_expiry() {
        let mock = MockHttpClient::new()
            .on(
                HttpMethod::Get,
                "/repos/owner/repo/installation",
                200,
                r#"{"id": 123}"#,
            )
            .on(
                HttpMethod::Post,
                "/app/installations/123/access_tokens",
                201,
                r#"{"token": "ghs_xxx", "expires_at": "2026-01-01T12:00:00Z"}"#,
            )
            .with_req_body(r#"{"permissions":{"contents":"write"},"repositories":["repo"]}"#);

        let creds = GitHubAppCredentials {
            app_id:          "test".to_string(),
            private_key_pem: test_rsa_key().to_string(),
            slug:            None,
        };

        let token = creds
            .mint_installation_token(
                &mock,
                "owner",
                "repo",
                "",
                serde_json::json!({ "contents": "write" }),
                None,
            )
            .await
            .unwrap();

        assert_eq!(token.token, "ghs_xxx");
        assert_eq!(
            token.expires_at,
            "2026-01-01T12:00:00Z"
                .parse::<chrono::DateTime<chrono::Utc>>()
                .unwrap()
        );
    }

    /// The multi-repository mint sends one request listing every projected
    /// repository name exactly once, primary first, with the shared
    /// permissions; the installation lookup uses the primary repository.
    #[tokio::test]
    async fn multi_repository_mint_lists_every_repository_name_once() {
        let access = GitHubRepositoryAccess::new(
            Some("git@github.com:owner/repo.git"),
            &[
                "owner/keystone".parse().unwrap(),
                "owner/arc".parse().unwrap(),
            ]
            .into_iter()
            .collect(),
            std::collections::HashMap::from([("contents".to_string(), "read".to_string())]),
        )
        .unwrap()
        .expect("origin should produce an access value");

        let mock = MockHttpClient::new()
            .on(
                HttpMethod::Get,
                "/repos/owner/repo/installation",
                200,
                r#"{"id": 123}"#,
            )
            .on(
                HttpMethod::Post,
                "/app/installations/123/access_tokens",
                201,
                r#"{"token": "ghs_multi", "expires_at": "2026-01-01T12:00:00Z"}"#,
            )
            .with_req_body(
                r#"{"permissions":{"contents":"read"},"repositories":["repo","arc","keystone"]}"#,
            );

        let creds = GitHubAppCredentials {
            app_id:          "test".to_string(),
            private_key_pem: test_rsa_key().to_string(),
            slug:            None,
        };

        let token = creds
            .mint_installation_token_for_repositories(
                &mock,
                access.owner(),
                &access.repository_names(),
                "",
                access.permissions_json().unwrap(),
                None,
            )
            .await
            .unwrap();

        assert_eq!(token.token, "ghs_multi");
    }

    #[tokio::test]
    async fn create_iat_requests_only_contents_write() {
        let mock = MockHttpClient::new()
            .on(
                HttpMethod::Get,
                "/repos/owner/repo/installation",
                200,
                r#"{"id": 123}"#,
            )
            .with_req_header("Authorization", "Bearer test-jwt")
            .on(
                HttpMethod::Post,
                "/app/installations/123/access_tokens",
                201,
                r#"{"token": "ghs_xxx", "expires_at": "2099-01-01T00:00:00Z"}"#,
            )
            .with_req_header("Authorization", "Bearer test-jwt")
            .with_req_body(r#"{"permissions":{"contents":"write"},"repositories":["repo"]}"#);

        let token = create_installation_access_token(&mock, "test-jwt", "owner", "repo", "")
            .await
            .unwrap();
        assert_eq!(token, "ghs_xxx");
    }

    // -----------------------------------------------------------------------
    // create_installation_access_token — failure modes
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn create_iat_not_installed() {
        let mock =
            MockHttpClient::new().on(HttpMethod::Get, "/repos/owner/repo/installation", 404, "");

        let err = create_installation_access_token(&mock, "jwt", "owner", "repo", "")
            .await
            .unwrap_err()
            .to_string();
        assert!(err.contains("not installed"), "got: {err}");
        assert!(err.contains("owner"), "got: {err}");
    }

    #[tokio::test]
    async fn create_iat_not_installed_uses_app_specific_install_url() {
        let mock =
            MockHttpClient::new().on(HttpMethod::Get, "/repos/owner/repo/installation", 404, "");
        let install_url =
            "https://github.com/organizations/owner/settings/apps/fabro-test/installations";

        let err = create_installation_access_token_with_permissions_and_install_url(
            &mock,
            "jwt",
            "owner",
            "repo",
            "",
            serde_json::json!({ "contents": "write" }),
            Some(install_url),
        )
        .await
        .unwrap_err()
        .to_string();

        assert!(err.contains("not installed"), "got: {err}");
        assert!(err.contains(install_url), "got: {err}");
        assert!(
            !err.contains("https://github.com/organizations/owner/settings/installations"),
            "got: {err}"
        );
    }

    #[tokio::test]
    async fn create_iat_suspended() {
        let mock =
            MockHttpClient::new().on(HttpMethod::Get, "/repos/owner/repo/installation", 403, "");

        let err = create_installation_access_token(&mock, "jwt", "owner", "repo", "")
            .await
            .unwrap_err()
            .to_string();
        assert!(err.contains("suspended"), "got: {err}");
    }

    #[tokio::test]
    async fn create_iat_no_repo_access() {
        let mock = MockHttpClient::new()
            .on(
                HttpMethod::Get,
                "/repos/owner/repo/installation",
                200,
                r#"{"id": 123}"#,
            )
            .on(
                HttpMethod::Post,
                "/app/installations/123/access_tokens",
                422,
                "",
            );

        let err = create_installation_access_token(&mock, "jwt", "owner", "repo", "")
            .await
            .unwrap_err()
            .to_string();
        assert!(err.contains("does not have access"), "got: {err}");
        assert!(err.contains("repo"), "got: {err}");
    }

    #[tokio::test]
    async fn find_open_pull_request_matches_the_expected_head_commit() {
        let mock = MockHttpClient::new()
            .on(
                HttpMethod::Get,
                "/repos/owner/repo/pulls?state=open&base=main&head=owner%3Afabro%2Frun%2F1",
                200,
                r#"[
                    {
                        "html_url": "https://github.com/owner/repo/pull/40",
                        "number": 40,
                        "node_id": "PR_wrong",
                        "title": "Old head",
                        "head": {"sha": "old-sha"}
                    },
                    {
                        "html_url": "https://github.com/owner/repo/pull/42",
                        "number": 42,
                        "node_id": "PR_expected",
                        "title": "Expected head",
                        "head": {"sha": "final-sha"}
                    }
                ]"#,
            )
            .with_req_header("Authorization", "Bearer ghu_test");
        let creds = GitHubCredentials::Pat("ghu_test".to_string());
        let ctx = GitHubContext::new(&creds, "https://api.test");

        let found = find_open_pull_request_with_client(
            &mock,
            &ctx,
            "owner",
            "repo",
            "main",
            "fabro/run/1",
            "final-sha",
        )
        .await
        .unwrap()
        .expect("matching pull request should be found");

        assert_eq!(found.number, 42);
        assert_eq!(found.node_id, "PR_expected");
        assert_eq!(found.title, "Expected head");
    }

    #[tokio::test]
    async fn create_iat_auth_failed() {
        let mock =
            MockHttpClient::new().on(HttpMethod::Get, "/repos/owner/repo/installation", 401, "");

        let err = create_installation_access_token(&mock, "jwt", "owner", "repo", "")
            .await
            .unwrap_err()
            .to_string();
        assert!(err.contains("authentication failed"), "got: {err}");
    }

    // -----------------------------------------------------------------------
    // create_installation_access_token_for_pr
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn create_iat_for_pr_requests_pr_permissions() {
        let mock = MockHttpClient::new()
            .on(
                HttpMethod::Get,
                "/repos/owner/repo/installation",
                200,
                r#"{"id": 456}"#,
            )
            .with_req_header("Authorization", "Bearer test-jwt")
            .on(
                HttpMethod::Post,
                "/app/installations/456/access_tokens",
                201,
                r#"{"token": "ghs_pr_token", "expires_at": "2099-01-01T00:00:00Z"}"#,
            )
            .with_req_header("Authorization", "Bearer test-jwt")
            .with_req_body(
                r#"{"permissions":{"contents":"write","pull_requests":"write"},"repositories":["repo"]}"#,
            );

        let token = create_installation_access_token_for_pr(&mock, "test-jwt", "owner", "repo", "")
            .await
            .unwrap();
        assert_eq!(token, "ghs_pr_token");
    }

    // -----------------------------------------------------------------------
    // branch_head_sha
    // -----------------------------------------------------------------------

    fn app_creds() -> GitHubCredentials {
        GitHubCredentials::App(GitHubAppCredentials {
            app_id:          "test".to_string(),
            private_key_pem: test_rsa_key().to_string(),
            slug:            None,
        })
    }

    /// Mock the installation-token exchange every App-credentialed call makes
    /// before it reaches the endpoint under test.
    fn mock_with_installation_token() -> MockHttpClient {
        MockHttpClient::new()
            .on(
                HttpMethod::Get,
                "/repos/owner/repo/installation",
                200,
                r#"{"id": 1}"#,
            )
            .on(
                HttpMethod::Post,
                "/app/installations/1/access_tokens",
                201,
                r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#,
            )
    }

    #[tokio::test]
    async fn branch_head_sha_returns_commit_on_200() {
        let mock = mock_with_installation_token().on(
            HttpMethod::Get,
            "/repos/owner/repo/branches/my-branch",
            200,
            r#"{"name": "my-branch", "commit": {"sha": "abc123"}}"#,
        );

        let creds = app_creds();
        let result = branch_head_sha_with_client(
            &mock,
            &GitHubContext::new(&creds, ""),
            "owner",
            "repo",
            "my-branch",
        )
        .await;

        assert_eq!(result.unwrap(), Some("abc123".to_string()));
    }

    #[tokio::test]
    async fn branch_head_sha_returns_none_on_404() {
        let mock = mock_with_installation_token().on(
            HttpMethod::Get,
            "/repos/owner/repo/branches/no-such-branch",
            404,
            "",
        );

        let creds = app_creds();
        let result = branch_head_sha_with_client(
            &mock,
            &GitHubContext::new(&creds, ""),
            "owner",
            "repo",
            "no-such-branch",
        )
        .await;

        assert_eq!(result.unwrap(), None);
    }

    #[tokio::test]
    async fn branch_head_sha_returns_error_on_500() {
        let mock = mock_with_installation_token().on(
            HttpMethod::Get,
            "/repos/owner/repo/branches/broken",
            500,
            "",
        );

        let creds = app_creds();
        let result = branch_head_sha_with_client(
            &mock,
            &GitHubContext::new(&creds, ""),
            "owner",
            "repo",
            "broken",
        )
        .await;

        assert!(result.is_err());
    }

    #[tokio::test]
    async fn branch_head_sha_with_token_uses_direct_bearer_token() {
        let mock = MockHttpClient::new()
            .on(
                HttpMethod::Get,
                "/repos/owner/repo/branches/my-branch",
                200,
                r#"{"name": "my-branch", "commit": {"sha": "abc123"}}"#,
            )
            .with_req_header("Authorization", "Bearer ghu_test");

        let creds = GitHubCredentials::Pat("ghu_test".to_string());
        let result = branch_head_sha_with_client(
            &mock,
            &GitHubContext::new(&creds, ""),
            "owner",
            "repo",
            "my-branch",
        )
        .await;

        assert_eq!(result.unwrap(), Some("abc123".to_string()));
    }

    // -----------------------------------------------------------------------
    // check_app_installed
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn check_app_installed_returns_true_on_200() {
        let mock = MockHttpClient::new()
            .on(
                HttpMethod::Get,
                "/repos/owner/repo/installation",
                200,
                r#"{"id": 1}"#,
            )
            .with_req_header("Authorization", "Bearer test-jwt");

        let result = check_app_installed(&mock, "test-jwt", "owner", "repo", "").await;
        assert!(result.unwrap());
    }

    #[tokio::test]
    async fn check_app_installed_returns_false_on_404() {
        let mock =
            MockHttpClient::new().on(HttpMethod::Get, "/repos/owner/repo/installation", 404, "");

        let result = check_app_installed(&mock, "test-jwt", "owner", "repo", "").await;
        assert!(!result.unwrap());
    }

    #[tokio::test]
    async fn check_app_installed_returns_error_on_401() {
        let mock =
            MockHttpClient::new().on(HttpMethod::Get, "/repos/owner/repo/installation", 401, "");

        let result = check_app_installed(&mock, "test-jwt", "owner", "repo", "").await;
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("authentication failed"),
            "expected auth error"
        );
    }

    // -----------------------------------------------------------------------
    // get_authenticated_app
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn get_authenticated_app_success() {
        let mock = MockHttpClient::new()
            .on(
                HttpMethod::Get,
                "/app",
                200,
                r#"{"slug": "my-fabro-app", "owner": {"login": "my-org"}}"#,
            )
            .with_req_header("Authorization", "Bearer test-jwt");

        let info = get_authenticated_app(&mock, "test-jwt", "").await.unwrap();
        assert_eq!(info.slug, "my-fabro-app");
        assert_eq!(info.owner.login, "my-org");
    }

    #[tokio::test]
    async fn get_authenticated_app_auth_failure() {
        let mock = MockHttpClient::new().on(HttpMethod::Get, "/app", 401, "");

        let result = get_authenticated_app(&mock, "bad-jwt", "").await;
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("authentication failed"),
            "expected auth error"
        );
    }

    // -----------------------------------------------------------------------
    // get_pull_request
    // -----------------------------------------------------------------------

    fn mock_pr_json() -> &'static str {
        r#"{
            "number": 42,
            "title": "Fix the bug",
            "body": "Detailed description",
            "state": "open",
            "draft": false,
            "merged": false,
            "merged_at": null,
            "mergeable": true,
            "additions": 10,
            "deletions": 3,
            "changed_files": 2,
            "html_url": "https://github.com/owner/repo/pull/42",
            "user": {"login": "testuser"},
            "head": {"ref": "feature-branch"},
            "base": {"ref": "main"},
            "created_at": "2026-01-01T12:00:00Z",
            "updated_at": "2026-01-02T12:00:00Z"
        }"#
    }

    #[tokio::test]
    async fn get_pr_success() {
        let mock = MockHttpClient::new()
            .on(
                HttpMethod::Get,
                "/repos/owner/repo/installation",
                200,
                r#"{"id": 1}"#,
            )
            .on(
                HttpMethod::Post,
                "/app/installations/1/access_tokens",
                201,
                r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#,
            )
            .on(
                HttpMethod::Get,
                "/repos/owner/repo/pulls/42",
                200,
                mock_pr_json(),
            );

        let pem = test_rsa_key();
        let creds = GitHubCredentials::App(GitHubAppCredentials {
            app_id:          "test".to_string(),
            private_key_pem: pem.to_string(),
            slug:            None,
        });
        let detail = get_pull_request_with_client(
            &mock,
            &GitHubContext::new(&creds, ""),
            "owner",
            "repo",
            42,
        )
        .await
        .unwrap();

        assert_eq!(detail.number, 42);
        assert_eq!(detail.title, "Fix the bug");
        assert_eq!(detail.state, "open");
        assert!(!detail.merged);
        assert_eq!(detail.merged_at, None);
        assert_eq!(detail.additions, 10);
        assert_eq!(detail.deletions, 3);
        assert_eq!(detail.changed_files, 2);
        assert_eq!(detail.user.login, "testuser");
        assert_eq!(detail.head.ref_name, "feature-branch");
        assert_eq!(detail.base.ref_name, "main");
    }

    #[tokio::test]
    async fn get_pr_not_found() {
        let mock = MockHttpClient::new()
            .on(
                HttpMethod::Get,
                "/repos/owner/repo/installation",
                200,
                r#"{"id": 1}"#,
            )
            .on(
                HttpMethod::Post,
                "/app/installations/1/access_tokens",
                201,
                r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#,
            )
            .on(HttpMethod::Get, "/repos/owner/repo/pulls/999", 404, "");

        let pem = test_rsa_key();
        let creds = GitHubCredentials::App(GitHubAppCredentials {
            app_id:          "test".to_string(),
            private_key_pem: pem.to_string(),
            slug:            None,
        });
        let err = get_pull_request_with_client(
            &mock,
            &GitHubContext::new(&creds, ""),
            "owner",
            "repo",
            999,
        )
        .await
        .unwrap_err();
        assert!(
            matches!(
                err,
                PullRequestApiError::NotFound {
                    number: 999,
                    ref owner,
                    ref repo,
                } if owner == "owner" && repo == "repo"
            ),
            "got: {err}"
        );
    }

    #[tokio::test]
    async fn get_pr_with_token_uses_direct_bearer_token() {
        let mock = MockHttpClient::new()
            .on(
                HttpMethod::Get,
                "/repos/owner/repo/pulls/42",
                200,
                mock_pr_json(),
            )
            .with_req_header("Authorization", "Bearer ghu_test");

        let creds = GitHubCredentials::Pat("ghu_test".to_string());
        let detail = get_pull_request_with_client(
            &mock,
            &GitHubContext::new(&creds, ""),
            "owner",
            "repo",
            42,
        )
        .await
        .unwrap();

        assert_eq!(detail.number, 42);
    }

    #[tokio::test]
    async fn resolve_clone_credentials_returns_token_for_token_credentials() {
        let creds = GitHubCredentials::Pat("ghu_test".to_string());

        let credentials =
            resolve_clone_credentials(&GitHubContext::new(&creds, ""), "owner", "repo")
                .await
                .unwrap();

        assert_eq!(
            credentials,
            (
                Some("x-access-token".to_string()),
                Some("ghu_test".to_string())
            )
        );
    }

    #[tokio::test]
    async fn clone_token_requests_only_contents_write() {
        let mock = MockHttpClient::new()
            .on(
                HttpMethod::Get,
                "/repos/owner/repo/installation",
                200,
                r#"{"id": 123}"#,
            )
            .on(
                HttpMethod::Post,
                "/app/installations/123/access_tokens",
                201,
                r#"{"token": "ghs_xxx", "expires_at": "2099-01-01T00:00:00Z"}"#,
            )
            .with_req_body(r#"{"permissions":{"contents":"write"},"repositories":["repo"]}"#);
        let credentials = GitHubCredentials::App(GitHubAppCredentials {
            app_id:          "test".to_string(),
            private_key_pem: test_rsa_key().to_string(),
            slug:            None,
        });
        let context = GitHubContext::new(&credentials, "");
        let token = mint_git_contents_write_token(&mock, &context, "owner", "repo")
            .await
            .unwrap();

        assert_eq!(token, "ghs_xxx");
    }

    #[test]
    fn installation_token_valid_token_rejects_expired_tokens() {
        let expired = InstallationToken {
            token:      "ghs_expired".to_string(),
            expires_at: chrono::Utc::now() - chrono::Duration::seconds(1),
        };
        assert!(expired.valid_token().is_err());

        let fresh = InstallationToken {
            token:      "ghs_fresh".to_string(),
            expires_at: chrono::Utc::now() + chrono::Duration::minutes(30),
        };
        assert_eq!(fresh.valid_token().unwrap(), "ghs_fresh");
        assert!(!fresh.near_expiry(std::time::Duration::from_mins(15)));
    }

    #[test]
    fn only_app_credentials_mint_installation_tokens() {
        let app = GitHubCredentials::App(GitHubAppCredentials {
            app_id:          "test".to_string(),
            private_key_pem: test_rsa_key().to_string(),
            slug:            None,
        });
        let pat = GitHubCredentials::Pat("ghp_personal".to_string());
        let installation = GitHubCredentials::Installation(InstallationToken {
            token:      "ghs_installation".to_string(),
            expires_at: chrono::Utc::now() + chrono::Duration::minutes(30),
        });

        assert!(app.mints_installation_token());
        assert!(!pat.mints_installation_token());
        assert!(!installation.mints_installation_token());
    }

    #[test]
    fn validate_static_github_token_rejects_installation_tokens() {
        validate_static_github_token("ghp_personal").unwrap();
        validate_static_github_token("gho_oauth").unwrap();
        validate_static_github_token("ghu_user").unwrap();

        let err = validate_static_github_token("ghs_installation")
            .unwrap_err()
            .to_string();
        assert!(err.contains("installation access token"), "got: {err}");
    }

    // -----------------------------------------------------------------------
    // merge_pull_request
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn merge_pr_success() {
        let mock = MockHttpClient::new()
            .on(
                HttpMethod::Get,
                "/repos/owner/repo/installation",
                200,
                r#"{"id": 1}"#,
            )
            .on(
                HttpMethod::Post,
                "/app/installations/1/access_tokens",
                201,
                r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#,
            )
            .on(
                HttpMethod::Put,
                "/repos/owner/repo/pulls/42/merge",
                200,
                r#"{"merged": true}"#,
            );

        let pem = test_rsa_key();
        let creds = GitHubCredentials::App(GitHubAppCredentials {
            app_id:          "test".to_string(),
            private_key_pem: pem.to_string(),
            slug:            None,
        });
        merge_pull_request_with_client(
            &mock,
            &GitHubContext::new(&creds, ""),
            "owner",
            "repo",
            42,
            MergeStrategy::Squash,
        )
        .await
        .unwrap();
    }

    #[tokio::test]
    async fn merge_pr_not_mergeable() {
        let mock = MockHttpClient::new()
            .on(
                HttpMethod::Get,
                "/repos/owner/repo/installation",
                200,
                r#"{"id": 1}"#,
            )
            .on(
                HttpMethod::Post,
                "/app/installations/1/access_tokens",
                201,
                r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#,
            )
            .on(HttpMethod::Put, "/repos/owner/repo/pulls/42/merge", 405, "");

        let pem = test_rsa_key();
        let creds = GitHubCredentials::App(GitHubAppCredentials {
            app_id:          "test".to_string(),
            private_key_pem: pem.to_string(),
            slug:            None,
        });
        let err = merge_pull_request_with_client(
            &mock,
            &GitHubContext::new(&creds, ""),
            "owner",
            "repo",
            42,
            MergeStrategy::Squash,
        )
        .await
        .unwrap_err();
        assert!(err.to_string().contains("not mergeable"), "got: {err}");
    }

    #[tokio::test]
    async fn merge_pr_conflict() {
        let mock = MockHttpClient::new()
            .on(
                HttpMethod::Get,
                "/repos/owner/repo/installation",
                200,
                r#"{"id": 1}"#,
            )
            .on(
                HttpMethod::Post,
                "/app/installations/1/access_tokens",
                201,
                r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#,
            )
            .on(HttpMethod::Put, "/repos/owner/repo/pulls/42/merge", 409, "");

        let pem = test_rsa_key();
        let creds = GitHubCredentials::App(GitHubAppCredentials {
            app_id:          "test".to_string(),
            private_key_pem: pem.to_string(),
            slug:            None,
        });
        let err = merge_pull_request_with_client(
            &mock,
            &GitHubContext::new(&creds, ""),
            "owner",
            "repo",
            42,
            MergeStrategy::Squash,
        )
        .await
        .unwrap_err();
        assert!(err.to_string().contains("merge conflict"), "got: {err}");
    }

    // -----------------------------------------------------------------------
    // close_pull_request
    // -----------------------------------------------------------------------

    #[tokio::test]
    async fn close_pr_success() {
        let mock = MockHttpClient::new()
            .on(
                HttpMethod::Get,
                "/repos/owner/repo/installation",
                200,
                r#"{"id": 1}"#,
            )
            .on(
                HttpMethod::Post,
                "/app/installations/1/access_tokens",
                201,
                r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#,
            )
            .on(
                HttpMethod::Patch,
                "/repos/owner/repo/pulls/42",
                200,
                mock_pr_json(),
            );

        let pem = test_rsa_key();
        let creds = GitHubCredentials::App(GitHubAppCredentials {
            app_id:          "test".to_string(),
            private_key_pem: pem.to_string(),
            slug:            None,
        });
        close_pull_request_with_client(&mock, &GitHubContext::new(&creds, ""), "owner", "repo", 42)
            .await
            .unwrap();
    }

    #[tokio::test]
    async fn close_pr_not_found() {
        let mock = MockHttpClient::new()
            .on(
                HttpMethod::Get,
                "/repos/owner/repo/installation",
                200,
                r#"{"id": 1}"#,
            )
            .on(
                HttpMethod::Post,
                "/app/installations/1/access_tokens",
                201,
                r#"{"token": "ghs_test", "expires_at": "2099-01-01T00:00:00Z"}"#,
            )
            .on(HttpMethod::Patch, "/repos/owner/repo/pulls/999", 404, "");

        let pem = test_rsa_key();
        let creds = GitHubCredentials::App(GitHubAppCredentials {
            app_id:          "test".to_string(),
            private_key_pem: pem.to_string(),
            slug:            None,
        });
        let err = close_pull_request_with_client(
            &mock,
            &GitHubContext::new(&creds, ""),
            "owner",
            "repo",
            999,
        )
        .await
        .unwrap_err();
        assert!(
            matches!(
                err,
                PullRequestApiError::NotFound {
                    number: 999,
                    ref owner,
                    ref repo,
                } if owner == "owner" && repo == "repo"
            ),
            "got: {err}"
        );
    }
}
