gdelt 0.1.0

CLI for GDELT Project - optimized for agentic usage with local data caching
//! Analytics command handlers.

use crate::analytics::{
    compare::{compare_topics, CompareConfig},
    entities::{extract_entities, EntitiesConfig, EntityType},
    report::{generate_report, ReportConfig, ReportFormat},
    sentiment::{analyze_sentiment, SentimentConfig, SentimentDimension},
    trends::{analyze_trends, Granularity, TrendsConfig},
};
use crate::cli::args::{
    AnalyticsCommand, AnalyticsCompareArgs, AnalyticsEntitiesArgs, AnalyticsSentimentArgs,
    AnalyticsTrendsArgs, EntityTypeArg, GlobalArgs, Granularity as ArgGranularity,
    ReportArgs, ReportCommand, ReportCustomArgs, SentimentDimension as ArgSentimentDimension,
};
use crate::cli::output::OutputWriter;
use crate::db::AnalyticsDb;
use crate::error::{ExitStatus, GdeltError, Result};
use chrono::{Duration, Utc};

/// Handle analytics commands
pub async fn handle_analytics(cmd: AnalyticsCommand, global: &GlobalArgs) -> Result<ExitStatus> {
    match cmd {
        AnalyticsCommand::Trends(args) => handle_trends(args, global).await,
        AnalyticsCommand::Entities(args) => handle_entities(args, global).await,
        AnalyticsCommand::Sentiment(args) => handle_sentiment(args, global).await,
        AnalyticsCommand::Compare(args) => handle_compare(args, global).await,
        AnalyticsCommand::Report(cmd) => handle_report(cmd, global).await,
    }
}

async fn handle_trends(args: AnalyticsTrendsArgs, global: &GlobalArgs) -> Result<ExitStatus> {
    if args.topics.is_empty() {
        return Err(GdeltError::Validation("At least one topic is required".into()));
    }

    let db = AnalyticsDb::open()?;

    let (start_date, end_date) = parse_timespan(&args.timespan)?;

    let config = TrendsConfig {
        topics: args.topics,
        granularity: convert_granularity(args.granularity),
        start_date: Some(start_date),
        end_date: Some(end_date),
        normalize: args.normalize,
        detect_anomalies: args.detect_anomalies,
        ..Default::default()
    };

    let results = analyze_trends(&db, &config)?;

    let writer = OutputWriter::new(global);
    writer.write_value(&results)?;

    Ok(ExitStatus::Success)
}

async fn handle_entities(args: AnalyticsEntitiesArgs, global: &GlobalArgs) -> Result<ExitStatus> {
    let db = AnalyticsDb::open()?;

    let (start_date, end_date) = parse_timespan(&args.timespan)?;

    let config = EntitiesConfig {
        entity_type: convert_entity_type(args.entity_type),
        min_count: args.min_count,
        limit: args.limit,
        start_date: Some(start_date),
        end_date: Some(end_date),
    };

    let results = extract_entities(&db, &config)?;

    let writer = OutputWriter::new(global);
    writer.write_value(&results)?;

    Ok(ExitStatus::Success)
}

async fn handle_sentiment(args: AnalyticsSentimentArgs, global: &GlobalArgs) -> Result<ExitStatus> {
    let db = AnalyticsDb::open()?;

    let (start_date, end_date) = parse_timespan(&args.timespan)?;

    let config = SentimentConfig {
        topic: args.topic,
        dimension: convert_sentiment_dimension(args.dimension),
        start_date: Some(start_date),
        end_date: Some(end_date),
        granularity: convert_granularity(args.granularity),
        compare_topic: args.compare_with,
    };

    let result = analyze_sentiment(&db, &config)?;

    let writer = OutputWriter::new(global);
    writer.write_value(&result)?;

    Ok(ExitStatus::Success)
}

async fn handle_compare(args: AnalyticsCompareArgs, global: &GlobalArgs) -> Result<ExitStatus> {
    let db = AnalyticsDb::open()?;

    let (start_date, end_date) = parse_timespan(&args.timespan)?;

    let config = CompareConfig {
        topic_a: args.topic_a,
        topic_b: args.topic_b,
        start_date: Some(start_date),
        end_date: Some(end_date),
    };

    let result = compare_topics(&db, &config)?;

    let writer = OutputWriter::new(global);
    writer.write_value(&result)?;

    Ok(ExitStatus::Success)
}

async fn handle_report(cmd: ReportCommand, global: &GlobalArgs) -> Result<ExitStatus> {
    match cmd {
        ReportCommand::Daily(args) => handle_daily_report(args, global).await,
        ReportCommand::Weekly(args) => handle_weekly_report(args, global).await,
        ReportCommand::Custom(args) => handle_custom_report(args, global).await,
    }
}

async fn handle_daily_report(args: ReportArgs, global: &GlobalArgs) -> Result<ExitStatus> {
    let db = AnalyticsDb::open()?;

    let today = Utc::now().naive_utc().date();
    let yesterday = today - Duration::days(1);

    let config = ReportConfig {
        topics: args.topics,
        start_date: yesterday.format("%Y-%m-%d").to_string(),
        end_date: today.format("%Y-%m-%d").to_string(),
        output_path: args.output,
        format: ReportFormat::Json,
    };

    let report = generate_report(&db, &config)?;

    if let Some(ref path) = config.output_path {
        let content = match path.extension().and_then(|e| e.to_str()) {
            Some("md") => report.to_markdown(),
            Some("html") => report.to_html(),
            _ => serde_json::to_string_pretty(&report)?,
        };
        std::fs::write(path, content)?;
        eprintln!("Report written to: {}", path.display());
    } else {
        let writer = OutputWriter::new(global);
        writer.write_value(&report)?;
    }

    Ok(ExitStatus::Success)
}

async fn handle_weekly_report(args: ReportArgs, global: &GlobalArgs) -> Result<ExitStatus> {
    let db = AnalyticsDb::open()?;

    let today = Utc::now().naive_utc().date();
    let week_ago = today - Duration::days(7);

    let config = ReportConfig {
        topics: args.topics,
        start_date: week_ago.format("%Y-%m-%d").to_string(),
        end_date: today.format("%Y-%m-%d").to_string(),
        output_path: args.output,
        format: ReportFormat::Json,
    };

    let report = generate_report(&db, &config)?;

    if let Some(ref path) = config.output_path {
        let content = match path.extension().and_then(|e| e.to_str()) {
            Some("md") => report.to_markdown(),
            Some("html") => report.to_html(),
            _ => serde_json::to_string_pretty(&report)?,
        };
        std::fs::write(path, content)?;
        eprintln!("Report written to: {}", path.display());
    } else {
        let writer = OutputWriter::new(global);
        writer.write_value(&report)?;
    }

    Ok(ExitStatus::Success)
}

async fn handle_custom_report(args: ReportCustomArgs, global: &GlobalArgs) -> Result<ExitStatus> {
    let db = AnalyticsDb::open()?;

    let config = ReportConfig {
        topics: args.topics,
        start_date: args.start,
        end_date: args.end,
        output_path: args.output,
        format: ReportFormat::Json,
    };

    let report = generate_report(&db, &config)?;

    if let Some(ref path) = config.output_path {
        let content = match path.extension().and_then(|e| e.to_str()) {
            Some("md") => report.to_markdown(),
            Some("html") => report.to_html(),
            _ => serde_json::to_string_pretty(&report)?,
        };
        std::fs::write(path, content)?;
        eprintln!("Report written to: {}", path.display());
    } else {
        let writer = OutputWriter::new(global);
        writer.write_value(&report)?;
    }

    Ok(ExitStatus::Success)
}

/// Parse a timespan string (e.g., "24h", "7d", "30d") to start/end dates
fn parse_timespan(timespan: &str) -> Result<(String, String)> {
    let today = Utc::now().naive_utc().date();

    let duration = if timespan.ends_with('h') {
        let hours: i64 = timespan
            .trim_end_matches('h')
            .parse()
            .map_err(|_| GdeltError::Validation(format!("Invalid timespan: {}", timespan)))?;
        Duration::hours(hours)
    } else if timespan.ends_with('d') {
        let days: i64 = timespan
            .trim_end_matches('d')
            .parse()
            .map_err(|_| GdeltError::Validation(format!("Invalid timespan: {}", timespan)))?;
        Duration::days(days)
    } else if timespan.ends_with('w') {
        let weeks: i64 = timespan
            .trim_end_matches('w')
            .parse()
            .map_err(|_| GdeltError::Validation(format!("Invalid timespan: {}", timespan)))?;
        Duration::weeks(weeks)
    } else if timespan.ends_with('m') {
        let months: i64 = timespan
            .trim_end_matches('m')
            .parse()
            .map_err(|_| GdeltError::Validation(format!("Invalid timespan: {}", timespan)))?;
        Duration::days(months * 30) // Approximate
    } else {
        return Err(GdeltError::Validation(format!(
            "Invalid timespan format: {}. Use 'Nh', 'Nd', 'Nw', or 'Nm'",
            timespan
        )));
    };

    let start = today - duration;

    Ok((
        start.format("%Y-%m-%d").to_string(),
        today.format("%Y-%m-%d").to_string(),
    ))
}

fn convert_granularity(arg: ArgGranularity) -> Granularity {
    match arg {
        ArgGranularity::Hour => Granularity::Hour,
        ArgGranularity::Day => Granularity::Day,
        ArgGranularity::Week => Granularity::Week,
        ArgGranularity::Month => Granularity::Month,
    }
}

fn convert_entity_type(arg: EntityTypeArg) -> EntityType {
    match arg {
        EntityTypeArg::Person => EntityType::Person,
        EntityTypeArg::Organization => EntityType::Organization,
        EntityTypeArg::Location => EntityType::Location,
        EntityTypeArg::Actor => EntityType::Actor,
        EntityTypeArg::Theme => EntityType::Theme,
        EntityTypeArg::All => EntityType::All,
    }
}

fn convert_sentiment_dimension(arg: ArgSentimentDimension) -> SentimentDimension {
    match arg {
        ArgSentimentDimension::Time => SentimentDimension::Time,
        ArgSentimentDimension::Region => SentimentDimension::Region,
        ArgSentimentDimension::Source => SentimentDimension::Source,
        ArgSentimentDimension::Entity => SentimentDimension::Entity,
    }
}