//! HTTP client for remote claim storage.
//!
//! Implements `ClaimStore` trait by calling StemeDB `/v1/claims` API endpoints.

use std::collections::HashMap;
use std::time::Duration;

use serde::{Deserialize, Serialize};
use tracing::{info, warn};

use crate::claim_store::{ClaimFilter, ClaimStore};
use crate::config::{HostedConfig, OfflineFallback};
use crate::remote::cache::ClaimCache;
use crate::types::{AuthoredClaim, AuthoredValue, ClaimStatus, ComparisonMode};
use crate::AphoriaError;

/// Remote claim store that queries claims from a hosted StemeDB server.
pub struct RemoteClaimStore {
    /// Base URL of the remote server.
    base_url: String,

    /// Project identifier.
    #[allow(dead_code)]
    project_id: String,

    /// API key for authentication.
    api_key: String,

    /// Maximum retry attempts.
    max_retries: u32,

    /// Delay between retries in milliseconds.
    retry_delay_ms: u64,

    /// Offline fallback strategy.
    offline_fallback: OfflineFallback,

    /// Local cache for offline access.
    cache: ClaimCache,
}

// ============================================================================
// DTOs (Data Transfer Objects)
// ============================================================================

#[derive(Debug, Clone, Serialize)]
struct CreateClaimRequest {
    claim: AuthoredClaimDto,
}

#[derive(Debug, Clone, Deserialize)]
struct CreateClaimResponse {
    #[allow(dead_code)]
    id: String,
    stored: bool,
}

#[derive(Debug, Clone, Serialize)]
struct ListClaimsQuery {
    #[serde(skip_serializing_if = "Option::is_none")]
    concept_path: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    predicate: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    authority_tier: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
struct ListClaimsResponse {
    claims: Vec<AuthoredClaimDto>,
}

#[derive(Debug, Clone, Serialize)]
struct SearchClaimsQuery {
    #[serde(skip_serializing_if = "Option::is_none")]
    concept_pattern: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    predicate: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    category: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    max_tier: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    limit: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
struct SearchClaimsResponse {
    claims: Vec<AuthoredClaimDto>,
}

#[derive(Debug, Clone, Serialize)]
struct ClaimStatsQuery {
    concept_path: String,
    predicate: String,
}

/// Statistics for a specific concept_path + predicate combination across all stored claims.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaimStatsResult {
    /// The concept path this stats result covers.
    pub concept_path: String,
    /// The predicate this stats result covers.
    pub predicate: String,
    /// Total number of claims matching the concept_path + predicate pair.
    pub matching_claims: usize,
    /// Claim count broken down by authority tier (tier number → count).
    pub by_tier: HashMap<u8, usize>,
    /// Claim count broken down by status string (e.g. "active", "deprecated").
    pub by_status: HashMap<String, usize>,
    /// The most frequently occurring value across matching claims, if any.
    pub most_common_value: Option<String>,
    /// Whether at least one matching claim has authoritative (Tier 1) backing.
    pub has_authoritative_backing: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct AuthoredClaimDto {
    id: String,
    concept_path: String,
    predicate: String,
    value: AuthoredValueDto,
    comparison: ComparisonModeDto,
    provenance: String,
    invariant: String,
    consequence: String,
    authority_tier: String,
    evidence: Vec<String>,
    category: String,
    status: ClaimStatusDto,
    supersedes: Option<String>,
    created_by: String,
    created_at: String,
    updated_at: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "value")]
enum AuthoredValueDto {
    Bool(bool),
    Number(f64),
    Text(String),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum ComparisonModeDto {
    Equals,
    NotEquals,
    Present,
    Absent,
    Contains,
    NotContains,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum ClaimStatusDto {
    Draft,
    Active,
    Deprecated,
    Superseded,
}

// ============================================================================
// Implementation
// ============================================================================

impl RemoteClaimStore {
    /// Create a new remote claim store from configuration.
    ///
    /// Returns an error if the configuration is invalid (e.g., missing URL or API key).
    pub fn new(config: &HostedConfig) -> Result<Self, AphoriaError> {
        let base_url = config
            .url
            .as_ref()
            .ok_or_else(|| AphoriaError::Config("hosted.url not set".to_string()))?
            .trim_end_matches('/')
            .to_string();

        let project_id = config
            .project_id
            .as_ref()
            .ok_or_else(|| AphoriaError::Config("hosted.project_id not set".to_string()))?
            .clone();

        let api_key = std::env::var(&config.api_key_env).map_err(|_| {
            AphoriaError::Config(format!("Environment variable ${} not set", config.api_key_env))
        })?;

        Ok(Self {
            base_url,
            project_id,
            api_key,
            max_retries: config.max_retries,
            retry_delay_ms: config.retry_delay_ms,
            offline_fallback: config.offline_fallback,
            cache: ClaimCache::new(),
        })
    }

    /// Make an HTTP request with retry logic.
    fn request<T: for<'de> Deserialize<'de>>(
        &self,
        method: &str,
        path: &str,
        body: Option<&impl Serialize>,
    ) -> Result<T, AphoriaError> {
        let url = format!("{}{}", self.base_url, path);
        let mut last_error = None;

        for attempt in 0..=self.max_retries {
            if attempt > 0 {
                std::thread::sleep(Duration::from_millis(self.retry_delay_ms * (1 << attempt)));
            }

            match self.do_request::<T>(method, &url, body) {
                Ok(response) => return Ok(response),
                Err(e) if is_retryable(&e) => {
                    warn!(attempt, error = %e, "Retrying request");
                    last_error = Some(e);
                }
                Err(e) => return Err(e),
            }
        }

        Err(last_error.unwrap_or_else(|| AphoriaError::Hosted("Max retries exceeded".to_string())))
    }

    /// Search claims by concept pattern, predicate, category, or tier.
    ///
    /// Queries `GET /v1/claims/search` with the supplied filters. Returns an empty
    /// vec when offline and the fallback strategy is `Skip`; returns an error when
    /// the fallback strategy is `Fail`.
    pub fn search_claims(
        &self,
        concept_pattern: Option<&str>,
        predicate: Option<&str>,
        category: Option<&str>,
        max_tier: Option<u8>,
        limit: Option<usize>,
    ) -> Result<Vec<AuthoredClaim>, AphoriaError> {
        let query = SearchClaimsQuery {
            concept_pattern: concept_pattern.map(str::to_string),
            predicate: predicate.map(str::to_string),
            category: category.map(str::to_string),
            max_tier: max_tier.map(|t| t.to_string()),
            limit: Some(limit.unwrap_or(50).to_string()),
        };

        let query_str = serde_qs::to_string(&query)
            .map_err(|e| AphoriaError::Config(format!("Failed to build search query: {e}")))?;

        let path = if query_str.is_empty() {
            "/v1/claims/search".to_string()
        } else {
            format!("/v1/claims/search?{}", query_str)
        };

        match self.request::<SearchClaimsResponse>("GET", &path, None::<&()>) {
            Ok(response) => {
                let claims: Vec<AuthoredClaim> =
                    response.claims.into_iter().map(dto_to_claim).collect();
                info!(count = claims.len(), "search_claims returned results");
                Ok(claims)
            }
            Err(e) if is_network_error(&e) => {
                self.handle_network_error("search_claims", &|| {
                    // Search results cannot be reproduced from the local cache,
                    // so return an empty vec as the best-effort offline result.
                    warn!("Remote unreachable for search_claims, returning empty result");
                    Ok(vec![])
                })
            }
            Err(e) => Err(e),
        }
    }

    /// Retrieve statistics for a specific concept_path + predicate pair.
    ///
    /// Queries `GET /v1/claims/stats?concept_path={}&predicate={}`. Returns a
    /// zero-valued `ClaimStatsResult` when offline and the fallback strategy is
    /// `Skip`; returns an error when the fallback strategy is `Fail`.
    pub fn get_claim_stats(
        &self,
        concept_path: &str,
        predicate: &str,
    ) -> Result<ClaimStatsResult, AphoriaError> {
        let query = ClaimStatsQuery {
            concept_path: concept_path.to_string(),
            predicate: predicate.to_string(),
        };

        let query_str = serde_qs::to_string(&query)
            .map_err(|e| AphoriaError::Config(format!("Failed to build stats query: {e}")))?;

        let path = format!("/v1/claims/stats?{}", query_str);

        match self.request::<ClaimStatsResult>("GET", &path, None::<&()>) {
            Ok(stats) => {
                info!(
                    concept_path,
                    predicate,
                    matching_claims = stats.matching_claims,
                    "get_claim_stats returned results"
                );
                Ok(stats)
            }
            Err(e) if is_network_error(&e) => {
                self.handle_network_error("get_claim_stats", &|| {
                    // Return a zero-stats result so callers can safely proceed offline.
                    Ok(ClaimStatsResult {
                        concept_path: concept_path.to_string(),
                        predicate: predicate.to_string(),
                        matching_claims: 0,
                        by_tier: HashMap::new(),
                        by_status: HashMap::new(),
                        most_common_value: None,
                        has_authoritative_backing: false,
                    })
                })
            }
            Err(e) => Err(e),
        }
    }

    /// Perform the actual HTTP request.
    fn do_request<T: for<'de> Deserialize<'de>>(
        &self,
        method: &str,
        url: &str,
        body: Option<&impl Serialize>,
    ) -> Result<T, AphoriaError> {
        let http_request = match method {
            "GET" => ureq::get(url),
            "POST" => ureq::post(url),
            "PUT" => ureq::put(url),
            "DELETE" => ureq::delete(url),
            _ => return Err(AphoriaError::Config(format!("Unsupported HTTP method: {}", method))),
        };

        let http_request = http_request
            .set("Content-Type", "application/json")
            .set("Authorization", &format!("Bearer {}", self.api_key));

        let response = if let Some(b) = body {
            let json = serde_json::to_string(b)
                .map_err(|e| AphoriaError::Config(format!("Failed to serialize request: {e}")))?;
            http_request.send_string(&json)
        } else {
            http_request.call()
        };

        let response =
            response.map_err(|e| AphoriaError::Hosted(format!("HTTP request failed: {e}")))?;

        if response.status() >= 200 && response.status() < 300 {
            let body = response
                .into_string()
                .map_err(|e| AphoriaError::Hosted(format!("Failed to read response: {e}")))?;
            serde_json::from_str(&body)
                .map_err(|e| AphoriaError::Config(format!("Failed to parse response: {e}")))
        } else {
            Err(AphoriaError::Hosted(format!("Server returned status {}", response.status())))
        }
    }
}

impl ClaimStore for RemoteClaimStore {
    fn save_claim(&self, claim: &AuthoredClaim) -> Result<(), AphoriaError> {
        let request = CreateClaimRequest { claim: claim_to_dto(claim) };

        let response: CreateClaimResponse = self.request("POST", "/v1/claims", Some(&request))?;

        if response.stored {
            info!(claim_id = %claim.id, "Claim stored remotely");
            Ok(())
        } else {
            Err(AphoriaError::Hosted("Claim not stored remotely".to_string()))
        }
    }

    fn load_claim(
        &self,
        concept_path: &str,
        predicate: &str,
    ) -> Result<Option<AuthoredClaim>, AphoriaError> {
        let path = format!("/v1/claims/{}/{}", concept_path, predicate);

        match self.request::<AuthoredClaimDto>("GET", &path, None::<&()>) {
            Ok(dto) => Ok(Some(dto_to_claim(dto))),
            Err(AphoriaError::Hosted(s)) if s.contains("404") => Ok(None),
            Err(e) if is_network_error(&e) => {
                // Network error: fall back to cache
                self.handle_network_error("load_claim", &|| {
                    let cached = self.cache.load()?;
                    Ok(cached
                        .into_iter()
                        .find(|c| c.concept_path == concept_path && c.predicate == predicate))
                })
            }
            Err(e) => Err(e),
        }
    }

    fn list_claims(&self, filter: &ClaimFilter) -> Result<Vec<AuthoredClaim>, AphoriaError> {
        let query = ListClaimsQuery {
            concept_path: filter.concept_path.clone(),
            predicate: filter.predicate.clone(),
            authority_tier: filter.authority_tier.clone(),
        };

        // Build query string
        let query_str = serde_qs::to_string(&query)
            .map_err(|e| AphoriaError::Config(format!("Failed to build query: {e}")))?;

        let path = if query_str.is_empty() {
            "/v1/claims".to_string()
        } else {
            format!("/v1/claims?{}", query_str)
        };

        match self.request::<ListClaimsResponse>("GET", &path, None::<&()>) {
            Ok(response) => {
                let claims: Vec<AuthoredClaim> =
                    response.claims.into_iter().map(dto_to_claim).collect();

                // Update cache on successful fetch
                if let Err(e) = self.cache.save(&claims, &self.base_url) {
                    warn!(error = %e, "Failed to update cache");
                }

                Ok(claims)
            }
            Err(e) if is_network_error(&e) => {
                // Network error: fall back to cache
                self.handle_network_error("list_claims", &|| self.cache.load())
            }
            Err(e) => Err(e),
        }
    }

    fn delete_claim(&self, concept_path: &str, predicate: &str) -> Result<bool, AphoriaError> {
        let path = format!("/v1/claims/{}/{}", concept_path, predicate);

        match self.request::<serde_json::Value>("DELETE", &path, None::<&()>) {
            Ok(_) => Ok(true),
            Err(AphoriaError::Hosted(s)) if s.contains("404") => Ok(false),
            Err(e) => Err(e),
        }
    }
}

impl RemoteClaimStore {
    /// Handle network errors based on offline fallback strategy.
    fn handle_network_error<T>(
        &self,
        operation: &str,
        fallback: &dyn Fn() -> Result<T, AphoriaError>,
    ) -> Result<T, AphoriaError> {
        match self.offline_fallback {
            OfflineFallback::Skip => {
                warn!(operation, "Remote unreachable, using cached claims");
                fallback()
            }
            OfflineFallback::Fail => {
                Err(AphoriaError::Hosted(format!("{}: remote unreachable", operation)))
            }
            OfflineFallback::Queue => {
                warn!(operation, "Remote unreachable, queue not implemented (using cache)");
                fallback()
            }
        }
    }
}

// ============================================================================
// Conversion Helpers
// ============================================================================

fn claim_to_dto(claim: &AuthoredClaim) -> AuthoredClaimDto {
    AuthoredClaimDto {
        id: claim.id.clone(),
        concept_path: claim.concept_path.clone(),
        predicate: claim.predicate.clone(),
        value: match &claim.value {
            AuthoredValue::Bool(b) => AuthoredValueDto::Bool(*b),
            AuthoredValue::Number(n) => AuthoredValueDto::Number(*n),
            AuthoredValue::Text(s) => AuthoredValueDto::Text(s.clone()),
        },
        comparison: match claim.comparison {
            ComparisonMode::Equals => ComparisonModeDto::Equals,
            ComparisonMode::NotEquals => ComparisonModeDto::NotEquals,
            ComparisonMode::Present => ComparisonModeDto::Present,
            ComparisonMode::Absent => ComparisonModeDto::Absent,
            ComparisonMode::Contains => ComparisonModeDto::Contains,
            ComparisonMode::NotContains => ComparisonModeDto::NotContains,
        },
        provenance: claim.provenance.clone(),
        invariant: claim.invariant.clone(),
        consequence: claim.consequence.clone(),
        authority_tier: claim.authority_tier.clone(),
        evidence: claim.evidence.clone(),
        category: claim.category.clone(),
        status: match claim.status {
            ClaimStatus::Draft => ClaimStatusDto::Draft,
            ClaimStatus::Active => ClaimStatusDto::Active,
            ClaimStatus::Deprecated => ClaimStatusDto::Deprecated,
            ClaimStatus::Superseded => ClaimStatusDto::Superseded,
        },
        supersedes: claim.supersedes.clone(),
        created_by: claim.created_by.clone(),
        created_at: claim.created_at.clone(),
        updated_at: claim.updated_at.clone(),
    }
}

fn dto_to_claim(dto: AuthoredClaimDto) -> AuthoredClaim {
    AuthoredClaim {
        id: dto.id,
        concept_path: dto.concept_path,
        predicate: dto.predicate,
        value: match dto.value {
            AuthoredValueDto::Bool(b) => AuthoredValue::Bool(b),
            AuthoredValueDto::Number(n) => AuthoredValue::Number(n),
            AuthoredValueDto::Text(s) => AuthoredValue::Text(s),
        },
        comparison: match dto.comparison {
            ComparisonModeDto::Equals => ComparisonMode::Equals,
            ComparisonModeDto::NotEquals => ComparisonMode::NotEquals,
            ComparisonModeDto::Present => ComparisonMode::Present,
            ComparisonModeDto::Absent => ComparisonMode::Absent,
            ComparisonModeDto::Contains => ComparisonMode::Contains,
            ComparisonModeDto::NotContains => ComparisonMode::NotContains,
        },
        provenance: dto.provenance,
        invariant: dto.invariant,
        consequence: dto.consequence,
        authority_tier: dto.authority_tier,
        evidence: dto.evidence,
        category: dto.category,
        status: match dto.status {
            ClaimStatusDto::Draft => ClaimStatus::Draft,
            ClaimStatusDto::Active => ClaimStatus::Active,
            ClaimStatusDto::Deprecated => ClaimStatus::Deprecated,
            ClaimStatusDto::Superseded => ClaimStatus::Superseded,
        },
        supersedes: dto.supersedes,
        created_by: dto.created_by,
        created_at: dto.created_at,
        updated_at: dto.updated_at,
    }
}

fn is_retryable(err: &AphoriaError) -> bool {
    matches!(err, AphoriaError::Hosted(_))
}

fn is_network_error(err: &AphoriaError) -> bool {
    matches!(err, AphoriaError::Hosted(_))
}

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

    #[test]
    fn test_remote_store_requires_url() {
        let config = HostedConfig { url: None, ..Default::default() };

        let result = RemoteClaimStore::new(&config);
        assert!(result.is_err());
    }

    #[test]
    fn test_remote_store_requires_project_id() {
        let config = HostedConfig {
            url: Some("https://example.com".to_string()),
            project_id: None,
            ..Default::default()
        };

        let result = RemoteClaimStore::new(&config);
        assert!(result.is_err());
    }

    #[test]
    fn test_remote_store_requires_api_key() {
        // Clear the env var if it exists
        std::env::remove_var("STEMEDB_API_KEY");

        let config = HostedConfig {
            url: Some("https://example.com".to_string()),
            project_id: Some("test-project".to_string()),
            api_key_env: "STEMEDB_API_KEY".to_string(),
            ..Default::default()
        };

        let result = RemoteClaimStore::new(&config);
        assert!(result.is_err());
    }

    #[test]
    fn test_remote_store_creation_with_valid_config() {
        // Set the API key
        std::env::set_var("TEST_API_KEY", "test_key_123");

        let config = HostedConfig {
            url: Some("https://example.com".to_string()),
            project_id: Some("test-project".to_string()),
            team_id: None,
            api_key_env: "TEST_API_KEY".to_string(),
            sync_mode: SyncMode::RemoteOnly,
            offline_fallback: OfflineFallback::Skip,
            max_retries: 3,
            retry_delay_ms: 1000,
        };

        let result = RemoteClaimStore::new(&config);
        assert!(result.is_ok());

        // Cleanup
        std::env::remove_var("TEST_API_KEY");
    }
}
