use std::collections::VecDeque;
use std::future::Future;
use std::num::NonZeroU64;
use std::path::Path;
use std::pin::Pin;
use std::sync::{Arc, RwLock};

use anyhow::{Context as _, Result, anyhow, bail};
use bytes::Bytes;
use fabro_api::types;
use fabro_http::header::{ACCEPT, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE};
use fabro_http::multipart::{Form, Part};
use fabro_model::{Model, ModelTestMode, ProviderId, ReasoningEffort};
use fabro_types::settings::run::MergeStrategy;
use fabro_types::{
    ArtifactUpload, BlobHash, EventEnvelope, PairId, PairMessageRecord, PairMessageRequest,
    PairRecord, PairStartRequest, PairTranscriptResponse, Run, RunEvent, RunEventDetailResponse,
    RunId, RunPairStatusResponse, RunProjection, SessionId, SessionRecord, StageId,
};
use fabro_util::exit::{ErrorExt, ExitClass};
use futures::future::BoxFuture;
use futures::{Stream, StreamExt};
use serde::{Deserialize, Serialize};
use tokio::fs::File;
use tokio::sync::Mutex;
use tokio::time;
use tokio_util::io::ReaderStream;

use crate::credential::Credential;
use crate::error::{
    ApiError, ApiFailure, api_failure_for, classify_api_error, classify_http_response,
    convert_type, is_not_found_error, raw_response_failure_error,
};
use crate::session::OAuthSession;
use crate::target::ServerTarget;
use crate::{AuthEntry, OAuthEntry, StoredSubject, sse};

const DEFAULT_CONTROL_PLANE_REQUEST_TIMEOUT: std::time::Duration =
    std::time::Duration::from_secs(30);
const DEFAULT_HEALTH_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
/// Matches the `Retry-After` the server sends on the 202
/// (`PULL_REQUEST_CREATION_RETRY_AFTER` in `fabro-server`).
const PULL_REQUEST_CREATION_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
/// Overall polling deadline. The server abandons a creation attempt after 10
/// minutes, but a creation can also sit pending behind the server's worker
/// pool (or a dead server), so the client needs its own bound.
const PULL_REQUEST_CREATION_POLL_DEADLINE: std::time::Duration = std::time::Duration::from_mins(15);

type TransportFuture = BoxFuture<'static, Result<(fabro_http::HttpClient, String)>>;

pub struct RunEventStream {
    stream:          progenitor_client::ByteStream,
    pending_bytes:   Vec<u8>,
    buffered_events: VecDeque<EventEnvelope>,
}

type HttpByteStream = Pin<Box<dyn Stream<Item = Result<Bytes>> + Send>>;

pub struct SessionEventStream {
    stream:          HttpByteStream,
    pending_bytes:   Vec<u8>,
    buffered_events: VecDeque<EventEnvelope>,
}

pub struct RewindRunResult {
    pub status:   u16,
    pub response: types::RewindResponse,
}

#[derive(Default)]
struct ListStoreRunsOptions {
    parent_id: Option<RunId>,
}

#[derive(Clone)]
struct ClientState {
    client:       fabro_api::ApiClient,
    http_client:  fabro_http::HttpClient,
    bearer_token: Option<String>,
    base_url:     String,
}

#[derive(Clone)]
pub struct Client {
    state:               Arc<RwLock<ClientState>>,
    oauth_session:       Option<OAuthSession>,
    /// Serializes rotation between this client's own tasks. The cross-process
    /// half lives in `AuthStore::acquire_refresh_lock`.
    local_refresh_lock:  Arc<Mutex<()>>,
    transport_connector: Option<TransportConnector>,
    request_timeout:     Option<std::time::Duration>,
}

#[derive(Clone)]
pub struct TransportConnector {
    connect: Arc<dyn Fn(Option<String>) -> TransportFuture + Send + Sync>,
}

#[derive(Default)]
pub struct ClientBuilder {
    target:              Option<ServerTarget>,
    credential:          Option<Credential>,
    oauth_session:       Option<OAuthSession>,
    transport:           Option<(String, fabro_http::HttpClient)>,
    transport_connector: Option<TransportConnector>,
    request_timeout:     Option<std::time::Duration>,
}

#[derive(Debug, Deserialize)]
struct CliTokenResponse {
    access_token:             String,
    access_token_expires_at:  chrono::DateTime<chrono::Utc>,
    refresh_token:            String,
    refresh_token_expires_at: chrono::DateTime<chrono::Utc>,
    subject:                  CliTokenSubject,
}

#[derive(Debug, Deserialize)]
struct CliTokenSubject {
    idp_issuer:  String,
    idp_subject: String,
    login:       String,
    name:        String,
    email:       String,
}

#[derive(Debug, Deserialize)]
struct OAuthErrorBody {
    error:             String,
    #[serde(default)]
    error_description: Option<String>,
}

#[derive(Debug, Serialize)]
struct ArtifactBatchUploadManifest {
    entries: Vec<ArtifactBatchUploadEntry>,
}

#[derive(Debug, Serialize)]
struct ArtifactBatchUploadEntry {
    part:           String,
    path:           String,
    #[serde(skip_serializing_if = "Option::is_none")]
    sha256:         Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    expected_bytes: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    content_type:   Option<String>,
}

impl RunEventStream {
    #[must_use]
    pub fn new(stream: progenitor_client::ByteStream) -> Self {
        Self {
            stream,
            pending_bytes: Vec::new(),
            buffered_events: VecDeque::new(),
        }
    }

    pub async fn next_event(&mut self) -> Result<Option<EventEnvelope>> {
        loop {
            if let Some(event) = self.buffered_events.pop_front() {
                return Ok(Some(event));
            }

            if let Some(chunk) = self.stream.next().await {
                let chunk = chunk.map_err(anyhow::Error::new)?;
                self.pending_bytes.extend_from_slice(&chunk);
                self.buffer_sse_events(false)?;
            } else {
                self.buffer_sse_events(true)?;
                return Ok(self.buffered_events.pop_front());
            }
        }
    }

    fn buffer_sse_events(&mut self, finalize: bool) -> Result<()> {
        for payload in sse::drain_sse_payloads(&mut self.pending_bytes, finalize) {
            self.buffered_events
                .push_back(serde_json::from_str(&payload)?);
        }
        Ok(())
    }
}

impl SessionEventStream {
    #[must_use]
    pub fn new(stream: HttpByteStream) -> Self {
        Self {
            stream,
            pending_bytes: Vec::new(),
            buffered_events: VecDeque::new(),
        }
    }

    pub async fn next_event(&mut self) -> Result<Option<EventEnvelope>> {
        loop {
            if let Some(event) = self.buffered_events.pop_front() {
                return Ok(Some(event));
            }

            if let Some(chunk) = self.stream.next().await {
                let chunk = chunk?;
                self.pending_bytes.extend_from_slice(&chunk);
                self.buffer_sse_events(false)?;
            } else {
                self.buffer_sse_events(true)?;
                return Ok(self.buffered_events.pop_front());
            }
        }
    }

    fn buffer_sse_events(&mut self, finalize: bool) -> Result<()> {
        for payload in sse::drain_sse_payloads(&mut self.pending_bytes, finalize) {
            self.buffered_events
                .push_back(serde_json::from_str(&payload)?);
        }
        Ok(())
    }
}

impl TransportConnector {
    pub fn new<F, Fut>(connect: F) -> Self
    where
        F: Fn(Option<String>) -> Fut + Send + Sync + 'static,
        Fut: Future<Output = Result<(fabro_http::HttpClient, String)>> + Send + 'static,
    {
        Self {
            connect: Arc::new(move |bearer_token| Box::pin(connect(bearer_token))),
        }
    }

    pub async fn connect(
        &self,
        bearer_token: Option<String>,
    ) -> Result<(fabro_http::HttpClient, String)> {
        (self.connect)(bearer_token).await
    }
}

impl ClientBuilder {
    #[must_use]
    pub fn target(mut self, target: ServerTarget) -> Self {
        self.target = Some(target);
        self
    }

    #[must_use]
    pub fn credential(mut self, credential: Credential) -> Self {
        self.credential = Some(credential);
        self
    }

    #[must_use]
    pub fn oauth_session(mut self, oauth_session: OAuthSession) -> Self {
        self.oauth_session = Some(oauth_session);
        self
    }

    #[must_use]
    pub fn transport(
        mut self,
        base_url: impl Into<String>,
        http_client: fabro_http::HttpClient,
    ) -> Self {
        self.transport = Some((base_url.into(), http_client));
        self
    }

    #[must_use]
    pub fn transport_connector(mut self, transport_connector: TransportConnector) -> Self {
        self.transport_connector = Some(transport_connector);
        self
    }

    #[must_use]
    pub fn request_timeout(mut self, timeout: std::time::Duration) -> Self {
        self.request_timeout = Some(timeout);
        self
    }

    pub async fn connect(self) -> Result<Client> {
        let bearer_token = self
            .credential
            .as_ref()
            .map(Credential::bearer_token)
            .map(ToOwned::to_owned);
        let target = self.target.clone().or_else(|| {
            self.oauth_session
                .as_ref()
                .map(|session| session.target.clone())
        });
        let uses_default_target_transport =
            self.transport.is_none() && self.transport_connector.is_none() && target.is_some();
        let request_timeout = self.request_timeout.or_else(|| {
            uses_default_target_transport.then_some(DEFAULT_CONTROL_PLANE_REQUEST_TIMEOUT)
        });
        let transport_connector = self
            .transport_connector
            .or_else(|| target.map(default_transport_connector));

        let state = if let Some((base_url, http_client)) = self.transport {
            client_state(base_url, http_client, bearer_token.clone())
        } else {
            let Some(transport_connector) = transport_connector.clone() else {
                bail!("client builder requires a target, transport, or transport connector");
            };
            let (http_client, base_url) = transport_connector.connect(bearer_token.clone()).await?;
            client_state(base_url, http_client, bearer_token.clone())
        };

        Ok(Client {
            state: Arc::new(RwLock::new(state)),
            oauth_session: self.oauth_session,
            local_refresh_lock: Arc::new(Mutex::new(())),
            transport_connector,
            request_timeout,
        })
    }
}

impl Client {
    #[must_use]
    pub fn builder() -> ClientBuilder {
        ClientBuilder::default()
    }

    #[must_use]
    pub fn from_http_client(
        base_url: impl Into<String>,
        http_client: fabro_http::HttpClient,
    ) -> Self {
        Self {
            state:               Arc::new(RwLock::new(client_state(
                base_url.into(),
                http_client,
                None,
            ))),
            oauth_session:       None,
            local_refresh_lock:  Arc::new(Mutex::new(())),
            transport_connector: None,
            request_timeout:     None,
        }
    }

    pub fn new_no_proxy(base_url: &str) -> Result<Self> {
        let http_client = fabro_http::HttpClientBuilder::new().no_proxy().build()?;
        Ok(Self::from_http_client(base_url.to_string(), http_client))
    }

    #[must_use]
    pub fn clone_for_reuse(&self) -> Self {
        self.clone()
    }

    #[must_use]
    pub fn api_client(&self) -> fabro_api::ApiClient {
        self.current_state().client
    }

    #[must_use]
    pub fn http_client(&self) -> fabro_http::HttpClient {
        self.current_state().http_client
    }

    #[must_use]
    pub fn base_url(&self) -> String {
        self.current_state().base_url
    }

    fn current_state(&self) -> ClientState {
        self.state
            .read()
            .expect("client state lock should not be poisoned")
            .clone()
    }

    fn replace_state(&self, state: ClientState) {
        *self
            .state
            .write()
            .expect("client state lock should not be poisoned") = state;
    }

    async fn with_request_timeout<T>(&self, future: impl Future<Output = T>) -> Result<T> {
        let Some(timeout) = self.request_timeout else {
            return Ok(future.await);
        };

        match time::timeout(timeout, future).await {
            Ok(value) => Ok(value),
            Err(_) => bail!("server request timed out after {timeout:?}"),
        }
    }

    async fn send_api<T, E, F, Fut>(
        &self,
        request: F,
    ) -> Result<progenitor_client::ResponseValue<T>>
    where
        F: FnOnce(fabro_api::ApiClient) -> Fut + Clone,
        Fut: Future<
            Output = std::result::Result<
                progenitor_client::ResponseValue<T>,
                progenitor_client::Error<E>,
            >,
        >,
        E: serde::Serialize + std::fmt::Debug + Send + Sync + 'static,
    {
        let state = self.current_state();
        match self
            .with_request_timeout(Box::pin(request.clone()(state.client.clone())))
            .await?
        {
            Ok(response) => Ok(response),
            Err(err) => {
                let mapped = classify_api_error(err).await;
                if self.should_refresh(mapped.failure.as_ref()) {
                    if let Some(failed_token) = state.bearer_token.as_deref() {
                        self.refresh_access_token(failed_token).await?;
                        let state = self.current_state();
                        let retry_response = self
                            .with_request_timeout(Box::pin(request(state.client.clone())))
                            .await?;
                        return match retry_response {
                            Ok(response) => Ok(response),
                            Err(err) => Err(classify_api_error(err).await.error),
                        };
                    }
                }
                Err(mapped.error)
            }
        }
    }

    fn should_refresh(&self, failure: Option<&ApiFailure>) -> bool {
        self.oauth_session.is_some()
            && failure.is_some_and(|failure| {
                failure.status == fabro_http::StatusCode::UNAUTHORIZED
                    && failure.code.as_deref() == Some("access_token_expired")
            })
    }

    async fn refresh_access_token(&self, failed_access_token: &str) -> Result<()> {
        fn session_expired() -> anyhow::Error {
            anyhow!("CLI session has expired. Run `fabro auth login` again.")
                .classify(ExitClass::AuthRequired)
        }

        let Some(oauth_session) = &self.oauth_session else {
            return Err(session_expired());
        };

        let _guard = self.local_refresh_lock.lock().await;
        let current_state = self.current_state();
        if current_state.bearer_token.as_deref() != Some(failed_access_token) {
            return Ok(());
        }

        // Refresh tokens are single-use, so rotation has to be serialized
        // across processes too, not just across this client's tasks. The
        // AuthStore calls below take the shorter auth-file lock inside this
        // guard; nothing takes the two in the other order.
        let _refresh_guard = oauth_session.auth_store.acquire_refresh_lock().await?;
        let Some(entry) = oauth_session.auth_store.get(&oauth_session.target)? else {
            self.rebuild_with_fallback(oauth_session).await?;
            return Err(session_expired());
        };
        let oauth_entry = match entry {
            AuthEntry::DevToken(entry) => {
                self.rebuild_client(Some(entry.token)).await?;
                return Ok(());
            }
            AuthEntry::OAuth(entry) => entry,
        };
        // Adopt a token another process already rotated, but only while it is
        // still usable. The caller retries once and does not refresh again, so
        // installing an expired token here would surface a 401. An expired one
        // falls through and rotates with the refresh token just read.
        if oauth_entry.access_token != failed_access_token
            && oauth_entry.access_token_expires_at > chrono::Utc::now()
        {
            self.rebuild_client(Some(oauth_entry.access_token)).await?;
            return Ok(());
        }
        if oauth_entry.refresh_token_expires_at <= chrono::Utc::now() {
            oauth_session.auth_store.remove(&oauth_session.target)?;
            self.rebuild_with_fallback(oauth_session).await?;
            return Err(session_expired());
        }
        let (http_client, base_url) = oauth_session.target.build_public_http_client()?;
        let response = http_client
            .post(format!("{base_url}/auth/cli/refresh"))
            .header(
                AUTHORIZATION,
                format!("Bearer {}", oauth_entry.refresh_token),
            )
            .send()
            .await?;

        if response.status().is_success() {
            let tokens = response
                .json::<CliTokenResponse>()
                .await
                .context("failed to parse CLI auth refresh response")?;
            let entry = OAuthEntry {
                access_token:             tokens.access_token.clone(),
                access_token_expires_at:  tokens.access_token_expires_at,
                refresh_token:            tokens.refresh_token.clone(),
                refresh_token_expires_at: tokens.refresh_token_expires_at,
                subject:                  StoredSubject {
                    idp_issuer:  tokens.subject.idp_issuer,
                    idp_subject: tokens.subject.idp_subject,
                    login:       tokens.subject.login,
                    name:        tokens.subject.name,
                    email:       tokens.subject.email,
                },
                logged_in_at:             oauth_entry.logged_in_at,
            };
            oauth_session
                .auth_store
                .put(&oauth_session.target, AuthEntry::OAuth(entry.clone()))
                .context("failed to persist refreshed CLI auth tokens")?;
            self.rebuild_client(Some(entry.access_token)).await?;
            return Ok(());
        }

        let status = response.status();
        let body = response.text().await.unwrap_or_default();
        let parsed_error = serde_json::from_str::<OAuthErrorBody>(&body).ok();
        let auth_recoverable = parsed_error.as_ref().is_some_and(|error| {
            matches!(
                error.error.as_str(),
                "refresh_token_expired" | "refresh_token_revoked"
            )
        });
        if auth_recoverable {
            oauth_session.auth_store.remove(&oauth_session.target)?;
            self.rebuild_with_fallback(oauth_session).await?;
        }

        let err = if let Some(parsed_error) = parsed_error {
            let message = parsed_error
                .error_description
                .filter(|value| !value.is_empty())
                .unwrap_or_else(|| format!("request failed with status {status}"));
            anyhow!("{message}")
        } else if body.is_empty() {
            anyhow!("request failed with status {status}")
        } else {
            anyhow!("request failed with status {status}: {body}")
        };

        Err(if auth_recoverable {
            err.classify(ExitClass::AuthRequired)
        } else {
            err
        })
    }

    async fn rebuild_with_fallback(&self, oauth_session: &OAuthSession) -> Result<()> {
        let credential = oauth_session.resolve_fallback();
        self.rebuild_client(
            credential
                .as_ref()
                .map(Credential::bearer_token)
                .map(ToOwned::to_owned),
        )
        .await
    }

    async fn rebuild_client(&self, bearer_token: Option<String>) -> Result<()> {
        let Some(transport_connector) = &self.transport_connector else {
            bail!("client transport cannot be rebuilt");
        };
        let (http_client, base_url) = transport_connector.connect(bearer_token.clone()).await?;
        self.replace_state(client_state(base_url, http_client, bearer_token));
        Ok(())
    }

    pub async fn send_http_response<T, F, Fut>(
        &self,
        request: F,
    ) -> Result<std::result::Result<fabro_http::Response, ApiError>>
    where
        F: FnOnce(fabro_http::HttpClient) -> Fut + Clone,
        Fut: Future<Output = std::result::Result<fabro_http::Response, T>>,
        T: Into<anyhow::Error>,
    {
        let state = self.current_state();
        let response = self
            .with_request_timeout(Box::pin(request.clone()(state.http_client.clone())))
            .await?
            .map_err(Into::into)?;
        match classify_http_response(response).await? {
            Ok(response) => Ok(Ok(response)),
            Err(failure) => {
                if self.should_refresh(Some(failure.api_failure())) {
                    if let Some(failed_token) = state.bearer_token.as_deref() {
                        self.refresh_access_token(failed_token).await?;
                        let state = self.current_state();
                        let response = self
                            .with_request_timeout(Box::pin(request(state.http_client.clone())))
                            .await?
                            .map_err(Into::into)?;
                        return classify_http_response(response).await;
                    }
                }
                Ok(Err(failure))
            }
        }
    }

    async fn send_http<T, F, Fut>(&self, request: F) -> Result<fabro_http::Response>
    where
        F: FnOnce(fabro_http::HttpClient) -> Fut + Clone,
        Fut: Future<Output = std::result::Result<fabro_http::Response, T>>,
        T: Into<anyhow::Error>,
    {
        match self.send_http_response(request).await? {
            Ok(response) => Ok(response),
            Err(failure) => Err(raw_response_failure_error(&failure)),
        }
    }

    pub async fn retrieve_resolved_server_settings(&self) -> Result<types::ServerSettings> {
        let url = format!("{}/api/v1/settings", self.base_url());
        let response = self
            .send_http(|http_client| async move { http_client.get(&url).send().await })
            .await?;

        response
            .json::<types::ServerSettings>()
            .await
            .context("server returned invalid JSON for server settings")
    }

    pub async fn create_run_session(
        &self,
        run_id: RunId,
        body: types::CreateRunSessionRequest,
    ) -> Result<SessionRecord> {
        let response = self
            .send_api(|client| {
                let body = body.clone();
                async move {
                    client
                        .create_run_session()
                        .id(run_id.to_string())
                        .body(body)
                        .send()
                        .await
                }
            })
            .await?;
        Ok(response.into_inner())
    }

    #[expect(
        clippy::disallowed_types,
        reason = "Client builds raw server API request URLs for wire transit; logging redaction is handled at log boundaries."
    )]
    pub async fn submit_session_turn_stream(
        &self,
        session_id: SessionId,
        input: impl Into<String>,
    ) -> Result<SessionEventStream> {
        let base_url = self.base_url();
        let mut url = fabro_http::Url::parse(&base_url)
            .with_context(|| format!("invalid server base URL {base_url}"))?;
        url.path_segments_mut()
            .map_err(|()| anyhow!("server base URL cannot accept path segments"))?
            .extend(["api", "v1", "sessions", &session_id.to_string(), "turns"]);
        let body = types::SubmitTurnRequest {
            input:   input.into(),
            turn_id: None,
        };
        let response = self
            .send_http(|http_client| {
                let url = url.clone();
                let body = body.clone();
                async move {
                    http_client
                        .post(url)
                        .header(ACCEPT, "text/event-stream")
                        .json(&body)
                        .send()
                        .await
                }
            })
            .await?;
        let stream = response
            .bytes_stream()
            .map(|chunk| chunk.map_err(anyhow::Error::new));
        Ok(SessionEventStream::new(Box::pin(stream)))
    }

    pub async fn create_run_from_manifest(&self, manifest: types::RunManifest) -> Result<RunId> {
        self.submit_create_run(manifest.into()).await
    }

    pub async fn create_run_from_intent(&self, intent: types::RunIntent) -> Result<RunId> {
        self.submit_create_run(intent.into()).await
    }

    async fn submit_create_run(&self, body: types::CreateRunRequest) -> Result<RunId> {
        let response = self
            .send_api(|client| async move { client.create_run().body(body.clone()).send().await })
            .await?;
        let status = response.into_inner();
        Ok(status.id)
    }

    pub async fn list_secrets(&self) -> Result<Vec<types::SecretMetadata>> {
        let response = self
            .send_api(|client| async move { client.list_secrets().send().await })
            .await?;
        Ok(response.into_inner().data)
    }

    pub async fn create_secret(
        &self,
        body: types::CreateSecretRequest,
    ) -> Result<types::SecretMetadata> {
        let response = self
            .send_api(
                |client| async move { client.create_secret().body(body.clone()).send().await },
            )
            .await?;
        Ok(response.into_inner())
    }

    pub async fn delete_secret_by_name(&self, name: &str) -> Result<()> {
        self.send_api(|client| async move {
            client
                .delete_secret_by_name()
                .body(types::DeleteSecretRequest {
                    name: name.to_string(),
                })
                .send()
                .await
        })
        .await?;
        Ok(())
    }

    pub async fn list_variables(&self) -> Result<Vec<types::Variable>> {
        let response = self
            .send_api(|client| async move { client.list_variables().send().await })
            .await?;
        Ok(response.into_inner().data)
    }

    pub async fn get_variable(&self, name: &str) -> Result<types::Variable> {
        let response = self
            .send_api(
                |client| async move { client.get_variable().name(name.to_string()).send().await },
            )
            .await?;
        Ok(response.into_inner())
    }

    pub async fn create_variable(
        &self,
        body: types::CreateVariableRequest,
    ) -> Result<types::Variable> {
        let response = self
            .send_api(
                |client| async move { client.create_variable().body(body.clone()).send().await },
            )
            .await?;
        Ok(response.into_inner())
    }

    pub async fn update_variable(
        &self,
        name: &str,
        body: types::UpdateVariableRequest,
    ) -> Result<types::Variable> {
        let response = self
            .send_api(|client| async move {
                client
                    .update_variable()
                    .name(name.to_string())
                    .body(body.clone())
                    .send()
                    .await
            })
            .await?;
        Ok(response.into_inner())
    }

    pub async fn delete_variable(&self, name: &str) -> Result<()> {
        self.send_api(|client| async move {
            client.delete_variable().name(name.to_string()).send().await
        })
        .await?;
        Ok(())
    }

    pub async fn list_models(
        &self,
        provider: Option<&str>,
        query: Option<&str>,
    ) -> Result<Vec<Model>> {
        let provider = provider.map(ProviderId::new);
        let mut offset = 0u64;
        let mut models = Vec::new();

        loop {
            let provider = provider.clone();
            let response = self
                .send_api(|client| async move {
                    let mut request = client.list_models().page_limit(100u64).page_offset(offset);
                    if let Some(provider) = provider {
                        request = request.provider(provider);
                    }
                    if let Some(query) = query {
                        request = request.query(query.to_string());
                    }
                    request.send().await
                })
                .await?;
            let parsed = response.into_inner();
            let count = parsed.data.len() as u64;
            models.extend(convert_type::<_, Vec<Model>>(parsed.data)?);
            if !parsed.meta.has_more {
                break;
            }
            offset += count;
        }

        Ok(models)
    }

    pub async fn list_providers(&self) -> Result<Vec<types::Provider>> {
        let response = self
            .send_api(|client| async move { client.list_providers().send().await })
            .await?;
        convert_type::<_, Vec<types::Provider>>(response.into_inner().data)
    }

    pub async fn test_provider_credentials(
        &self,
        provider: &ProviderId,
        api_key: &str,
    ) -> Result<()> {
        self.send_api(|client| async move {
            client
                .test_provider_credentials()
                .provider(provider.to_string())
                .body(types::ProviderCredentialTestRequest {
                    api_key: api_key.to_string(),
                })
                .send()
                .await
        })
        .await?;
        Ok(())
    }

    pub async fn test_model(
        &self,
        id: &str,
        provider: Option<&ProviderId>,
        mode: Option<ModelTestMode>,
        reasoning_effort: Option<ReasoningEffort>,
    ) -> Result<types::ModelTestResult> {
        let response = self
            .send_api(|client| async move {
                let mut request = client.test_model().id(id.to_string());
                if let Some(provider) = provider {
                    request = request.provider(provider.clone());
                }
                if let Some(mode) = mode {
                    request = request.mode(mode);
                }
                if let Some(reasoning_effort) = reasoning_effort {
                    request = request.reasoning_effort(reasoning_effort);
                }
                request.send().await
            })
            .await?;
        Ok(response.into_inner())
    }

    pub async fn attach_events(&self, run_ids: &[String]) -> Result<progenitor_client::ByteStream> {
        let response = self
            .send_api(|client| async move {
                let mut request = client.attach_events();
                if !run_ids.is_empty() {
                    request = request.run_id(run_ids.join(","));
                }
                request.send().await
            })
            .await?;
        Ok(response.into_inner())
    }

    pub async fn get_system_info(&self) -> Result<types::SystemInfoResponse> {
        let response = self
            .send_api(|client| async move { client.get_system_info().send().await })
            .await?;
        Ok(response.into_inner())
    }

    pub async fn get_system_disk_usage(&self, verbose: bool) -> Result<types::DiskUsageResponse> {
        let response = self
            .send_api(|client| async move {
                client.get_system_disk_usage().verbose(verbose).send().await
            })
            .await?;
        Ok(response.into_inner())
    }

    pub async fn get_system_repair_runs(&self) -> Result<types::SystemRepairRunsResponse> {
        let response = self
            .send_api(|client| async move { client.get_system_repair_runs().send().await })
            .await?;
        Ok(response.into_inner())
    }

    pub async fn prune_runs(
        &self,
        body: types::PruneRunsRequest,
    ) -> Result<types::PruneRunsResponse> {
        let response = self
            .send_api(|client| async move { client.prune_runs().body(body.clone()).send().await })
            .await?;
        Ok(response.into_inner())
    }

    pub async fn get_health(&self) -> Result<()> {
        match time::timeout(
            DEFAULT_HEALTH_REQUEST_TIMEOUT,
            self.send_api(|client| async move { client.get_health().send().await }),
        )
        .await
        {
            Ok(result) => {
                result?;
            }
            Err(_) => bail!("server health check timed out"),
        }
        Ok(())
    }

    pub async fn run_diagnostics(&self) -> Result<types::DiagnosticsReport> {
        let response = self
            .send_api(|client| async move { client.run_diagnostics().send().await })
            .await?;
        Ok(response.into_inner())
    }

    pub async fn get_github_repo(
        &self,
        owner: &str,
        name: &str,
    ) -> Result<types::RepoCheckResponse> {
        let response = self
            .send_api(|client| async move {
                client
                    .get_github_repo()
                    .owner(owner.to_string())
                    .name(name.to_string())
                    .send()
                    .await
            })
            .await?;
        Ok(response.into_inner())
    }

    pub async fn run_preflight(
        &self,
        manifest: types::RunManifest,
    ) -> Result<types::PreflightResponse> {
        self.send_api(
            |client| async move { client.run_preflight().body(manifest.clone()).send().await },
        )
        .await
        .map(progenitor_client::ResponseValue::into_inner)
    }

    pub async fn validate_run_manifest(
        &self,
        manifest: types::RunManifest,
    ) -> Result<types::ValidateResponse> {
        self.send_api(|client| async move {
            client
                .validate_run_manifest()
                .body(manifest.clone())
                .send()
                .await
        })
        .await
        .map(progenitor_client::ResponseValue::into_inner)
    }

    pub async fn render_workflow_graph(
        &self,
        request: types::RenderWorkflowGraphRequest,
    ) -> Result<Vec<u8>> {
        let response = self
            .send_api(|client| async move {
                client
                    .render_workflow_graph()
                    .body(request.clone())
                    .send()
                    .await
            })
            .await?;
        let mut stream = response.into_inner();
        let mut bytes = Vec::new();
        while let Some(chunk) = stream.next().await {
            let chunk = chunk.map_err(anyhow::Error::new)?;
            bytes.extend_from_slice(&chunk);
        }
        Ok(bytes)
    }

    pub async fn start_run(&self, run_id: &RunId, resume: bool) -> Result<Run> {
        let response = self
            .send_api(|client| async move {
                client
                    .start_run()
                    .id(run_id.to_string())
                    .body(types::StartRunRequest { resume })
                    .send()
                    .await
            })
            .await?;
        convert_type(response.into_inner())
    }

    pub async fn cancel_run(&self, run_id: &RunId) -> Result<Run> {
        let response = self
            .send_api(
                |client| async move { client.cancel_run().id(run_id.to_string()).send().await },
            )
            .await?;
        convert_type(response.into_inner())
    }

    pub async fn approve_run(&self, run_id: &RunId) -> Result<Run> {
        let response = self
            .send_api(
                |client| async move { client.approve_run().id(run_id.to_string()).send().await },
            )
            .await?;
        convert_type(response.into_inner())
    }

    pub async fn deny_run(&self, run_id: &RunId, reason: Option<String>) -> Result<Run> {
        let body = types::DenyRunRequest { reason };
        let response = self
            .send_api(|client| {
                let body = body.clone();
                async move {
                    client
                        .deny_run()
                        .id(run_id.to_string())
                        .body(body)
                        .send()
                        .await
                }
            })
            .await?;
        convert_type(response.into_inner())
    }

    pub async fn interrupt_run(&self, run_id: &RunId) -> Result<()> {
        self.send_api(|client| async move {
            client.interrupt_run().id(run_id.to_string()).send().await
        })
        .await?;
        Ok(())
    }

    pub async fn steer_run(&self, run_id: &RunId, text: String, interrupt: bool) -> Result<()> {
        let body: types::SteerRunRequest = types::SteerRunRequest::builder()
            .text(text)
            .interrupt(interrupt)
            .try_into()
            .map_err(|e| anyhow!("failed to build SteerRunRequest: {e}"))?;
        self.send_api(|client| {
            let body = body.clone();
            async move {
                client
                    .steer_run()
                    .id(run_id.to_string())
                    .body(body)
                    .send()
                    .await
            }
        })
        .await?;
        Ok(())
    }

    pub async fn get_run_pair_status(&self, run_id: &RunId) -> Result<RunPairStatusResponse> {
        let response = self
            .send_api(|client| async move {
                client
                    .get_run_pair_status()
                    .id(run_id.to_string())
                    .send()
                    .await
            })
            .await?;
        convert_type(response.into_inner())
    }

    pub async fn start_run_pair(&self, run_id: &RunId, stage_id: StageId) -> Result<PairRecord> {
        let body = PairStartRequest { stage_id };
        let response = self
            .send_api(|client| {
                let body = body.clone();
                async move {
                    client
                        .start_run_pair()
                        .id(run_id.to_string())
                        .body(body)
                        .send()
                        .await
                }
            })
            .await?;
        convert_type(response.into_inner())
    }

    pub async fn get_run_pair(&self, run_id: &RunId, pair_id: &PairId) -> Result<PairRecord> {
        let response = self
            .send_api(|client| async move {
                client
                    .get_run_pair()
                    .id(run_id.to_string())
                    .pair_id(*pair_id)
                    .send()
                    .await
            })
            .await?;
        convert_type(response.into_inner())
    }

    pub async fn end_run_pair(&self, run_id: &RunId, pair_id: &PairId) -> Result<PairRecord> {
        let response = self
            .send_api(|client| async move {
                client
                    .end_run_pair()
                    .id(run_id.to_string())
                    .pair_id(*pair_id)
                    .send()
                    .await
            })
            .await?;
        convert_type(response.into_inner())
    }

    pub async fn send_run_pair_message(
        &self,
        run_id: &RunId,
        pair_id: &PairId,
        request: PairMessageRequest,
    ) -> Result<PairMessageRecord> {
        let body = request;
        let response = self
            .send_api(|client| {
                let body = body.clone();
                async move {
                    client
                        .send_run_pair_message()
                        .id(run_id.to_string())
                        .pair_id(*pair_id)
                        .body(body)
                        .send()
                        .await
                }
            })
            .await?;
        convert_type(response.into_inner())
    }

    pub async fn get_run_pair_transcript(
        &self,
        run_id: &RunId,
        pair_id: &PairId,
        since_seq: Option<u32>,
        limit: Option<u32>,
    ) -> Result<PairTranscriptResponse> {
        let response = self
            .send_api(|client| async move {
                let mut builder = client
                    .get_run_pair_transcript()
                    .id(run_id.to_string())
                    .pair_id(*pair_id);
                if let Some(since_seq) = since_seq.and_then(non_zero_u64_from_u32) {
                    builder = builder.since_seq(since_seq);
                }
                if let Some(limit) = limit.and_then(non_zero_u64_from_u32) {
                    builder = builder.limit(limit);
                }
                builder.send().await
            })
            .await?;
        convert_type(response.into_inner())
    }

    pub async fn get_run_event_detail(
        &self,
        run_id: &RunId,
        seq: u32,
        max_content_length: Option<u32>,
    ) -> Result<RunEventDetailResponse> {
        let seq = non_zero_u64_from_u32(seq).context("event seq must be non-zero")?;
        let max_content_length = max_content_length.and_then(non_zero_u64_from_u32);
        let response = self
            .send_api(|client| async move {
                let mut builder = client
                    .get_run_event_detail()
                    .id(run_id.to_string())
                    .seq(seq);
                if let Some(max_content_length) = max_content_length {
                    builder = builder.max_content_length(max_content_length);
                }
                builder.send().await
            })
            .await?;
        convert_type(response.into_inner())
    }

    pub async fn archive_run(&self, run_id: &RunId) -> Result<Run> {
        let response = self
            .send_api(
                |client| async move { client.archive_run().id(run_id.to_string()).send().await },
            )
            .await?;
        convert_type(response.into_inner())
    }

    pub async fn unarchive_run(&self, run_id: &RunId) -> Result<Run> {
        let response = self
            .send_api(
                |client| async move { client.unarchive_run().id(run_id.to_string()).send().await },
            )
            .await?;
        convert_type(response.into_inner())
    }

    pub async fn rewind_run(
        &self,
        run_id: &RunId,
        request: types::RewindRequest,
    ) -> Result<RewindRunResult> {
        let response = self
            .send_api(|client| async move {
                client
                    .rewind_run()
                    .id(run_id.to_string())
                    .body(request)
                    .send()
                    .await
            })
            .await?;
        let status = response.status().as_u16();
        Ok(RewindRunResult {
            status,
            response: response.into_inner(),
        })
    }

    pub async fn fork_run(
        &self,
        run_id: &RunId,
        request: types::ForkRequest,
    ) -> Result<types::ForkResponse> {
        let response = self
            .send_api(|client| async move {
                client
                    .fork_run()
                    .id(run_id.to_string())
                    .body(request)
                    .send()
                    .await
            })
            .await?;
        Ok(response.into_inner())
    }

    pub async fn run_timeline(&self, run_id: &RunId) -> Result<Vec<types::TimelineEntryResponse>> {
        let response = self
            .send_api(|client| async move {
                client
                    .get_run_timeline()
                    .id(run_id.to_string())
                    .send()
                    .await
            })
            .await?;
        Ok(response.into_inner())
    }

    pub async fn list_store_runs(&self) -> Result<Vec<Run>> {
        self.list_store_runs_with_options(ListStoreRunsOptions::default())
            .await
    }

    pub async fn list_store_runs_by_parent(&self, parent_id: RunId) -> Result<Vec<Run>> {
        self.list_store_runs_with_options(ListStoreRunsOptions {
            parent_id: Some(parent_id),
        })
        .await
    }

    async fn list_store_runs_with_options(
        &self,
        options: ListStoreRunsOptions,
    ) -> Result<Vec<Run>> {
        let mut all_runs = Vec::new();
        let mut offset = 0_u64;
        let limit = 100_u64;
        let parent_id = options.parent_id.map(|run_id| run_id.to_string());

        loop {
            let response = self
                .send_api(|client| {
                    let parent_id = parent_id.clone();
                    async move {
                        let mut request = client
                            .list_runs()
                            .page_limit(limit)
                            .page_offset(offset)
                            .include_archived(true);
                        if let Some(parent_id) = parent_id {
                            request = request.parent_id(parent_id);
                        }
                        request.send().await
                    }
                })
                .await?;
            let parsed = response.into_inner();
            let batch = parsed
                .data
                .into_iter()
                .map(convert_type)
                .collect::<Result<Vec<_>>>()?;
            let batch_len = batch.len() as u64;
            all_runs.extend(batch);

            if !parsed.meta.has_more || batch_len == 0 {
                break;
            }
            offset += batch_len;
        }

        Ok(all_runs)
    }

    pub async fn link_run_parent(&self, child_id: &RunId, parent_id: &RunId) -> Result<Run> {
        let body = types::UpdateRunParentRequest {
            parent_id: parent_id.to_string(),
        };
        let response = self
            .send_api(|client| async move {
                client
                    .link_run_parent()
                    .id(child_id.to_string())
                    .body(body.clone())
                    .send()
                    .await
            })
            .await?;
        convert_type(response.into_inner())
    }

    pub async fn unlink_run_parent(&self, child_id: &RunId) -> Result<Run> {
        let response = self
            .send_api(|client| async move {
                client
                    .unlink_run_parent()
                    .id(child_id.to_string())
                    .send()
                    .await
            })
            .await?;
        convert_type(response.into_inner())
    }

    pub async fn retrieve_run(&self, run_id: &RunId) -> Result<Run> {
        let response = self
            .send_api(
                |client| async move { client.retrieve_run().id(run_id.to_string()).send().await },
            )
            .await?;
        convert_type(response.into_inner())
    }

    pub async fn resolve_run(&self, selector: &str) -> Result<Run> {
        let response = self
            .send_api(|client| async move {
                client
                    .resolve_run()
                    .selector(selector.to_string())
                    .send()
                    .await
            })
            .await?;
        convert_type(response.into_inner())
    }

    pub async fn get_run_state(&self, run_id: &RunId) -> Result<RunProjection> {
        let response = self
            .send_api(
                |client| async move { client.get_run_state().id(run_id.to_string()).send().await },
            )
            .await?;
        convert_type(response.into_inner())
    }

    pub async fn get_run_logs(&self, run_id: &RunId) -> Result<Option<Vec<u8>>> {
        let response = self
            .current_state()
            .client
            .get_run_logs()
            .id(run_id.to_string())
            .send()
            .await;
        match response {
            Ok(response) => {
                let mut stream = response.into_inner();
                let mut bytes = Vec::new();
                while let Some(chunk) = stream.next().await {
                    let chunk = chunk.map_err(anyhow::Error::new)?;
                    bytes.extend_from_slice(&chunk);
                }
                Ok(Some(bytes))
            }
            Err(err) => {
                let err = classify_api_error(err).await.error;
                if is_not_found_error(&err) {
                    Ok(None)
                } else {
                    Err(err)
                }
            }
        }
    }

    pub async fn create_run_pull_request(
        &self,
        run_id: &RunId,
        force: bool,
        model: Option<String>,
    ) -> Result<fabro_types::PullRequestLink> {
        let mut creation = self
            .request_run_pull_request_creation(run_id, force, model)
            .await?;
        let creation_id = creation.id;
        let deadline = std::time::Instant::now() + PULL_REQUEST_CREATION_POLL_DEADLINE;
        loop {
            match creation.status {
                fabro_types::PullRequestCreationStatus::Pending => {
                    if std::time::Instant::now() >= deadline {
                        bail!(
                            "Pull request creation {creation_id} is still pending after {} \
                             minutes. Check its status with: fabro pr create {run_id}",
                            PULL_REQUEST_CREATION_POLL_DEADLINE.as_secs() / 60
                        );
                    }
                    time::sleep(PULL_REQUEST_CREATION_POLL_INTERVAL).await;
                    creation = self.get_run_pull_request_creation(run_id).await?;
                    if creation.id != creation_id {
                        bail!(
                            "Pull request creation {creation_id} was superseded by {}",
                            creation.id
                        );
                    }
                }
                fabro_types::PullRequestCreationStatus::Succeeded => {
                    return creation.pull_request.ok_or_else(|| {
                        anyhow!(
                            "Pull request creation {} succeeded without a pull request record",
                            creation.id
                        )
                    });
                }
                fabro_types::PullRequestCreationStatus::Failed => {
                    bail!(
                        "Pull request creation failed: {}",
                        creation
                            .error
                            .as_deref()
                            .unwrap_or("the server did not provide an error")
                    );
                }
            }
        }
    }

    pub async fn request_run_pull_request_creation(
        &self,
        run_id: &RunId,
        force: bool,
        model: Option<String>,
    ) -> Result<fabro_types::PullRequestCreation> {
        let body = types::CreateRunPullRequestRequest { force, model };
        let response = self
            .send_api(|client| async move {
                client
                    .create_run_pull_request()
                    .id(run_id.to_string())
                    .body(body.clone())
                    .send()
                    .await
            })
            .await
            .map_err(add_pr_upgrade_hint)?;
        Ok(response.into_inner())
    }

    pub async fn get_run_pull_request_creation(
        &self,
        run_id: &RunId,
    ) -> Result<fabro_types::PullRequestCreation> {
        let response = self
            .send_api(|client| async move {
                client
                    .get_run_pull_request_creation()
                    .id(run_id.to_string())
                    .send()
                    .await
            })
            .await
            .map_err(add_pr_upgrade_hint)?;
        Ok(response.into_inner())
    }

    pub async fn get_run_pull_request(
        &self,
        run_id: &RunId,
    ) -> Result<fabro_types::PullRequestResponse> {
        let response = self
            .send_api(|client| async move {
                client
                    .get_run_pull_request()
                    .id(run_id.to_string())
                    .send()
                    .await
            })
            .await
            .map_err(add_pr_upgrade_hint)?;
        Ok(response.into_inner())
    }

    pub async fn link_run_pull_request(
        &self,
        run_id: &RunId,
        html_url: String,
    ) -> Result<fabro_types::PullRequestLink> {
        let body = types::LinkRunPullRequestRequest { html_url };
        let response = self
            .send_api(|client| async move {
                client
                    .link_run_pull_request()
                    .id(run_id.to_string())
                    .body(body.clone())
                    .send()
                    .await
            })
            .await
            .map_err(add_pr_upgrade_hint)?;
        convert_type(response.into_inner())
    }

    pub async fn unlink_run_pull_request(
        &self,
        run_id: &RunId,
    ) -> Result<fabro_types::PullRequestLink> {
        let response = self
            .send_api(|client| async move {
                client
                    .unlink_run_pull_request()
                    .id(run_id.to_string())
                    .send()
                    .await
            })
            .await
            .map_err(add_pr_upgrade_hint)?;
        convert_type(response.into_inner())
    }

    pub async fn merge_run_pull_request(
        &self,
        run_id: &RunId,
        method: MergeStrategy,
    ) -> Result<types::MergeRunPullRequestResponse> {
        let body = types::MergeRunPullRequestRequest { method };
        let response = self
            .send_api(|client| async move {
                client
                    .merge_run_pull_request()
                    .id(run_id.to_string())
                    .body(body.clone())
                    .send()
                    .await
            })
            .await
            .map_err(add_pr_upgrade_hint)?;
        convert_type(response.into_inner())
    }

    pub async fn close_run_pull_request(
        &self,
        run_id: &RunId,
    ) -> Result<types::CloseRunPullRequestResponse> {
        let response = self
            .send_api(|client| async move {
                client
                    .close_run_pull_request()
                    .id(run_id.to_string())
                    .send()
                    .await
            })
            .await
            .map_err(add_pr_upgrade_hint)?;
        convert_type(response.into_inner())
    }

    pub async fn list_run_events(
        &self,
        run_id: &RunId,
        since_seq: Option<u32>,
        limit: Option<usize>,
    ) -> Result<Vec<EventEnvelope>> {
        let mut next_since_seq = since_seq;
        let mut all_events = Vec::new();

        loop {
            let page = EventPageCursor::Ascending {
                since_seq: next_since_seq,
            };
            let (page_events, has_more) = self.fetch_run_events_page(run_id, page, limit).await?;
            let next_page_since_seq = page_events.last().map(|event| event.seq.saturating_add(1));
            all_events.extend(page_events);

            if limit.is_some() || !has_more || next_page_since_seq.is_none() {
                break;
            }
            next_since_seq = next_page_since_seq;
        }

        Ok(all_events)
    }

    /// Returns the newest `max_events` in ascending sequence order.
    pub async fn list_run_events_tail(
        &self,
        run_id: &RunId,
        max_events: usize,
    ) -> Result<Vec<EventEnvelope>> {
        if max_events == 0 {
            return Ok(Vec::new());
        }

        // Fetch two events when the caller asks for one so an older server
        // that silently ignores the new order parameter can be detected.
        let fetch_target = max_events.max(2);
        let mut before_seq = None;
        let mut descending_events: Vec<EventEnvelope> = Vec::new();
        loop {
            let remaining = fetch_target - descending_events.len();
            let (page_events, has_more) = self
                .fetch_run_events_page(
                    run_id,
                    EventPageCursor::Descending { before_seq },
                    Some(remaining),
                )
                .await?;

            let keeps_descending = descending_events
                .last()
                .into_iter()
                .chain(&page_events)
                .is_sorted_by(|previous, next| previous.seq > next.seq);
            if !keeps_descending {
                // An older server ignored the order parameter and returned
                // ascending history; fetch everything and slice the tail.
                let mut events = self.list_run_events(run_id, None, None).await?;
                let tail_start = events.len().saturating_sub(max_events);
                return Ok(events.split_off(tail_start));
            }

            before_seq = page_events.last().map(|event| event.seq);
            descending_events.extend(page_events);
            if descending_events.len() >= fetch_target || !has_more || before_seq.is_none() {
                break;
            }
        }

        descending_events.reverse();
        let tail_start = descending_events.len().saturating_sub(max_events);
        Ok(descending_events.split_off(tail_start))
    }

    pub async fn list_run_events_until(
        &self,
        run_id: &RunId,
        since_seq: Option<u32>,
        max_events: usize,
    ) -> Result<Vec<EventEnvelope>> {
        if max_events == 0 {
            return Ok(Vec::new());
        }

        let mut next_since_seq = since_seq;
        let mut all_events = Vec::new();
        while all_events.len() < max_events {
            let remaining = max_events - all_events.len();
            let page = EventPageCursor::Ascending {
                since_seq: next_since_seq,
            };
            let (page_events, has_more) = self
                .fetch_run_events_page(run_id, page, Some(remaining))
                .await?;
            let next_page_since_seq = page_events.last().map(|event| event.seq.saturating_add(1));
            all_events.extend(page_events);

            if !has_more || next_page_since_seq.is_none() {
                break;
            }
            next_since_seq = next_page_since_seq;
        }

        Ok(all_events)
    }

    async fn fetch_run_events_page(
        &self,
        run_id: &RunId,
        cursor: EventPageCursor,
        limit: Option<usize>,
    ) -> Result<(Vec<EventEnvelope>, bool)> {
        let response = self
            .send_api(|client| async move {
                let mut request = client.list_run_events().id(run_id.to_string());
                match cursor {
                    EventPageCursor::Ascending { since_seq } => {
                        if let Some(seq) = since_seq.and_then(non_zero_u64_from_u32) {
                            request = request.since_seq(seq);
                        }
                    }
                    EventPageCursor::Descending { before_seq } => {
                        request = request.order(types::ListRunEventsOrder::Desc);
                        if let Some(seq) = before_seq.and_then(non_zero_u64_from_u32) {
                            request = request.before_seq(seq);
                        }
                    }
                }
                let page_limit = limit.map(|limit| limit.min(1000));
                if let Some(limit) = page_limit.and_then(non_zero_u64_from_usize) {
                    request = request.limit(limit);
                }
                request.send().await
            })
            .await?;
        let parsed = response.into_inner();
        let events = parsed
            .data
            .into_iter()
            .map(convert_type::<_, EventEnvelope>)
            .collect::<Result<Vec<EventEnvelope>>>()?;
        Ok((events, parsed.meta.has_more))
    }

    pub async fn attach_run_events(
        &self,
        run_id: &RunId,
        since_seq: Option<u32>,
    ) -> Result<RunEventStream> {
        let response = self
            .send_api(|client| async move {
                let mut request = client.attach_run_events().id(run_id.to_string());
                if let Some(seq) = since_seq.and_then(non_zero_u64_from_u32) {
                    request = request.since_seq(seq);
                }
                request.send().await
            })
            .await?;
        Ok(RunEventStream::new(response.into_inner()))
    }

    pub async fn list_run_questions(&self, run_id: &RunId) -> Result<Vec<types::ApiQuestion>> {
        let response = self
            .send_api(|client| async move {
                client
                    .list_run_questions()
                    .id(run_id.to_string())
                    .page_limit(100)
                    .page_offset(0)
                    .send()
                    .await
            })
            .await?;
        Ok(response.into_inner().data)
    }

    pub async fn submit_run_answer(
        &self,
        run_id: &RunId,
        qid: &str,
        body: types::SubmitAnswerRequest,
    ) -> Result<()> {
        self.send_api(|client| async move {
            client
                .submit_run_answer()
                .id(run_id.to_string())
                .qid(qid)
                .body(body.clone())
                .send()
                .await
        })
        .await?;
        Ok(())
    }

    pub async fn append_run_event(&self, run_id: &RunId, event: &RunEvent) -> Result<u32> {
        let body: types::RunEvent = convert_type(event)?;
        let response = self
            .send_api(|client| async move {
                client
                    .append_run_event()
                    .id(run_id.to_string())
                    .body(body.clone())
                    .send()
                    .await
            })
            .await?;
        u32::try_from(response.into_inner().seq).context("append_run_event returned invalid seq")
    }

    pub async fn write_run_blob(&self, run_id: &RunId, data: &[u8]) -> Result<BlobHash> {
        let response = self
            .send_api(|client| async move {
                client
                    .write_run_blob()
                    .id(run_id.to_string())
                    .body(data.to_vec())
                    .send()
                    .await
            })
            .await?;
        Ok(response.into_inner().hash)
    }

    pub async fn read_run_blob(
        &self,
        run_id: &RunId,
        blob_hash: &BlobHash,
    ) -> Result<Option<Bytes>> {
        let response = self
            .current_state()
            .client
            .read_run_blob()
            .id(run_id.to_string())
            .blob_hash(*blob_hash)
            .send()
            .await;
        match response {
            Ok(response) => {
                let mut stream = response.into_inner();
                let mut bytes = Vec::new();
                while let Some(chunk) = stream.next().await {
                    let chunk = chunk.map_err(anyhow::Error::new)?;
                    bytes.extend_from_slice(&chunk);
                }
                Ok(Some(Bytes::from(bytes)))
            }
            Err(err) => {
                let err = classify_api_error(err).await.error;
                if is_not_found_error(&err) {
                    Ok(None)
                } else {
                    Err(err)
                }
            }
        }
    }

    #[expect(
        clippy::disallowed_types,
        reason = "Client builds raw server API request URLs for wire transit; logging redaction is handled at log boundaries."
    )]
    pub async fn delete_store_run(&self, run_id: &RunId, force: bool) -> Result<()> {
        let base_url = self.base_url();
        let mut url = fabro_http::Url::parse(&base_url)
            .with_context(|| format!("invalid server base URL {base_url}"))?;
        url.path_segments_mut()
            .map_err(|()| anyhow!("server base URL cannot accept path segments"))?
            .extend(["api", "v1", "runs", &run_id.to_string()]);
        if force {
            url.query_pairs_mut().append_pair("force", "true");
        }

        self.send_http(|http_client| async move { http_client.delete(url.clone()).send().await })
            .await?;
        Ok(())
    }

    pub async fn list_run_artifacts(&self, run_id: &RunId) -> Result<Vec<types::RunArtifactEntry>> {
        let response = self
            .send_api(|client| async move {
                client
                    .list_run_artifacts()
                    .id(run_id.to_string())
                    .send()
                    .await
            })
            .await?;
        Ok(response.into_inner().data)
    }

    pub async fn download_stage_artifact(
        &self,
        run_id: &RunId,
        stage_id: &StageId,
        retry: u32,
        filename: &str,
    ) -> Result<Vec<u8>> {
        let response = self
            .send_api(|client| async move {
                client
                    .get_stage_artifact()
                    .id(run_id.to_string())
                    .stage_id(stage_id.to_string())
                    .retry(retry.cast_signed())
                    .filename(filename)
                    .send()
                    .await
            })
            .await?;
        let mut stream = response.into_inner();
        let mut bytes = Vec::new();
        while let Some(chunk) = stream.next().await {
            let chunk = chunk.map_err(anyhow::Error::new)?;
            bytes.extend_from_slice(&chunk);
        }
        Ok(bytes)
    }

    #[expect(
        clippy::disallowed_types,
        reason = "Client builds raw server API request URLs for wire transit; logging redaction is handled at log boundaries."
    )]
    fn stage_artifacts_url(
        &self,
        run_id: &RunId,
        stage_id: &StageId,
        retry: u32,
    ) -> Result<fabro_http::Url> {
        let base_url = self.base_url();
        let mut url = fabro_http::Url::parse(&base_url)
            .with_context(|| format!("invalid server base URL {base_url}"))?;
        url.path_segments_mut()
            .map_err(|()| anyhow!("server base URL cannot accept path segments"))?
            .extend([
                "api",
                "v1",
                "runs",
                &run_id.to_string(),
                "stages",
                &stage_id.to_string(),
                "artifacts",
            ]);
        url.query_pairs_mut()
            .append_pair("retry", &retry.to_string());
        Ok(url)
    }

    pub async fn upload_stage_artifact_file(
        &self,
        run_id: &RunId,
        stage_id: &StageId,
        retry: u32,
        filename: &str,
        path: &Path,
        bearer_token: &str,
    ) -> Result<()> {
        let mut url = self.stage_artifacts_url(run_id, stage_id, retry)?;
        url.query_pairs_mut().append_pair("filename", filename);

        let file = File::open(path)
            .await
            .with_context(|| format!("failed to open artifact {}", path.display()))?;
        let content_length = file
            .metadata()
            .await
            .with_context(|| format!("failed to stat artifact {}", path.display()))?
            .len();
        let body = fabro_http::Body::wrap_stream(ReaderStream::new(file));

        let response = self
            .current_state()
            .http_client
            .post(url)
            .bearer_auth(bearer_token)
            .header(CONTENT_TYPE, "application/octet-stream")
            .header(CONTENT_LENGTH, content_length.to_string())
            .body(body)
            .send()
            .await
            .with_context(|| format!("failed to upload artifact {}", path.display()))?;
        classify_http_response(response)
            .await?
            .map(|_| ())
            .map_err(|failure| raw_response_failure_error(&failure))
    }

    pub async fn upload_stage_artifact_batch(
        &self,
        run_id: &RunId,
        stage_id: &StageId,
        retry: u32,
        artifact_capture_dir: &Path,
        artifacts: &[ArtifactUpload],
        bearer_token: &str,
    ) -> Result<()> {
        let url = self.stage_artifacts_url(run_id, stage_id, retry)?;
        let mut manifest_entries = Vec::with_capacity(artifacts.len());
        let mut file_parts = Vec::with_capacity(artifacts.len());

        for (index, artifact) in artifacts.iter().enumerate() {
            let part_name = format!("file{}", index + 1);
            let path = artifact_capture_dir.join(&artifact.path);
            let file = File::open(&path)
                .await
                .with_context(|| format!("failed to open artifact {}", path.display()))?;
            let content_length = file
                .metadata()
                .await
                .with_context(|| format!("failed to stat artifact {}", path.display()))?
                .len();

            manifest_entries.push(ArtifactBatchUploadEntry {
                part:           part_name.clone(),
                path:           artifact.path.clone(),
                sha256:         Some(artifact.content_sha256.clone()),
                expected_bytes: Some(artifact.bytes),
                content_type:   Some(artifact.mime.clone()),
            });

            file_parts.push((
                part_name,
                Part::stream_with_length(
                    fabro_http::Body::wrap_stream(ReaderStream::new(file)),
                    content_length,
                )
                .file_name(artifact.path.clone()),
            ));
        }

        let manifest = ArtifactBatchUploadManifest {
            entries: manifest_entries,
        };
        let manifest_part =
            Part::text(serde_json::to_string(&manifest)?).mime_str("application/json")?;
        let mut form = Form::new().part("manifest", manifest_part);
        for (part_name, part) in file_parts {
            form = form.part(part_name, part);
        }

        let response = self
            .current_state()
            .http_client
            .post(url)
            .bearer_auth(bearer_token)
            .multipart(form)
            .send()
            .await
            .context("failed to upload artifact batch")?;
        classify_http_response(response)
            .await?
            .map(|_| ())
            .map_err(|failure| raw_response_failure_error(&failure))
    }

    pub async fn generate_preview_url(
        &self,
        run_id: &RunId,
        port: u16,
        expires_in_secs: u64,
        signed: bool,
    ) -> Result<types::PreviewUrlResponse> {
        let expires_in_secs = NonZeroU64::new(expires_in_secs)
            .ok_or_else(|| anyhow!("preview expiry must be greater than zero"))?;
        let response = self
            .send_api(|client| async move {
                client
                    .generate_preview_url()
                    .id(run_id.to_string())
                    .body(types::PreviewUrlRequest {
                        expires_in_secs,
                        port: i64::from(port),
                        signed,
                    })
                    .send()
                    .await
            })
            .await?;
        Ok(response.into_inner())
    }

    pub async fn create_run_ssh_access(
        &self,
        run_id: &RunId,
        ttl_minutes: f64,
    ) -> Result<types::SshAccessResponse> {
        let response = self
            .send_api(|client| async move {
                client
                    .create_run_ssh_access()
                    .id(run_id.to_string())
                    .body(types::SshAccessRequest { ttl_minutes })
                    .send()
                    .await
            })
            .await?;
        Ok(response.into_inner())
    }

    pub async fn list_sandbox_files(
        &self,
        run_id: &RunId,
        path: &str,
        depth: Option<u32>,
    ) -> Result<Vec<types::SandboxFileEntry>> {
        let response = self
            .send_api(|client| async move {
                let mut request = client
                    .list_sandbox_files()
                    .id(run_id.to_string())
                    .path(path);
                if let Some(depth) = depth.and_then(non_zero_u64_from_u32) {
                    request = request.depth(depth);
                }
                request.send().await
            })
            .await?;
        Ok(response.into_inner().data)
    }

    pub async fn get_sandbox_file(&self, run_id: &RunId, path: &str) -> Result<Vec<u8>> {
        let response = self
            .send_api(|client| async move {
                client
                    .get_sandbox_file()
                    .id(run_id.to_string())
                    .path(path)
                    .send()
                    .await
            })
            .await?;
        let mut stream = response.into_inner();
        let mut bytes = Vec::new();
        while let Some(chunk) = stream.next().await {
            let chunk = chunk.map_err(anyhow::Error::new)?;
            bytes.extend_from_slice(&chunk);
        }
        Ok(bytes)
    }

    pub async fn put_sandbox_file(&self, run_id: &RunId, path: &str, bytes: Vec<u8>) -> Result<()> {
        self.send_api(|client| async move {
            client
                .put_sandbox_file()
                .id(run_id.to_string())
                .path(path)
                .body(bytes.clone())
                .send()
                .await
        })
        .await?;
        Ok(())
    }
}

fn client_state(
    base_url: String,
    http_client: fabro_http::HttpClient,
    bearer_token: Option<String>,
) -> ClientState {
    let client = fabro_api::ApiClient::new_with_client(&base_url, http_client.clone());
    ClientState {
        client,
        http_client,
        bearer_token,
        base_url,
    }
}

fn default_transport_connector(target: ServerTarget) -> TransportConnector {
    TransportConnector::new(move |bearer_token| {
        let target = target.clone();
        async move { connect_target_transport(&target, bearer_token.as_deref()) }
    })
}

fn connect_target_transport(
    target: &ServerTarget,
    bearer_token: Option<&str>,
) -> Result<(fabro_http::HttpClient, String)> {
    if let Some(api_url) = target.as_http_url() {
        let mut builder = fabro_http::HttpClientBuilder::new();
        builder = match bearer_token {
            Some(token) => apply_bearer_token_auth(builder, token)?,
            None => builder,
        };
        let http_client = builder.build()?;
        return Ok((http_client, api_url.to_string()));
    }

    let Some(path) = target.as_unix_socket_path() else {
        bail!("server target must be an http(s) URL or absolute Unix socket path");
    };
    let mut builder = fabro_http::HttpClientBuilder::new()
        .unix_socket(path)
        .no_proxy();
    builder = match bearer_token {
        Some(token) => apply_bearer_token_auth(builder, token)?,
        None => builder,
    };
    let http_client = builder.build()?;
    Ok((http_client, "http://fabro".to_string()))
}

pub fn apply_bearer_token_auth(
    builder: fabro_http::HttpClientBuilder,
    token: &str,
) -> Result<fabro_http::HttpClientBuilder> {
    let mut headers = fabro_http::HeaderMap::new();
    headers.insert(
        AUTHORIZATION,
        fabro_http::HeaderValue::from_str(&format!("Bearer {token}"))
            .context("invalid bearer token header value")?,
    );
    Ok(builder.default_headers(headers))
}

#[derive(Clone, Copy)]
enum EventPageCursor {
    Ascending { since_seq: Option<u32> },
    Descending { before_seq: Option<u32> },
}

fn non_zero_u64_from_u32(value: u32) -> Option<NonZeroU64> {
    NonZeroU64::new(u64::from(value))
}

fn non_zero_u64_from_usize(value: usize) -> Option<NonZeroU64> {
    u64::try_from(value).ok().and_then(NonZeroU64::new)
}

// A 404 without a structured error code means the server didn't know the
// route — PR commands moved server-side in a recent release. Point users at
// an upgrade rather than leaving them with an opaque message. A 404 with a
// code (e.g. no_stored_record) is a normal app-level response and passes
// through unchanged.
fn add_pr_upgrade_hint(err: anyhow::Error) -> anyhow::Error {
    let is_missing_route = api_failure_for(&err).is_some_and(|failure| {
        failure.status == fabro_http::StatusCode::NOT_FOUND && failure.code.is_none()
    });
    if is_missing_route {
        anyhow!(
            "{err}\n\n\
             The fabro server may not support pull request endpoints — `fabro pr` commands \
             moved server-side in a recent release. Upgrade the fabro server."
        )
    } else {
        err
    }
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;
    use std::time::Duration;

    use chrono::Duration as ChronoDuration;
    use fabro_util::exit;
    use httpmock::Method::{GET, POST};
    use httpmock::MockServer;
    use serde_json::json;
    use tokio::io::{AsyncReadExt, AsyncWriteExt};
    use tokio::net::TcpListener;

    use super::*;
    use crate::error::tag_with_failure;
    use crate::{AuthStore, DevTokenEntry};

    fn oauth_entry(login: &str) -> OAuthEntry {
        let now = chrono::Utc::now();
        OAuthEntry {
            access_token:             format!("access-{login}"),
            access_token_expires_at:  now + ChronoDuration::minutes(10),
            refresh_token:            format!("refresh-{login}"),
            refresh_token_expires_at: now + ChronoDuration::days(30),
            subject:                  StoredSubject {
                idp_issuer:  "https://github.com".to_string(),
                idp_subject: "12345".to_string(),
                login:       login.to_string(),
                name:        format!("Name {login}"),
                email:       format!("{login}@example.com"),
            },
            logged_in_at:             now,
        }
    }

    fn run_event_json(run_id: &RunId, seq: u32) -> serde_json::Value {
        json!({
            "seq": seq,
            "event": "run.running",
            "id": format!("evt-{seq}"),
            "run_id": run_id,
            "ts": "2026-07-24T12:00:00Z",
            "properties": {},
        })
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn refresh_access_token_allows_plain_http_targets() {
        let temp = tempfile::tempdir().unwrap();
        let auth_store = AuthStore::new(temp.path().join("auth.json"));
        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
        let port = listener.local_addr().unwrap().port();
        let server = tokio::spawn(async move {
            let (mut stream, _) = listener.accept().await.unwrap();
            let mut request = vec![0_u8; 4096];
            let read = stream.read(&mut request).await.unwrap();
            let request = String::from_utf8_lossy(&request[..read]);
            assert!(
                request.starts_with("POST /auth/cli/refresh HTTP/1.1"),
                "unexpected refresh request: {request}"
            );
            let body = serde_json::json!({
                "access_token": "access-refreshed",
                "access_token_expires_at": (chrono::Utc::now() + ChronoDuration::minutes(10)).to_rfc3339(),
                "refresh_token": "refresh-refreshed",
                "refresh_token_expires_at": (chrono::Utc::now() + ChronoDuration::days(30)).to_rfc3339(),
                "subject": {
                    "idp_issuer": "https://github.com",
                    "idp_subject": "12345",
                    "login": "octocat",
                    "name": "Name octocat",
                    "email": "octocat@example.com"
                }
            })
            .to_string();
            let response = format!(
                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
                body.len(),
                body
            );
            stream.write_all(response.as_bytes()).await.unwrap();
        });
        let target = ServerTarget::http_url(format!("http://localhost:{port}")).unwrap();
        let entry = oauth_entry("octocat");
        auth_store
            .put(&target, AuthEntry::OAuth(entry.clone()))
            .unwrap();

        let client = Client::builder()
            .target(target.clone())
            .credential(Credential::OAuth(entry))
            .oauth_session(OAuthSession::new(target.clone(), auth_store.clone()))
            .transport("http://localhost", fabro_http::test_http_client().unwrap())
            .connect()
            .await
            .unwrap();

        client.refresh_access_token("access-octocat").await.unwrap();
        let refreshed = auth_store.get(&target).unwrap().unwrap();
        let AuthEntry::OAuth(refreshed) = refreshed else {
            panic!("expected OAuth entry");
        };
        assert_eq!(refreshed.access_token, "access-refreshed");
        assert_eq!(refreshed.refresh_token, "refresh-refreshed");
        server.abort();
    }

    #[tokio::test]
    async fn request_timeout_does_not_cap_stream_body_after_headers() {
        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
        let addr = listener.local_addr().unwrap();
        let server = tokio::spawn(async move {
            let (mut stream, _) = listener.accept().await.unwrap();
            let mut request = vec![0_u8; 4096];
            let read = stream.read(&mut request).await.unwrap();
            let request = String::from_utf8_lossy(&request[..read]);
            assert!(
                request.starts_with("GET /api/v1/attach HTTP/1.1"),
                "unexpected attach request: {request}"
            );

            let body = b"data: hello\n\n";
            let headers = format!(
                "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
                body.len()
            );
            stream.write_all(headers.as_bytes()).await.unwrap();
            time::sleep(Duration::from_millis(100)).await;
            stream.write_all(body).await.unwrap();
        });

        let target = ServerTarget::http_url(format!("http://{addr}")).unwrap();
        let client = Client::builder()
            .target(target)
            .request_timeout(Duration::from_millis(50))
            .connect()
            .await
            .unwrap();

        let mut stream = client.attach_events(&[]).await.unwrap();
        let chunk = time::timeout(Duration::from_millis(500), stream.next())
            .await
            .unwrap()
            .unwrap()
            .unwrap();

        assert_eq!(chunk, Bytes::from_static(b"data: hello\n\n"));
        server.await.unwrap();
    }

    #[tokio::test]
    async fn list_models_allows_custom_provider_filters() {
        let server = MockServer::start_async().await;
        let mock = server
            .mock_async(|when, then| {
                when.method(GET)
                    .path("/api/v1/models")
                    .query_param("provider", "bedrock");
                then.status(200)
                    .header("Content-Type", "application/json")
                    .body(
                        serde_json::json!({
                            "data": [],
                            "meta": { "has_more": false }
                        })
                        .to_string(),
                    );
            })
            .await;

        let client = Client::new_no_proxy(&server.url("")).unwrap();
        let models = client.list_models(Some("bedrock"), None).await.unwrap();

        mock.assert_async().await;
        assert!(models.is_empty());
    }

    #[tokio::test]
    async fn list_run_events_tail_pages_backward_and_returns_ascending() {
        let server = MockServer::start_async().await;
        let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap();
        let newest_page = server
            .mock_async(|when, then| {
                when.method(GET)
                    .path(format!("/api/v1/runs/{run_id}/events"))
                    .query_param("order", "desc")
                    .query_param("limit", "5");
                then.status(200)
                    .header("Content-Type", "application/json")
                    .json_body(json!({
                        "data": [
                            run_event_json(&run_id, 6),
                            run_event_json(&run_id, 5),
                            run_event_json(&run_id, 4),
                        ],
                        "meta": { "has_more": true },
                    }));
            })
            .await;
        let older_page = server
            .mock_async(|when, then| {
                when.method(GET)
                    .path(format!("/api/v1/runs/{run_id}/events"))
                    .query_param("order", "desc")
                    .query_param("before_seq", "4")
                    .query_param("limit", "2");
                then.status(200)
                    .header("Content-Type", "application/json")
                    .json_body(json!({
                        "data": [
                            run_event_json(&run_id, 3),
                            run_event_json(&run_id, 2),
                        ],
                        "meta": { "has_more": true },
                    }));
            })
            .await;

        let client = Client::new_no_proxy(&server.url("")).unwrap();
        let events = client.list_run_events_tail(&run_id, 5).await.unwrap();

        newest_page.assert_async().await;
        older_page.assert_async().await;
        let seqs = events
            .into_iter()
            .map(|event| event.seq)
            .collect::<Vec<_>>();
        assert_eq!(seqs, vec![2, 3, 4, 5, 6]);
    }

    #[tokio::test]
    async fn list_run_events_tail_falls_back_when_server_ignores_descending_order() {
        let server = MockServer::start_async().await;
        let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap();
        let unsupported_descending_page = server
            .mock_async(|when, then| {
                when.method(GET)
                    .path(format!("/api/v1/runs/{run_id}/events"))
                    .query_param("order", "desc")
                    .query_param("limit", "3");
                then.status(200)
                    .header("Content-Type", "application/json")
                    .json_body(json!({
                        "data": [
                            run_event_json(&run_id, 1),
                            run_event_json(&run_id, 2),
                            run_event_json(&run_id, 3),
                        ],
                        "meta": { "has_more": true },
                    }));
            })
            .await;
        let full_history = server
            .mock_async(|when, then| {
                when.method(GET)
                    .path(format!("/api/v1/runs/{run_id}/events"))
                    .query_param_missing("order")
                    .query_param_missing("before_seq")
                    .query_param_missing("since_seq")
                    .query_param_missing("limit");
                then.status(200)
                    .header("Content-Type", "application/json")
                    .json_body(json!({
                        "data": [
                            run_event_json(&run_id, 1),
                            run_event_json(&run_id, 2),
                            run_event_json(&run_id, 3),
                            run_event_json(&run_id, 4),
                            run_event_json(&run_id, 5),
                        ],
                        "meta": { "has_more": false },
                    }));
            })
            .await;

        let client = Client::new_no_proxy(&server.url("")).unwrap();
        let events = client.list_run_events_tail(&run_id, 3).await.unwrap();

        unsupported_descending_page.assert_async().await;
        full_history.assert_async().await;
        let seqs = events
            .into_iter()
            .map(|event| event.seq)
            .collect::<Vec<_>>();
        assert_eq!(seqs, vec![3, 4, 5]);
    }

    #[tokio::test]
    async fn test_provider_credentials_posts_api_key() {
        let server = MockServer::start_async().await;
        let mock = server
            .mock_async(|when, then| {
                when.method(POST)
                    .path("/api/v1/providers/openrouter/credentials/test")
                    .json_body(json!({ "api_key": "sk-test" }));
                then.status(200)
                    .header("Content-Type", "application/json")
                    .json_body(json!({ "ok": true }));
            })
            .await;

        let client = Client::new_no_proxy(&server.url("")).unwrap();
        client
            .test_provider_credentials(&ProviderId::new("openrouter"), "sk-test")
            .await
            .unwrap();

        mock.assert_async().await;
    }

    async fn oauth_client(
        server: &MockServer,
    ) -> (tempfile::TempDir, Client, AuthStore, ServerTarget) {
        let temp = tempfile::tempdir().unwrap();
        let auth_store = AuthStore::new(temp.path().join("auth.json"));
        let target = ServerTarget::http_url(server.base_url()).unwrap();
        let entry = oauth_entry("octocat");
        auth_store
            .put(&target, AuthEntry::OAuth(entry.clone()))
            .unwrap();

        let client = Client::builder()
            .target(target.clone())
            .credential(Credential::OAuth(entry))
            .oauth_session(OAuthSession::new(target.clone(), auth_store.clone()))
            .transport(
                server.base_url(),
                fabro_http::HttpClientBuilder::new()
                    .no_proxy()
                    .build()
                    .unwrap(),
            )
            .connect()
            .await
            .unwrap();

        (temp, client, auth_store, target)
    }

    #[tokio::test]
    async fn refresh_access_token_reinstalls_stored_dev_token_without_refresh_request() {
        let server = MockServer::start();
        let refresh_mock = server.mock(|when, then| {
            when.method(POST).path("/auth/cli/refresh");
            then.status(500);
        });
        let temp = tempfile::tempdir().unwrap();
        let auth_store = AuthStore::new(temp.path().join("auth.json"));
        let target = ServerTarget::http_url(server.base_url()).unwrap();
        let old_token =
            "fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd";
        let stored_token =
            "fabro_dev_abababababababababababababababababababababababababababababababab";
        auth_store
            .put(
                &target,
                AuthEntry::DevToken(DevTokenEntry {
                    token:        stored_token.to_string(),
                    logged_in_at: chrono::Utc::now(),
                }),
            )
            .unwrap();

        let seen_tokens = Arc::new(std::sync::Mutex::new(Vec::new()));
        let seen_tokens_for_connector = Arc::clone(&seen_tokens);
        let base_url = server.base_url();
        let client = Client::builder()
            .target(target.clone())
            .credential(Credential::DevToken(old_token.to_string()))
            .oauth_session(OAuthSession::new(target.clone(), auth_store.clone()))
            .transport_connector(TransportConnector::new(move |bearer_token| {
                let seen_tokens = Arc::clone(&seen_tokens_for_connector);
                let base_url = base_url.clone();
                async move {
                    seen_tokens.lock().unwrap().push(bearer_token);
                    Ok((fabro_http::test_http_client().unwrap(), base_url))
                }
            }))
            .connect()
            .await
            .unwrap();

        client.refresh_access_token(old_token).await.unwrap();

        assert_eq!(refresh_mock.calls(), 0);
        assert_eq!(*seen_tokens.lock().unwrap(), vec![
            Some(old_token.to_string()),
            Some(stored_token.to_string()),
        ]);
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn concurrent_clients_refresh_a_rotating_token_once() {
        let server = MockServer::start_async().await;
        let refresh_mock = server
            .mock_async(|when, then| {
                when.method(POST)
                    .path("/auth/cli/refresh")
                    .header("authorization", "Bearer refresh-octocat");
                then.status(200)
                    .delay(Duration::from_millis(100))
                    .header("Content-Type", "application/json")
                    .json_body(json!({
                        "access_token": "access-refreshed",
                        "access_token_expires_at": (chrono::Utc::now()
                            + ChronoDuration::minutes(10))
                            .to_rfc3339(),
                        "refresh_token": "refresh-refreshed",
                        "refresh_token_expires_at": (chrono::Utc::now()
                            + ChronoDuration::days(30))
                            .to_rfc3339(),
                        "subject": {
                            "idp_issuer": "https://github.com",
                            "idp_subject": "12345",
                            "login": "octocat",
                            "name": "Name octocat",
                            "email": "octocat@example.com"
                        }
                    }));
            })
            .await;
        let temp = tempfile::tempdir().unwrap();
        let auth_store = AuthStore::new(temp.path().join("auth.json"));
        let target = ServerTarget::http_url(server.base_url()).unwrap();
        let entry = oauth_entry("octocat");
        auth_store
            .put(&target, AuthEntry::OAuth(entry.clone()))
            .unwrap();

        // Two separately built clients hold separate in-process mutexes, so the
        // only thing serializing them is the lock file. That works in one
        // process because flock conflicts across distinct descriptors.
        let no_proxy_connector = || {
            let base_url = server.base_url();
            TransportConnector::new(move |_bearer_token| {
                let base_url = base_url.clone();
                async move { Ok((fabro_http::test_http_client().unwrap(), base_url)) }
            })
        };
        let first = Client::builder()
            .target(target.clone())
            .credential(Credential::OAuth(entry.clone()))
            .oauth_session(OAuthSession::new(target.clone(), auth_store.clone()))
            .transport_connector(no_proxy_connector())
            .connect()
            .await
            .unwrap();
        let second = Client::builder()
            .target(target.clone())
            .credential(Credential::OAuth(entry))
            .oauth_session(OAuthSession::new(target.clone(), auth_store.clone()))
            .transport_connector(no_proxy_connector())
            .connect()
            .await
            .unwrap();

        let (first_result, second_result) = tokio::join!(
            first.refresh_access_token("access-octocat"),
            second.refresh_access_token("access-octocat"),
        );

        first_result.unwrap();
        second_result.unwrap();
        refresh_mock.assert_calls_async(1).await;
        assert_eq!(
            first.current_state().bearer_token.as_deref(),
            Some("access-refreshed")
        );
        assert_eq!(
            second.current_state().bearer_token.as_deref(),
            Some("access-refreshed")
        );
        // The rotated refresh token must be what landed in the store, or the
        // next rotation would replay a spent one.
        let stored = match auth_store.get(&target).unwrap().unwrap() {
            AuthEntry::OAuth(stored) => stored,
            AuthEntry::DevToken(_) => panic!("expected an OAuth entry"),
        };
        assert_eq!(stored.access_token, "access-refreshed");
        assert_eq!(stored.refresh_token, "refresh-refreshed");
    }

    #[tokio::test]
    async fn refresh_access_token_rotates_when_the_stored_token_is_also_expired() {
        let server = MockServer::start_async().await;
        let refresh_mock = server
            .mock_async(|when, then| {
                when.method(POST)
                    .path("/auth/cli/refresh")
                    .header("authorization", "Bearer refresh-octocat");
                then.status(200)
                    .header("Content-Type", "application/json")
                    .json_body(json!({
                        "access_token": "access-refreshed",
                        "access_token_expires_at": (chrono::Utc::now()
                            + ChronoDuration::minutes(10))
                            .to_rfc3339(),
                        "refresh_token": "refresh-refreshed",
                        "refresh_token_expires_at": (chrono::Utc::now()
                            + ChronoDuration::days(30))
                            .to_rfc3339(),
                        "subject": {
                            "idp_issuer": "https://github.com",
                            "idp_subject": "12345",
                            "login": "octocat",
                            "name": "Name octocat",
                            "email": "octocat@example.com"
                        }
                    }));
            })
            .await;
        let temp = tempfile::tempdir().unwrap();
        let auth_store = AuthStore::new(temp.path().join("auth.json"));
        let target = ServerTarget::http_url(server.base_url()).unwrap();

        // A sibling process rotated the store a while ago, and that token has
        // since expired too. Adopting it would 401 on the caller's single retry.
        let mut stored = oauth_entry("octocat");
        stored.access_token = "access-stale".to_string();
        stored.access_token_expires_at = chrono::Utc::now() - ChronoDuration::minutes(1);
        auth_store.put(&target, AuthEntry::OAuth(stored)).unwrap();

        let base_url = server.base_url();
        let client = Client::builder()
            .target(target.clone())
            .credential(Credential::OAuth(oauth_entry("octocat")))
            .oauth_session(OAuthSession::new(target, auth_store))
            .transport_connector(TransportConnector::new(move |_bearer_token| {
                let base_url = base_url.clone();
                async move { Ok((fabro_http::test_http_client().unwrap(), base_url)) }
            }))
            .connect()
            .await
            .unwrap();

        client.refresh_access_token("access-octocat").await.unwrap();

        refresh_mock.assert_calls_async(1).await;
        assert_eq!(
            client.current_state().bearer_token.as_deref(),
            Some("access-refreshed")
        );
    }

    #[tokio::test]
    async fn refresh_access_token_classifies_expired_refresh_tokens() {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(POST)
                .path("/auth/cli/refresh")
                .header("authorization", "Bearer refresh-octocat");
            then.status(401)
                .header("Content-Type", "application/json")
                .json_body(json!({
                    "error": "refresh_token_expired",
                    "error_description": "CLI session has expired. Run `fabro auth login` again."
                }));
        });

        let (_temp, client, auth_store, target) = oauth_client(&server).await;
        let err = client
            .refresh_access_token("access-octocat")
            .await
            .unwrap_err();

        assert_eq!(exit::exit_code_for(&err), 4);
        assert!(auth_store.get(&target).unwrap().is_none());
    }

    #[tokio::test]
    async fn refresh_access_token_keeps_server_errors_as_exit_1() {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(POST)
                .path("/auth/cli/refresh")
                .header("authorization", "Bearer refresh-octocat");
            then.status(500)
                .header("Content-Type", "application/json")
                .json_body(json!({
                    "error": "server_error",
                    "error_description": "OAuth server exploded."
                }));
        });

        let (_temp, client, auth_store, target) = oauth_client(&server).await;
        let err = client
            .refresh_access_token("access-octocat")
            .await
            .unwrap_err();

        assert_eq!(exit::exit_code_for(&err), 1);
        assert!(auth_store.get(&target).unwrap().is_some());
    }

    #[tokio::test]
    async fn refresh_access_token_keeps_login_not_permitted_as_exit_1() {
        let server = MockServer::start();
        server.mock(|when, then| {
            when.method(POST)
                .path("/auth/cli/refresh")
                .header("authorization", "Bearer refresh-octocat");
            then.status(403)
                .header("Content-Type", "application/json")
                .json_body(json!({
                    "error": "unauthorized",
                    "error_description": "Login not permitted for this user."
                }));
        });

        let (_temp, client, auth_store, target) = oauth_client(&server).await;
        let err = client
            .refresh_access_token("access-octocat")
            .await
            .unwrap_err();

        assert_eq!(exit::exit_code_for(&err), 1);
        assert!(auth_store.get(&target).unwrap().is_some());
    }

    #[test]
    fn add_pr_upgrade_hint_appends_on_unstructured_404() {
        let err = tag_with_failure(
            anyhow!("request failed with status 404 Not Found"),
            ApiFailure {
                status: fabro_http::StatusCode::NOT_FOUND,
                code:   None,
            },
        );
        let wrapped = super::add_pr_upgrade_hint(err);
        let message = wrapped.to_string();
        assert!(
            message.contains("Upgrade the fabro server"),
            "expected upgrade hint, got: {message}"
        );
        assert!(message.contains("status 404"), "original preserved");
    }

    #[test]
    fn add_pr_upgrade_hint_does_not_touch_structured_404() {
        let err = tag_with_failure(
            anyhow!("No pull request found in store. Create one first with: fabro pr create abc"),
            ApiFailure {
                status: fabro_http::StatusCode::NOT_FOUND,
                code:   Some("no_stored_record".to_string()),
            },
        );
        let wrapped = super::add_pr_upgrade_hint(err);
        let message = wrapped.to_string();
        assert!(
            !message.contains("Upgrade the fabro server"),
            "hint should only fire on unstructured 404s, got: {message}"
        );
    }
}
