//! HTTP request builders and response extraction helpers.
//!
//! The stock axum `Request::builder()…oneshot()` chain is verbose;
//! centralising the common shapes here turns ~8 repetitive lines per
//! call site into one function call, and localises URL/header shape
//! changes to this file.

use axum::body::Body;
use axum::http::{Request, header};
use axum::response::Response;

use doctate_common::{
    API_KEY_HEADER, CONTENT_TYPE_AUDIO_MP4, FIELD_AUDIO, FIELD_CASE_ID, FIELD_RECORDED_AT,
};

/// Plain form-urlencoded POST with a session cookie. `body` is passed
/// verbatim; CSRF-protected calls should use [`csrf_form_post`] to add
/// the token automatically.
pub fn form_post<U: AsRef<str>>(uri: U, cookie: &str, body: impl Into<String>) -> Request<Body> {
    Request::builder()
        .method("POST")
        .uri(uri.as_ref())
        .header(header::COOKIE, cookie)
        .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
        .body(Body::from(body.into()))
        .unwrap()
}

/// Form-urlencoded POST that appends `csrf_token={csrf}` automatically.
/// Pass an empty `extra_body` when the form has no other fields
/// (e.g. POST /web/cases/{id}/close).
pub fn csrf_form_post<U: AsRef<str>>(
    uri: U,
    cookie: &str,
    csrf: &str,
    extra_body: &str,
) -> Request<Body> {
    let body = if extra_body.is_empty() {
        format!("csrf_token={csrf}")
    } else {
        format!("{extra_body}&csrf_token={csrf}")
    };
    form_post(uri, cookie, body)
}

/// GET with a session cookie — authenticated `/web/*` pages.
pub fn get_with_cookie<U: AsRef<str>>(uri: U, cookie: &str) -> Request<Body> {
    Request::builder()
        .method("GET")
        .uri(uri.as_ref())
        .header(header::COOKIE, cookie)
        .body(Body::empty())
        .unwrap()
}

/// GET with `X-API-Key` — the public `/api/*` surface used by the
/// watch/desktop clients.
pub fn get_with_api_key<U: AsRef<str>>(uri: U, api_key: &str) -> Request<Body> {
    Request::builder()
        .method("GET")
        .uri(uri.as_ref())
        .header(API_KEY_HEADER, api_key)
        .body(Body::empty())
        .unwrap()
}

/// GET with `X-API-Key` and a conditional `If-None-Match` header. Used
/// by the oneliners ETag tests and the upload watermark-invalidation
/// tests.
pub fn get_with_api_key_if_none_match<U: AsRef<str>>(
    uri: U,
    api_key: &str,
    if_none_match: &str,
) -> Request<Body> {
    Request::builder()
        .method("GET")
        .uri(uri.as_ref())
        .header(API_KEY_HEADER, api_key)
        .header(header::IF_NONE_MATCH, if_none_match)
        .body(Body::empty())
        .unwrap()
}

/// Consume a response body into raw bytes.
pub async fn body_bytes(response: Response) -> Vec<u8> {
    axum::body::to_bytes(response.into_body(), usize::MAX)
        .await
        .unwrap()
        .to_vec()
}

/// Consume a response body into a UTF-8 string. Panics on non-UTF-8 —
/// which is correct for the HTML/JSON responses these tests assert on.
pub async fn body_string(response: Response) -> String {
    String::from_utf8(body_bytes(response).await).unwrap()
}

/// Consume a response body into a `serde_json::Value`. Panics on
/// malformed JSON, which the test should want to surface loudly.
pub async fn body_json(response: Response) -> serde_json::Value {
    let bytes = body_bytes(response).await;
    serde_json::from_slice(&bytes).unwrap()
}

/// Borrow a header value as a `&str`. Returns `None` if the header is
/// absent or not valid UTF-8.
pub fn header_opt<'a>(response: &'a Response, name: &str) -> Option<&'a str> {
    response.headers().get(name).and_then(|v| v.to_str().ok())
}

/// Header value as an owned `String`, `""` if missing. Matches the
/// existing style in `oneliners_api_test.rs`'s `header_str` helper.
pub fn header_str(response: &Response, name: &str) -> String {
    header_opt(response, name).unwrap_or("").to_owned()
}

/// Build a multipart body for `POST /api/upload`. Returns
/// `(content_type_header_value, body_bytes)` — pass the first as the
/// `Content-Type` header verbatim.
///
/// Uses the server's canonical field names (`case_id`, `recorded_at`,
/// `audio`) from `doctate_common::constants`, so a server-side rename
/// propagates here for free.
pub fn multipart_upload_body(case_id: &str, recorded_at: &str, audio: &[u8]) -> (String, Vec<u8>) {
    let boundary = "----testboundary";
    let mut body = Vec::new();

    // case_id
    body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
    body.extend_from_slice(
        format!("Content-Disposition: form-data; name=\"{FIELD_CASE_ID}\"\r\n\r\n").as_bytes(),
    );
    body.extend_from_slice(case_id.as_bytes());
    body.extend_from_slice(b"\r\n");

    // recorded_at
    body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
    body.extend_from_slice(
        format!("Content-Disposition: form-data; name=\"{FIELD_RECORDED_AT}\"\r\n\r\n").as_bytes(),
    );
    body.extend_from_slice(recorded_at.as_bytes());
    body.extend_from_slice(b"\r\n");

    // audio
    body.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
    body.extend_from_slice(
        format!(
            "Content-Disposition: form-data; name=\"{FIELD_AUDIO}\"; filename=\"test.m4a\"\r\n"
        )
        .as_bytes(),
    );
    body.extend_from_slice(format!("Content-Type: {CONTENT_TYPE_AUDIO_MP4}\r\n\r\n").as_bytes());
    body.extend_from_slice(audio);
    body.extend_from_slice(b"\r\n");

    // end
    body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());

    (format!("multipart/form-data; boundary={boundary}"), body)
}
