use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;

use chrono::{DateTime, Utc};
use tokio::sync::watch;
use tokio::time::MissedTickBehavior;
use tracing::{error, info, warn};

use crate::error::{Result, ScheduleError, WorkflowError};
use crate::metrics::WorkflowMetrics;
use crate::orchestrator::WorkflowOrchestrator;
use crate::schedule::{
    compute_next_fire_after_tz, compute_next_fire_at_tz, normalize_cron, RunStatus, ScheduleRun,
    ScheduledWorkflow,
};
use crate::schedule_store::ScheduleStore;

pub struct WorkflowScheduler {
    store: Arc<ScheduleStore>,
    orchestrator: Arc<WorkflowOrchestrator>,
    nats: Option<Arc<async_nats::Client>>,
    metrics: Arc<WorkflowMetrics>,
    tick_interval: Duration,
    shutdown: watch::Receiver<bool>,
    /// UUID identifying this process instance. Used as the lock owner in
    /// distributed deployments to prevent double-fires across instances.
    instance_id: String,
}

impl WorkflowScheduler {
    pub fn new(
        store: Arc<ScheduleStore>,
        orchestrator: Arc<WorkflowOrchestrator>,
        nats: Option<Arc<async_nats::Client>>,
        metrics: Arc<WorkflowMetrics>,
        shutdown: watch::Receiver<bool>,
    ) -> Self {
        Self {
            store,
            orchestrator,
            nats,
            metrics,
            tick_interval: Duration::from_secs(30),
            shutdown,
            instance_id: uuid::Uuid::new_v4().to_string(),
        }
    }

    /// Override the default 30-second tick interval (useful in tests).
    pub fn with_tick_interval(mut self, interval: Duration) -> Self {
        self.tick_interval = interval;
        self
    }

    /// Drive the scheduling loop until the shutdown signal fires.
    ///
    /// Call from a `tokio::spawn` — the returned future completes when
    /// `watch::Sender::send(true)` is called on the paired sender.
    pub async fn run(self: Arc<Self>) {
        let mut shutdown = self.shutdown.clone();
        let mut interval = tokio::time::interval(self.tick_interval);
        interval.set_missed_tick_behavior(MissedTickBehavior::Skip);

        info!(
            instance_id = %self.instance_id,
            tick = ?self.tick_interval,
            "WorkflowScheduler started"
        );

        loop {
            tokio::select! {
                changed = shutdown.changed() => {
                    if changed.is_ok() && *shutdown.borrow() {
                        info!(instance_id = %self.instance_id, "WorkflowScheduler shutting down");
                        break;
                    }
                }
                _ = interval.tick() => {
                    let now = Utc::now();
                    match self.store.load_enabled().await {
                        Ok(schedules) => {
                            for s in schedules.into_iter().filter(|s| s.is_due(now)) {
                                let scheduler = Arc::clone(&self);
                                let s = s.clone();
                                tokio::spawn(async move {
                                    if let Err(e) = scheduler.fire_schedule(&s, now).await {
                                        error!(
                                            schedule_id = %s.id,
                                            template    = %s.template_name,
                                            error       = %e,
                                            "Schedule fire error"
                                        );
                                    }
                                });
                            }
                        }
                        Err(e) => error!(error = %e, "Failed to load enabled schedules"),
                    }
                }
            }
        }
    }

    async fn fire_schedule(&self, s: &ScheduledWorkflow, now: DateTime<Utc>) -> Result<()> {
        // Acquire distributed lock — prevents double-fires in multi-instance
        // deployments. Lock has a 120-second TTL enforced by the store.
        let acquired = self
            .store
            .try_acquire_fire_lock(&s.id, &self.instance_id, &now)
            .await?;

        if !acquired {
            info!(
                schedule_id = %s.id,
                template    = %s.template_name,
                instance_id = %self.instance_id,
                "Fire lock held by another instance — skipping this tick"
            );
            self.metrics.schedules_skipped.inc();
            return Ok(());
        }

        let result = self.fire_with_lock(s, now).await;

        // Release the lock unconditionally. A failure here is non-fatal since
        // the TTL will expire automatically after 120 s.
        if let Err(e) = self.store.release_fire_lock(&s.id, &self.instance_id).await {
            warn!(
                schedule_id = %s.id,
                instance_id = %self.instance_id,
                error       = %e,
                "Failed to release schedule fire lock (TTL will expire automatically)"
            );
        }

        result
    }

    /// Execute a single schedule fire — called after the distributed lock is
    /// held.
    async fn fire_with_lock(&self, s: &ScheduledWorkflow, now: DateTime<Utc>) -> Result<()> {
        let tz = s.timezone.as_deref();

        let normalized = normalize_cron(&s.cron_expression);
        let cron_schedule = cron::Schedule::from_str(&normalized).map_err(|e| {
            WorkflowError::Schedule(ScheduleError::InvalidCron {
                expr: s.cron_expression.clone(),
                reason: e.to_string(),
            })
        })?;

        // Concurrency guard: skip if an active workflow for this template exists.
        // Now safe to check without races because we hold the distributed lock.
        if !s.allow_concurrent
            && self
                .orchestrator
                .has_active_workflow_for_template(&s.template_name)
        {
            let next_fire_at = compute_next_fire_at_tz(&s.cron_expression, tz);
            self.store
                .update_after_fire(&s.id, now, next_fire_at)
                .await?;
            self.store
                .record_run(&ScheduleRun {
                    id: uuid::Uuid::new_v4().to_string(),
                    schedule_id: s.id.clone(),
                    workflow_instance_id: None,
                    fired_at: now,
                    status: RunStatus::Skipped,
                    notes: Some("Active workflow for template already exists".to_string()),
                })
                .await?;
            self.metrics.schedules_skipped.inc();
            info!(
                schedule_id = %s.id,
                template    = %s.template_name,
                "Schedule skipped: concurrent run in progress"
            );
            return Ok(());
        }

        // Determine which time slots to fire — timezone-aware.
        let last = s.last_fired_at.unwrap_or(s.created_at);
        let fire_times = compute_fire_times_tz(&cron_schedule, last, now, s.catch_up, tz);

        let mut last_fire_time = now;
        for &fire_time in &fire_times {
            last_fire_time = fire_time;
            match self
                .orchestrator
                .start_workflow(&s.template_name, s.initial_context.clone())
                .await
            {
                Ok(workflow_id) => {
                    self.store
                        .record_run(&ScheduleRun {
                            id: uuid::Uuid::new_v4().to_string(),
                            schedule_id: s.id.clone(),
                            workflow_instance_id: Some(workflow_id.clone()),
                            fired_at: fire_time,
                            status: RunStatus::Fired,
                            notes: None,
                        })
                        .await?;
                    self.metrics.schedules_fired.inc();
                    info!(
                        schedule_id = %s.id,
                        template    = %s.template_name,
                        workflow_id = %workflow_id,
                        fired_at    = %fire_time,
                        timezone    = ?tz,
                        "Schedule fired"
                    );
                }
                Err(e) => {
                    error!(
                        schedule_id = %s.id,
                        template    = %s.template_name,
                        error       = %e,
                        "Workflow start failed for scheduled fire"
                    );
                    self.store
                        .record_run(&ScheduleRun {
                            id: uuid::Uuid::new_v4().to_string(),
                            schedule_id: s.id.clone(),
                            workflow_instance_id: None,
                            fired_at: fire_time,
                            status: RunStatus::Failed,
                            notes: Some(e.to_string()),
                        })
                        .await?;
                    self.metrics.schedules_failed.inc();
                }
            }
        }

        // Advance the pointer to the next scheduled time (timezone-aware).
        let next_fire_at = compute_next_fire_after_tz(&s.cron_expression, &last_fire_time, tz);
        self.store
            .update_after_fire(&s.id, last_fire_time, next_fire_at)
            .await?;

        // Publish NATS event — graceful skip when NATS is unavailable.
        if let Some(nats) = &self.nats {
            let event = serde_json::json!({
                "type":        "schedule_fired",
                "schedule_id": s.id,
                "template":    s.template_name,
                "timezone":    s.timezone,
                "fires":       fire_times.len(),
                "timestamp":   Utc::now().to_rfc3339(),
            });
            if let Err(e) = nats
                .publish("vapora.schedule.fired", event.to_string().into())
                .await
            {
                warn!(error = %e, "Failed to publish vapora.schedule.fired");
            }
        }

        Ok(())
    }
}

/// Compute which UTC time slots to fire, optionally in a named timezone.
///
/// - `catch_up = true`: all missed slots since `last`, capped at 10, converted
///   to UTC.
/// - `catch_up = false`: always exactly one slot (`now`), timezone ignored.
///
/// When `tz` is `Some` the cron iterator runs in that timezone so that
/// e.g. "every day at 09:00 America/New_York" fires at 14:00 UTC (or 13:00
/// during EDT).
#[cfg_attr(not(test), allow(dead_code))]
fn compute_fire_times_tz(
    schedule: &cron::Schedule,
    last: DateTime<Utc>,
    now: DateTime<Utc>,
    catch_up: bool,
    tz: Option<&str>,
) -> Vec<DateTime<Utc>> {
    if !catch_up {
        return vec![now];
    }

    let collected: Vec<DateTime<Utc>> = match tz.and_then(|s| s.parse::<chrono_tz::Tz>().ok()) {
        Some(tz) => schedule
            .after(&last.with_timezone(&tz))
            .take_while(|t| t.with_timezone(&Utc) <= now)
            .take(10)
            .map(|t| t.with_timezone(&Utc))
            .collect(),
        None => schedule
            .after(&last)
            .take_while(|t| t <= &now)
            .take(10)
            .collect(),
    };

    if collected.is_empty() {
        vec![now]
    } else {
        collected
    }
}

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

    #[test]
    fn test_normalize_cron_5field() {
        let result = normalize_cron("0 9 * * 1-5");
        assert_eq!(result, "0 0 9 * * 1-5 *");
    }

    #[test]
    fn test_normalize_cron_7field() {
        let expr = "0 0 9 * * 1-5 *";
        let result = normalize_cron(expr);
        assert_eq!(result, expr);
    }

    #[test]
    fn test_compute_fires_no_catchup() {
        let normalized = normalize_cron("0 0 * * *"); // Every day at midnight
        let schedule = cron::Schedule::from_str(&normalized).expect("valid cron");

        let now = Utc::now();
        let last = now - chrono::Duration::days(3);

        let fires = compute_fire_times_tz(&schedule, last, now, false, None);
        // Without catch_up: always exactly one fire at `now`.
        assert_eq!(fires.len(), 1);
        assert_eq!(fires[0], now);
    }

    #[test]
    fn test_compute_fires_with_catchup() {
        let normalized = normalize_cron("0 0 * * *"); // Every day at midnight
        let schedule = cron::Schedule::from_str(&normalized).expect("valid cron");

        // last fired 5 days ago → should produce up to 5 missed midnight slots.
        let now = Utc::now();
        let last = now - chrono::Duration::days(5);

        let fires = compute_fire_times_tz(&schedule, last, now, true, None);
        assert!(fires.len() > 1, "catch_up should produce multiple fires");
        assert!(fires.len() <= 10, "catch_up is capped at 10");
        for &t in &fires {
            assert!(t <= now, "catch_up fire time must not be in the future");
        }
    }

    #[test]
    fn test_compute_fires_with_catchup_named_tz() {
        let normalized = normalize_cron("0 0 * * *"); // Every day at midnight (in tz)
        let schedule = cron::Schedule::from_str(&normalized).expect("valid cron");

        let now = Utc::now();
        let last = now - chrono::Duration::days(3);

        let fires_utc = compute_fire_times_tz(&schedule, last, now, true, None);
        let fires_tz = compute_fire_times_tz(&schedule, last, now, true, Some("America/New_York"));

        // Both produce multiple slots. The UTC times differ because midnight NY
        // is 05:00 UTC (or 04:00 EDT) — so the same count but different instants.
        assert!(!fires_utc.is_empty());
        assert!(!fires_tz.is_empty());
        if fires_utc.len() == fires_tz.len() && fires_utc.len() > 0 {
            // Timestamps must differ (different timezone means different UTC offset).
            assert_ne!(
                fires_utc[0], fires_tz[0],
                "UTC and NY midnight must be different UTC instants"
            );
        }
    }

    #[test]
    fn test_instance_id_is_unique() {
        // Each WorkflowScheduler gets a distinct UUID to prevent lock collisions.
        let id1 = uuid::Uuid::new_v4().to_string();
        let id2 = uuid::Uuid::new_v4().to_string();
        assert_ne!(id1, id2);
    }
}
