//! Cron scheduler — the v1.1.4 time-based trigger source.
//!
//! A single tokio task polls `cron_trigger_details` on a tick (default
//! 30s; `PICLOUD_CRON_TICK_INTERVAL_MS`). For each enabled cron trigger
//! whose next scheduled fire is due, it enqueues ONE outbox row
//! (`source_kind = 'cron'`) and updates `last_fired_at` — both in the
//! same transaction, claimed via `FOR UPDATE SKIP LOCKED` so a future
//! multi-node deploy can't double-fire.
//!
//! The scheduler does NOT dispatch or touch the `ExecutionGate`: it only
//! enqueues. The existing dispatcher picks the row up and acquires the
//! gate exactly as it does for kv/docs/dead_letter rows.
//!
//! **Catch-up policy (matches the brief):** a trigger that missed N fire
//! windows since `last_fired_at` fires exactly ONCE on the next tick,
//! not N times. This falls out of the design: [`next_due`] returns a
//! single canonical scheduled time (the first slot after the reference
//! point), and after firing we set `last_fired_at = now`, so the next
//! tick computes from `now` and sees only future slots. Backfilling
//! missed windows is intentionally out of scope (an explicit replay
//! action is the v1.2+ escape hatch).

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

use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use cron::Schedule;
use picloud_shared::TriggerEvent;
use sqlx::PgPool;
use uuid::Uuid;

/// Validate a 6-field cron expression. Returns the parse error message
/// on failure.
///
/// # Errors
///
/// Returns the underlying parse error string when `schedule` is not a
/// valid cron expression.
pub fn validate_schedule(schedule: &str) -> Result<(), String> {
    Schedule::from_str(schedule)
        .map(|_| ())
        .map_err(|e| e.to_string())
}

/// Validate an IANA timezone name (e.g. `America/Los_Angeles`).
///
/// # Errors
///
/// Returns an error string when `timezone` is not a known IANA name.
pub fn validate_timezone(timezone: &str) -> Result<(), String> {
    Tz::from_str(timezone)
        .map(|_| ())
        .map_err(|_| format!("unknown IANA timezone: {timezone}"))
}

/// Compute whether a cron trigger is due, and if so its canonical
/// scheduled-at moment (UTC).
///
/// Returns `Some(scheduled_at)` when the first scheduled slot after the
/// reference point (`last_fired_at`, or `created_at` if never fired) is
/// at/before `now`; `None` otherwise. Returns `None` if the schedule or
/// timezone fails to parse (the row is skipped — it should never have
/// been inserted, since the admin endpoint validates).
#[must_use]
pub fn next_due(
    schedule: &str,
    timezone: &str,
    last_fired_at: Option<DateTime<Utc>>,
    created_at: DateTime<Utc>,
    now: DateTime<Utc>,
) -> Option<DateTime<Utc>> {
    let sched = Schedule::from_str(schedule).ok()?;
    let tz = Tz::from_str(timezone).ok()?;
    // Reference: the last actual fire, or creation if never fired. A
    // never-fired trigger fires at its first slot at/after creation.
    let base = last_fired_at.unwrap_or(created_at);
    let base_tz = base.with_timezone(&tz);
    let next = sched.after(&base_tz).next()?;
    let next_utc = next.with_timezone(&Utc);
    (next_utc <= now).then_some(next_utc)
}

/// Spawn the scheduler loop. Runs for the process lifetime.
pub fn spawn_cron_scheduler(pool: PgPool, tick_interval_ms: u32) {
    // Floor the tick at 1s so a misconfigured 0 can't spin.
    let interval = Duration::from_millis(u64::from(tick_interval_ms).max(1_000));
    tokio::spawn(async move {
        let mut ticker = tokio::time::interval(interval);
        // Skip the immediate first fire so we don't race startup.
        ticker.tick().await;
        loop {
            ticker.tick().await;
            if let Err(e) = tick(&pool, Utc::now()).await {
                tracing::warn!(?e, "cron scheduler tick errored");
            }
        }
    });
}

#[derive(sqlx::FromRow)]
struct DueRow {
    id: Uuid,
    app_id: Uuid,
    script_id: Uuid,
    registered_by_principal: Uuid,
    created_at: DateTime<Utc>,
    schedule: String,
    timezone: String,
    last_fired_at: Option<DateTime<Utc>>,
}

/// One scheduler tick: claim enabled cron rows, enqueue the due ones,
/// bump `last_fired_at`. Returns the number of triggers fired.
async fn tick(pool: &PgPool, now: DateTime<Utc>) -> Result<usize, sqlx::Error> {
    let mut tx = pool.begin().await?;
    let rows: Vec<DueRow> = sqlx::query_as(
        "SELECT t.id, t.app_id, t.script_id, t.registered_by_principal, t.created_at, \
                d.schedule, d.timezone, d.last_fired_at \
         FROM cron_trigger_details d \
         JOIN triggers t ON t.id = d.trigger_id \
         WHERE t.enabled = TRUE AND t.app_id IS NOT NULL \
         FOR UPDATE OF d SKIP LOCKED",
    )
    .fetch_all(&mut *tx)
    .await?;

    let mut fired = 0usize;
    for r in rows {
        let Some(scheduled_at) =
            next_due(&r.schedule, &r.timezone, r.last_fired_at, r.created_at, now)
        else {
            continue;
        };

        let event = TriggerEvent::Cron {
            schedule: r.schedule.clone(),
            timezone: r.timezone.clone(),
            scheduled_at,
            fired_at: now,
        };
        let payload = serde_json::to_value(&event)
            .map_err(|e| sqlx::Error::Decode(Box::new(std::io::Error::other(e))))?;

        // Enqueue exactly one outbox row. Relies on the same column
        // defaults the OutboxEventEmitter uses (next_attempt_at = NOW(),
        // attempt_count = 0, claimed_at NULL → immediately due).
        sqlx::query(
            "INSERT INTO outbox \
                (app_id, source_kind, trigger_id, script_id, payload, \
                 origin_principal, trigger_depth) \
             VALUES ($1, 'cron', $2, $3, $4, $5, 0)",
        )
        .bind(r.app_id)
        .bind(r.id)
        .bind(r.script_id)
        .bind(payload)
        .bind(r.registered_by_principal)
        .execute(&mut *tx)
        .await?;

        sqlx::query("UPDATE cron_trigger_details SET last_fired_at = $2 WHERE trigger_id = $1")
            .bind(r.id)
            .bind(now)
            .execute(&mut *tx)
            .await?;

        fired += 1;
    }

    tx.commit().await?;
    Ok(fired)
}

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

    #[test]
    fn valid_six_field_schedule_accepted() {
        // sec min hour dom mon dow — "every weekday at 9am".
        validate_schedule("0 0 9 * * MON-FRI").unwrap();
        validate_schedule("*/5 * * * * *").unwrap();
        validate_schedule("0 0 0 1 1 *").unwrap();
    }

    #[test]
    fn invalid_schedules_rejected() {
        // 5-field (no seconds) is not the format we accept.
        assert!(validate_schedule("* * * * *").is_err());
        // Gibberish.
        assert!(validate_schedule("not a cron").is_err());
        assert!(validate_schedule("").is_err());
        // Out-of-range hour.
        assert!(validate_schedule("0 0 99 * * *").is_err());
    }

    #[test]
    fn known_timezones_accepted() {
        validate_timezone("UTC").unwrap();
        validate_timezone("America/Los_Angeles").unwrap();
        validate_timezone("Europe/Berlin").unwrap();
    }

    #[test]
    fn unknown_timezones_rejected() {
        assert!(validate_timezone("Mars/Phobos").is_err());
        assert!(validate_timezone("PST").is_err()); // abbreviations aren't IANA names
        assert!(validate_timezone("").is_err());
    }

    fn ts(s: &str) -> DateTime<Utc> {
        DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
    }

    #[test]
    fn due_when_next_slot_is_at_or_before_now() {
        // Every minute at second 0. Last fired 90s ago → the next slot
        // after that is due now.
        let created = ts("2026-06-01T00:00:00Z");
        let last = Some(ts("2026-06-15T11:58:10Z"));
        let now = ts("2026-06-15T12:00:05Z");
        let due = next_due("0 * * * * *", "UTC", last, created, now);
        assert_eq!(due, Some(ts("2026-06-15T11:59:00Z")));
    }

    #[test]
    fn not_due_when_next_slot_is_in_the_future() {
        let created = ts("2026-06-01T00:00:00Z");
        let last = Some(ts("2026-06-15T12:00:00Z"));
        let now = ts("2026-06-15T12:00:30Z");
        // Next minute slot is 12:01:00 — still in the future.
        assert_eq!(next_due("0 * * * * *", "UTC", last, created, now), None);
    }

    #[test]
    fn never_fired_uses_created_at_as_reference() {
        let created = ts("2026-06-15T12:00:10Z");
        let now = ts("2026-06-15T12:01:30Z");
        // First slot after creation is 12:01:00, which is <= now → due.
        let due = next_due("0 * * * * *", "UTC", None, created, now);
        assert_eq!(due, Some(ts("2026-06-15T12:01:00Z")));
    }

    /// Catch-up policy: a trigger that missed many windows fires exactly
    /// ONCE. We simulate two consecutive scheduler ticks the way the DB
    /// loop does — fire once, set last_fired = now, then re-evaluate.
    #[test]
    fn catch_up_fires_exactly_once_after_missed_windows() {
        let created = ts("2026-06-15T09:00:00Z");
        // Last fired over 5 minutes (5 windows) ago.
        let mut last_fired = Some(ts("2026-06-15T11:54:30Z"));
        let now = ts("2026-06-15T12:00:05Z");

        // Tick 1: due → fire once, advance last_fired to `now`.
        let first = next_due("0 * * * * *", "UTC", last_fired, created, now);
        assert!(first.is_some(), "should be due after missing windows");
        last_fired = Some(now);

        // Tick 2 (same wall-clock): NOT due again — only one fire total,
        // not one-per-missed-window.
        let second = next_due("0 * * * * *", "UTC", last_fired, created, now);
        assert_eq!(second, None, "catch-up must fire exactly once");
    }

    #[test]
    fn timezone_affects_fire_time() {
        // "9am every day" in Los Angeles. On 2026-06-15, PDT = UTC-7, so
        // 09:00 local = 16:00 UTC.
        let created = ts("2026-06-15T00:00:00Z");
        let last = Some(ts("2026-06-15T15:59:00Z"));
        let now = ts("2026-06-15T16:00:30Z");
        let due = next_due("0 0 9 * * *", "America/Los_Angeles", last, created, now);
        assert_eq!(due, Some(ts("2026-06-15T16:00:00Z")));
        // Sanity: the same expression in UTC would NOT be due at 16:00.
        assert_eq!(next_due("0 0 9 * * *", "UTC", last, created, now), None);
    }

    #[test]
    fn bad_schedule_or_tz_yields_none() {
        let created = ts("2026-06-15T00:00:00Z");
        let now = ts("2026-06-15T12:00:00Z");
        assert_eq!(next_due("garbage", "UTC", None, created, now), None);
        assert_eq!(
            next_due("0 * * * * *", "Mars/Phobos", None, created, now),
            None
        );
    }

    #[test]
    fn utc_offset_constructor_smoke() {
        // Guard the chrono TimeZone import is actually exercised.
        let dt = Utc.with_ymd_and_hms(2026, 6, 15, 12, 0, 0).unwrap();
        assert_eq!(dt, ts("2026-06-15T12:00:00Z"));
    }
}
