//! HTTP client for pushing observations to a hosted StemeDB server.
//!
//! In hosted mode, all observations are automatically synced to a central
//! team server, enabling pattern aggregation across projects.

use std::time::Duration;

use ed25519_dalek::SigningKey;
use serde::{Deserialize, Serialize};
use stemedb_core::types::Assertion;
use tracing::{info, instrument, warn};

use crate::community::{CommunityExtractor, SharedPattern};
use crate::config::{HostedConfig, OfflineFallback};
use crate::AphoriaError;

/// HTTP client for pushing observations to a hosted StemeDB server.
pub struct HostedClient {
    /// Base URL of the server (e.g., "https://episteme.acme.corp").
    base_url: String,

    /// Project identifier.
    project_id: String,

    /// Optional team identifier.
    team_id: Option<String>,

    /// Agent's public key (hex-encoded).
    agent_id: String,

    /// Optional API key for authentication.
    api_key: Option<String>,

    /// Maximum retry attempts.
    max_retries: u32,

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

    /// Behavior when server is unreachable.
    offline_fallback: OfflineFallback,
}

/// Request payload for pushing observations.
#[derive(Debug, Clone, Serialize)]
pub struct PushObservationsRequest {
    /// The observations to push.
    pub observations: Vec<ObservationDto>,

    /// Project identifier.
    pub project_id: String,

    /// Optional team identifier.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub team_id: Option<String>,

    /// Client version for debugging.
    pub client_version: String,
}

/// A single observation in the request.
#[derive(Debug, Clone, Serialize)]
pub struct ObservationDto {
    /// The subject (concept path).
    pub subject: String,

    /// The predicate being claimed.
    pub predicate: String,

    /// The object value.
    pub object: ObjectValueDto,

    /// Confidence score (0.0 to 1.0).
    pub confidence: f32,

    /// Source hash (hex-encoded).
    pub source_hash: String,

    /// Signatures (hex-encoded).
    pub signatures: Vec<SignatureDto>,

    /// Timestamp of the observation.
    pub timestamp: u64,

    /// Source metadata as JSON string.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source_metadata: Option<String>,
}

/// Object value in the DTO.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", content = "value")]
pub enum ObjectValueDto {
    /// Textual value
    Text(String),
    /// Numeric value
    Number(f64),
    /// Boolean value
    Boolean(bool),
    /// Entity reference
    Reference(String),
}

/// Signature entry in the DTO.
#[derive(Debug, Clone, Serialize)]
pub struct SignatureDto {
    /// Agent's public key (hex-encoded).
    pub agent_id: String,
    /// Signature bytes (hex-encoded).
    pub signature: String,
    /// Timestamp of signature.
    pub timestamp: u64,
    /// Signature version.
    pub version: u8,
}

/// Response from pushing observations.
#[derive(Debug, Clone, Deserialize)]
pub struct PushObservationsResponse {
    /// Number of observations accepted.
    pub accepted: usize,

    /// Number of observations deduplicated (already existed).
    pub deduplicated: usize,

    /// Hashes of created assertions (hex-encoded).
    /// Note: This field is included in the server response but not currently used by the client.
    #[allow(dead_code)]
    pub hashes: Vec<String>,
}

// ============================================================================
// Cross-Project Learning Types (reserved for future use)
// ============================================================================

/// Request payload for pushing learned patterns.
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize)]
pub struct PushPatternsRequest {
    /// BLAKE3 hash of the organization identifier.
    ///
    /// Privacy: Only the hash is sent, not the actual org name.
    pub org_hash: String,

    /// The patterns to push.
    pub patterns: Vec<SharedPattern>,

    /// Client version for debugging and compatibility.
    pub client_version: String,
}

/// Response from pushing patterns.
#[allow(dead_code)]
#[derive(Debug, Clone, Default, Deserialize)]
pub struct PushPatternsResponse {
    /// Number of patterns accepted as new.
    pub accepted: usize,

    /// Number of patterns merged with existing.
    pub merged: usize,

    /// Number of patterns that were duplicates.
    pub deduplicated: usize,
}

/// Query parameters for getting community extractors.
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize)]
pub struct GetCommunityExtractorsQuery {
    /// Only return extractors promoted after this timestamp.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub since: Option<u64>,

    /// Minimum project count threshold.
    pub min_projects: u64,
}

impl HostedClient {
    /// Create a new hosted client if hosted mode is configured.
    ///
    /// Returns `None` if hosted mode is not enabled (no URL configured).
    /// Returns `Err` if configuration is invalid.
    pub fn new(
        config: &HostedConfig,
        signing_key: &SigningKey,
        fallback_project_name: &str,
    ) -> Result<Option<Self>, AphoriaError> {
        let base_url = match &config.url {
            Some(url) => url.trim_end_matches('/').to_string(),
            None => return Ok(None),
        };

        // Get project ID (from config or fallback to project name)
        let project_id =
            config.project_id.clone().unwrap_or_else(|| fallback_project_name.to_string());

        // Get agent ID from signing key
        let agent_id = hex::encode(signing_key.verifying_key().to_bytes());

        // Try to get API key from environment
        let api_key = if !config.api_key_env.is_empty() {
            std::env::var(&config.api_key_env).ok()
        } else {
            None
        };

        Ok(Some(Self {
            base_url,
            project_id,
            team_id: config.team_id.clone(),
            agent_id,
            api_key,
            max_retries: config.max_retries,
            retry_delay_ms: config.retry_delay_ms,
            offline_fallback: config.offline_fallback,
        }))
    }

    /// Push observations to the remote server.
    ///
    /// Returns the number of observations successfully pushed.
    #[instrument(skip(self, observations), fields(count = observations.len(), project = %self.project_id))]
    pub fn push_observations(&self, observations: Vec<Assertion>) -> Result<usize, AphoriaError> {
        if observations.is_empty() {
            return Ok(0);
        }

        // Convert assertions to DTOs
        let observation_dtos: Vec<ObservationDto> =
            observations.iter().map(assertion_to_dto).collect();

        let request = PushObservationsRequest {
            observations: observation_dtos,
            project_id: self.project_id.clone(),
            team_id: self.team_id.clone(),
            client_version: env!("CARGO_PKG_VERSION").to_string(),
        };

        let url = format!("{}/v1/aphoria/observations", self.base_url);

        // Retry loop
        let mut last_error = None;
        for attempt in 0..=self.max_retries {
            if attempt > 0 {
                info!(attempt, "Retrying push to hosted server");
                std::thread::sleep(Duration::from_millis(self.retry_delay_ms));
            }

            match self.do_push(&url, &request) {
                Ok(response) => {
                    info!(
                        accepted = response.accepted,
                        deduplicated = response.deduplicated,
                        "Pushed observations to hosted server"
                    );
                    return Ok(response.accepted);
                }
                Err(e) => {
                    warn!(attempt, error = %e, "Failed to push to hosted server");
                    last_error = Some(e);
                }
            }
        }

        // All retries failed
        let error = last_error.unwrap_or_else(|| {
            AphoriaError::Hosted("Unknown error during hosted sync".to_string())
        });

        match self.offline_fallback {
            OfflineFallback::Skip => {
                warn!(error = %error, "Hosted sync failed, continuing (offline_fallback=skip)");
                Ok(0)
            }
            OfflineFallback::Fail => Err(error),
            OfflineFallback::Queue => {
                // Not yet implemented - treat as skip with warning
                warn!(
                    error = %error,
                    "Hosted sync failed, queue not implemented (treating as skip)"
                );
                Ok(0)
            }
        }
    }

    /// Perform the actual HTTP POST request.
    fn do_push(
        &self,
        url: &str,
        request: &PushObservationsRequest,
    ) -> Result<PushObservationsResponse, AphoriaError> {
        let mut http_request = ureq::post(url)
            .set("Content-Type", "application/json")
            .set("X-Agent-Id", &self.agent_id);

        // Add authorization header if API key is set
        if let Some(ref api_key) = self.api_key {
            http_request = http_request.set("Authorization", &format!("Bearer {}", api_key));
        }

        let body = serde_json::to_string(request)
            .map_err(|e| AphoriaError::Hosted(format!("Failed to serialize request: {e}")))?;

        let response = http_request
            .send_string(&body)
            .map_err(|e| AphoriaError::Hosted(format!("HTTP error: {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::Hosted(format!("Failed to parse response: {e}")))
        } else {
            Err(AphoriaError::Hosted(format!("Server returned status {}", response.status())))
        }
    }

    // ========================================================================
    // Cross-Project Learning Methods
    // ========================================================================

    /// Compute the organization hash for pattern attribution.
    ///
    /// Uses BLAKE3 hash of (project_id, team_id) for privacy.
    pub fn compute_org_hash(&self) -> String {
        let mut hasher = blake3::Hasher::new();
        hasher.update(self.project_id.as_bytes());
        if let Some(ref team_id) = self.team_id {
            hasher.update(b":");
            hasher.update(team_id.as_bytes());
        }
        hex::encode(hasher.finalize().as_bytes())
    }

    /// Push learned patterns to the hosted server.
    ///
    /// Patterns are anonymized before sending - only normalized patterns,
    /// project counts (not identifiers), and confidence scores are sent.
    #[instrument(skip(self, patterns), fields(count = patterns.len(), project = %self.project_id))]
    pub fn push_patterns(
        &self,
        patterns: Vec<SharedPattern>,
    ) -> Result<PushPatternsResponse, AphoriaError> {
        if patterns.is_empty() {
            return Ok(PushPatternsResponse::default());
        }

        let request = PushPatternsRequest {
            org_hash: self.compute_org_hash(),
            patterns,
            client_version: env!("CARGO_PKG_VERSION").to_string(),
        };

        let url = format!("{}/v1/aphoria/patterns", self.base_url);

        // Retry loop
        let mut last_error = None;
        for attempt in 0..=self.max_retries {
            if attempt > 0 {
                info!(attempt, "Retrying pattern push to hosted server");
                std::thread::sleep(Duration::from_millis(self.retry_delay_ms));
            }

            match self.do_push_patterns(&url, &request) {
                Ok(response) => {
                    info!(
                        accepted = response.accepted,
                        merged = response.merged,
                        deduplicated = response.deduplicated,
                        "Pushed patterns to hosted server"
                    );
                    return Ok(response);
                }
                Err(e) => {
                    warn!(attempt, error = %e, "Failed to push patterns to hosted server");
                    last_error = Some(e);
                }
            }
        }

        // All retries failed
        let error = last_error.unwrap_or_else(|| {
            AphoriaError::Hosted("Unknown error during pattern sync".to_string())
        });

        match self.offline_fallback {
            OfflineFallback::Skip => {
                warn!(error = %error, "Pattern sync failed, continuing (offline_fallback=skip)");
                Ok(PushPatternsResponse::default())
            }
            OfflineFallback::Fail => Err(error),
            OfflineFallback::Queue => {
                warn!(
                    error = %error,
                    "Pattern sync failed, queue not implemented (treating as skip)"
                );
                Ok(PushPatternsResponse::default())
            }
        }
    }

    /// Perform the actual HTTP POST request for patterns.
    fn do_push_patterns(
        &self,
        url: &str,
        request: &PushPatternsRequest,
    ) -> Result<PushPatternsResponse, AphoriaError> {
        let mut http_request = ureq::post(url)
            .set("Content-Type", "application/json")
            .set("X-Agent-Id", &self.agent_id);

        if let Some(ref api_key) = self.api_key {
            http_request = http_request.set("Authorization", &format!("Bearer {}", api_key));
        }

        let body = serde_json::to_string(request)
            .map_err(|e| AphoriaError::Hosted(format!("Failed to serialize request: {e}")))?;

        let response = http_request
            .send_string(&body)
            .map_err(|e| AphoriaError::Hosted(format!("HTTP error: {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::Hosted(format!("Failed to parse response: {e}")))
        } else {
            Err(AphoriaError::Hosted(format!("Server returned status {}", response.status())))
        }
    }

    /// Get community extractors from the hosted server.
    ///
    /// Returns extractors that have been aggregated from patterns across
    /// many organizations and promoted to community extractors.
    #[instrument(skip(self), fields(project = %self.project_id))]
    pub fn get_community_extractors(
        &self,
        since: Option<u64>,
        min_projects: u64,
    ) -> Result<Vec<CommunityExtractor>, AphoriaError> {
        let mut url = format!("{}/v1/aphoria/community/extractors", self.base_url);

        // Build query string
        let mut params = vec![format!("min_projects={}", min_projects)];
        if let Some(ts) = since {
            params.push(format!("since={}", ts));
        }
        if !params.is_empty() {
            url = format!("{}?{}", url, params.join("&"));
        }

        // Retry loop
        let mut last_error = None;
        for attempt in 0..=self.max_retries {
            if attempt > 0 {
                info!(attempt, "Retrying community extractors fetch");
                std::thread::sleep(Duration::from_millis(self.retry_delay_ms));
            }

            match self.do_get_extractors(&url) {
                Ok(extractors) => {
                    info!(count = extractors.len(), "Fetched community extractors");
                    return Ok(extractors);
                }
                Err(e) => {
                    warn!(attempt, error = %e, "Failed to fetch community extractors");
                    last_error = Some(e);
                }
            }
        }

        // All retries failed
        let error = last_error.unwrap_or_else(|| {
            AphoriaError::Hosted("Unknown error during extractor fetch".to_string())
        });

        match self.offline_fallback {
            OfflineFallback::Skip => {
                warn!(error = %error, "Extractor fetch failed, continuing (offline_fallback=skip)");
                Ok(vec![])
            }
            OfflineFallback::Fail => Err(error),
            OfflineFallback::Queue => {
                warn!(
                    error = %error,
                    "Extractor fetch failed, queue not implemented (treating as skip)"
                );
                Ok(vec![])
            }
        }
    }

    /// Perform the actual HTTP GET request for extractors.
    fn do_get_extractors(&self, url: &str) -> Result<Vec<CommunityExtractor>, AphoriaError> {
        let mut http_request =
            ureq::get(url).set("Accept", "application/json").set("X-Agent-Id", &self.agent_id);

        if let Some(ref api_key) = self.api_key {
            http_request = http_request.set("Authorization", &format!("Bearer {}", api_key));
        }

        let response =
            http_request.call().map_err(|e| AphoriaError::Hosted(format!("HTTP error: {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::Hosted(format!("Failed to parse response: {e}")))
        } else {
            Err(AphoriaError::Hosted(format!("Server returned status {}", response.status())))
        }
    }

    /// Get the base URL for the hosted server.
    pub fn base_url(&self) -> &str {
        &self.base_url
    }

    /// Get the project ID.
    pub fn project_id(&self) -> &str {
        &self.project_id
    }
}

/// Convert an Assertion to an ObservationDto for the API.
fn assertion_to_dto(assertion: &Assertion) -> ObservationDto {
    use stemedb_core::types::ObjectValue;

    let object = match &assertion.object {
        ObjectValue::Text(s) => ObjectValueDto::Text(s.clone()),
        ObjectValue::Number(n) => ObjectValueDto::Number(*n),
        ObjectValue::Boolean(b) => ObjectValueDto::Boolean(*b),
        ObjectValue::Reference(e) => ObjectValueDto::Reference(e.clone()),
    };

    let signatures: Vec<SignatureDto> = assertion
        .signatures
        .iter()
        .map(|s| SignatureDto {
            agent_id: hex::encode(s.agent_id),
            signature: hex::encode(s.signature),
            timestamp: s.timestamp,
            version: s.version,
        })
        .collect();

    let source_metadata =
        assertion.source_metadata.as_ref().and_then(|m| String::from_utf8(m.clone()).ok());

    ObservationDto {
        subject: assertion.subject.clone(),
        predicate: assertion.predicate.clone(),
        object,
        confidence: assertion.confidence,
        source_hash: hex::encode(assertion.source_hash),
        signatures,
        timestamp: assertion.timestamp,
        source_metadata,
    }
}

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

    #[test]
    fn test_client_not_created_without_url() {
        let config = HostedConfig::default();
        let key = generate_signing_key();
        let client = HostedClient::new(&config, &key, "test-project").expect("should not fail");
        assert!(client.is_none());
    }

    #[test]
    fn test_client_created_with_url() {
        let config = HostedConfig {
            url: Some("https://episteme.acme.corp".to_string()),
            project_id: Some("my-project".to_string()),
            team_id: Some("platform".to_string()),
            sync_mode: SyncMode::RemoteOnly,
            offline_fallback: OfflineFallback::Skip,
            max_retries: 3,
            retry_delay_ms: 1000,
            api_key_env: String::new(),
        };
        let key = generate_signing_key();
        let client =
            HostedClient::new(&config, &key, "fallback-project").expect("should not fail").unwrap();

        assert_eq!(client.base_url, "https://episteme.acme.corp");
        assert_eq!(client.project_id, "my-project");
        assert_eq!(client.team_id, Some("platform".to_string()));
        assert_eq!(client.agent_id.len(), 64); // 32 bytes hex-encoded
    }

    #[test]
    fn test_client_uses_fallback_project_name() {
        let config = HostedConfig {
            url: Some("https://episteme.acme.corp".to_string()),
            project_id: None, // Not set
            ..Default::default()
        };
        let key = generate_signing_key();
        let client =
            HostedClient::new(&config, &key, "fallback-project").expect("should not fail").unwrap();

        assert_eq!(client.project_id, "fallback-project");
    }

    #[test]
    fn test_assertion_to_dto() {
        use stemedb_core::types::{
            Assertion, HlcTimestamp, LifecycleStage, ObjectValue, SignatureEntry, SourceClass,
        };

        let assertion = Assertion {
            subject: "code://rust/myapp/tls".to_string(),
            predicate: "enabled".to_string(),
            object: ObjectValue::Boolean(true),
            parent_hash: None,
            source_hash: [1u8; 32],
            source_class: SourceClass::Community,
            visual_hash: None,
            epoch: None,
            source_metadata: Some(b"{\"file\":\"test.rs\"}".to_vec()),
            lifecycle: LifecycleStage::Approved,
            signatures: vec![SignatureEntry {
                agent_id: [2u8; 32],
                signature: [3u8; 64],
                timestamp: 12345,
                version: 1,
            }],
            confidence: 0.9,
            timestamp: 67890,
            hlc_timestamp: HlcTimestamp::default(),
            vector: None,
        };

        let dto = assertion_to_dto(&assertion);

        assert_eq!(dto.subject, "code://rust/myapp/tls");
        assert_eq!(dto.predicate, "enabled");
        assert!(matches!(dto.object, ObjectValueDto::Boolean(true)));
        assert_eq!(dto.confidence, 0.9);
        assert_eq!(dto.timestamp, 67890);
        assert_eq!(dto.signatures.len(), 1);
        assert_eq!(dto.signatures[0].version, 1);
        assert_eq!(dto.source_metadata, Some("{\"file\":\"test.rs\"}".to_string()));
    }

    #[test]
    fn test_compute_org_hash() {
        let config = HostedConfig {
            url: Some("https://episteme.acme.corp".to_string()),
            project_id: Some("my-project".to_string()),
            team_id: Some("platform".to_string()),
            ..Default::default()
        };
        let key = generate_signing_key();
        let client =
            HostedClient::new(&config, &key, "fallback-project").expect("should not fail").unwrap();

        let hash = client.compute_org_hash();

        // Hash should be 64 hex characters (32 bytes)
        assert_eq!(hash.len(), 64);

        // Same inputs should produce same hash
        let hash2 = client.compute_org_hash();
        assert_eq!(hash, hash2);
    }

    #[test]
    fn test_compute_org_hash_without_team() {
        let config = HostedConfig {
            url: Some("https://episteme.acme.corp".to_string()),
            project_id: Some("my-project".to_string()),
            team_id: None,
            ..Default::default()
        };
        let key = generate_signing_key();
        let client =
            HostedClient::new(&config, &key, "fallback-project").expect("should not fail").unwrap();

        let hash = client.compute_org_hash();
        assert_eq!(hash.len(), 64);

        // With team should produce different hash
        let config_with_team = HostedConfig {
            url: Some("https://episteme.acme.corp".to_string()),
            project_id: Some("my-project".to_string()),
            team_id: Some("platform".to_string()),
            ..Default::default()
        };
        let client_with_team = HostedClient::new(&config_with_team, &key, "fallback-project")
            .expect("should not fail")
            .unwrap();
        let hash_with_team = client_with_team.compute_org_hash();

        assert_ne!(hash, hash_with_team);
    }

    #[test]
    fn test_push_patterns_empty() {
        let config = HostedConfig {
            url: Some("https://episteme.acme.corp".to_string()),
            project_id: Some("my-project".to_string()),
            ..Default::default()
        };
        let key = generate_signing_key();
        let client =
            HostedClient::new(&config, &key, "fallback-project").expect("should not fail").unwrap();

        // Empty patterns should return default response without making HTTP call
        let result = client.push_patterns(vec![]);
        assert!(result.is_ok());
        let response = result.unwrap();
        assert_eq!(response.accepted, 0);
        assert_eq!(response.merged, 0);
        assert_eq!(response.deduplicated, 0);
    }

    #[test]
    fn test_accessors() {
        let config = HostedConfig {
            url: Some("https://episteme.acme.corp".to_string()),
            project_id: Some("my-project".to_string()),
            ..Default::default()
        };
        let key = generate_signing_key();
        let client =
            HostedClient::new(&config, &key, "fallback-project").expect("should not fail").unwrap();

        assert_eq!(client.base_url(), "https://episteme.acme.corp");
        assert_eq!(client.project_id(), "my-project");
    }
}
