//! HTTP transport. Mirrors `mizanFetch` and `mizanCall` in
//! `frontends/mizan-base/src/index.ts`.
//!
//! - `mizan_fetch(client, context, params)` → `GET /api/mizan/ctx/<name>/?params`
//! - `mizan_call(client, fn_name, args)` → `POST /api/mizan/call/` with
//!   `{fn, args}` body. On response, applies any `merge` entries first,
//!   then `invalidate` entries, then returns the `result` field.
//!
//! Retries: 3 attempts total, 200ms × attempt linear backoff. Retries
//! on network errors and 5xx; surfaces 4xx immediately (matches TS).
//!
//! CSRF: the reqwest cookie jar stores the CSRF cookie from the
//! `/session/` bootstrap; on every call we read it via
//! `reqwest::cookie::Jar::cookies(&url)` and add it as the configured
//! header. Both names come from `MizanConfig`.

use std::time::Duration;

use reqwest::{Method, Url};
use serde::Deserialize;
use serde_json::Value;

use crate::client::MizanClient;
use crate::error::MizanError;


const MAX_ATTEMPTS: u32 = 3;
const BACKOFF_BASE: Duration = Duration::from_millis(200);


/// `GET /api/mizan/ctx/<context>/?params`.
pub async fn mizan_fetch(client: &MizanClient, context: &str, params: &Value) -> Result<Value, MizanError> {
    let mut url = Url::parse(&format!("{}/ctx/{}/", client.config().base_url.trim_end_matches('/'), context))
        .map_err(|e| MizanError::transport(format!("invalid base_url: {e}")))?;
    if let Value::Object(map) = params {
        let mut qp = url.query_pairs_mut();
        for (k, v) in map {
            let s = match v {
                Value::String(s) => s.clone(),
                other => other.to_string(),
            };
            qp.append_pair(k, &s);
        }
    }

    let body = request_with_retry(client, Method::GET, url, None).await?;
    serde_json::from_str(&body).map_err(|e| MizanError::transport(format!("decode: {e}")))
}


/// `POST /api/mizan/call/` with `{fn, args}` body. Applies merge +
/// invalidation entries from the response before returning `result`.
pub async fn mizan_call(client: &MizanClient, fn_name: &str, args: Value) -> Result<Value, MizanError> {
    let url = Url::parse(&format!("{}/call/", client.config().base_url.trim_end_matches('/')))
        .map_err(|e| MizanError::transport(format!("invalid base_url: {e}")))?;
    let payload = serde_json::json!({ "fn": fn_name, "args": args });
    let body_bytes = serde_json::to_vec(&payload)
        .map_err(|e| MizanError::transport(format!("encode: {e}")))?;
    let body = request_with_retry(client, Method::POST, url, Some(body_bytes)).await?;

    let response: CallResponse = serde_json::from_str(&body)
        .map_err(|e| MizanError::transport(format!("decode: {e}")))?;

    if let Some(merges) = response.merge {
        for entry in &merges {
            client.context_registry()
                .merge(&entry.context, entry.params.as_ref(), &entry.slot, &entry.value)
                .await;
        }
    }
    if let Some(invalidations) = response.invalidate {
        for entry in invalidations {
            match entry {
                InvalidateEntry::Broad(name) => {
                    client.invalidation_queue().invalidate(name).await;
                }
                InvalidateEntry::Scoped { context, params } => {
                    client.invalidation_queue().invalidate_scoped(context, params).await;
                }
            }
        }
    }

    Ok(response.result.unwrap_or(Value::Null))
}


async fn request_with_retry(
    client: &MizanClient,
    method: Method,
    url: Url,
    body: Option<Vec<u8>>,
) -> Result<String, MizanError> {
    client.ensure_session_ready().await?;

    let mut last_err: Option<MizanError> = None;
    for attempt in 0..MAX_ATTEMPTS {
        let headers = client.resolve_headers().await;
        let mut req = client.http().request(method.clone(), url.clone()).headers(headers);
        if let Some(bytes) = &body {
            req = req.header(reqwest::header::CONTENT_TYPE, "application/json")
                     .body(bytes.clone());
        }
        match req.send().await {
            Ok(res) => {
                let status = res.status().as_u16();
                let text = res.text().await.unwrap_or_default();
                if status < 400 {
                    return Ok(text);
                }
                if (400..500).contains(&status) {
                    return Err(MizanError::from_response(status, text));
                }
                last_err = Some(MizanError::from_response(status, text));
            }
            Err(e) => {
                last_err = Some(MizanError::transport(e.to_string()));
            }
        }
        if attempt + 1 < MAX_ATTEMPTS {
            tokio::time::sleep(BACKOFF_BASE.saturating_mul(attempt + 1)).await;
        }
    }
    Err(last_err.unwrap_or_else(|| MizanError::transport("retry budget exhausted")))
}


#[derive(Deserialize)]
struct CallResponse {
    result: Option<Value>,
    #[serde(default)]
    merge: Option<Vec<MergeEntry>>,
    #[serde(default)]
    invalidate: Option<Vec<InvalidateEntry>>,
}


#[derive(Deserialize)]
struct MergeEntry {
    context: String,
    #[serde(default)]
    params: Option<Value>,
    slot: String,
    value: Value,
}


#[derive(Deserialize)]
#[serde(untagged)]
enum InvalidateEntry {
    Broad(String),
    Scoped { context: String, params: Value },
}
