use std::time::Duration;

use reqwest::{Client, StatusCode};
use serde::de::DeserializeOwned;

/// Result of a JSON GET request.
#[derive(Debug)]
pub enum JsonOutcome<T> {
    /// 2xx response with a parsed body.
    Found(T),
    /// 404 response.
    NotFound,
    /// Any other status code (3xx, 4xx other than 404, 5xx).
    Other(u16),
}

/// Result of a HEAD request.
#[derive(Debug)]
pub enum HeadOutcome {
    /// 2xx response.
    Found,
    /// 404 response.
    NotFound,
    /// Any other status code (3xx, 4xx other than 404, 5xx).
    Other(u16),
}

/// Errors that propagate out of the transport (network layer).
/// HTTP status codes are surfaced via `JsonOutcome` / `HeadOutcome` instead.
#[derive(Debug)]
pub enum TransportError {
    /// Request timed out.
    Timeout,
    /// Connection / DNS / TLS failure or other transport-level issue.
    Network(String),
    /// 2xx body could not be parsed as JSON or did not match the schema.
    Decode(String),
}

impl std::fmt::Display for TransportError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Timeout => f.write_str("request timed out"),
            Self::Network(msg) => write!(f, "network error: {msg}"),
            Self::Decode(msg) => write!(f, "decode error: {msg}"),
        }
    }
}

impl std::error::Error for TransportError {}

/// Abstracts the HTTP fetches needed by the source adapters.
/// One impl per runtime: `ReqwestTransport` for the native CLI, a `FetchTransport`
/// (in a future `onym-wasm` crate) for the browser.
pub trait Transport {
    fn fetch_json<T: DeserializeOwned>(
        &self,
        url: &str,
    ) -> impl std::future::Future<Output = Result<JsonOutcome<T>, TransportError>>;

    fn head(
        &self,
        url: &str,
    ) -> impl std::future::Future<Output = Result<HeadOutcome, TransportError>>;
}

const HTTP_TIMEOUT: Duration = Duration::from_secs(15);

/// HTTP transport backed by a [`reqwest::Client`].
pub struct ReqwestTransport {
    client: Client,
}

impl ReqwestTransport {
    /// Builds a `ReqwestTransport` with a 15-second timeout and an identifying user-agent.
    pub fn new() -> Result<Self, TransportError> {
        let user_agent = format!(
            "onym/{} (https://github.com/rvben/onym)",
            env!("CARGO_PKG_VERSION")
        );
        let client = Client::builder()
            .user_agent(user_agent)
            .timeout(HTTP_TIMEOUT)
            .build()
            .map_err(|e| TransportError::Network(format!("http client init failed: {e}")))?;
        Ok(Self { client })
    }
}

fn map_reqwest_error(err: reqwest::Error) -> TransportError {
    if err.is_timeout() {
        TransportError::Timeout
    } else if err.is_decode() {
        TransportError::Decode(err.to_string())
    } else {
        TransportError::Network(err.to_string())
    }
}

impl Transport for ReqwestTransport {
    async fn fetch_json<T: DeserializeOwned>(
        &self,
        url: &str,
    ) -> Result<JsonOutcome<T>, TransportError> {
        let resp = self
            .client
            .get(url)
            .send()
            .await
            .map_err(map_reqwest_error)?;
        let status = resp.status();
        if status == StatusCode::NOT_FOUND {
            return Ok(JsonOutcome::NotFound);
        }
        if status.is_success() {
            let body: T = resp.json().await.map_err(map_reqwest_error)?;
            return Ok(JsonOutcome::Found(body));
        }
        Ok(JsonOutcome::Other(status.as_u16()))
    }

    async fn head(&self, url: &str) -> Result<HeadOutcome, TransportError> {
        let resp = self
            .client
            .head(url)
            .send()
            .await
            .map_err(map_reqwest_error)?;
        let status = resp.status();
        if status == StatusCode::NOT_FOUND {
            return Ok(HeadOutcome::NotFound);
        }
        if status.is_success() {
            return Ok(HeadOutcome::Found);
        }
        Ok(HeadOutcome::Other(status.as_u16()))
    }
}

#[cfg(any(test, feature = "test-support"))]
pub mod test_support {
    //! Test-only helpers for unit tests within this crate.

    use std::cell::RefCell;
    use std::collections::{HashMap, VecDeque};

    use serde::de::DeserializeOwned;

    use super::{HeadOutcome, JsonOutcome, Transport, TransportError};

    /// Canned response for a single URL.
    pub enum Canned {
        Json(serde_json::Value),
        /// 2xx with no body (used for HEAD outcomes).
        Found,
        NotFound,
        Other(u16),
        Network(String),
    }

    /// Test transport that returns canned responses keyed by URL.
    /// Per-URL responses are consumed in FIFO order (first queued = first returned);
    /// if a URL has no remaining canned response, the call panics with the URL —
    /// this surfaces missing fixtures loudly in tests.
    pub struct MockTransport {
        json: RefCell<HashMap<String, VecDeque<Canned>>>,
        head: RefCell<HashMap<String, VecDeque<Canned>>>,
    }

    impl Default for MockTransport {
        fn default() -> Self {
            Self {
                json: RefCell::new(HashMap::new()),
                head: RefCell::new(HashMap::new()),
            }
        }
    }

    impl MockTransport {
        pub fn new() -> Self {
            Self::default()
        }

        pub fn json(self, url: &str, canned: Canned) -> Self {
            self.json
                .borrow_mut()
                .entry(url.to_string())
                .or_default()
                .push_back(canned);
            self
        }

        pub fn head(self, url: &str, canned: Canned) -> Self {
            self.head
                .borrow_mut()
                .entry(url.to_string())
                .or_default()
                .push_back(canned);
            self
        }

        fn pop(map: &RefCell<HashMap<String, VecDeque<Canned>>>, url: &str) -> Canned {
            let mut guard = map.borrow_mut();
            let queue = guard
                .get_mut(url)
                .unwrap_or_else(|| panic!("MockTransport: no canned response for {url}"));
            queue
                .pop_front()
                .unwrap_or_else(|| panic!("MockTransport: queue exhausted for {url}"))
        }
    }

    impl Transport for MockTransport {
        async fn fetch_json<T: DeserializeOwned>(
            &self,
            url: &str,
        ) -> Result<JsonOutcome<T>, TransportError> {
            match Self::pop(&self.json, url) {
                Canned::Json(value) => {
                    let parsed: T = serde_json::from_value(value)
                        .map_err(|e| TransportError::Decode(e.to_string()))?;
                    Ok(JsonOutcome::Found(parsed))
                }
                Canned::Found => {
                    panic!("Canned::Found on a JSON request for {url} (use Canned::Json)")
                }
                Canned::NotFound => Ok(JsonOutcome::NotFound),
                Canned::Other(code) => Ok(JsonOutcome::Other(code)),
                Canned::Network(msg) => Err(TransportError::Network(msg)),
            }
        }

        async fn head(&self, url: &str) -> Result<HeadOutcome, TransportError> {
            match Self::pop(&self.head, url) {
                Canned::Json(_) => panic!("Canned::Json on a HEAD request for {url}"),
                Canned::Found => Ok(HeadOutcome::Found),
                Canned::NotFound => Ok(HeadOutcome::NotFound),
                Canned::Other(code) => Ok(HeadOutcome::Other(code)),
                Canned::Network(msg) => Err(TransportError::Network(msg)),
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn reqwest_transport_can_be_constructed() {
        let _transport = ReqwestTransport::new().expect("client builds");
    }

    #[tokio::test]
    async fn mock_transport_returns_canned_json() {
        use test_support::{Canned, MockTransport};

        #[derive(serde::Deserialize)]
        struct Body {
            ok: bool,
        }

        let mock = MockTransport::new().json(
            "https://example.test/x",
            Canned::Json(serde_json::json!({"ok": true})),
        );

        let outcome: JsonOutcome<Body> = mock.fetch_json("https://example.test/x").await.unwrap();
        match outcome {
            JsonOutcome::Found(b) => assert!(b.ok),
            _ => panic!("expected Found"),
        }
    }

    #[tokio::test]
    async fn mock_transport_returns_not_found() {
        use test_support::{Canned, MockTransport};

        let mock = MockTransport::new().json("https://example.test/y", Canned::NotFound);
        let outcome: JsonOutcome<serde_json::Value> =
            mock.fetch_json("https://example.test/y").await.unwrap();
        assert!(matches!(outcome, JsonOutcome::NotFound));
    }
}
