// crates/atomcode-core/src/coding_plan/client.rs
//
// Blocking HTTP client for the three CodingPlan REST endpoints. Reuses the
// OAuth token already on disk (from `crate::auth`) — the token authenticates
// both `atomgit.com` and `api.gitcode.com` (same backend, different front
// domains). Every request carries `ATOMCODE_USER_AGENT` so AtomGit's
// API gateway sees a consistent identifier.
//
// Blocking (not async) is deliberate: the coding-plan flow runs synchronously
// before / outside the agent event loop. Async would force tokio::block_on
// or channel plumbing at every call site for zero concurrency benefit.

use anyhow::{anyhow, Context, Result};

use super::types::{ClaimResponse, ModelEntry, PlanType, StatusResponse};
use crate::auth;

/// Default CodingPlan REST API base URL.
/// Override with the `ATOMCODE_CODINGPLAN_API_BASE` environment variable.
const DEFAULT_API_BASE: &str = "https://api.gitcode.com/api/v5";

/// Return the CodingPlan REST API base URL, reading
/// `ATOMCODE_CODINGPLAN_API_BASE` once at first call and caching the
/// result for the process lifetime.
///
/// Read order:
///   1. `ATOMCODE_CODINGPLAN_API_BASE` env var (trimmed, trailing `/`
///      stripped, empty value treated as unset).
///   2. [`DEFAULT_API_BASE`].
pub fn api_base_url() -> String {
    use std::sync::OnceLock;
    static BASE: OnceLock<String> = OnceLock::new();
    BASE.get_or_init(|| {
        std::env::var("ATOMCODE_CODINGPLAN_API_BASE")
            .ok()
            .map(|v| v.trim().trim_end_matches('/').to_string())
            .filter(|v| !v.is_empty())
            .unwrap_or_else(|| DEFAULT_API_BASE.to_string())
    })
    .clone()
}

/// Typed error surfaced when the API rejects the bearer token (401/403).
///
/// Carried inside `anyhow::Error` by every `Client` method so the
/// orchestrator can `downcast_ref::<AuthExpired>()` and decide to
/// re-run OAuth instead of just printing the failure. Before this
/// existed `/codingplan` would emit "already logged in" + "claim failed
/// — run `atomcode login` again" and leave the user to do it manually,
/// even though `/login` would have fixed it in one step.
///
/// The Display text matches the legacy error string verbatim so
/// rendered reports stay byte-identical when no recovery happens
/// (e.g. running `atomcode` against a server we never reach for
/// re-auth, or a non-interactive scripted invocation).
#[derive(Debug)]
pub struct AuthExpired {
    pub status: u16,
}

impl std::fmt::Display for AuthExpired {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "authentication failed ({}) — run `atomcode login` again",
            self.status
        )
    }
}

impl std::error::Error for AuthExpired {}

/// True iff `err` (or any error in its cause chain) is an `AuthExpired`.
/// Centralised so the orchestrator and shell callers agree on what
/// "stale token" looks like — anywhere we want to decide "rerun OAuth?"
/// goes through here.
pub fn is_auth_expired(err: &anyhow::Error) -> bool {
    err.chain().any(|e| e.is::<AuthExpired>())
}

/// Token-authenticated blocking REST client for CodingPlan endpoints.
pub struct Client {
    http: reqwest::blocking::Client,
    token: String,
}

impl Client {
    /// Build a client using the currently-stored OAuth token. Refreshes
    /// the token if expired. Errors with a user-facing message if the
    /// user isn't logged in.
    pub fn from_stored_auth() -> Result<Self> {
        if !auth::is_logged_in() {
            return Err(anyhow!(
                "not logged in — run `atomcode login` (or the codingplan flow) first"
            ));
        }
        // If the local access token can't be made valid (expired and the
        // broker refused our refresh_token, or the file is malformed),
        // there's no way to proceed without a fresh OAuth round-trip.
        // Surface as `AuthExpired` so the orchestrator triggers the same
        // recovery path it uses for an API-side 401, instead of bailing
        // with a generic "build client" error that callers can't act on.
        let token = match auth::get_valid_token() {
            Ok(t) => t,
            Err(e) => {
                return Err(anyhow::Error::new(AuthExpired { status: 401 })
                    .context(format!("local token unusable: {:#}", e)));
            }
        };
        // Timeouts are critical here: `/status` and the background drift
        // monitor both call these endpoints synchronously from the TUI
        // event loop, and without a cap a slow / unreachable gateway
        // hangs the entire UI until the OS eventually gives up (minutes
        // on a VPN flap). 5s connect + 10s total covers every realistic
        // latency for a healthy path and fails fast otherwise — the
        // error surfaces as a benign "status fetch failed" line next to
        // the rest of the status report.
        let http = reqwest::blocking::Client::builder()
            .connect_timeout(std::time::Duration::from_secs(5))
            .timeout(std::time::Duration::from_secs(10))
            .user_agent(crate::ATOMCODE_USER_AGENT)
            .build()
            .unwrap_or_else(|_| reqwest::blocking::Client::new());
        Ok(Self { http, token })
    }

    /// `POST /coding-plan/claim-v2` — claim a specific CodingPlan tier.
    /// Server reports `duplicate=true` when the user already holds the
    /// tier (or a higher one); callers should treat that as success and
    /// stop the cascade rather than retrying lower tiers — those would
    /// either also report duplicate or unnecessarily downgrade.
    ///
    /// Body shape: `{"plan_type": "Max" | "Pro" | "Lite"}`. The user
    /// asked us to start at Max and walk down, so the orchestrator
    /// (`step_claim`) calls this in `PlanType::CASCADE_ORDER`.
    pub fn claim_v2(&self, plan_type: PlanType) -> Result<ClaimResponse> {
        let url = format!("{}/coding-plan/claim-v2", api_base_url());
        let body_str = format!(r#"{{"plan_type":"{}"}}"#, plan_type.as_str());
        let resp = self
            .http
            .post(&url)
            .bearer_auth(&self.token)
            .header("Content-Type", "application/json")
            .body(body_str)
            .send()
            .with_context(|| format!("POST {} failed", url))?;

        let status = resp.status();
        if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
            return Err(anyhow::Error::new(AuthExpired {
                status: status.as_u16(),
            }));
        }
        let body = resp.text().unwrap_or_default();
        if !status.is_success() {
            return Err(anyhow!("{}", format_api_error("claim-v2", status, &body)));
        }
        serde_json::from_str::<ClaimResponse>(&body).with_context(|| {
            format!(
                "parse claim-v2 response (body: {})",
                truncate_for_error(&body, 200)
            )
        })
    }

    /// `GET /coding-plan/models-v2?plan_type=<tier>` — model catalogue
    /// from the v2 endpoint. Every entry now carries `plan_available`
    /// telling the caller whether the user's tier covers that model;
    /// the renderer uses it to apply strikethrough on locked entries.
    /// Empty list is a legitimate return when the entitlement hasn't
    /// been provisioned yet.
    pub fn list_models_v2(&self, plan_type: PlanType) -> Result<Vec<ModelEntry>> {
        let url = format!(
            "{}/coding-plan/models-v2?plan_type={}",
            api_base_url(),
            plan_type.as_str()
        );
        let resp = self
            .http
            .get(&url)
            .bearer_auth(&self.token)
            .send()
            .with_context(|| format!("GET {} failed", url))?;

        let status = resp.status();
        if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
            return Err(anyhow::Error::new(AuthExpired {
                status: status.as_u16(),
            }));
        }
        let body = resp.text().unwrap_or_default();
        if !status.is_success() {
            return Err(anyhow!("{}", format_api_error("models-v2", status, &body)));
        }
        serde_json::from_str::<Vec<ModelEntry>>(&body).with_context(|| {
            format!(
                "parse models-v2 response (body: {})",
                truncate_for_error(&body, 200)
            )
        })
    }

    /// `GET /coding-plan/status-v2` — audit/quota/expiry snapshot. Same
    /// response envelope as v1; only the path changed under the v2
    /// rollout, so the parser type stays put.
    pub fn status_v2(&self) -> Result<StatusResponse> {
        let url = format!("{}/coding-plan/status-v2", api_base_url());
        let resp = self
            .http
            .get(&url)
            .bearer_auth(&self.token)
            .send()
            .with_context(|| format!("GET {} failed", url))?;

        let status = resp.status();
        if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
            return Err(anyhow::Error::new(AuthExpired {
                status: status.as_u16(),
            }));
        }
        let body = resp.text().unwrap_or_default();
        if !status.is_success() {
            return Err(anyhow!("{}", format_api_error("status-v2", status, &body)));
        }
        serde_json::from_str::<StatusResponse>(&body).with_context(|| {
            format!(
                "parse status-v2 response (body: {})",
                truncate_for_error(&body, 200)
            )
        })
    }
}

fn truncate_for_error(s: &str, max_chars: usize) -> String {
    if s.chars().count() <= max_chars {
        s.to_string()
    } else {
        let head: String = s.chars().take(max_chars).collect();
        format!("{}…", head)
    }
}

/// Format a non-2xx response from any CodingPlan endpoint into a
/// user-facing error string. Tries three body shapes in priority order:
///
///   1. Product payload `{"message": "..."}` (non-empty) — shown verbatim
///      (e.g. `全平台日限额已满` from a 429).
///   2. Spring default error body `{"timestamp":..,"status":..,"error":..,
///      "path":".."}` — rendered as `HTTP <code> — 接口暂不可用 (<path>)`
///      so the user sees which endpoint 404'd without staring at raw JSON.
///   3. Raw text fallback — `CodingPlan <descriptor> returned <status> — <body>`
///      with a 200-char body cap.
///
/// `descriptor` is a short name for the caller (`claim` / `models` /
/// `status`) used only in shape-3 fallback where no structured info exists.
fn format_api_error(descriptor: &str, status: reqwest::StatusCode, body: &str) -> String {
    if let Ok(val) = serde_json::from_str::<serde_json::Value>(body) {
        // Shape 1: product payload with non-empty `message`.
        if let Some(msg) = val.get("message").and_then(|v| v.as_str()) {
            if !msg.is_empty() {
                return msg.to_string();
            }
        }
        // Shape 2: Spring error body with `path` (and no usable message).
        if let Some(path) = val.get("path").and_then(|v| v.as_str()) {
            return format!("HTTP {} — 接口暂不可用 ({})", status.as_u16(), path);
        }
    }
    // Shape 3: raw text fallback.
    format!(
        "CodingPlan {} returned {} — {}",
        descriptor,
        status,
        truncate_for_error(body, 200)
    )
}

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

    /// `AuthExpired` must Display identically to the legacy
    /// `anyhow!("authentication failed (NNN) — run `atomcode login` again")`
    /// string so existing renderers / log scrapers / users that grep
    /// for the hint don't see a stealth wording change.
    #[test]
    fn auth_expired_display_matches_legacy_string() {
        let e = AuthExpired { status: 401 };
        assert_eq!(
            e.to_string(),
            "authentication failed (401) — run `atomcode login` again"
        );
        let e = AuthExpired { status: 403 };
        assert_eq!(
            e.to_string(),
            "authentication failed (403) — run `atomcode login` again"
        );
    }

    /// `is_auth_expired` finds the marker on a direct `AuthExpired`
    /// error AND through the `with_context` chain that callers wrap
    /// around it ("build client: ...", "list models-v2: ..."). Without
    /// walking the chain we'd miss it the moment any step layers a
    /// context onto the original anyhow::Error.
    #[test]
    fn is_auth_expired_walks_cause_chain() {
        let raw = anyhow::Error::new(AuthExpired { status: 401 });
        assert!(is_auth_expired(&raw));

        let wrapped: anyhow::Error =
            Err::<(), _>(anyhow::Error::new(AuthExpired { status: 401 }))
                .context("list models-v2")
                .unwrap_err();
        assert!(is_auth_expired(&wrapped));

        let unrelated = anyhow!("some other failure");
        assert!(!is_auth_expired(&unrelated));
    }

    #[test]
    fn format_api_error_extracts_message_from_product_payload() {
        let body = r#"{"success":false,"duplicate":false,"message":"全平台日限额已满"}"#;
        let msg = format_api_error("claim", reqwest::StatusCode::TOO_MANY_REQUESTS, body);
        assert_eq!(msg, "全平台日限额已满");
    }

    #[test]
    fn format_api_error_uses_path_from_spring_error_body() {
        let body = r#"{"timestamp":"2026-04-23T06:44:11.638+00:00","status":404,"error":"Not Found","path":"/api/v5/coding-plan/claim"}"#;
        let msg = format_api_error("claim", reqwest::StatusCode::NOT_FOUND, body);
        assert_eq!(msg, "HTTP 404 — 接口暂不可用 (/api/v5/coding-plan/claim)");
    }

    #[test]
    fn format_api_error_path_takes_precedence_when_message_empty() {
        let body = r#"{"message":"","path":"/api/v5/coding-plan/models"}"#;
        let msg = format_api_error("models", reqwest::StatusCode::NOT_FOUND, body);
        assert_eq!(msg, "HTTP 404 — 接口暂不可用 (/api/v5/coding-plan/models)");
    }

    #[test]
    fn format_api_error_falls_back_on_non_json_body() {
        let body = "<html>502 Bad Gateway</html>";
        let msg = format_api_error("status", reqwest::StatusCode::BAD_GATEWAY, body);
        assert!(msg.contains("502"), "status code missing: {}", msg);
        assert!(
            msg.contains("CodingPlan status"),
            "descriptor missing: {}",
            msg
        );
        assert!(
            msg.contains("502 Bad Gateway"),
            "body should be echoed: {}",
            msg
        );
    }

    #[test]
    fn format_api_error_falls_back_on_json_with_no_known_fields() {
        let body = r#"{"foo":"bar"}"#;
        let msg = format_api_error("claim", reqwest::StatusCode::INTERNAL_SERVER_ERROR, body);
        assert!(msg.contains("500"), "status code missing: {}", msg);
        assert!(
            msg.contains("CodingPlan claim"),
            "descriptor missing: {}",
            msg
        );
    }

    #[test]
    fn format_api_error_message_wins_over_path_when_both_present() {
        let body = r#"{"message":"全平台日限额已满","path":"/api/v5/coding-plan/claim"}"#;
        let msg = format_api_error("claim", reqwest::StatusCode::TOO_MANY_REQUESTS, body);
        assert_eq!(msg, "全平台日限额已满");
    }
}