use std::str::FromStr;

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::config::ScheduleConfig;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum RunStatus {
    Fired,
    Skipped,
    Failed,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduleRun {
    pub id: String,
    pub schedule_id: String,
    pub workflow_instance_id: Option<String>,
    pub fired_at: DateTime<Utc>,
    pub status: RunStatus,
    pub notes: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduledWorkflow {
    pub id: String,
    pub template_name: String,
    pub cron_expression: String,
    pub initial_context: serde_json::Value,
    pub enabled: bool,
    pub allow_concurrent: bool,
    pub catch_up: bool,
    /// IANA timezone identifier for cron evaluation (e.g. "America/New_York").
    /// When `None`, UTC is used.
    #[serde(default)]
    pub timezone: Option<String>,
    pub last_fired_at: Option<DateTime<Utc>>,
    pub next_fire_at: Option<DateTime<Utc>>,
    pub runs_count: u64,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

impl ScheduledWorkflow {
    /// Build from TOML config, deriving a stable ID from the template name
    /// so repeated calls to `store.upsert` are idempotent.
    pub fn from_config(template_name: &str, sc: &ScheduleConfig) -> Self {
        let next_fire_at = compute_next_fire_at_tz(&sc.cron, sc.timezone.as_deref());

        Self {
            id: template_name.to_string(),
            template_name: template_name.to_string(),
            cron_expression: sc.cron.clone(),
            initial_context: sc
                .initial_context
                .clone()
                .unwrap_or(serde_json::Value::Object(Default::default())),
            enabled: true,
            allow_concurrent: sc.allow_concurrent,
            catch_up: sc.catch_up,
            timezone: sc.timezone.clone(),
            last_fired_at: None,
            next_fire_at,
            runs_count: 0,
            created_at: Utc::now(),
            updated_at: Utc::now(),
        }
    }

    /// True when the scheduled fire time has arrived or passed.
    pub fn is_due(&self, now: DateTime<Utc>) -> bool {
        self.next_fire_at.is_some_and(|t| t <= now)
    }
}

/// Normalise a cron expression to the 7-field format required by the `cron`
/// crate (`sec min hour dom month dow year`).
///
/// - 5-field standard shell cron (`min hour dom month dow`) → prepend `0`
///   (seconds) and append `*` (any year).
/// - 6-field (`sec min hour dom month dow`) → append `*` (any year).
/// - 7-field → unchanged.
pub(crate) fn normalize_cron(expr: &str) -> String {
    match expr.split_whitespace().count() {
        5 => format!("0 {} *", expr),
        6 => format!("{} *", expr),
        _ => expr.to_string(),
    }
}

/// Validate a cron expression (5-field, 6-field, or 7-field).
///
/// Returns `Ok(())` if parseable, `Err(description)` otherwise.
pub fn validate_cron_expression(expr: &str) -> Result<(), String> {
    let normalized = normalize_cron(expr);
    cron::Schedule::from_str(&normalized)
        .map(|_| ())
        .map_err(|e| e.to_string())
}

/// Validate an IANA timezone string (e.g. `"America/New_York"`).
///
/// Returns `Ok(())` when recognised, `Err(description)` otherwise.
pub fn validate_timezone(tz: &str) -> Result<(), String> {
    tz.parse::<chrono_tz::Tz>()
        .map(|_| ())
        .map_err(|_| format!("'{}' is not a valid IANA timezone identifier", tz))
}

/// Compute the next scheduled fire time after UTC now, evaluated in `tz`.
///
/// Returns `None` if the expression is invalid or has no future occurrences.
/// When `tz` is `None`, UTC is used.
pub fn compute_next_fire_at_tz(expr: &str, tz: Option<&str>) -> Option<DateTime<Utc>> {
    let normalized = normalize_cron(expr);
    let schedule = cron::Schedule::from_str(&normalized).ok()?;
    match tz.and_then(|s| s.parse::<chrono_tz::Tz>().ok()) {
        Some(tz) => schedule.upcoming(tz).next().map(|t| t.with_timezone(&Utc)),
        None => schedule.upcoming(Utc).next(),
    }
}

/// Compute the next scheduled fire time after `after`, evaluated in `tz`.
///
/// When `tz` is `None`, UTC is used.
pub fn compute_next_fire_after_tz(
    expr: &str,
    after: &DateTime<Utc>,
    tz: Option<&str>,
) -> Option<DateTime<Utc>> {
    let normalized = normalize_cron(expr);
    let schedule = cron::Schedule::from_str(&normalized).ok()?;
    match tz.and_then(|s| s.parse::<chrono_tz::Tz>().ok()) {
        Some(tz) => schedule
            .after(&after.with_timezone(&tz))
            .next()
            .map(|t| t.with_timezone(&Utc)),
        None => schedule.after(after).next(),
    }
}

/// UTC convenience wrapper — delegates to `compute_next_fire_at_tz` with no
/// timezone.
pub fn compute_next_fire_at(expr: &str) -> Option<DateTime<Utc>> {
    compute_next_fire_at_tz(expr, None)
}

/// UTC convenience wrapper — delegates to `compute_next_fire_after_tz` with no
/// timezone.
pub fn compute_next_fire_after(expr: &str, after: &DateTime<Utc>) -> Option<DateTime<Utc>> {
    compute_next_fire_after_tz(expr, after, None)
}

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

    #[test]
    fn test_run_status_serde() {
        for status in [RunStatus::Fired, RunStatus::Skipped, RunStatus::Failed] {
            let json = serde_json::to_string(&status).unwrap();
            let round_tripped: RunStatus = serde_json::from_str(&json).unwrap();
            assert_eq!(status, round_tripped);
        }
    }

    #[test]
    fn test_schedule_is_due() {
        let now = Utc::now();
        let past = now - chrono::Duration::seconds(60);
        let future = now + chrono::Duration::seconds(60);

        let mut s = ScheduledWorkflow {
            id: "test".to_string(),
            template_name: "test".to_string(),
            cron_expression: "0 * * * *".to_string(),
            initial_context: serde_json::json!({}),
            enabled: true,
            allow_concurrent: false,
            catch_up: false,
            timezone: None,
            last_fired_at: None,
            next_fire_at: None,
            runs_count: 0,
            created_at: now,
            updated_at: now,
        };

        assert!(!s.is_due(now), "None next_fire_at should not be due");

        s.next_fire_at = Some(past);
        assert!(s.is_due(now), "Past next_fire_at should be due");

        s.next_fire_at = Some(now);
        assert!(s.is_due(now), "Exact now should be due");

        s.next_fire_at = Some(future);
        assert!(!s.is_due(now), "Future next_fire_at should not be due");
    }

    #[test]
    fn test_validate_timezone_valid() {
        assert!(validate_timezone("America/New_York").is_ok());
        assert!(validate_timezone("Europe/London").is_ok());
        assert!(validate_timezone("Asia/Tokyo").is_ok());
        assert!(validate_timezone("UTC").is_ok());
    }

    #[test]
    fn test_validate_timezone_invalid() {
        assert!(validate_timezone("Not/ATimezone").is_err());
        assert!(validate_timezone("New York").is_err());
        assert!(validate_timezone("").is_err());
    }

    #[test]
    fn test_compute_next_fire_at_tz_utc() {
        // UTC fallback: any future result is valid
        let result = compute_next_fire_at_tz("0 9 * * *", None);
        assert!(result.is_some(), "should produce a future time");
        assert!(result.unwrap() > Utc::now());
    }

    #[test]
    fn test_compute_next_fire_at_tz_named() {
        let result_tz = compute_next_fire_at_tz("0 9 * * *", Some("America/New_York"));
        let result_utc = compute_next_fire_at_tz("0 9 * * *", None);
        // Both must be Some and in the future.
        assert!(result_tz.is_some());
        assert!(result_utc.is_some());
        // They represent 09:00 in different timezones, so they must differ
        // (EST = UTC-5, EDT = UTC-4; New_York 09:00 != UTC 09:00).
        assert_ne!(
            result_tz.unwrap(),
            result_utc.unwrap(),
            "timezone-aware result must differ from UTC result"
        );
    }

    #[test]
    fn test_compute_next_fire_at_tz_invalid_tz_fallback() {
        // Unknown timezone is silently treated as UTC (parse fails, match hits None
        // arm).
        let result = compute_next_fire_at_tz("0 9 * * *", Some("Invalid/Zone"));
        assert!(result.is_some(), "falls back to UTC on unknown timezone");
    }
}
