//! HTTP client for the chain's oracle endpoints.
//!
//! Owns the network protocol — submits proofs, polls observation requests,
//! reads `/chain-info` for chain ID discovery. The set of endpoints is
//! intentionally narrow: enough to run the SDK's event loop, no broader
//! "general node client" surface. If you need to query e.g. block height,
//! use `reqwest` directly against the chain's HTTP API.
//!
//! ## Endpoint map
//!
//! | SDK method | Chain endpoint |
//! |---|---|
//! | [`ChainClient::chain_info`] | `GET /chain-info` |
//! | [`ChainClient::submit_submission`] | `POST /oracle/submissions` |
//! | [`ChainClient::matching_observation_requests`] | `GET /oracle/observation-requests/matching?oracle_id=<hex>` |
//! | [`ChainClient::submit_observation_request_response`] | `POST /oracle/observation-requests/{id}/submissions` |
//!
//! All responses come back as JSON; the structs the SDK deserializes them
//! into are intentionally permissive (only the fields the runtime uses are
//! required) so non-breaking field additions on the chain side don't break
//! older SDK versions.

use fialucci_types::{OracleId, OracleObservationRequestId, SignedOracleDataSubmission};
use serde::Deserialize;
use std::time::Duration;

use crate::handler::ObservationRequest;

/// HTTP client for a single Fialucci chain node.
///
/// Cheap to clone — the inner `reqwest::Client` shares its connection pool.
#[derive(Debug, Clone)]
pub struct ChainClient {
    base_url: String,
    http: reqwest::Client,
}

impl ChainClient {
    /// Build a client pointed at `base_url` (e.g. `"http://127.0.0.1:8080"`).
    ///
    /// `base_url` is trimmed of any trailing slash so endpoint paths can be
    /// concatenated unambiguously.
    pub fn new(base_url: impl Into<String>) -> Result<Self, ClientError> {
        let raw = base_url.into();
        let base = raw.trim_end_matches('/').to_string();
        if base.is_empty() {
            return Err(ClientError::InvalidUrl("base_url is empty".to_string()));
        }
        let http = reqwest::Client::builder()
            .timeout(Duration::from_secs(30))
            .build()
            .map_err(ClientError::Http)?;
        Ok(Self {
            base_url: base,
            http,
        })
    }

    /// `GET /chain-info` — returns the chain's identifier + coin denom.
    ///
    /// Used at startup to discover `chain_id` (which the SDK then injects
    /// into every signed proof body so the dev never needs to spell it out).
    pub async fn chain_info(&self) -> Result<ChainInfo, ClientError> {
        let url = format!("{}/chain-info", self.base_url);
        let resp = self
            .http
            .get(&url)
            .send()
            .await
            .map_err(ClientError::Http)?;
        check_status(&resp).await?;
        resp.json::<ChainInfo>().await.map_err(ClientError::Http)
    }

    /// `POST /oracle/submissions` — submit a signed oracle data submission.
    ///
    /// Uses the whitepaper-aligned `/submissions` path. The legacy
    /// `/oracle/proofs` alias is identical on the chain side; this client
    /// uses the preferred name.
    pub async fn submit_submission(
        &self,
        submission: &SignedOracleDataSubmission,
    ) -> Result<SubmissionAck, ClientError> {
        let url = format!("{}/oracle/submissions", self.base_url);
        let resp = self
            .http
            .post(&url)
            .json(submission)
            .send()
            .await
            .map_err(ClientError::Http)?;
        check_status(&resp).await?;
        resp.json::<SubmissionAck>()
            .await
            .map_err(ClientError::Http)
    }

    /// `GET /oracle/observation-requests/matching?oracle_id=<hex>` —
    /// list open observation requests this oracle is eligible to answer.
    pub async fn matching_observation_requests(
        &self,
        oracle_id: OracleId,
    ) -> Result<Vec<ObservationRequest>, ClientError> {
        let url = format!(
            "{}/oracle/observation-requests/matching?oracle_id={}",
            self.base_url,
            hex::encode(oracle_id.0)
        );
        let resp = self
            .http
            .get(&url)
            .send()
            .await
            .map_err(ClientError::Http)?;
        check_status(&resp).await?;
        let body: MatchingObservationRequestsResponse =
            resp.json().await.map_err(ClientError::Http)?;
        Ok(body.requests)
    }

    /// `POST /oracle/observation-requests/{id}/submissions` — submit a
    /// signed proof that answers a specific observation request.
    pub async fn submit_observation_request_response(
        &self,
        request_id: OracleObservationRequestId,
        submission: &SignedOracleDataSubmission,
    ) -> Result<SubmissionAck, ClientError> {
        let url = format!(
            "{}/oracle/observation-requests/{}/submissions",
            self.base_url,
            hex::encode(request_id.0)
        );
        let resp = self
            .http
            .post(&url)
            .json(submission)
            .send()
            .await
            .map_err(ClientError::Http)?;
        check_status(&resp).await?;
        resp.json::<SubmissionAck>()
            .await
            .map_err(ClientError::Http)
    }
}

async fn check_status(resp: &reqwest::Response) -> Result<(), ClientError> {
    let status = resp.status();
    if status.is_success() {
        return Ok(());
    }
    // Don't move the response (caller still wants to read its body if 2xx);
    // we read it conditionally only on the error path via a clone-of-info.
    let url = resp.url().to_string();
    Err(
        ClientError::Http(reqwest::Response::error_for_status_ref(resp).unwrap_err())
            .with_context(format!("HTTP {} from {}", status, url)),
    )
}

/// Response shape of `GET /chain-info`.
#[derive(Debug, Clone, Deserialize)]
pub struct ChainInfo {
    /// The chain's identifier (matches the node's `FIALUCCI_CHAIN_ID`).
    pub chain_id: String,
    /// The chain's coin denomination string (e.g. `"Lucci"`).
    pub coin: String,
}

/// Response shape of `POST /oracle/submissions` (and the observation-request
/// variant).
///
/// Intentionally permissive: only `status` and `proof_id` are required;
/// chain extensions may add fields without breaking older SDK versions.
#[derive(Debug, Clone, Deserialize)]
pub struct SubmissionAck {
    /// One of `"accepted"`, `"duplicate"`, `"rejected"`.
    pub status: String,
    /// Hex-encoded `OracleDataSubmissionId` (the proof's deterministic ID).
    pub proof_id: Option<String>,
    /// Free-form error message when `status == "rejected"`.
    pub error: Option<String>,
}

#[derive(Debug, Deserialize)]
struct MatchingObservationRequestsResponse {
    requests: Vec<ObservationRequest>,
}

/// Errors surfaced by [`ChainClient`].
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
    /// `reqwest`-side failure (DNS, TLS, timeout, non-2xx status without
    /// further context).
    #[error("HTTP error: {0}")]
    Http(reqwest::Error),
    /// Bad URL — typically an empty `base_url`.
    #[error("invalid URL: {0}")]
    InvalidUrl(String),
    /// Generic context wrapper added on top of an underlying error.
    #[error("{context}: {source}")]
    WithContext {
        /// Human-readable context (URL, HTTP status, etc.).
        context: String,
        /// Underlying error.
        source: Box<ClientError>,
    },
}

impl ClientError {
    fn with_context(self, context: String) -> Self {
        ClientError::WithContext {
            context,
            source: Box::new(self),
        }
    }
}

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

    #[test]
    fn empty_base_url_rejected() {
        match ChainClient::new("") {
            Err(ClientError::InvalidUrl(_)) => {}
            other => panic!("expected InvalidUrl, got {other:?}"),
        }
    }

    #[test]
    fn trailing_slash_is_stripped() {
        let c = ChainClient::new("http://127.0.0.1:8080/").unwrap();
        assert_eq!(c.base_url, "http://127.0.0.1:8080");
    }
}
