//! Community extractor loader for cross-project learning.
//!
//! Handles pulling community extractors from the hosted server and saving
//! them to disk as YAML declarative extractors.

use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};

use tracing::{info, instrument, warn};

use crate::community::CommunityExtractor;
use crate::config::CrossProjectConfig;
use crate::error::AphoriaError;
use crate::hosted::HostedClient;

/// Default directory for community extractors.
const COMMUNITY_EXTRACTORS_DIR: &str = ".aphoria/extractors/community";

/// Loads community extractors from the hosted server.
///
/// Pulls extractors that have been aggregated from patterns across
/// many organizations and saves them as YAML files.
pub struct CommunityExtractorLoader<'a> {
    client: &'a HostedClient,
    #[allow(dead_code)] // Reserved for future filter logic
    config: &'a CrossProjectConfig,
    existing_names: HashSet<String>,
    output_dir: PathBuf,
}

impl<'a> CommunityExtractorLoader<'a> {
    /// Create a new loader with the default output directory.
    pub fn new(client: &'a HostedClient, config: &'a CrossProjectConfig) -> Self {
        Self::with_output_dir(client, config, PathBuf::from(COMMUNITY_EXTRACTORS_DIR))
    }

    /// Create a new loader with a custom output directory.
    pub fn with_output_dir(
        client: &'a HostedClient,
        config: &'a CrossProjectConfig,
        output_dir: PathBuf,
    ) -> Self {
        // Load existing extractor names from disk
        let existing_names = Self::load_existing_names(&output_dir);

        Self { client, config, existing_names, output_dir }
    }

    /// Load existing extractor names from the output directory.
    fn load_existing_names(dir: &Path) -> HashSet<String> {
        let mut names = HashSet::new();
        if let Ok(entries) = fs::read_dir(dir) {
            for entry in entries.flatten() {
                if let Some(name) = entry.path().file_stem() {
                    if let Some(name_str) = name.to_str() {
                        names.insert(name_str.to_string());
                    }
                }
            }
        }
        names
    }

    /// Get the last sync timestamp from disk.
    fn get_last_sync_timestamp(&self) -> Option<u64> {
        let path = self.output_dir.join(".last_sync");
        fs::read_to_string(&path).ok().and_then(|s| s.trim().parse::<u64>().ok())
    }

    /// Update the last sync timestamp on disk.
    fn update_last_sync_timestamp(&self) -> Result<(), AphoriaError> {
        let path = self.output_dir.join(".last_sync");

        // Ensure parent directory exists
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).map_err(|e| {
                AphoriaError::Io(std::io::Error::other(format!(
                    "Failed to create directory: {}",
                    e
                )))
            })?;
        }

        let timestamp = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);

        fs::write(&path, timestamp.to_string()).map_err(|e| {
            AphoriaError::Io(std::io::Error::other(format!(
                "Failed to write last sync timestamp: {}",
                e
            )))
        })
    }

    /// Pull new community extractors from the hosted server.
    ///
    /// Only returns extractors that we don't already have locally.
    #[instrument(skip(self), fields(project = %self.client.project_id()))]
    pub fn pull(&self, min_projects: u64) -> Result<Vec<CommunityExtractor>, AphoriaError> {
        let last_sync = self.get_last_sync_timestamp();
        let extractors = self.client.get_community_extractors(last_sync, min_projects)?;

        // Filter out extractors we already have
        let new_extractors: Vec<_> =
            extractors.into_iter().filter(|e| !self.existing_names.contains(&e.name)).collect();

        info!(
            total = new_extractors.len(),
            existing = self.existing_names.len(),
            "Pulled community extractors"
        );

        Ok(new_extractors)
    }

    /// Save community extractors to disk as YAML files.
    ///
    /// Returns the paths of the saved files.
    #[instrument(skip(self, extractors), fields(count = extractors.len()))]
    pub fn save(&self, extractors: &[CommunityExtractor]) -> Result<Vec<PathBuf>, AphoriaError> {
        if extractors.is_empty() {
            return Ok(vec![]);
        }

        // Create output directory if it doesn't exist
        fs::create_dir_all(&self.output_dir).map_err(|e| {
            AphoriaError::Io(std::io::Error::other(format!(
                "Failed to create extractors directory: {}",
                e
            )))
        })?;

        let mut paths = Vec::new();

        for extractor in extractors {
            let filename = format!("{}.yaml", sanitize_filename(&extractor.name));
            let path = self.output_dir.join(&filename);

            let yaml = self.to_yaml(extractor)?;

            // Atomic write: write to temp file, then rename
            let temp_path = path.with_extension("yaml.tmp");
            fs::write(&temp_path, &yaml).map_err(|e| {
                AphoriaError::Io(std::io::Error::other(format!(
                    "Failed to write extractor {}: {}",
                    extractor.name, e
                )))
            })?;
            fs::rename(&temp_path, &path).map_err(|e| {
                AphoriaError::Io(std::io::Error::other(format!(
                    "Failed to rename extractor {} temp file: {}",
                    extractor.name, e
                )))
            })?;

            info!(name = %extractor.name, path = %path.display(), "Saved community extractor");
            paths.push(path);
        }

        // Update sync timestamp
        self.update_last_sync_timestamp()?;

        Ok(paths)
    }

    /// Convert a CommunityExtractor to YAML format.
    fn to_yaml(&self, extractor: &CommunityExtractor) -> Result<String, AphoriaError> {
        let languages: String =
            extractor.languages.iter().map(|l| format!("  - {}", l)).collect::<Vec<_>>().join("\n");

        let yaml = format!(
            r#"# Community extractor: {}
# Provenance: {} orgs, {} projects, promoted {}
# Version: {}
#
# This extractor was generated from patterns observed across many organizations.
# It is safe to edit but will be overwritten on the next pull.

name: {}
description: "{}"
languages:
{}
pattern: '{}'
claim:
  subject: "{}"
  predicate: "{}"
  value_type: {}
  description: "{}"
confidence: {:.2}
"#,
            extractor.name,
            extractor.provenance.organization_count,
            extractor.provenance.total_project_count,
            format_timestamp(extractor.provenance.promoted_at),
            extractor.provenance.version,
            extractor.name,
            extractor.description.replace('"', "\\\""),
            languages,
            extractor.pattern.replace('\'', "''"),
            extractor.claim.subject.replace('"', "\\\""),
            extractor.claim.predicate.replace('"', "\\\""),
            extractor.claim.value_type,
            extractor.claim.description.replace('"', "\\\""),
            extractor.confidence,
        );

        Ok(yaml)
    }

    /// Get the output directory path.
    pub fn output_dir(&self) -> &Path {
        &self.output_dir
    }

    /// Get the count of existing extractors.
    pub fn existing_count(&self) -> usize {
        self.existing_names.len()
    }
}

/// Sanitize a string for use as a filename.
fn sanitize_filename(name: &str) -> String {
    name.chars()
        .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
        .collect()
}

/// Format a Unix timestamp as an ISO 8601 date.
fn format_timestamp(timestamp: u64) -> String {
    use chrono::{TimeZone, Utc};
    Utc.timestamp_opt(timestamp as i64, 0)
        .single()
        .map(|dt| dt.format("%Y-%m-%d").to_string())
        .unwrap_or_else(|| "unknown".to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::community::{CommunityClaimDef, CommunityExtractorProvenance};
    use tempfile::TempDir;

    fn create_test_extractor(name: &str) -> CommunityExtractor {
        CommunityExtractor {
            id: format!("ce-{}", name),
            name: name.to_string(),
            description: format!("Detects {} patterns", name),
            languages: vec!["rust".to_string(), "python".to_string()],
            pattern: r#"pattern_\d+"#.to_string(),
            claim: CommunityClaimDef {
                subject: format!("{}/config", name),
                predicate: "value".to_string(),
                value_type: "text".to_string(),
                description: "Test claim".to_string(),
            },
            confidence: 0.9,
            provenance: CommunityExtractorProvenance {
                organization_count: 10,
                total_project_count: 50,
                promoted_at: 1706832000,
                version: 1,
            },
        }
    }

    #[test]
    fn test_sanitize_filename() {
        assert_eq!(sanitize_filename("tls_version"), "tls_version");
        assert_eq!(sanitize_filename("tls-version"), "tls-version");
        assert_eq!(sanitize_filename("tls/version"), "tls_version");
        assert_eq!(sanitize_filename("tls version"), "tls_version");
        assert_eq!(sanitize_filename("tls.version"), "tls_version");
    }

    #[test]
    fn test_format_timestamp() {
        // 2024-02-01 00:00:00 UTC
        assert_eq!(format_timestamp(1706745600), "2024-02-01");
    }

    #[test]
    fn test_to_yaml() {
        // We can't create a real HostedClient, so we test the YAML generation directly
        let extractor = create_test_extractor("test_extractor");

        // Test the yaml generation logic inline
        let yaml = format!(
            r#"# Community extractor: {}
# Provenance: {} orgs, {} projects, promoted {}
# Version: {}
#
# This extractor was generated from patterns observed across many organizations.
# It is safe to edit but will be overwritten on the next pull.

name: {}
description: "{}"
languages:
  - rust
  - python
pattern: '{}'
claim:
  subject: "{}"
  predicate: "{}"
  value_type: {}
  description: "{}"
confidence: {:.2}
"#,
            extractor.name,
            extractor.provenance.organization_count,
            extractor.provenance.total_project_count,
            format_timestamp(extractor.provenance.promoted_at),
            extractor.provenance.version,
            extractor.name,
            extractor.description,
            extractor.pattern,
            extractor.claim.subject,
            extractor.claim.predicate,
            extractor.claim.value_type,
            extractor.claim.description,
            extractor.confidence,
        );

        assert!(yaml.contains("name: test_extractor"));
        assert!(yaml.contains("# Provenance: 10 orgs, 50 projects"));
        assert!(yaml.contains("confidence: 0.90"));
    }

    #[test]
    fn test_load_existing_names() {
        let temp_dir = TempDir::new().expect("create temp dir");

        // Create some fake extractor files
        fs::write(temp_dir.path().join("extractor1.yaml"), "").expect("write");
        fs::write(temp_dir.path().join("extractor2.yaml"), "").expect("write");
        fs::write(temp_dir.path().join("not_yaml.txt"), "").expect("write");

        let names = CommunityExtractorLoader::load_existing_names(temp_dir.path());

        assert!(names.contains("extractor1"));
        assert!(names.contains("extractor2"));
        assert!(names.contains("not_yaml")); // Still loads non-yaml files
        assert_eq!(names.len(), 3);
    }

    #[test]
    fn test_get_last_sync_timestamp() {
        let temp_dir = TempDir::new().expect("create temp dir");

        // Write a timestamp file
        fs::write(temp_dir.path().join(".last_sync"), "1706832000").expect("write");

        // Should return the timestamp
        let content = fs::read_to_string(temp_dir.path().join(".last_sync"))
            .ok()
            .and_then(|s| s.trim().parse::<u64>().ok());
        assert_eq!(content, Some(1706832000));
    }
}
