asurada 0.2.1

Asurada — a memory + cognition daemon that grows with the user. Local-first, BYOK, shared by Devist/Webchemist Core/etc.
//! Asurada 의 능동적 음성 표현.
//!
//! 트리거 3종 → 단일 음성 큐로 직렬화:
//!   1. 매일 첫 가동 시 인사 (프로젝트 상태 요약 포함)
//!   2. 새 advice 발생 후 자동 발화 (dedup)
//!   3. 조용 시간대는 발화 보류
//!
//! 사일런스 원칙: 인사 시점에 advice 0건 + 모든 프로젝트 활동 중이면 *침묵*.

use anyhow::Result;
use chrono::{DateTime, Duration, Local, NaiveTime, Utc};
use rusqlite::{params, Connection};
use std::sync::Arc;
use tokio::sync::mpsc;

use crate::ai::{elevenlabs::ElevenLabsClient, speech::condense_for_speech};
use crate::config::Config;

/// 음성 큐 — 백그라운드 task 에 발화 요청을 직렬 전달.
#[derive(Clone)]
pub struct VoiceQueue {
    tx: mpsc::UnboundedSender<SpeakRequest>,
}

#[derive(Debug, Clone)]
pub struct SpeakRequest {
    pub text: String,
    /// 출처 식별 (인사 / advice id 등) — 로깅 용.
    pub source: String,
}

impl VoiceQueue {
    pub fn enqueue(&self, req: SpeakRequest) {
        let _ = self.tx.send(req);
    }

    /// TTS off 거나 키 없으면 None — 큐 자체를 만들지 않음.
    pub fn spawn(cfg: &Config) -> Option<Self> {
        if !cfg.tts.enabled {
            tracing::info!("[voice] TTS off — voice queue 비활성");
            return None;
        }
        let key = cfg.tts.api_key.clone()?;
        let voice_id = cfg.tts.voice_id.clone()?;
        let quiet_hours = cfg.tts.quiet_hours.clone();

        let client = match ElevenLabsClient::new(key) {
            Ok(c) => Arc::new(c),
            Err(e) => {
                tracing::warn!("[voice] ElevenLabs init 실패: {}", e);
                return None;
            }
        };

        let (tx, mut rx) = mpsc::unbounded_channel::<SpeakRequest>();
        let voice_id_arc = Arc::new(voice_id);
        tokio::spawn(async move {
            while let Some(req) = rx.recv().await {
                if in_quiet_hours(quiet_hours.as_deref()) {
                    tracing::debug!("[voice] quiet hours — skip: {}", req.source);
                    continue;
                }
                tracing::info!("[voice] speak ({}): {}", req.source, req.text);
                if let Err(e) = client.speak(&req.text, &voice_id_arc).await {
                    tracing::warn!("[voice] speak failed ({}): {}", req.source, e);
                }
            }
        });

        Some(Self { tx })
    }
}

/// 조용 시간대 판정 — "HH:MM-HH:MM" (local time). 자정 가로지르는 범위 지원.
pub fn in_quiet_hours(spec: Option<&str>) -> bool {
    let Some(s) = spec else {
        return false;
    };
    let parts: Vec<&str> = s.split('-').collect();
    if parts.len() != 2 {
        return false;
    }
    let Ok(start) = NaiveTime::parse_from_str(parts[0].trim(), "%H:%M") else {
        return false;
    };
    let Ok(end) = NaiveTime::parse_from_str(parts[1].trim(), "%H:%M") else {
        return false;
    };
    let now = Local::now().time();
    if start <= end {
        now >= start && now < end
    } else {
        // wrap (예: 21:00-09:00 → 21:00 이후 OR 09:00 이전)
        now >= start || now < end
    }
}

// ── 일일 인사 ───────────────────────────────────────────────

/// 오늘 (local date) 이미 인사했는지.
pub fn already_greeted_today(conn: &Connection, user_id: &str) -> Result<bool> {
    let mut stmt = conn.prepare(
        "SELECT created_at FROM events
         WHERE user_id = ?1 AND event_type = 'voice.greeting'
         ORDER BY created_at DESC LIMIT 1",
    )?;
    let last: Option<String> = stmt.query_row(params![user_id], |r| r.get(0)).ok();
    let Some(last_str) = last else {
        return Ok(false);
    };
    let Ok(last_dt) = DateTime::parse_from_rfc3339(&last_str) else {
        return Ok(false);
    };
    let last_local = last_dt.with_timezone(&Local).date_naive();
    let today = Local::now().date_naive();
    Ok(last_local == today)
}

/// 인사 이벤트 기록 (dedup 용).
pub fn mark_greeted(conn: &Connection, user_id: &str, body: &str) -> Result<()> {
    crate::db::event::insert(
        conn,
        crate::db::event::EventInput {
            user_id: user_id.into(),
            project: "_".into(),
            event_type: "voice.greeting".into(),
            path: None,
            payload: serde_json::json!({"text": body}),
        },
    )?;
    Ok(())
}

#[derive(Debug, Clone)]
pub struct ProjectInsight {
    pub project: String,
    pub idle_days: i64,
    pub pending_advice: usize,
    pub last_prompt_preview: Option<String>,
}

/// 등록된 모든 프로젝트의 활동 상태 조사.
pub fn survey_projects(conn: &Connection, user_id: &str) -> Result<Vec<ProjectInsight>> {
    let projects = crate::db::project::list(conn, user_id)?;
    let now = Utc::now();
    let mut out = Vec::with_capacity(projects.len());

    for p in projects {
        // 마지막 사용자 활동 (hook.user_prompt) 시각 + prompt
        let mut stmt = conn.prepare(
            "SELECT created_at, json_extract(payload, '$.prompt') FROM events
             WHERE user_id = ?1 AND project = ?2
               AND event_type = 'hook.user_prompt'
             ORDER BY created_at DESC LIMIT 1",
        )?;
        let last: Option<(String, Option<String>)> = stmt
            .query_row(params![user_id, &p.name], |r| Ok((r.get(0)?, r.get(1)?)))
            .ok();

        let (idle_days, last_prompt) = match last {
            Some((ts, prompt)) => {
                let dt = DateTime::parse_from_rfc3339(&ts)
                    .map(|d| d.with_timezone(&Utc))
                    .unwrap_or(now);
                let days = (now - dt).num_days();
                (days, prompt)
            }
            None => (-1, None), // 활동 없음 표식
        };

        // 미확인 advice 개수
        let pending_count: i64 = conn
            .query_row(
                "SELECT COUNT(*) FROM advice
                 WHERE user_id = ?1 AND project = ?2 AND confirmed_at IS NULL",
                params![user_id, &p.name],
                |r| r.get(0),
            )
            .unwrap_or(0);

        out.push(ProjectInsight {
            project: p.name,
            idle_days,
            pending_advice: pending_count as usize,
            last_prompt_preview: last_prompt.map(|p| {
                let s: String = p.chars().take(50).collect();
                if p.chars().count() > 50 {
                    format!("{}", s)
                } else {
                    s
                }
            }),
        });
    }
    Ok(out)
}

/// 인사 텍스트 작성 — 사일런스 원칙: 모든 게 무난하면 None.
pub fn compose_greeting(insights: &[ProjectInsight]) -> Option<String> {
    let stalled: Vec<&ProjectInsight> = insights
        .iter()
        .filter(|i| i.idle_days >= 3 && i.last_prompt_preview.is_some())
        .collect();
    let total_pending: usize = insights.iter().map(|i| i.pending_advice).sum();

    if stalled.is_empty() && total_pending == 0 {
        // 모든 프로젝트 활발 + 보류 advice 없음 → 침묵.
        return None;
    }

    let mut s = String::from("안녕하세요. ");

    if total_pending > 0 {
        s.push_str(&format!(
            "보류 중인 advice 가 {}건 있습니다. ",
            total_pending
        ));
    }

    for st in stalled.iter().take(2) {
        let preview = st.last_prompt_preview.as_deref().unwrap_or("");
        s.push_str(&format!(
            "{}{}일째 멈춰있어요. 마지막에 {}. ",
            st.project, st.idle_days, preview
        ));
    }

    if stalled.len() > 2 {
        s.push_str(&format!("{}개 프로젝트도 정체 중. ", stalled.len() - 2));
    }

    Some(s)
}

/// 일일 인사 한 번 시도. 다음 케이스에서 *return early* (mark 안 함):
///   - 이미 오늘 인사함
///   - 조용 시간대 — 발화 못 하므로 mark 미루고 다음 cycle 대기
///     (Mac sleep 후 wake 가 조용 시간대 전이면 그때 fire)
///   - 할 말 없음 → 사일런스 처리 (오늘 dedup mark 만)
pub fn try_daily_greeting(
    conn: &Connection,
    user_id: &str,
    queue: &VoiceQueue,
    quiet_hours: Option<&str>,
) -> Result<()> {
    if already_greeted_today(conn, user_id)? {
        return Ok(());
    }
    if in_quiet_hours(quiet_hours) {
        // 조용 시간대 — 인사 보류. mark 안 하고 다음 5분 cycle 에 재시도.
        // (사용자가 9시 5분에 Mac 깨우면 그 직후 cycle 에 발화)
        return Ok(());
    }
    let insights = survey_projects(conn, user_id)?;
    let Some(text) = compose_greeting(&insights) else {
        // 사일런스 — 오늘 dedup 마커만.
        crate::db::event::insert(
            conn,
            crate::db::event::EventInput {
                user_id: user_id.into(),
                project: "_".into(),
                event_type: "voice.greeting".into(),
                path: None,
                payload: serde_json::json!({"text": null, "skipped": "all_clear"}),
            },
        )?;
        return Ok(());
    };
    queue.enqueue(SpeakRequest {
        text: text.clone(),
        source: "daily_greeting".into(),
    });
    mark_greeted(conn, user_id, &text)?;
    Ok(())
}

// ── advice 자동 발화 ────────────────────────────────────────

/// 아직 발화하지 않은 미확인 advice 들 한 번씩 enqueue. dedup 은 voice.spoken
/// 이벤트로 — 같은 advice id 의 voice.spoken 있으면 skip.
pub fn try_speak_new_advice(
    conn: &Connection,
    user_id: &str,
    queue: &VoiceQueue,
    limit: usize,
) -> Result<usize> {
    let pending = crate::db::advice::list_pending(conn, user_id, None, limit)?;
    let mut spoken = 0usize;
    for adv in pending {
        if already_spoken(conn, user_id, &adv.id)? {
            continue;
        }
        let text = condense_for_speech(&adv.text, &adv.severity, &adv.project);
        queue.enqueue(SpeakRequest {
            text,
            source: format!("advice:{}", &adv.id[..8.min(adv.id.len())]),
        });
        // 발화 시도 기록 (실제 재생 실패해도 dedup 유지 — 시끄러움 방지)
        crate::db::event::insert(
            conn,
            crate::db::event::EventInput {
                user_id: user_id.into(),
                project: adv.project.clone(),
                event_type: "voice.spoken".into(),
                path: None,
                payload: serde_json::json!({"advice_id": adv.id}),
            },
        )?;
        spoken += 1;
    }
    Ok(spoken)
}

fn already_spoken(conn: &Connection, user_id: &str, advice_id: &str) -> Result<bool> {
    let mut stmt = conn.prepare(
        "SELECT 1 FROM events
         WHERE user_id = ?1 AND event_type = 'voice.spoken'
           AND json_extract(payload, '$.advice_id') = ?2
         LIMIT 1",
    )?;
    Ok(stmt
        .query_row(params![user_id, advice_id], |_| Ok(()))
        .is_ok())
}

// ── 사용자가 직접 호출하는 helpers ─────────────────────────

/// 현재 시점의 인사 텍스트 미리보기 — CLI `asurada voice greet --dry-run`.
pub fn preview_greeting(conn: &Connection, user_id: &str) -> Result<Option<String>> {
    let insights = survey_projects(conn, user_id)?;
    Ok(compose_greeting(&insights))
}

/// 마지막 인사 시각 (조회용).
pub fn last_greeting_time(conn: &Connection, user_id: &str) -> Result<Option<String>> {
    let mut stmt = conn.prepare(
        "SELECT created_at FROM events
         WHERE user_id = ?1 AND event_type = 'voice.greeting'
         ORDER BY created_at DESC LIMIT 1",
    )?;
    Ok(stmt.query_row(params![user_id], |r| r.get(0)).ok())
}

/// dummy 사용 (Duration import 경고 회피)
#[allow(dead_code)]
fn _duration_marker(_: Duration) {}