use std::sync::{Arc, Weak};
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, Local};
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tracing::{info, warn};
use crate::config::TimerPreset;
#[derive(Debug, Clone)]
pub enum TimerOrigin {
Chat { room_id: String },
Voice { device_id: String },
}
tokio::task_local! {
static TIMER_ORIGIN_TL: TimerOrigin;
}
pub fn scope_timer_origin<F: std::future::Future>(
origin: TimerOrigin,
fut: F,
) -> impl std::future::Future<Output = F::Output> {
TIMER_ORIGIN_TL.scope(origin, fut)
}
pub fn current_origin() -> Option<TimerOrigin> {
TIMER_ORIGIN_TL.try_with(|o| o.clone()).ok()
}
#[derive(Debug, Clone)]
pub struct TimerSnapshot {
pub label: String,
pub fires_at: DateTime<Local>,
pub kind: TimerKind,
}
#[derive(Debug, Clone)]
pub enum TimerKind {
Single,
Preset {
name: String,
step_index: usize,
total_steps: usize,
cycle: u32,
total_cycles: u32,
},
}
struct ActiveTimer {
handle: JoinHandle<()>,
snapshot: TimerSnapshot,
}
pub struct TimerManager {
inner: Mutex<Option<ActiveTimer>>,
agent: tokio::sync::OnceCell<Weak<crate::agent::Agent>>,
serve_state: tokio::sync::OnceCell<Weak<crate::serve::ServeState>>,
}
impl TimerManager {
pub fn new() -> Arc<Self> {
Arc::new(Self {
inner: Mutex::new(None),
agent: tokio::sync::OnceCell::new(),
serve_state: tokio::sync::OnceCell::new(),
})
}
pub fn set_agent(&self, agent: Weak<crate::agent::Agent>) {
let _ = self.agent.set(agent);
}
pub fn set_serve_state(&self, state: Weak<crate::serve::ServeState>) {
let _ = self.serve_state.set(state);
}
pub async fn set_single(
self: &Arc<Self>,
minutes: f64,
label: String,
origin: TimerOrigin,
) -> Result<TimerSnapshot> {
if !minutes.is_finite() || minutes <= 0.0 {
anyhow::bail!("minutes must be a positive finite number (got {minutes})");
}
let duration = Duration::from_secs_f64(minutes * 60.0);
let fires_at = Local::now()
+ chrono::Duration::from_std(duration).context("timer duration overflow")?;
let snapshot = TimerSnapshot {
label: label.clone(),
fires_at,
kind: TimerKind::Single,
};
let me = Arc::clone(self);
let origin_for_fire = origin.clone();
let label_for_fire = label.clone();
let handle = tokio::spawn(async move {
tokio::time::sleep(duration).await;
let prompt = format!(
"[Timer] '{label}' ({minutes:.1} min) elapsed. Tell the user the timer is up. Keep it short.",
label = label_for_fire,
minutes = minutes,
);
me.dispatch_fire(&label_for_fire, &prompt, &origin_for_fire);
let mut slot = me.inner.lock().await;
if let Some(active) = slot.as_ref()
&& matches!(active.snapshot.kind, TimerKind::Single)
&& active.snapshot.label == label_for_fire
{
*slot = None;
}
});
self.replace_active(ActiveTimer {
handle,
snapshot: snapshot.clone(),
})
.await;
Ok(snapshot)
}
pub async fn set_preset(
self: &Arc<Self>,
preset: TimerPreset,
cycles_override: Option<u32>,
origin: TimerOrigin,
) -> Result<TimerSnapshot> {
let cycles = cycles_override.unwrap_or(preset.cycles).max(1);
if preset.steps.is_empty() {
anyhow::bail!("preset '{}' has no steps", preset.name);
}
for step in &preset.steps {
if !step.minutes.is_finite() || step.minutes <= 0.0 {
anyhow::bail!(
"preset '{}' step '{}' has invalid minutes: {}",
preset.name,
step.label,
step.minutes
);
}
}
let total_steps = preset.steps.len();
let first = &preset.steps[0];
let first_dur = Duration::from_secs_f64(first.minutes * 60.0);
let fires_at = Local::now()
+ chrono::Duration::from_std(first_dur).context("preset duration overflow")?;
let snapshot = TimerSnapshot {
label: first.label.clone(),
fires_at,
kind: TimerKind::Preset {
name: preset.name.clone(),
step_index: 0,
total_steps,
cycle: 1,
total_cycles: cycles,
},
};
let me = Arc::clone(self);
let preset_for_task = preset.clone();
let origin_for_task = origin.clone();
let handle = tokio::spawn(async move {
me.run_preset_loop(preset_for_task, cycles, origin_for_task)
.await;
});
self.replace_active(ActiveTimer {
handle,
snapshot: snapshot.clone(),
})
.await;
Ok(snapshot)
}
async fn run_preset_loop(
self: Arc<Self>,
preset: TimerPreset,
cycles: u32,
origin: TimerOrigin,
) {
let total_steps = preset.steps.len();
for cycle in 1..=cycles {
for (i, step) in preset.steps.iter().enumerate() {
let duration = Duration::from_secs_f64(step.minutes * 60.0);
{
let mut slot = self.inner.lock().await;
if let Some(active) = slot.as_mut() {
active.snapshot.label = step.label.clone();
active.snapshot.fires_at = Local::now()
+ chrono::Duration::from_std(duration)
.unwrap_or(chrono::Duration::zero());
if let TimerKind::Preset {
step_index,
cycle: c,
..
} = &mut active.snapshot.kind
{
*step_index = i;
*c = cycle;
}
} else {
return;
}
}
tokio::time::sleep(duration).await;
let is_last_step = cycle == cycles && i + 1 == total_steps;
let next_label = if is_last_step {
None
} else if i + 1 < total_steps {
Some(preset.steps[i + 1].label.clone())
} else {
Some(preset.steps[0].label.clone())
};
let prompt = if is_last_step {
format!(
"[Timer: {preset_name}] All {cycles} cycle(s) complete. The final '{label}' step ({minutes:.1} min) just ended. Tell the user the full Pomodoro is finished. Keep it short.",
preset_name = preset.name,
cycles = cycles,
label = step.label,
minutes = step.minutes,
)
} else {
format!(
"[Timer: {preset_name}] Step {cur}/{total} of cycle {cycle}/{cycles}: '{label}' ({minutes:.1} min) ended. Next step '{next}' is already auto-scheduled by the preset — do NOT call timer_set / timer_preset. Just tell the user to switch. Keep it short.",
preset_name = preset.name,
cur = i + 1,
total = total_steps,
cycle = cycle,
cycles = cycles,
label = step.label,
minutes = step.minutes,
next = next_label.as_deref().unwrap_or(""),
)
};
self.dispatch_fire(&step.label, &prompt, &origin);
}
}
let mut slot = self.inner.lock().await;
if let Some(active) = slot.as_ref()
&& matches!(active.snapshot.kind, TimerKind::Preset { ref name, .. } if name == &preset.name)
{
*slot = None;
}
}
fn dispatch_fire(&self, task_name: &str, prompt: &str, origin: &TimerOrigin) {
match origin {
TimerOrigin::Chat { room_id } => match self.agent.get().and_then(Weak::upgrade) {
Some(agent) => {
let task_name = task_name.to_string();
let prompt = prompt.to_string();
let room_id = room_id.clone();
tokio::spawn(async move {
if let Err(e) = agent.trigger(&task_name, &prompt, &room_id).await {
warn!("Timer fire (chat) failed for {task_name} -> {room_id}: {e:#}");
}
});
}
None => warn!(
"Timer fire (chat) skipped for {task_name}: agent not wired into TimerManager"
),
},
TimerOrigin::Voice { device_id } => {
match self.serve_state.get().and_then(Weak::upgrade) {
Some(state) => {
let task_name = task_name.to_string();
let prompt = prompt.to_string();
let device_id = device_id.clone();
tokio::spawn(async move {
match crate::serve::push_voice_text_to_subscriber(
state,
device_id.clone(),
Some(task_name.clone()),
prompt,
)
.await
{
Ok(()) => {}
Err(e) => {
let reason = match e {
crate::serve::VoicePushError::Offline => {
"offline".to_string()
}
crate::serve::VoicePushError::NoVoice => {
"no voice providers".to_string()
}
crate::serve::VoicePushError::NotConfigured => {
"voice_pipeline not configured for room_profile"
.to_string()
}
crate::serve::VoicePushError::Other(msg) => msg,
};
warn!(
"Timer fire (voice) failed for {task_name} -> device={device_id}: {reason}"
);
}
}
});
}
None => warn!(
"Timer fire (voice) skipped for {task_name}: serve_state not wired into TimerManager"
),
}
}
}
}
async fn replace_active(&self, new: ActiveTimer) {
let mut slot = self.inner.lock().await;
if let Some(prev) = slot.take() {
info!("Timer: replacing previous timer '{}'", prev.snapshot.label);
prev.handle.abort();
}
*slot = Some(new);
}
pub async fn cancel(&self) -> Option<TimerSnapshot> {
let mut slot = self.inner.lock().await;
slot.take().map(|t| {
t.handle.abort();
t.snapshot
})
}
pub async fn current(&self) -> Option<TimerSnapshot> {
self.inner.lock().await.as_ref().map(|t| t.snapshot.clone())
}
}
pub fn find_preset<'a>(presets: &'a [TimerPreset], name: &str) -> Result<&'a TimerPreset> {
presets
.iter()
.find(|p| p.name.eq_ignore_ascii_case(name))
.ok_or_else(|| {
let known: Vec<&str> = presets.iter().map(|p| p.name.as_str()).collect();
anyhow!("unknown timer preset '{name}'. Known presets: {known:?}")
})
}