//! REST client for the relay's legacy HTTP+JSON endpoints.
//!
//! Targets the same routes the Go CLI uses today: `POST /clips`,
//! `POST /clips/binary`, `GET /clips/latest`, `GET /devices`,
//! `POST /auth/device-code`, `GET /auth/device-code/poll`,
//! `POST /auth/device/revoke`, `POST /auth/key-bundle/retry`.
//! The legacy `/auth/pair` and `/auth/pair-token/new` routes were
//! retired in the OAuth-only migration.
//!
//! Retry: 3 attempts with exponential backoff (1s, 2s) matching
//! `cinch/cmd/push.go:188-203`.

use std::time::Duration;

use reqwest::{multipart, Client, StatusCode};

use crate::protocol::{Clip, DeviceInfo};
use crate::rest::{
    DeviceCodeCompleteRequest, DeviceCodeDenyRequest, DeviceCodePollResponse, DeviceCodeRequest,
    DeviceCodeResponse, DeviceRevokeRequest, ErrorResponse, KeyBundlePutRequest, KeyBundleResponse,
    PullResponse, PushRequest, PushResponse, RegisterDevicePublicKeyRequest,
};

const MAX_ATTEMPTS: u32 = 3;
const REQUEST_TIMEOUT_SECS: u64 = 30;

/// A relay-recorded deletion event returned by `GET /tombstones`.
#[derive(Debug, serde::Deserialize)]
pub struct Tombstone {
    pub clip_id: String,
    pub deleted_at: String, // RFC3339
}

/// Filter shape for `RestClient::list_clips`. Mirrors the relay's `ListFilter`.
#[derive(Debug, Default, Clone)]
pub struct ListClipsFilter {
    pub limit: u32,
    pub source: Option<String>,
    pub exclude_source: Option<String>,
    pub exclude_image: bool,
    pub exclude_text: bool,
    pub clip_ids: Vec<String>,
}

#[derive(Debug, thiserror::Error)]
pub enum HttpError {
    #[error("network: {0}")]
    Network(String),
    #[error("auth required (401)")]
    Unauthorized,
    #[error("relay error ({status}): {message}")]
    Relay {
        status: u16,
        message: String,
        fix: String,
    },
    #[error("decode response: {0}")]
    Decode(String),
    #[error("build request: {0}")]
    Build(String),
}

#[derive(Debug, Clone)]
pub struct RestClient {
    base_url: String,
    token: String,
    client: Client,
}

impl RestClient {
    /// Construct a new client. `relay_url` is trimmed of any trailing slash.
    pub fn new(relay_url: impl Into<String>, token: impl Into<String>) -> Result<Self, HttpError> {
        let base = relay_url.into().trim_end_matches('/').to_string();
        let client = Client::builder()
            .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
            .build()
            .map_err(|e| HttpError::Build(e.to_string()))?;
        Ok(Self {
            base_url: base,
            token: token.into(),
            client,
        })
    }

    /// `POST /clips` with JSON body — text and encrypted-binary path.
    pub async fn push_clip_json(&self, req: &PushRequest) -> Result<PushResponse, HttpError> {
        let url = format!("{}/clips", self.base_url);
        let resp = self
            .send_with_retry(|| {
                self.client
                    .post(&url)
                    .bearer_auth(&self.token)
                    .json(req)
                    .build()
            })
            .await?;
        decode_push_response(resp).await
    }

    /// `POST /clips/binary` — multipart form for unencrypted binary.
    /// `data` is the raw file bytes; metadata fields are sent as form fields.
    pub async fn push_clip_binary(
        &self,
        data: Vec<u8>,
        content_type: &str,
        source: &str,
        label: Option<&str>,
        target_device_id: Option<&str>,
    ) -> Result<PushResponse, HttpError> {
        let url = format!("{}/clips/binary", self.base_url);
        let mut last_err: Option<HttpError> = None;
        for attempt in 0..MAX_ATTEMPTS {
            if attempt > 0 {
                tokio::time::sleep(Duration::from_secs(1u64 << attempt)).await;
            }
            // Multipart parts must be rebuilt per attempt because their bodies
            // are consumed by `.send()`.
            let mut form = multipart::Form::new()
                .part(
                    "file",
                    multipart::Part::bytes(data.clone()).file_name("upload"),
                )
                .text("content_type", content_type.to_string())
                .text("source", source.to_string());
            if let Some(l) = label.filter(|s| !s.is_empty()) {
                form = form.text("label", l.to_string());
            }
            if let Some(d) = target_device_id.filter(|s| !s.is_empty()) {
                form = form.text("target_device_id", d.to_string());
            }
            let resp = self
                .client
                .post(&url)
                .bearer_auth(&self.token)
                .multipart(form)
                .send()
                .await;
            match resp {
                Ok(r) => return decode_push_response(r).await,
                Err(e) => last_err = Some(HttpError::Network(e.to_string())),
            }
        }
        Err(last_err.unwrap_or(HttpError::Network("max retries exceeded".into())))
    }

    /// `POST /pull` — request the local Mac clipboard via WS round-trip.
    pub async fn pull_clipboard(&self) -> Result<PullResponse, HttpError> {
        let url = format!("{}/pull", self.base_url);
        let resp = self
            .send_with_retry(|| self.client.post(&url).bearer_auth(&self.token).build())
            .await?;
        decode_json_response::<PullResponse>(resp).await
    }

    /// `GET /clips/latest?source=...` — most recent clip matching `source`.
    pub async fn get_latest_clip(&self, source: &str) -> Result<Clip, HttpError> {
        let url = format!("{}/clips/latest", self.base_url);
        let resp = self
            .send_with_retry(|| {
                self.client
                    .get(&url)
                    .bearer_auth(&self.token)
                    .query(&[("source", source)])
                    .build()
            })
            .await?;
        decode_json_response::<Clip>(resp).await
    }

    /// `GET /clips/{id}/media` — raw image bytes for image clips.
    pub async fn get_clip_media(&self, clip_id: &str) -> Result<Vec<u8>, HttpError> {
        let url = format!("{}/clips/{}/media", self.base_url, clip_id);
        let resp = self
            .send_with_retry(|| self.client.get(&url).bearer_auth(&self.token).build())
            .await?;
        let status = resp.status();
        if status == StatusCode::UNAUTHORIZED {
            return Err(HttpError::Unauthorized);
        }
        if !status.is_success() {
            return Err(HttpError::Relay {
                status: status.as_u16(),
                message: format!("Image not found on relay (HTTP {}).", status.as_u16()),
                fix: String::new(),
            });
        }
        resp.bytes()
            .await
            .map(|b| b.to_vec())
            .map_err(|e| HttpError::Decode(e.to_string()))
    }

    /// `POST /auth/device-code` — start the device-code flow. The relay
    /// returns a `verification_uri` for the user to open in a browser.
    /// `machine_id` is opaque (empty string disables relay-side dedup).
    pub async fn start_device_code(
        &self,
        relay_url: &str,
        hostname: &str,
        machine_id: &str,
        user_hint: Option<&str>,
    ) -> Result<DeviceCodeResponse, HttpError> {
        let url = format!("{}/auth/device-code", relay_url.trim_end_matches('/'));
        let req = DeviceCodeRequest {
            hostname: Some(hostname.to_string()),
            machine_id: if machine_id.is_empty() {
                None
            } else {
                Some(machine_id.to_string())
            },
            user_hint: user_hint.map(|s| s.to_string()),
        };
        let resp = self
            .client
            .post(&url)
            .json(&req)
            .send()
            .await
            .map_err(|e| HttpError::Network(e.to_string()))?;
        decode_json_response::<DeviceCodeResponse>(resp).await
    }

    /// `GET /auth/device-code/poll?code=...` — single poll. Caller drives
    /// the loop and respects `interval` from the start response.
    pub async fn poll_device_code(
        &self,
        relay_url: &str,
        device_code: &str,
    ) -> Result<DeviceCodePollResponse, HttpError> {
        let url = format!("{}/auth/device-code/poll", relay_url.trim_end_matches('/'));
        let resp = self
            .client
            .get(&url)
            .query(&[("code", device_code)])
            .send()
            .await
            .map_err(|e| HttpError::Network(e.to_string()))?;
        decode_json_response::<DeviceCodePollResponse>(resp).await
    }

    /// `POST /auth/device-code/complete` — approve a pending device-code
    /// login from an already-authenticated local device.
    pub async fn complete_device_code(&self, user_code: &str) -> Result<(), HttpError> {
        let url = format!("{}/auth/device-code/complete", self.base_url);
        let body = DeviceCodeCompleteRequest {
            user_code: user_code.to_string(),
            user_id: String::new(),
            device_id: String::new(),
            token: String::new(),
        };
        let resp = self
            .client
            .post(&url)
            .bearer_auth(&self.token)
            .json(&body)
            .send()
            .await
            .map_err(|e| HttpError::Network(e.to_string()))?;
        decode_json_response::<serde_json::Value>(resp)
            .await
            .map(|_| ())
    }

    /// `POST /cinch.v1.AuthService/DeviceCodeDeny` (Connect-RPC unary, JSON encoding)
    /// — reject a pending device-code login from this already-signed-in device.
    pub async fn deny_device_code(&self, user_code: &str) -> Result<(), HttpError> {
        let url = format!("{}/cinch.v1.AuthService/DeviceCodeDeny", self.base_url);
        let body = DeviceCodeDenyRequest {
            user_code: user_code.to_string(),
        };
        let resp = self
            .client
            .post(&url)
            .bearer_auth(&self.token)
            .json(&body)
            .send()
            .await
            .map_err(|e| HttpError::Network(e.to_string()))?;
        decode_json_response::<serde_json::Value>(resp)
            .await
            .map(|_| ())
    }

    /// `GET /health` — liveness probe used by the wizard before issuing a
    /// device code, so URL typos surface as a clean error before the user
    /// is sent to a browser.
    pub async fn probe_relay(&self, relay_url: &str) -> Result<(), HttpError> {
        let url = format!("{}/health", relay_url.trim_end_matches('/'));
        let resp = self
            .client
            .get(&url)
            .send()
            .await
            .map_err(|e| HttpError::Network(e.to_string()))?;
        if resp.status().is_success() {
            Ok(())
        } else {
            Err(HttpError::Relay {
                status: resp.status().as_u16(),
                message: format!("health check failed: HTTP {}", resp.status().as_u16()),
                fix: String::new(),
            })
        }
    }

    /// `POST /auth/key-bundle` — publish an encrypted user-key bundle
    /// for `target_device_id`. Called by any device that holds the
    /// user's master key when the relay broadcasts a
    /// `key_exchange_requested` event for a freshly-paired peer.
    /// `ephemeral_public_key` and `encrypted_bundle` are both
    /// base64url-encoded. Bearer-authenticated.
    pub async fn post_key_bundle(
        &self,
        target_device_id: &str,
        ephemeral_public_key: &str,
        encrypted_bundle: &str,
    ) -> Result<(), HttpError> {
        let url = format!("{}/auth/key-bundle", self.base_url);
        let body = KeyBundlePutRequest {
            device_id: target_device_id.to_string(),
            ephemeral_public_key: ephemeral_public_key.to_string(),
            encrypted_bundle: encrypted_bundle.to_string(),
        };
        let resp = self
            .client
            .post(&url)
            .bearer_auth(&self.token)
            .json(&body)
            .send()
            .await
            .map_err(|e| HttpError::Network(e.to_string()))?;
        let status = resp.status();
        if status == StatusCode::UNAUTHORIZED {
            return Err(HttpError::Unauthorized);
        }
        if !status.is_success() {
            return Err(HttpError::Relay {
                status: status.as_u16(),
                message: format!("post key bundle failed: HTTP {}", status.as_u16()),
                fix: String::new(),
            });
        }
        Ok(())
    }

    /// `POST /auth/device/public-key` — register the X25519 public key
    /// for the calling device so the relay can include it in
    /// ListPendingKeyExchanges sweeps and broadcast
    /// `key_exchange_requested` events for it. Called once after the
    /// OAuth-only login flow finishes installing local credentials.
    /// Bearer-authenticated.
    pub async fn register_device_public_key(
        &self,
        public_key: &str,
        fingerprint: &str,
    ) -> Result<(), HttpError> {
        let url = format!("{}/auth/device/public-key", self.base_url);
        let body = RegisterDevicePublicKeyRequest {
            public_key: public_key.to_string(),
            fingerprint: fingerprint.to_string(),
        };
        let resp = self
            .client
            .post(&url)
            .bearer_auth(&self.token)
            .json(&body)
            .send()
            .await
            .map_err(|e| HttpError::Network(e.to_string()))?;
        let status = resp.status();
        if status == StatusCode::UNAUTHORIZED {
            return Err(HttpError::Unauthorized);
        }
        if !status.is_success() {
            return Err(HttpError::Relay {
                status: status.as_u16(),
                message: format!("register public key failed: HTTP {}", status.as_u16()),
                fix: String::new(),
            });
        }
        Ok(())
    }

    /// `POST /auth/key-bundle/retry` — ask the relay to re-broadcast
    /// `key_exchange_requested` for the calling device. Used when the
    /// initial key handoff missed (no key-bearer was online at login
    /// time). Bearer-authenticated.
    pub async fn retry_key_bundle(&self) -> Result<(), HttpError> {
        let url = format!("{}/auth/key-bundle/retry", self.base_url);
        let resp = self
            .client
            .post(&url)
            .bearer_auth(&self.token)
            .send()
            .await
            .map_err(|e| HttpError::Network(e.to_string()))?;
        let status = resp.status();
        if status == StatusCode::UNAUTHORIZED {
            return Err(HttpError::Unauthorized);
        }
        if !status.is_success() {
            return Err(HttpError::Relay {
                status: status.as_u16(),
                message: format!("retry key bundle failed: HTTP {}", status.as_u16()),
                fix: String::new(),
            });
        }
        Ok(())
    }

    /// `POST /auth/device/revoke` — revoke the active device server-side.
    /// Best-effort: callers should still wipe local credentials regardless
    /// of relay reachability.
    pub async fn revoke_device(&self, device_id: &str) -> Result<(), HttpError> {
        let url = format!("{}/auth/device/revoke", self.base_url);
        let body = DeviceRevokeRequest {
            device_id: device_id.to_string(),
        };
        let resp = self
            .client
            .post(&url)
            .bearer_auth(&self.token)
            .json(&body)
            .send()
            .await
            .map_err(|e| HttpError::Network(e.to_string()))?;
        let status = resp.status();
        if !status.is_success() {
            return Err(HttpError::Relay {
                status: status.as_u16(),
                message: format!("revoke failed: HTTP {}", status.as_u16()),
                fix: String::new(),
            });
        }
        Ok(())
    }

    /// `PUT /devices/{device_id}/nickname` — set or clear a human-readable
    /// nickname for a paired device. An empty string clears the nickname.
    /// Task 5.9 uses this path; the desktop `set_device_nickname` command
    /// delegates here rather than calling reqwest directly.
    pub async fn set_device_nickname(
        &self,
        device_id: &str,
        nickname: &str,
    ) -> Result<(), HttpError> {
        let url = format!("{}/devices/{}/nickname", self.base_url, device_id);
        #[derive(serde::Serialize)]
        struct NicknameBody<'a> {
            nickname: &'a str,
        }
        let resp = self
            .client
            .put(&url)
            .bearer_auth(&self.token)
            .json(&NicknameBody { nickname })
            .send()
            .await
            .map_err(|e| HttpError::Network(e.to_string()))?;
        let status = resp.status();
        if !status.is_success() {
            let body = resp.text().await.unwrap_or_default();
            return Err(HttpError::Relay {
                status: status.as_u16(),
                message: format!("set_device_nickname failed: {}", body),
                fix: String::new(),
            });
        }
        Ok(())
    }

    /// `PUT /devices/self/retention` — set this device's remote retention
    /// (in days). The relay only exposes a self-targeted endpoint; per-device
    /// retention writes are not supported over REST.
    pub async fn set_remote_retention(&self, days: i32) -> Result<(), HttpError> {
        let url = format!("{}/devices/self/retention", self.base_url);
        #[derive(serde::Serialize)]
        struct Body {
            remote_retention_days: i32,
        }
        let resp = self
            .client
            .put(&url)
            .bearer_auth(&self.token)
            .json(&Body {
                remote_retention_days: days,
            })
            .send()
            .await
            .map_err(|e| HttpError::Network(e.to_string()))?;
        let status = resp.status();
        if !status.is_success() {
            let body = resp.text().await.unwrap_or_default();
            return Err(HttpError::Relay {
                status: status.as_u16(),
                message: format!("set_remote_retention failed: {}", body),
                fix: String::new(),
            });
        }
        Ok(())
    }

    /// `GET /auth/key-bundle` — fetch the encrypted user-key bundle the
    /// desktop publishes after a pair. Bearer-authenticated.
    /// Always returns 200; an absent bundle is signalled by empty
    /// `ephemeral_public_key`/`encrypted_bundle` plus a non-empty
    /// `pending_since` RFC3339 timestamp, so callers can poll without
    /// distinguishing "not yet" from "device unknown" via status code.
    pub async fn get_key_bundle(&self) -> Result<KeyBundleResponse, HttpError> {
        let url = format!("{}/auth/key-bundle", self.base_url);
        let resp = self
            .client
            .get(&url)
            .bearer_auth(&self.token)
            .send()
            .await
            .map_err(|e| HttpError::Network(e.to_string()))?;
        decode_json_response::<KeyBundleResponse>(resp).await
    }

    /// `GET /tombstones[?since=<rfc3339>]` — deleted clip IDs since a timestamp.
    pub async fn list_tombstones(
        &self,
        since: Option<chrono::DateTime<chrono::Utc>>,
    ) -> Result<Vec<Tombstone>, HttpError> {
        let url = format!("{}/tombstones", self.base_url);
        let resp = self
            .send_with_retry(|| {
                let mut req = self.client.get(&url).bearer_auth(&self.token);
                if let Some(ts) = since {
                    req = req.query(&[("since", ts.to_rfc3339())]);
                }
                req.build()
            })
            .await?;
        decode_json_response::<Vec<Tombstone>>(resp).await
    }

    /// `GET /clips[?since=<rfc3339>][&limit=<n>]` — list clips, optionally filtered to those
    /// newer than `since`. Returns oldest-first when `since` is provided.
    /// `limit` caps the number of results (relay maximum is 100).
    pub async fn list_clips_since(
        &self,
        since: Option<chrono::DateTime<chrono::Utc>>,
        limit: u32,
    ) -> Result<Vec<Clip>, HttpError> {
        let url = format!("{}/clips", self.base_url);
        let resp = self
            .send_with_retry(|| {
                let mut req = self.client.get(&url).bearer_auth(&self.token);
                if let Some(ts) = since {
                    req = req.query(&[("since", ts.to_rfc3339())]);
                }
                req = req.query(&[("limit", limit.to_string())]);
                req.build()
            })
            .await?;
        decode_json_response::<Vec<Clip>>(resp).await
    }

    /// `GET /clips?...` — list clips with the given filter, newest-first.
    /// Limit is clamped server-side; the client clamps to 200 to match the relay cap.
    pub async fn list_clips(&self, filter: ListClipsFilter) -> Result<Vec<Clip>, HttpError> {
        let url = format!("{}/clips", self.base_url);
        let resp = self
            .send_with_retry(|| {
                let mut req = self.client.get(&url).bearer_auth(&self.token);
                let limit = if filter.limit == 0 {
                    50
                } else {
                    filter.limit.min(200)
                };
                req = req.query(&[("limit", limit.to_string())]);
                if let Some(s) = &filter.source {
                    req = req.query(&[("source", s.as_str())]);
                }
                if let Some(s) = &filter.exclude_source {
                    req = req.query(&[("exclude_source", s.as_str())]);
                }
                if filter.exclude_image {
                    req = req.query(&[("exclude_image", "true")]);
                }
                if filter.exclude_text {
                    req = req.query(&[("exclude_text", "true")]);
                }
                for id in &filter.clip_ids {
                    req = req.query(&[("clip_id", id.as_str())]);
                }
                req.build()
            })
            .await?;
        decode_json_response::<Vec<Clip>>(resp).await
    }

    /// `GET /clips?clip_id=<id>&limit=1` — fetch one clip by ID.
    pub async fn get_clip_by_id(&self, clip_id: &str) -> Result<Clip, HttpError> {
        let clips = self
            .list_clips(ListClipsFilter {
                limit: 1,
                clip_ids: vec![clip_id.to_string()],
                ..Default::default()
            })
            .await?;
        clips.into_iter().next().ok_or_else(|| HttpError::Relay {
            status: 404,
            message: format!("Clip {} not found.", clip_id),
            fix: String::new(),
        })
    }

    /// `GET /clips/latest?exclude_source=<key>` — latest clip whose source != exclude_source.
    pub async fn get_latest_clip_excluding(&self, exclude_source: &str) -> Result<Clip, HttpError> {
        let url = format!("{}/clips/latest", self.base_url);
        let resp = self
            .send_with_retry(|| {
                self.client
                    .get(&url)
                    .bearer_auth(&self.token)
                    .query(&[("exclude_source", exclude_source)])
                    .build()
            })
            .await?;
        decode_json_response::<Clip>(resp).await
    }

    /// `DELETE /clips/{id}` — remove a clip. 404 is treated as success.
    pub async fn delete_clip(&self, clip_id: &str) -> Result<(), HttpError> {
        let url = format!("{}/clips/{}", self.base_url, clip_id);
        let resp = self
            .send_with_retry(|| self.client.delete(&url).bearer_auth(&self.token).build())
            .await?;
        let status = resp.status();
        if status == StatusCode::NOT_FOUND || status.is_success() {
            return Ok(());
        }
        if status == StatusCode::UNAUTHORIZED {
            return Err(HttpError::Unauthorized);
        }
        Err(HttpError::Relay {
            status: status.as_u16(),
            message: format!("Delete clip failed (HTTP {}).", status.as_u16()),
            fix: String::new(),
        })
    }

    /// `POST /clips/{id}/pin` — set or clear pin state. Best-effort: 404 treated as success.
    pub async fn set_clip_pin(
        &self,
        clip_id: &str,
        is_pinned: bool,
        pin_note: Option<&str>,
    ) -> Result<(), HttpError> {
        let url = format!("{}/clips/{}/pin", self.base_url, clip_id);
        #[derive(serde::Serialize)]
        struct PinBody<'a> {
            is_pinned: bool,
            #[serde(skip_serializing_if = "Option::is_none")]
            pin_note: Option<&'a str>,
        }
        let body = PinBody {
            is_pinned,
            pin_note,
        };
        let resp = self
            .send_with_retry(|| {
                self.client
                    .post(&url)
                    .bearer_auth(&self.token)
                    .json(&body)
                    .build()
            })
            .await?;
        let status = resp.status();
        if status == StatusCode::NOT_FOUND || status.is_success() {
            return Ok(());
        }
        if status == StatusCode::UNAUTHORIZED {
            return Err(HttpError::Unauthorized);
        }
        Err(HttpError::Relay {
            status: status.as_u16(),
            message: format!("Set clip pin failed (HTTP {}).", status.as_u16()),
            fix: String::new(),
        })
    }

    /// `GET /devices` — list of paired devices for the current user.
    pub async fn list_devices(&self) -> Result<Vec<DeviceInfo>, HttpError> {
        let url = format!("{}/devices", self.base_url);
        let resp = self
            .send_with_retry(|| self.client.get(&url).bearer_auth(&self.token).build())
            .await?;
        decode_json_response::<Vec<DeviceInfo>>(resp).await
    }

    async fn send_with_retry<F>(&self, build: F) -> Result<reqwest::Response, HttpError>
    where
        F: Fn() -> Result<reqwest::Request, reqwest::Error>,
    {
        let mut last_err: Option<HttpError> = None;
        for attempt in 0..MAX_ATTEMPTS {
            if attempt > 0 {
                tokio::time::sleep(Duration::from_secs(1u64 << attempt)).await;
            }
            let req = build().map_err(|e| HttpError::Build(e.to_string()))?;
            match self.client.execute(req).await {
                Ok(resp) => return Ok(resp),
                Err(e) => last_err = Some(HttpError::Network(e.to_string())),
            }
        }
        Err(last_err.unwrap_or(HttpError::Network("max retries exceeded".into())))
    }
}

async fn decode_push_response(resp: reqwest::Response) -> Result<PushResponse, HttpError> {
    decode_json_response::<PushResponse>(resp).await
}

async fn decode_json_response<T: serde::de::DeserializeOwned>(
    resp: reqwest::Response,
) -> Result<T, HttpError> {
    let status = resp.status();
    if status == StatusCode::UNAUTHORIZED {
        return Err(HttpError::Unauthorized);
    }
    if !status.is_success() {
        let err: ErrorResponse = resp.json().await.unwrap_or_default();
        let message = if !err.message.is_empty() {
            err.message
        } else {
            err.error
        };
        return Err(HttpError::Relay {
            status: status.as_u16(),
            message,
            fix: err.fix,
        });
    }
    resp.json::<T>()
        .await
        .map_err(|e| HttpError::Decode(e.to_string()))
}

#[cfg(test)]
mod tests {
    use crate::proto::cinch::v1::DeviceCodeStartRequest;

    #[test]
    fn device_code_start_request_includes_user_hint_when_set() {
        let req = DeviceCodeStartRequest {
            hostname: Some("dev-box-3".into()),
            machine_id: Some("m1".into()),
            user_hint: Some("alice@example.com".into()),
        };
        let bytes = serde_json::to_vec(&req).unwrap();
        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(parsed["user_hint"], "alice@example.com");
    }

    #[test]
    fn device_code_start_request_omits_user_hint_when_none() {
        let req = DeviceCodeStartRequest {
            hostname: Some("dev-box-3".into()),
            machine_id: Some("m1".into()),
            user_hint: None,
        };
        let bytes = serde_json::to_vec(&req).unwrap();
        let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
        assert!(
            parsed.get("user_hint").is_none(),
            "user_hint must omit when None"
        );
    }
}
