//! Per-request HTTP response header capture (WOR-1287).
//!
//! Why a side channel: the `response_headers` budget gate needs the HTTP
//! headers of the response that answered one specific tool call, but the
//! [`JsonRpcMessage`](crate::protocol::JsonRpcMessage) envelope is a serde
//! wire type shared with cassettes and must not grow transport baggage.
//! The URL transports instead record each response's headers here, keyed
//! by the JSON-RPC request id, and the executor takes the entry back out
//! after the call resolves.

use std::collections::HashMap;
use std::sync::{Arc, Mutex};

/// Retention cap. Most recorded entries are never taken (initialize,
/// tools/list, notifications have no header check), so the map would grow
/// for the whole run without a bound. Request ids are monotonic, so the
/// smallest id is always the oldest entry and the safest to evict.
const CAP: usize = 64;

/// Cloneable side channel mapping JSON-RPC request ids to the HTTP
/// response headers that answered them (WOR-1287).
///
/// One instance is shared between a URL transport's writer task (which
/// records) and the executor (which takes). Cloning shares the underlying
/// map, mirroring how the transports share their session state.
#[derive(Debug, Clone, Default)]
pub struct HeaderCapture {
    inner: Arc<Mutex<HashMap<i64, HashMap<String, String>>>>,
}

impl HeaderCapture {
    /// Record the response headers for request `id`.
    ///
    /// Names are lowercased so the matcher's case-insensitive lookup is a
    /// plain map hit; duplicate header values are joined with `", "` per
    /// RFC 9110 list semantics; non-UTF8 values are skipped because the
    /// YAML-declared expectations can only ever be UTF-8. A poisoned
    /// mutex is treated as a no-op (same pattern as the transports'
    /// session state) so a panicked sibling task cannot cascade.
    pub fn record(&self, id: i64, headers: &reqwest::header::HeaderMap) {
        let mut map: HashMap<String, String> = HashMap::new();
        for (name, value) in headers {
            let Ok(text) = value.to_str() else {
                continue;
            };
            let key = name.as_str().to_ascii_lowercase();
            map.entry(key)
                .and_modify(|existing| {
                    existing.push_str(", ");
                    existing.push_str(text);
                })
                .or_insert_with(|| text.to_string());
        }
        let Ok(mut guard) = self.inner.lock() else {
            return;
        };
        guard.insert(id, map);
        while guard.len() > CAP {
            let Some(oldest) = guard.keys().min().copied() else {
                break;
            };
            guard.remove(&oldest);
        }
    }

    /// Take the recorded headers for request `id` out of the channel.
    ///
    /// Removal (rather than a clone) keeps the map from accumulating
    /// entries the executor already consumed. `None` means the transport
    /// never saw a response for that id (or the entry was evicted).
    pub fn take(&self, id: i64) -> Option<HashMap<String, String>> {
        self.inner.lock().ok().and_then(|mut g| g.remove(&id))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use reqwest::header::{HeaderMap, HeaderName, HeaderValue};

    fn header_map(pairs: &[(&str, &str)]) -> HeaderMap {
        let mut map = HeaderMap::new();
        for (name, value) in pairs {
            map.append(
                HeaderName::from_bytes(name.as_bytes()).unwrap(),
                HeaderValue::from_str(value).unwrap(),
            );
        }
        map
    }

    #[test]
    fn recorded_headers_round_trip_through_take_with_lowercased_names() {
        let capture = HeaderCapture::default();
        capture.record(7, &header_map(&[("X-Trace-Id", "trace-1")]));
        let taken = capture.take(7).expect("entry recorded");
        assert_eq!(taken.get("x-trace-id").map(String::as_str), Some("trace-1"));
        // Taking removes the entry; a second take finds nothing.
        assert!(capture.take(7).is_none());
    }

    #[test]
    fn duplicate_header_values_are_joined_with_comma_space() {
        let capture = HeaderCapture::default();
        capture.record(
            1,
            &header_map(&[("set-thing", "a=1"), ("set-thing", "b=2")]),
        );
        let taken = capture.take(1).expect("entry recorded");
        assert_eq!(taken.get("set-thing").map(String::as_str), Some("a=1, b=2"));
    }

    #[test]
    fn oldest_entry_is_evicted_once_the_cap_is_exceeded() {
        let capture = HeaderCapture::default();
        let headers = header_map(&[("x-n", "v")]);
        for id in 0..=(CAP as i64) {
            capture.record(id, &headers);
        }
        // Ids are monotonic, so id 0 is the oldest and gets evicted.
        assert!(capture.take(0).is_none(), "oldest entry should be evicted");
        assert!(capture.take(CAP as i64).is_some(), "newest entry survives");
    }
}
