//! HTTP client for StemeDB API.
//!
//! This module provides a client for submitting assertions and querying
//! the StemeDB API from the ontology layer.

use crate::dto::{
    assertion_to_request, CreateResponse, LayeredResponse, LensQueryDto, QueryResponse,
    SkepticResponse,
};
use stemedb_core::types::Assertion;
use thiserror::Error;
use tracing::{info, instrument, warn};

/// Errors that can occur when communicating with StemeDB.
#[derive(Debug, Error)]
pub enum ClientError {
    /// HTTP request failed.
    #[error("HTTP error: {0}")]
    Http(#[from] reqwest::Error),

    /// API returned an error response.
    #[error("API error ({status}): {message}")]
    ApiError {
        /// HTTP status code
        status: u16,
        /// Error message from API
        message: String,
    },

    /// Failed to parse response.
    #[error("Failed to parse response: {0}")]
    ParseError(String),

    /// Server not available.
    #[error("Server not available at {url}: {message}")]
    ServerUnavailable {
        /// The URL that was attempted
        url: String,
        /// Error message
        message: String,
    },
}

/// Client for StemeDB HTTP API.
///
/// # Example
///
/// ```ignore
/// use stemedb_ontology::client::StemeClient;
///
/// let client = StemeClient::new("http://localhost:18180");
///
/// // Submit an assertion
/// let hash = client.assert(&assertion).await?;
///
/// // Query for conflicts
/// let skeptic = client.skeptic("Semaglutide:Type2Diabetes", "hba1c_change_percent").await?;
/// ```
#[derive(Debug, Clone)]
pub struct StemeClient {
    base_url: String,
    http_client: reqwest::Client,
}

impl StemeClient {
    /// Create a new client with the given base URL.
    ///
    /// # Arguments
    ///
    /// * `base_url` - The base URL of the StemeDB API (e.g., "http://localhost:18180")
    pub fn new(base_url: impl Into<String>) -> Self {
        Self {
            base_url: base_url.into(),
            http_client: reqwest::Client::builder()
                .timeout(std::time::Duration::from_secs(30))
                .build()
                .unwrap_or_else(|_| reqwest::Client::new()),
        }
    }

    /// Create a client with a custom reqwest client.
    ///
    /// Useful for testing or custom configurations.
    pub fn with_client(base_url: impl Into<String>, client: reqwest::Client) -> Self {
        Self { base_url: base_url.into(), http_client: client }
    }

    /// Submit an assertion to StemeDB.
    ///
    /// Returns the content-addressed hash on success.
    ///
    /// # Arguments
    ///
    /// * `assertion` - The assertion to submit
    ///
    /// # Errors
    ///
    /// Returns `ClientError` if the request fails or the API returns an error.
    #[instrument(skip(self, assertion), fields(subject = %assertion.subject, predicate = %assertion.predicate))]
    pub async fn assert(&self, assertion: &Assertion) -> Result<String, ClientError> {
        let request = assertion_to_request(assertion);
        let url = format!("{}/v1/assert", self.base_url);

        info!(url = %url, "Submitting assertion");

        let response = self.http_client.post(&url).json(&request).send().await.map_err(|e| {
            if e.is_connect() {
                ClientError::ServerUnavailable {
                    url: url.clone(),
                    message: format!(
                        "Connection failed: {}. Ensure StemeDB is running: cargo run -p stemedb-api",
                        e
                    ),
                }
            } else {
                ClientError::Http(e)
            }
        })?;

        let status = response.status();
        if !status.is_success() {
            let text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
            warn!(status = status.as_u16(), error = %text, "API error");
            return Err(ClientError::ApiError { status: status.as_u16(), message: text });
        }

        let result: CreateResponse =
            response.json().await.map_err(|e| ClientError::ParseError(e.to_string()))?;

        info!(hash = %result.hash, "Assertion created");
        Ok(result.hash)
    }

    /// Submit multiple assertions in sequence.
    ///
    /// Returns a vector of (hash, error) pairs. Continues on individual failures.
    ///
    /// # Arguments
    ///
    /// * `assertions` - The assertions to submit
    #[instrument(skip(self, assertions), fields(count = assertions.len()))]
    pub async fn assert_batch(&self, assertions: &[Assertion]) -> Vec<Result<String, ClientError>> {
        let mut results = Vec::with_capacity(assertions.len());

        for assertion in assertions {
            results.push(self.assert(assertion).await);
        }

        results
    }

    /// Query the Skeptic lens for conflict analysis.
    ///
    /// # Arguments
    ///
    /// * `subject` - The subject to query
    /// * `predicate` - The predicate to query
    ///
    /// # Returns
    ///
    /// A `SkepticResponse` containing conflict analysis.
    #[instrument(skip(self))]
    pub async fn skeptic(
        &self,
        subject: &str,
        predicate: &str,
    ) -> Result<SkepticResponse, ClientError> {
        let url = format!(
            "{}/v1/skeptic?subject={}&predicate={}",
            self.base_url,
            urlencoding::encode(subject),
            urlencoding::encode(predicate)
        );

        info!(url = %url, "Querying skeptic lens");

        let response = self.http_client.get(&url).send().await.map_err(|e| {
            if e.is_connect() {
                ClientError::ServerUnavailable {
                    url: url.clone(),
                    message: format!(
                        "Connection failed: {}. Ensure StemeDB is running: cargo run -p stemedb-api",
                        e
                    ),
                }
            } else {
                ClientError::Http(e)
            }
        })?;

        let status = response.status();
        if !status.is_success() {
            let text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
            warn!(status = status.as_u16(), error = %text, "Skeptic query failed");
            return Err(ClientError::ApiError { status: status.as_u16(), message: text });
        }

        let result: SkepticResponse =
            response.json().await.map_err(|e| ClientError::ParseError(e.to_string()))?;

        info!(
            conflict_score = result.conflict_score,
            claims_count = result.claims.len(),
            status = ?result.status,
            "Skeptic query completed"
        );

        Ok(result)
    }

    /// Query the LayeredConsensus lens for per-tier analysis.
    ///
    /// # Arguments
    ///
    /// * `subject` - The subject to query
    /// * `predicate` - The predicate to query
    ///
    /// # Returns
    ///
    /// A `LayeredResponse` with per-tier consensus results.
    #[instrument(skip(self))]
    pub async fn layered(
        &self,
        subject: &str,
        predicate: &str,
    ) -> Result<LayeredResponse, ClientError> {
        let url = format!(
            "{}/v1/layered?subject={}&predicate={}",
            self.base_url,
            urlencoding::encode(subject),
            urlencoding::encode(predicate)
        );

        info!(url = %url, "Querying layered consensus");

        let response = self.http_client.get(&url).send().await.map_err(|e| {
            if e.is_connect() {
                ClientError::ServerUnavailable {
                    url: url.clone(),
                    message: format!(
                        "Connection failed: {}. Ensure StemeDB is running: cargo run -p stemedb-api",
                        e
                    ),
                }
            } else {
                ClientError::Http(e)
            }
        })?;

        let status = response.status();
        if !status.is_success() {
            let text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
            warn!(status = status.as_u16(), error = %text, "Layered query failed");
            return Err(ClientError::ApiError { status: status.as_u16(), message: text });
        }

        let result: LayeredResponse =
            response.json().await.map_err(|e| ClientError::ParseError(e.to_string()))?;

        info!(
            overall_conflict = result.overall_conflict_score,
            tiers_count = result.tiers.len(),
            total_candidates = result.total_candidates,
            "Layered query completed"
        );

        Ok(result)
    }

    /// Query assertions with optional lens.
    ///
    /// # Arguments
    ///
    /// * `subject` - Optional subject filter
    /// * `predicate` - Optional predicate filter
    /// * `lens` - Optional lens for conflict resolution
    ///
    /// # Returns
    ///
    /// A `QueryResponse` containing matching assertions.
    #[instrument(skip(self))]
    pub async fn query(
        &self,
        subject: Option<&str>,
        predicate: Option<&str>,
        lens: Option<LensQueryDto>,
    ) -> Result<QueryResponse, ClientError> {
        let mut url = format!("{}/v1/query?", self.base_url);

        let mut params = Vec::new();
        if let Some(s) = subject {
            params.push(format!("subject={}", urlencoding::encode(s)));
        }
        if let Some(p) = predicate {
            params.push(format!("predicate={}", urlencoding::encode(p)));
        }
        if let Some(l) = lens {
            params.push(format!("lens={}", l));
        }

        url.push_str(&params.join("&"));

        info!(url = %url, "Querying assertions");

        let response = self.http_client.get(&url).send().await.map_err(|e| {
            if e.is_connect() {
                ClientError::ServerUnavailable {
                    url: url.clone(),
                    message: format!(
                        "Connection failed: {}. Ensure StemeDB is running: cargo run -p stemedb-api",
                        e
                    ),
                }
            } else {
                ClientError::Http(e)
            }
        })?;

        let status = response.status();
        if !status.is_success() {
            let text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
            warn!(status = status.as_u16(), error = %text, "Query failed");
            return Err(ClientError::ApiError { status: status.as_u16(), message: text });
        }

        let result: QueryResponse =
            response.json().await.map_err(|e| ClientError::ParseError(e.to_string()))?;

        info!(
            total_count = result.total_count,
            has_more = result.has_more,
            conflict_score = ?result.conflict_score,
            "Query completed"
        );

        Ok(result)
    }

    /// List distinct predicates for a subject.
    ///
    /// # Arguments
    ///
    /// * `subject` - The subject to list predicates for
    ///
    /// # Returns
    ///
    /// A list of distinct predicate strings.
    #[instrument(skip(self))]
    pub async fn list_predicates(&self, subject: &str) -> Result<Vec<String>, ClientError> {
        let url = format!("{}/v1/query?subject={}", self.base_url, urlencoding::encode(subject));

        info!(url = %url, "Listing predicates for subject");

        let response = self.http_client.get(&url).send().await.map_err(|e| {
            if e.is_connect() {
                ClientError::ServerUnavailable {
                    url: url.clone(),
                    message: format!(
                        "Connection failed: {}. Ensure StemeDB is running: cargo run -p stemedb-api",
                        e
                    ),
                }
            } else {
                ClientError::Http(e)
            }
        })?;

        let status = response.status();
        if !status.is_success() {
            let text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
            warn!(status = status.as_u16(), error = %text, "List predicates failed");
            return Err(ClientError::ApiError { status: status.as_u16(), message: text });
        }

        let result: QueryResponse =
            response.json().await.map_err(|e| ClientError::ParseError(e.to_string()))?;

        // Extract unique predicates from assertions
        let mut predicates: Vec<String> =
            result.assertions.iter().map(|a| a.predicate.clone()).collect();
        predicates.sort();
        predicates.dedup();

        info!(predicate_count = predicates.len(), "Listed predicates");

        Ok(predicates)
    }

    /// Check if the StemeDB server is reachable.
    ///
    /// # Returns
    ///
    /// `true` if the server responded, `false` otherwise.
    pub async fn health_check(&self) -> bool {
        let url = format!("{}/v1/health", self.base_url);
        self.http_client.get(&url).send().await.map(|r| r.status().is_success()).unwrap_or(false)
    }

    /// Get the base URL of this client.
    pub fn base_url(&self) -> &str {
        &self.base_url
    }
}

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

    #[test]
    fn test_client_creation() {
        let client = StemeClient::new("http://localhost:18180");
        assert_eq!(client.base_url(), "http://localhost:18180");
    }

    #[test]
    fn test_client_with_trailing_slash() {
        let client = StemeClient::new("http://localhost:18180/");
        // Note: This will produce double slashes in URLs, which the API should handle
        assert_eq!(client.base_url(), "http://localhost:18180/");
    }
}
