//! Deferred agent start via one-shot timers.
//!
//! Provides [`TimerStore`] for persisting timers to `.storkit/timers.json`,
//! a 30-second tick loop ([`spawn_timer_tick_loop`]) that fires due timers,
//! and command parsing / handling for the `timer` bot command.

use chrono::{DateTime, Duration, Local, NaiveTime, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};

use crate::chat::util::strip_bot_mention;

// ── Data types ─────────────────────────────────────────────────────────────

/// A single scheduled timer entry.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TimerEntry {
    /// The full story ID (filename stem, e.g. `421_story_foo`).
    pub story_id: String,
    /// UTC instant at which the timer should fire.
    pub scheduled_at: DateTime<Utc>,
}

// ── TimerStore ─────────────────────────────────────────────────────────────

/// Persistent store for pending timers, backed by a JSON file.
pub struct TimerStore {
    path: PathBuf,
    timers: Mutex<Vec<TimerEntry>>,
}

impl TimerStore {
    /// Load the timer store from `path`.  Returns an empty store if the file
    /// does not exist or cannot be parsed.
    pub fn load(path: PathBuf) -> Self {
        let timers = if path.exists() {
            std::fs::read_to_string(&path)
                .ok()
                .and_then(|s| serde_json::from_str::<Vec<TimerEntry>>(&s).ok())
                .unwrap_or_default()
        } else {
            Vec::new()
        };
        Self {
            path,
            timers: Mutex::new(timers),
        }
    }

    fn save_locked(path: &Path, timers: &[TimerEntry]) -> Result<(), String> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)
                .map_err(|e| format!("Failed to create directory: {e}"))?;
        }
        let content =
            serde_json::to_string_pretty(timers).map_err(|e| format!("Serialize failed: {e}"))?;
        std::fs::write(path, content).map_err(|e| format!("Failed to write timers: {e}"))
    }

    /// Add a timer and persist to disk.
    pub fn add(&self, story_id: String, scheduled_at: DateTime<Utc>) -> Result<(), String> {
        let mut timers = self.timers.lock().unwrap();
        timers.push(TimerEntry {
            story_id,
            scheduled_at,
        });
        Self::save_locked(&self.path, &timers)
    }

    /// Remove the timer for `story_id`.  Returns `true` if one was removed.
    pub fn remove(&self, story_id: &str) -> bool {
        let mut timers = self.timers.lock().unwrap();
        let before = timers.len();
        timers.retain(|t| t.story_id != story_id);
        let removed = timers.len() < before;
        if removed {
            let _ = Self::save_locked(&self.path, &timers);
        }
        removed
    }

    /// Return all pending timers (cloned).
    pub fn list(&self) -> Vec<TimerEntry> {
        self.timers.lock().unwrap().clone()
    }

    /// Add or update a timer for `story_id`.
    ///
    /// - If no timer exists for `story_id`, adds it.
    /// - If a timer already exists and `scheduled_at` is **later**, updates it.
    /// - If a timer already exists and `scheduled_at` is earlier or equal, no-op.
    ///
    /// Use this instead of [`add`] when auto-scheduling from rate-limit events to
    /// avoid creating duplicates and to always keep the latest reset time.
    pub fn upsert(&self, story_id: String, scheduled_at: DateTime<Utc>) -> Result<(), String> {
        let mut timers = self.timers.lock().unwrap();
        if let Some(existing) = timers.iter_mut().find(|t| t.story_id == story_id) {
            if scheduled_at > existing.scheduled_at {
                existing.scheduled_at = scheduled_at;
                Self::save_locked(&self.path, &timers)?;
            }
        } else {
            timers.push(TimerEntry {
                story_id,
                scheduled_at,
            });
            Self::save_locked(&self.path, &timers)?;
        }
        Ok(())
    }

    /// Remove and return all timers whose `scheduled_at` is ≤ `now`.
    /// Persists the updated list to disk if any timers were removed.
    pub fn take_due(&self, now: DateTime<Utc>) -> Vec<TimerEntry> {
        let mut timers = self.timers.lock().unwrap();
        let mut due = Vec::new();
        let mut remaining = Vec::new();
        for t in timers.drain(..) {
            if t.scheduled_at <= now {
                due.push(t);
            } else {
                remaining.push(t);
            }
        }
        *timers = remaining;
        if !due.is_empty() {
            let _ = Self::save_locked(&self.path, &timers);
        }
        due
    }
}

// ── Tick loop ──────────────────────────────────────────────────────────────

/// Spawn a background tokio task that fires due timers every 30 seconds.
///
/// Same pattern as the watchdog in `agents::pool::auto_assign`.
/// When a timer fires, `start_agent` is called for the story.  If all coders
/// are busy the story remains in `2_current/` and auto-assign will pick it up.
pub fn spawn_timer_tick_loop(
    store: Arc<TimerStore>,
    agents: Arc<crate::agents::AgentPool>,
    project_root: PathBuf,
) {
    tokio::spawn(async move {
        let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
        loop {
            interval.tick().await;
            let now = Utc::now();
            let due = store.take_due(now);
            for entry in due {
                crate::slog!(
                    "[timer] Timer fired for story {}; calling start_agent",
                    entry.story_id
                );
                match agents
                    .start_agent(&project_root, &entry.story_id, None, None)
                    .await
                {
                    Ok(info) => {
                        crate::slog!(
                            "[timer] Started agent {} for story {}",
                            info.agent_name,
                            entry.story_id
                        );
                    }
                    Err(e) => {
                        crate::slog!(
                            "[timer] Failed to start agent for story {}: {e}",
                            entry.story_id
                        );
                    }
                }
            }
        }
    });
}

/// Spawn a background task that listens for [`WatcherEvent::RateLimitHardBlock`]
/// events and auto-schedules a timer for the blocked story.
///
/// If a timer already exists for the story, it is updated to the later reset time
/// rather than creating a duplicate (via [`TimerStore::upsert`]).
pub fn spawn_rate_limit_auto_scheduler(
    store: Arc<TimerStore>,
    mut watcher_rx: tokio::sync::broadcast::Receiver<crate::io::watcher::WatcherEvent>,
) {
    tokio::spawn(async move {
        loop {
            match watcher_rx.recv().await {
                Ok(crate::io::watcher::WatcherEvent::RateLimitHardBlock {
                    story_id,
                    agent_name,
                    reset_at,
                }) => {
                    crate::slog!(
                        "[timer] Auto-scheduling timer for story {story_id} \
                         (agent {agent_name}) to resume at {reset_at}"
                    );
                    match store.upsert(story_id.clone(), reset_at) {
                        Ok(()) => {
                            crate::slog!(
                                "[timer] Timer upserted for story {story_id}; \
                                 scheduled at {reset_at}"
                            );
                        }
                        Err(e) => {
                            crate::slog!(
                                "[timer] Failed to upsert timer for story {story_id}: {e}"
                            );
                        }
                    }
                }
                Ok(_) => {}
                Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
                    crate::slog!(
                        "[timer] Rate-limit auto-scheduler lagged, skipped {n} events"
                    );
                }
                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
                    crate::slog!(
                        "[timer] Watcher channel closed, stopping rate-limit auto-scheduler"
                    );
                    break;
                }
            }
        }
    });
}

// ── Command types ──────────────────────────────────────────────────────────

/// A parsed `timer` command.
#[derive(Debug, PartialEq)]
pub enum TimerCommand {
    /// `timer <story_id_or_number> <HH:MM>` — schedule a deferred start.
    Schedule {
        story_number_or_id: String,
        hhmm: String,
    },
    /// `timer list` — list all pending timers.
    List,
    /// `timer cancel <story_id_or_number>` — remove a pending timer.
    Cancel { story_number_or_id: String },
    /// Malformed arguments.
    BadArgs,
}

// ── Command extraction ─────────────────────────────────────────────────────

/// Parse a `timer` command from a raw message body.
///
/// Strips the bot mention prefix and matches the `timer` keyword.
/// Returns `None` when the message is not a timer command at all.
pub fn extract_timer_command(
    message: &str,
    bot_name: &str,
    bot_user_id: &str,
) -> Option<TimerCommand> {
    let stripped = strip_bot_mention(message, bot_name, bot_user_id);
    let trimmed = stripped
        .trim()
        .trim_start_matches(|c: char| !c.is_alphanumeric());

    let (cmd, args) = match trimmed.split_once(char::is_whitespace) {
        Some((c, a)) => (c, a.trim()),
        None => (trimmed, ""),
    };

    if !cmd.eq_ignore_ascii_case("timer") {
        return None;
    }

    // `timer` with no args or `timer list`
    if args.is_empty() || args.eq_ignore_ascii_case("list") {
        return Some(TimerCommand::List);
    }

    let (sub, rest) = match args.split_once(char::is_whitespace) {
        Some((s, r)) => (s, r.trim()),
        None => (args, ""),
    };

    // `timer cancel <id>`
    if sub.eq_ignore_ascii_case("cancel") {
        if rest.is_empty() {
            return Some(TimerCommand::BadArgs);
        }
        return Some(TimerCommand::Cancel {
            story_number_or_id: rest.to_string(),
        });
    }

    // `timer <id> <HH:MM>`
    if rest.is_empty() {
        return Some(TimerCommand::BadArgs);
    }

    Some(TimerCommand::Schedule {
        story_number_or_id: sub.to_string(),
        hhmm: rest.to_string(),
    })
}

// ── Command handler ────────────────────────────────────────────────────────

/// Handle a parsed `timer` command.  Returns a markdown-formatted response.
pub async fn handle_timer_command(
    cmd: TimerCommand,
    store: &TimerStore,
    project_root: &Path,
) -> String {
    match cmd {
        TimerCommand::Schedule {
            story_number_or_id,
            hhmm,
        } => {
            let story_id = match resolve_story_id(&story_number_or_id, project_root) {
                Some(id) => id,
                None => {
                    return format!(
                        "No story with number or ID **{story_number_or_id}** found."
                    );
                }
            };

            // The story must already be in 2_current/ — the timer does not move stories.
            let current_dir = project_root.join(".storkit").join("work").join("2_current");
            let story_file = current_dir.join(format!("{story_id}.md"));
            if !story_file.exists() {
                return format!(
                    "Story **{story_id}** is not in `work/2_current/`. \
                     Move it to current before scheduling a timer."
                );
            }

            let scheduled_at = match next_occurrence_of_hhmm(&hhmm) {
                Some(t) => t,
                None => {
                    return format!(
                        "Invalid time **{hhmm}**. Use `HH:MM` format (e.g. `14:30`)."
                    );
                }
            };

            match store.add(story_id.clone(), scheduled_at) {
                Ok(()) => {
                    let local_time = scheduled_at.with_timezone(&Local);
                    format!(
                        "Timer set for **{story_id}** at **{}** (server local time).",
                        local_time.format("%Y-%m-%d %H:%M")
                    )
                }
                Err(e) => format!("Failed to save timer: {e}"),
            }
        }
        TimerCommand::List => {
            let timers = store.list();
            if timers.is_empty() {
                return "No pending timers.".to_string();
            }
            let mut lines = vec!["**Pending timers:**".to_string()];
            for t in &timers {
                let local_time = t.scheduled_at.with_timezone(&Local);
                lines.push(format!(
                    "- **{}** → {}",
                    t.story_id,
                    local_time.format("%Y-%m-%d %H:%M")
                ));
            }
            lines.join("\n")
        }
        TimerCommand::Cancel { story_number_or_id } => {
            let story_id = resolve_story_id(&story_number_or_id, project_root)
                .unwrap_or(story_number_or_id.clone());
            if store.remove(&story_id) {
                format!("Timer for **{story_id}** cancelled.")
            } else {
                format!("No timer found for **{story_id}**.")
            }
        }
        TimerCommand::BadArgs => {
            "Usage:\n\
             - `timer <story_id> <HH:MM>` — schedule deferred start\n\
             - `timer list` — show pending timers\n\
             - `timer cancel <story_id>` — remove a timer"
                .to_string()
        }
    }
}

// ── Helpers ────────────────────────────────────────────────────────────────

/// Parse `HH:MM` and return the next UTC instant at which the server-local
/// clock will read that time.  If the time has already passed today, returns
/// tomorrow's occurrence.
pub fn next_occurrence_of_hhmm(hhmm: &str) -> Option<DateTime<Utc>> {
    let (hh, mm) = hhmm.split_once(':')?;
    let hours: u32 = hh.parse().ok()?;
    let minutes: u32 = mm.parse().ok()?;
    if hours > 23 || minutes > 59 {
        return None;
    }
    let target_time = NaiveTime::from_hms_opt(hours, minutes, 0)?;
    let now_local = Local::now();
    let today = now_local.date_naive();
    let candidate = today.and_time(target_time);
    let candidate_local = Local.from_local_datetime(&candidate).single()?;
    if candidate_local > now_local {
        Some(candidate_local.to_utc())
    } else {
        let tomorrow = today + Duration::days(1);
        let tomorrow_candidate = tomorrow.and_time(target_time);
        let tomorrow_local = Local.from_local_datetime(&tomorrow_candidate).single()?;
        Some(tomorrow_local.to_utc())
    }
}

/// Resolve a story ID from a numeric story number or a full ID string.
///
/// Searches all pipeline stages.  Returns `None` only when the input is
/// numeric but no matching file is found.
fn resolve_story_id(number_or_id: &str, project_root: &Path) -> Option<String> {
    const STAGES: &[&str] = &[
        "1_backlog",
        "2_current",
        "3_qa",
        "4_merge",
        "5_done",
        "6_archived",
    ];

    // Full ID (contains underscores) — return as-is; validation happens at file-check time.
    if number_or_id.contains('_') {
        return Some(number_or_id.to_string());
    }

    // Numeric lookup.
    if !number_or_id.chars().all(|c| c.is_ascii_digit()) {
        return None;
    }

    for stage in STAGES {
        let dir = project_root.join(".storkit").join("work").join(stage);
        if !dir.exists() {
            continue;
        }
        if let Ok(entries) = std::fs::read_dir(&dir) {
            for entry in entries.flatten() {
                let path = entry.path();
                if path.extension().and_then(|e| e.to_str()) != Some("md") {
                    continue;
                }
                if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
                    let file_num = stem
                        .split('_')
                        .next()
                        .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
                        .unwrap_or("");
                    if file_num == number_or_id {
                        return Some(stem.to_string());
                    }
                }
            }
        }
    }
    None
}

// ── Tests ──────────────────────────────────────────────────────────────────

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

    // ── next_occurrence_of_hhmm ─────────────────────────────────────────

    #[test]
    fn valid_hhmm_returns_some() {
        let result = next_occurrence_of_hhmm("14:30");
        assert!(result.is_some(), "valid HH:MM should return Some");
    }

    #[test]
    fn invalid_hhmm_missing_colon_returns_none() {
        assert!(next_occurrence_of_hhmm("1430").is_none());
    }

    #[test]
    fn invalid_hhmm_bad_hours_returns_none() {
        assert!(next_occurrence_of_hhmm("25:00").is_none());
    }

    #[test]
    fn invalid_hhmm_bad_minutes_returns_none() {
        assert!(next_occurrence_of_hhmm("12:60").is_none());
    }

    #[test]
    fn next_occurrence_is_in_the_future() {
        let result = next_occurrence_of_hhmm("14:30").unwrap();
        assert!(result > Utc::now(), "next occurrence must be in the future");
    }

    // ── extract_timer_command ───────────────────────────────────────────

    #[test]
    fn non_timer_command_returns_none() {
        assert!(extract_timer_command("Timmy help", "Timmy", "@bot:home").is_none());
    }

    #[test]
    fn timer_list_no_args() {
        assert_eq!(
            extract_timer_command("Timmy timer", "Timmy", "@bot:home"),
            Some(TimerCommand::List)
        );
    }

    #[test]
    fn timer_list_explicit() {
        assert_eq!(
            extract_timer_command("Timmy timer list", "Timmy", "@bot:home"),
            Some(TimerCommand::List)
        );
    }

    #[test]
    fn timer_cancel_story_id() {
        assert_eq!(
            extract_timer_command(
                "Timmy timer cancel 421_story_foo",
                "Timmy",
                "@bot:home"
            ),
            Some(TimerCommand::Cancel {
                story_number_or_id: "421_story_foo".to_string()
            })
        );
    }

    #[test]
    fn timer_cancel_no_arg_is_bad_args() {
        assert_eq!(
            extract_timer_command("Timmy timer cancel", "Timmy", "@bot:home"),
            Some(TimerCommand::BadArgs)
        );
    }

    #[test]
    fn timer_schedule_with_story_id() {
        assert_eq!(
            extract_timer_command(
                "Timmy timer 421_story_foo 14:30",
                "Timmy",
                "@bot:home"
            ),
            Some(TimerCommand::Schedule {
                story_number_or_id: "421_story_foo".to_string(),
                hhmm: "14:30".to_string(),
            })
        );
    }

    #[test]
    fn timer_schedule_with_number() {
        assert_eq!(
            extract_timer_command("Timmy timer 421 14:30", "Timmy", "@bot:home"),
            Some(TimerCommand::Schedule {
                story_number_or_id: "421".to_string(),
                hhmm: "14:30".to_string(),
            })
        );
    }

    #[test]
    fn timer_schedule_missing_time_is_bad_args() {
        assert_eq!(
            extract_timer_command(
                "Timmy timer 421_story_foo",
                "Timmy",
                "@bot:home"
            ),
            Some(TimerCommand::BadArgs)
        );
    }

    // ── TimerStore ──────────────────────────────────────────────────────

    #[test]
    fn timer_store_empty_on_missing_file() {
        let dir = TempDir::new().unwrap();
        let store = TimerStore::load(dir.path().join("timers.json"));
        assert!(store.list().is_empty());
    }

    #[test]
    fn timer_store_add_and_list() {
        let dir = TempDir::new().unwrap();
        let store = TimerStore::load(dir.path().join("timers.json"));
        let t = Utc::now() + Duration::hours(1);
        store.add("story_1".to_string(), t).unwrap();
        let list = store.list();
        assert_eq!(list.len(), 1);
        assert_eq!(list[0].story_id, "story_1");
    }

    #[test]
    fn timer_store_remove() {
        let dir = TempDir::new().unwrap();
        let store = TimerStore::load(dir.path().join("timers.json"));
        let t = Utc::now() + Duration::hours(1);
        store.add("story_1".to_string(), t).unwrap();
        assert!(store.remove("story_1"));
        assert!(!store.remove("story_1")); // already gone
        assert!(store.list().is_empty());
    }

    #[test]
    fn timer_store_persists_and_reloads() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("timers.json");
        let t = Utc::now() + Duration::hours(2);
        {
            let store = TimerStore::load(path.clone());
            store.add("421_story_foo".to_string(), t).unwrap();
        }
        // Reload from disk.
        let store2 = TimerStore::load(path);
        let list = store2.list();
        assert_eq!(list.len(), 1);
        assert_eq!(list[0].story_id, "421_story_foo");
    }

    #[test]
    fn take_due_returns_only_past_entries() {
        let dir = TempDir::new().unwrap();
        let store = TimerStore::load(dir.path().join("timers.json"));
        let past = Utc::now() - Duration::minutes(1);
        let future = Utc::now() + Duration::hours(1);
        store.add("past_story".to_string(), past).unwrap();
        store.add("future_story".to_string(), future).unwrap();

        let due = store.take_due(Utc::now());
        assert_eq!(due.len(), 1);
        assert_eq!(due[0].story_id, "past_story");
        assert_eq!(store.list().len(), 1);
        assert_eq!(store.list()[0].story_id, "future_story");
    }

    // ── AC3: upsert ─────────────────────────────────────────────────────

    #[test]
    fn upsert_adds_new_timer_when_none_exists() {
        let dir = TempDir::new().unwrap();
        let store = TimerStore::load(dir.path().join("timers.json"));
        let t = Utc::now() + Duration::hours(1);
        store.upsert("story_1".to_string(), t).unwrap();
        let list = store.list();
        assert_eq!(list.len(), 1);
        assert_eq!(list[0].story_id, "story_1");
        assert_eq!(list[0].scheduled_at, t);
    }

    #[test]
    fn upsert_updates_to_later_time() {
        let dir = TempDir::new().unwrap();
        let store = TimerStore::load(dir.path().join("timers.json"));
        let early = Utc::now() + Duration::hours(1);
        let later = Utc::now() + Duration::hours(2);
        store.upsert("story_1".to_string(), early).unwrap();
        store.upsert("story_1".to_string(), later).unwrap();
        let list = store.list();
        assert_eq!(list.len(), 1, "should not create duplicate");
        assert_eq!(list[0].scheduled_at, later, "should update to later time");
    }

    #[test]
    fn upsert_does_not_downgrade_to_earlier_time() {
        let dir = TempDir::new().unwrap();
        let store = TimerStore::load(dir.path().join("timers.json"));
        let later = Utc::now() + Duration::hours(2);
        let earlier = Utc::now() + Duration::hours(1);
        store.upsert("story_1".to_string(), later).unwrap();
        store.upsert("story_1".to_string(), earlier).unwrap();
        let list = store.list();
        assert_eq!(list.len(), 1);
        assert_eq!(
            list[0].scheduled_at, later,
            "should keep the later time, not downgrade"
        );
    }

    // ── AC2: spawn_rate_limit_auto_scheduler ────────────────────────────

    /// AC2: a RateLimitHardBlock event causes the auto-scheduler to add a timer.
    #[tokio::test]
    async fn rate_limit_auto_scheduler_adds_timer_on_hard_block() {
        use crate::io::watcher::WatcherEvent;

        let dir = TempDir::new().unwrap();
        let store = Arc::new(TimerStore::load(dir.path().join("timers.json")));
        let (watcher_tx, watcher_rx) = tokio::sync::broadcast::channel::<WatcherEvent>(16);

        spawn_rate_limit_auto_scheduler(Arc::clone(&store), watcher_rx);

        let reset_at = Utc::now() + Duration::hours(1);
        watcher_tx
            .send(WatcherEvent::RateLimitHardBlock {
                story_id: "423_story_test".to_string(),
                agent_name: "coder-1".to_string(),
                reset_at,
            })
            .unwrap();

        // Give the spawned task time to process the event.
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;

        let list = store.list();
        assert_eq!(list.len(), 1, "expected one timer after hard block");
        assert_eq!(list[0].story_id, "423_story_test");
        assert_eq!(list[0].scheduled_at, reset_at);
    }

    /// AC3 integration: a second hard block with a later reset_at updates the
    /// existing timer rather than creating a duplicate.
    #[tokio::test]
    async fn rate_limit_auto_scheduler_upserts_on_repeated_hard_block() {
        use crate::io::watcher::WatcherEvent;

        let dir = TempDir::new().unwrap();
        let store = Arc::new(TimerStore::load(dir.path().join("timers.json")));
        let (watcher_tx, watcher_rx) = tokio::sync::broadcast::channel::<WatcherEvent>(16);

        spawn_rate_limit_auto_scheduler(Arc::clone(&store), watcher_rx);

        let first = Utc::now() + Duration::hours(1);
        let second = Utc::now() + Duration::hours(2);

        watcher_tx
            .send(WatcherEvent::RateLimitHardBlock {
                story_id: "423_story_test".to_string(),
                agent_name: "coder-1".to_string(),
                reset_at: first,
            })
            .unwrap();
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
        watcher_tx
            .send(WatcherEvent::RateLimitHardBlock {
                story_id: "423_story_test".to_string(),
                agent_name: "coder-1".to_string(),
                reset_at: second,
            })
            .unwrap();
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;

        let list = store.list();
        assert_eq!(list.len(), 1, "should not create a duplicate timer");
        assert_eq!(list[0].scheduled_at, second, "should update to later time");
    }

    #[test]
    fn multiple_timers_same_time_all_returned() {
        let dir = TempDir::new().unwrap();
        let store = TimerStore::load(dir.path().join("timers.json"));
        let past = Utc::now() - Duration::minutes(1);
        store.add("story_a".to_string(), past).unwrap();
        store.add("story_b".to_string(), past).unwrap();

        let due = store.take_due(Utc::now());
        assert_eq!(due.len(), 2, "both timers at same time must fire");
    }

    // ── handle_timer_command ────────────────────────────────────────────

    #[tokio::test]
    async fn handle_list_empty() {
        let dir = TempDir::new().unwrap();
        let store = TimerStore::load(dir.path().join("timers.json"));
        let result = handle_timer_command(TimerCommand::List, &store, dir.path()).await;
        assert!(result.contains("No pending timers"), "unexpected: {result}");
    }

    #[tokio::test]
    async fn handle_cancel_not_found() {
        let dir = TempDir::new().unwrap();
        let store = TimerStore::load(dir.path().join("timers.json"));
        let result = handle_timer_command(
            TimerCommand::Cancel {
                story_number_or_id: "421_story_foo".to_string(),
            },
            &store,
            dir.path(),
        )
        .await;
        assert!(
            result.contains("No timer found"),
            "unexpected: {result}"
        );
    }

    #[tokio::test]
    async fn handle_schedule_story_not_in_current() {
        let dir = TempDir::new().unwrap();
        // Set up directory structure with no story in 2_current
        std::fs::create_dir_all(dir.path().join(".storkit/work/2_current")).unwrap();
        let store = TimerStore::load(dir.path().join("timers.json"));
        let result = handle_timer_command(
            TimerCommand::Schedule {
                story_number_or_id: "421_story_foo".to_string(),
                hhmm: "14:30".to_string(),
            },
            &store,
            dir.path(),
        )
        .await;
        assert!(
            result.contains("not in `work/2_current/`"),
            "unexpected: {result}"
        );
    }

    #[tokio::test]
    async fn handle_schedule_success() {
        let dir = TempDir::new().unwrap();
        let current_dir = dir.path().join(".storkit/work/2_current");
        std::fs::create_dir_all(&current_dir).unwrap();
        std::fs::write(current_dir.join("421_story_foo.md"), "---\nname: Foo\n---").unwrap();
        let store = TimerStore::load(dir.path().join("timers.json"));
        let result = handle_timer_command(
            TimerCommand::Schedule {
                story_number_or_id: "421_story_foo".to_string(),
                hhmm: "23:59".to_string(),
            },
            &store,
            dir.path(),
        )
        .await;
        assert!(
            result.contains("Timer set for"),
            "unexpected: {result}"
        );
        assert_eq!(store.list().len(), 1);
    }

    #[tokio::test]
    async fn handle_schedule_invalid_time() {
        let dir = TempDir::new().unwrap();
        let current_dir = dir.path().join(".storkit/work/2_current");
        std::fs::create_dir_all(&current_dir).unwrap();
        std::fs::write(current_dir.join("421_story_foo.md"), "---\nname: Foo\n---").unwrap();
        let store = TimerStore::load(dir.path().join("timers.json"));
        let result = handle_timer_command(
            TimerCommand::Schedule {
                story_number_or_id: "421_story_foo".to_string(),
                hhmm: "99:00".to_string(),
            },
            &store,
            dir.path(),
        )
        .await;
        assert!(result.contains("Invalid time"), "unexpected: {result}");
    }

    #[tokio::test]
    async fn handle_cancel_existing_timer() {
        let dir = TempDir::new().unwrap();
        let store = TimerStore::load(dir.path().join("timers.json"));
        let future = Utc::now() + Duration::hours(1);
        store.add("421_story_foo".to_string(), future).unwrap();
        let result = handle_timer_command(
            TimerCommand::Cancel {
                story_number_or_id: "421_story_foo".to_string(),
            },
            &store,
            dir.path(),
        )
        .await;
        assert!(result.contains("cancelled"), "unexpected: {result}");
        assert!(store.list().is_empty());
    }

    #[tokio::test]
    async fn handle_list_with_entries() {
        let dir = TempDir::new().unwrap();
        let store = TimerStore::load(dir.path().join("timers.json"));
        let future = Utc::now() + Duration::hours(1);
        store.add("421_story_foo".to_string(), future).unwrap();
        let result = handle_timer_command(TimerCommand::List, &store, dir.path()).await;
        assert!(result.contains("421_story_foo"), "unexpected: {result}");
        assert!(result.contains("Pending timers"), "unexpected: {result}");
    }
}
