//! Timer service — deferred agent start via one-shot timers.
//!
//! Provides [`TimerStore`] for persisting timers to `.huskies/timers.json`
//! and command parsing / handling for the `timer` bot command.
//! Due timers are fired by the unified background tick loop in `main`.
//!
//! Follows service-module conventions:
//! - `mod.rs` (this file) — public API, typed [`Error`], orchestration
//! - `io.rs` — the ONLY place that performs side effects (filesystem, clock, spawn)
//! - `parse.rs` — pure: command parsing, time display formatting
//! - `persist.rs` — pure: serialisation/deserialisation of `timers.json`
//! - `schedule.rs` — pure: next-fire-time calculation given a reference instant

pub(super) mod io;
pub(super) mod parse;
pub(super) mod persist;
pub(super) mod schedule;
/// Generic scheduled-timer store: MCP-callable timers with `Mcp` / `Prompt` actions.
pub mod scheduled;

pub use io::{TimerStore, next_occurrence_of_hhmm, spawn_rate_limit_auto_scheduler, tick_once};
pub use parse::{TimerCommand, extract_timer_command};
pub use persist::TimerEntry;
pub use scheduled::ScheduledTimerStore;

use std::path::Path;

// ── Error type ────────────────────────────────────────────────────────────────

/// Typed errors returned by `service::timer` operations.
///
/// HTTP handlers and bot commands may map these to user-facing messages:
/// - [`Error::Parse`] → "Invalid time format"
/// - [`Error::DuplicateSchedule`] → "Timer already scheduled"
/// - [`Error::NoSuchSchedule`] → "No timer found"
/// - [`Error::Io`] → "Internal error saving timer"
#[derive(Debug)]
#[allow(dead_code)]
pub enum Error {
    /// The supplied `HH:MM` string could not be parsed or is out of range.
    Parse(String),
    /// A timer already exists for the given story ID.
    DuplicateSchedule(String),
    /// No timer exists for the given story ID.
    NoSuchSchedule(String),
    /// A filesystem read or write operation failed.
    Io(String),
}

impl std::fmt::Display for Error {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Parse(msg) => write!(f, "Parse error: {msg}"),
            Self::DuplicateSchedule(id) => {
                write!(f, "Timer already exists for story '{id}'")
            }
            Self::NoSuchSchedule(id) => write!(f, "No timer found for story '{id}'"),
            Self::Io(msg) => write!(f, "I/O error: {msg}"),
        }
    }
}

// ── Typed public API ──────────────────────────────────────────────────────────

/// Schedule a new timer for `story_id` to fire at the next `HH:MM` occurrence.
#[allow(dead_code)]
///
/// Returns the scheduled UTC instant on success.
///
/// # Errors
/// - [`Error::DuplicateSchedule`] if a timer already exists for `story_id`.
/// - [`Error::Parse`] if `hhmm` is not a valid `HH:MM` string.
/// - [`Error::Io`] if persisting the timer to disk fails.
pub fn schedule_timer(
    store: &TimerStore,
    story_id: &str,
    hhmm: &str,
    timezone: Option<&str>,
) -> Result<chrono::DateTime<chrono::Utc>, Error> {
    if store.list().iter().any(|t| t.story_id == story_id) {
        return Err(Error::DuplicateSchedule(story_id.to_string()));
    }
    let scheduled_at = next_occurrence_of_hhmm(hhmm, timezone)
        .ok_or_else(|| Error::Parse(format!("invalid HH:MM: '{hhmm}'")))?;
    store
        .add(story_id.to_string(), scheduled_at)
        .map_err(Error::Io)?;
    Ok(scheduled_at)
}

/// Cancel an existing timer for `story_id`.
#[allow(dead_code)]
///
/// # Errors
/// - [`Error::NoSuchSchedule`] if no timer exists for `story_id`.
pub fn cancel_timer(store: &TimerStore, story_id: &str) -> Result<(), Error> {
    if store.remove(story_id) {
        Ok(())
    } else {
        Err(Error::NoSuchSchedule(story_id.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 {
    // Load the configured timezone (if any) from project.toml.
    let config_tz: Option<String> = crate::config::ProjectConfig::load(project_root)
        .ok()
        .and_then(|c| c.timezone);
    let tz_str: Option<&str> = config_tz.as_deref();

    match cmd {
        TimerCommand::Schedule {
            story_number_or_id,
            hhmm,
        } => {
            let story_id = match io::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 be in backlog or current.  When the timer fires,
            // backlog stories are moved to current automatically.
            // Check CRDT state first, then fall back to filesystem.
            let in_valid_stage =
                if let Ok(Some(item)) = crate::pipeline_state::read_typed(&story_id) {
                    use crate::pipeline_state::Stage;
                    matches!(item.stage, Stage::Backlog | Stage::Coding { .. })
                } else {
                    let work_dir = project_root.join(".huskies").join("work");
                    work_dir
                        .join("1_backlog")
                        .join(format!("{story_id}.md"))
                        .exists()
                        || work_dir
                            .join("2_current")
                            .join(format!("{story_id}.md"))
                            .exists()
                };
            if !in_valid_stage {
                return format!("Story **{story_id}** is not in backlog or current.");
            }

            let scheduled_at = match next_occurrence_of_hhmm(&hhmm, tz_str) {
                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 (display_time, tz_label) = parse::format_in_timezone(scheduled_at, tz_str);
                    format!("Timer set for **{story_id}** at **{display_time}** ({tz_label}).")
                }
                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 (display_time, _) = parse::format_in_timezone(t.scheduled_at, tz_str);
                lines.push(format!("- **{}** → {}", t.story_id, display_time));
            }
            lines.join("\n")
        }
        TimerCommand::Cancel { story_number_or_id } => {
            let story_id = io::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(),
    }
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::{Duration, Utc};
    use tempfile::TempDir;

    // ── Error Display ─────────────────────────────────────────────────────────

    #[test]
    fn error_parse_display() {
        let e = Error::Parse("bad value".to_string());
        assert!(e.to_string().contains("Parse error"));
        assert!(e.to_string().contains("bad value"));
    }

    #[test]
    fn error_duplicate_schedule_display() {
        let e = Error::DuplicateSchedule("421_story_foo".to_string());
        assert!(e.to_string().contains("already exists"));
        assert!(e.to_string().contains("421_story_foo"));
    }

    #[test]
    fn error_no_such_schedule_display() {
        let e = Error::NoSuchSchedule("421_story_foo".to_string());
        assert!(e.to_string().contains("No timer found"));
        assert!(e.to_string().contains("421_story_foo"));
    }

    #[test]
    fn error_io_display() {
        let e = Error::Io("disk full".to_string());
        assert!(e.to_string().contains("I/O error"));
        assert!(e.to_string().contains("disk full"));
    }

    // ── schedule_timer ─────────────────────────────────────────────────────

    #[test]
    fn schedule_timer_returns_duplicate_when_already_exists() {
        let dir = TempDir::new().unwrap();
        let store = TimerStore::load(dir.path().join("timers.json"));
        let t = Utc::now() + Duration::hours(1);
        store.add("421_story_foo".to_string(), t).unwrap();
        let result = schedule_timer(&store, "421_story_foo", "14:30", None);
        assert!(
            matches!(result, Err(Error::DuplicateSchedule(_))),
            "expected DuplicateSchedule: {result:?}"
        );
    }

    #[test]
    fn schedule_timer_returns_parse_error_for_bad_hhmm() {
        let dir = TempDir::new().unwrap();
        let store = TimerStore::load(dir.path().join("timers.json"));
        let result = schedule_timer(&store, "421_story_foo", "99:99", None);
        assert!(
            matches!(result, Err(Error::Parse(_))),
            "expected Parse error: {result:?}"
        );
    }

    // ── cancel_timer ───────────────────────────────────────────────────────

    #[test]
    fn cancel_timer_returns_no_such_when_missing() {
        let dir = TempDir::new().unwrap();
        let store = TimerStore::load(dir.path().join("timers.json"));
        let result = cancel_timer(&store, "421_story_foo");
        assert!(
            matches!(result, Err(Error::NoSuchSchedule(_))),
            "expected NoSuchSchedule: {result:?}"
        );
    }

    #[test]
    fn cancel_timer_succeeds_when_exists() {
        let dir = TempDir::new().unwrap();
        let store = TimerStore::load(dir.path().join("timers.json"));
        let t = Utc::now() + Duration::hours(1);
        store.add("421_story_foo".to_string(), t).unwrap();
        assert!(cancel_timer(&store, "421_story_foo").is_ok());
        assert!(store.list().is_empty());
    }

    // ── 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_backlog_or_current() {
        let dir = TempDir::new().unwrap();
        // Ensure CRDT content store is initialised so the DB-first lookup works.
        crate::db::ensure_content_store();
        // No story written — "9950_story_timer_neg" should not be found.
        let store = TimerStore::load(dir.path().join("timers.json"));
        let result = handle_timer_command(
            TimerCommand::Schedule {
                story_number_or_id: "9950_story_timer_neg".to_string(),
                hhmm: "14:30".to_string(),
            },
            &store,
            dir.path(),
        )
        .await;
        assert!(
            result.contains("not in backlog or current"),
            "unexpected: {result}"
        );
    }

    #[tokio::test]
    async fn handle_schedule_accepts_backlog_story() {
        let dir = TempDir::new().unwrap();
        let backlog_dir = dir.path().join(".huskies/work/1_backlog");
        std::fs::create_dir_all(&backlog_dir).unwrap();
        std::fs::write(
            backlog_dir.join("421_story_foo.md"),
            "---\nname: Foo\n---\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: "14:30".to_string(),
            },
            &store,
            dir.path(),
        )
        .await;
        assert!(
            result.contains("Timer set"),
            "backlog story should be accepted: {result}"
        );
    }

    #[tokio::test]
    async fn handle_schedule_success() {
        let dir = TempDir::new().unwrap();
        let current_dir = dir.path().join(".huskies/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(".huskies/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}");
    }

    // ── firing a timer for a backlog story moves it to current ───────────

    /// When a timer fires for a story in backlog, the tick loop calls
    /// `move_story_to_current` before `start_agent`.  This test exercises
    /// that exact sequence (minus the agent pool) to prove the story ends
    /// up in `2_current/` after firing.
    #[test]
    fn fired_timer_for_backlog_story_moves_to_current() {
        use std::fs;

        let dir = TempDir::new().unwrap();
        let root = dir.path();
        let backlog = root.join(".huskies/work/1_backlog");
        let current = root.join(".huskies/work/2_current");
        fs::create_dir_all(&backlog).unwrap();
        fs::create_dir_all(&current).unwrap();
        let content = "---\nname: Foo\n---\n";
        fs::write(backlog.join("421_story_foo.md"), content).unwrap();
        crate::db::ensure_content_store();
        crate::db::write_content(crate::db::ContentKey::Story("421_story_foo"), content);

        // Add a past timer so take_due returns it immediately.
        let store = TimerStore::load(root.join("timers.json"));
        let past = Utc::now() - Duration::seconds(1);
        store.add("421_story_foo".to_string(), past).unwrap();

        // Drain due timers — same as the tick loop does.
        let due = store.take_due(Utc::now());
        assert_eq!(due.len(), 1, "expected one fired timer");

        // Apply the move-to-current step the tick loop performs.
        for entry in &due {
            crate::agents::lifecycle::move_story_to_current(&entry.story_id)
                .expect("move_story_to_current should succeed for backlog story");
        }

        // Story must still be accessible in the content store after the move.
        assert!(
            crate::db::read_content(crate::db::ContentKey::Story("421_story_foo")).is_some(),
            "story should be in the content store after timer fires"
        );
        // Timer was consumed.
        assert!(
            store.list().is_empty(),
            "fired timer should be removed from store"
        );
    }
}
