//! HTTP client for `PUT /api/cases/{case_id}/oneliner` — send the
//! doctor's manual oneliner override to the server.
//!
//! Returns the persisted [`OnelinerState`] on success (always
//! `Manual { text, set_at }` if the server obeyed the contract).
//! Errors are split into terminal (operator must fix — wrong API key,
//! unknown case, validation refused) and transient (network blip /
//! 5xx, retry sensible) so the caller can surface the right message.

use doctate_common::API_KEY_HEADER;
use doctate_common::join_url;
use doctate_common::oneliners::{
    ONELINER_OVERRIDE_PATH_TEMPLATE, OnelinerOverrideRequest, OnelinerState,
};
use reqwest::StatusCode;
use thiserror::Error;
use uuid::Uuid;

#[derive(Debug, Error)]
pub enum PutOnelinerError {
    /// Server rejected the request for a reason the user must address:
    /// 401 (bad API key), 403 (no permission), 404 (unknown case),
    /// 400/422 (validation failure). Surfacing the body lets the UI
    /// show the server-side message.
    #[error("HTTP {status}: {body}")]
    Terminal { status: u16, body: String },
    /// Network error or 408/429/5xx — the caller may retry after a
    /// backoff. The string is the most useful diagnostic available.
    #[error("transient: {0}")]
    Transient(String),
    /// Server replied 200 but the body did not parse as
    /// [`OnelinerState`]. Treated as terminal because there is no
    /// obvious retry strategy: a body-shape mismatch is not a hiccup.
    #[error("parse response: {0}")]
    Parse(String),
}

/// Fire a `PUT /api/cases/{case_id}/oneliner` request and return the
/// persisted state on success. The server sets `set_at` on the
/// returned variant — clients should not pre-populate it.
pub async fn put_oneliner(
    http: &reqwest::Client,
    server_url: &str,
    api_key: &str,
    case_id: Uuid,
    text: &str,
) -> Result<OnelinerState, PutOnelinerError> {
    let path = ONELINER_OVERRIDE_PATH_TEMPLATE.replace("{case_id}", &case_id.to_string());
    let url = join_url(server_url, &path);

    let body = OnelinerOverrideRequest {
        text: text.to_owned(),
    };

    let resp = match http
        .put(&url)
        .header(API_KEY_HEADER, api_key)
        .json(&body)
        .send()
        .await
    {
        Ok(r) => r,
        Err(e) => return Err(PutOnelinerError::Transient(format!("network: {e}"))),
    };

    let status = resp.status();
    if status == StatusCode::OK {
        match resp.json::<OnelinerState>().await {
            Ok(s) => Ok(s),
            Err(e) => Err(PutOnelinerError::Parse(format!("{e}"))),
        }
    } else if status.as_u16() == 408 || status.as_u16() == 429 || status.is_server_error() {
        let body = resp.text().await.unwrap_or_default();
        Err(PutOnelinerError::Transient(format!(
            "HTTP {status}: {body}"
        )))
    } else {
        let body = resp.text().await.unwrap_or_default();
        Err(PutOnelinerError::Terminal {
            status: status.as_u16(),
            body,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use wiremock::matchers::{header, method, path_regex};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    const TEST_API_KEY: &str = "test-key";

    async fn mock_server_with(status: u16, body: serde_json::Value) -> MockServer {
        let server = MockServer::start().await;
        Mock::given(method("PUT"))
            .and(path_regex(r"^/api/cases/[0-9a-fA-F-]+/oneliner$"))
            .and(header(API_KEY_HEADER, TEST_API_KEY))
            .respond_with(ResponseTemplate::new(status).set_body_json(body))
            .mount(&server)
            .await;
        server
    }

    #[tokio::test]
    async fn put_oneliner_happy_path_returns_manual_state() {
        let server = mock_server_with(
            200,
            json!({
                "kind": "manual",
                "text": "55 J., Knieschmerz",
                "set_at": "2026-04-26T12:00:00Z"
            }),
        )
        .await;

        let http = reqwest::Client::new();
        let case_id = Uuid::new_v4();
        let result = put_oneliner(
            &http,
            &server.uri(),
            TEST_API_KEY,
            case_id,
            "55 J., Knieschmerz",
        )
        .await
        .expect("ok");
        match result {
            OnelinerState::Manual { text, set_at } => {
                assert_eq!(text, "55 J., Knieschmerz");
                assert_eq!(set_at, "2026-04-26T12:00:00Z");
            }
            other => panic!("expected Manual, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn put_oneliner_401_is_terminal() {
        let server = mock_server_with(401, json!({"error": "Unauthorized"})).await;
        let http = reqwest::Client::new();

        let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x")
            .await
            .expect_err("must err");
        match err {
            PutOnelinerError::Terminal { status, .. } => assert_eq!(status, 401),
            other => panic!("expected Terminal, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn put_oneliner_400_is_terminal() {
        let server = mock_server_with(400, json!({"error": "empty text"})).await;
        let http = reqwest::Client::new();

        let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x")
            .await
            .expect_err("must err");
        assert!(matches!(
            err,
            PutOnelinerError::Terminal { status: 400, .. }
        ));
    }

    #[tokio::test]
    async fn put_oneliner_404_is_terminal() {
        let server = mock_server_with(404, json!({"error": "case not found"})).await;
        let http = reqwest::Client::new();

        let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x")
            .await
            .expect_err("must err");
        assert!(matches!(
            err,
            PutOnelinerError::Terminal { status: 404, .. }
        ));
    }

    #[tokio::test]
    async fn put_oneliner_503_is_transient() {
        let server = mock_server_with(503, json!({"error": "service unavailable"})).await;
        let http = reqwest::Client::new();

        let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x")
            .await
            .expect_err("must err");
        assert!(matches!(err, PutOnelinerError::Transient(_)));
    }

    #[tokio::test]
    async fn put_oneliner_429_is_transient() {
        let server = mock_server_with(429, json!({"error": "too many requests"})).await;
        let http = reqwest::Client::new();

        let err = put_oneliner(&http, &server.uri(), TEST_API_KEY, Uuid::new_v4(), "x")
            .await
            .expect_err("must err");
        assert!(matches!(err, PutOnelinerError::Transient(_)));
    }

    #[tokio::test]
    async fn put_oneliner_network_error_is_transient() {
        // Point at a URL nothing is listening on.
        let http = reqwest::Client::builder()
            .timeout(std::time::Duration::from_millis(200))
            .build()
            .unwrap();
        let err = put_oneliner(
            &http,
            "http://127.0.0.1:1", // reserved port
            TEST_API_KEY,
            Uuid::new_v4(),
            "x",
        )
        .await
        .expect_err("must err");
        assert!(matches!(err, PutOnelinerError::Transient(_)));
    }
}
