//! Live analysis events broadcast to admin SSE subscribers.
//!
//! A thin wrapper over a `tokio::sync::broadcast` channel. The worker
//! publishes per-page progress (`Started`/`Completed`/`Failed`) and the
//! admin enqueue path publishes `Enqueued`; the
//! `GET /v1/admin/analysis/status/stream` SSE endpoint forwards each event.
//! Discrete events (rather than the crawler's snapshot-on-change) map
//! directly onto "this page/chapter/manga just changed state", which the
//! dashboard applies incrementally.

use serde::Serialize;
use tokio::sync::broadcast;
use uuid::Uuid;

/// One live analysis event. Serialized with a `kind` discriminator so the
/// frontend can switch on it.
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum AnalysisEvent {
    /// A bulk re-enqueue happened. `manga_id` / `chapter_id` scope it (both
    /// `None` = whole library). The UI optimistically marks in-scope pages
    /// as queued.
    Enqueued {
        count: u64,
        manga_id: Option<Uuid>,
        chapter_id: Option<Uuid>,
    },
    /// The worker began analyzing a page. Carries the manga title and
    /// chapter number (alongside the ids) so the dashboard's "now
    /// analyzing" banner can name the target without a lookup.
    Started {
        page_id: Uuid,
        manga_id: Uuid,
        manga_title: String,
        chapter_id: Uuid,
        chapter_number: i32,
        page_number: i32,
    },
    /// The worker finished a page successfully.
    Completed {
        page_id: Uuid,
        manga_id: Uuid,
        manga_title: String,
        chapter_id: Uuid,
        chapter_number: i32,
        page_number: i32,
    },
    /// The worker failed a page (this attempt).
    Failed {
        page_id: Uuid,
        manga_id: Uuid,
        manga_title: String,
        chapter_id: Uuid,
        chapter_number: i32,
        page_number: i32,
    },
    /// The worker cancelled an in-flight dispatch (daemon shutdown or
    /// settings reload toggling analysis off). The lease was released,
    /// not failed. The dashboard's "now analyzing" banner clears on
    /// `Completed`/`Failed`/`Cancelled`; before this variant existed it
    /// listened only to the first two, so a cancel-mid-dispatch left
    /// the banner stuck on the cancelled page until a later one
    /// kicked off and overwrote it.
    Cancelled {
        page_id: Uuid,
        manga_id: Uuid,
        manga_title: String,
        chapter_id: Uuid,
        chapter_number: i32,
        page_number: i32,
    },
}

/// Broadcaster shared on `AppState`. Cheap to clone via `Arc`. Publishing
/// when there are no subscribers is a no-op (the send error is ignored).
pub struct AnalysisEvents {
    tx: broadcast::Sender<AnalysisEvent>,
}

impl AnalysisEvents {
    pub fn new() -> Self {
        // Buffer is generous; a slow subscriber that lags is dropped frames
        // (the SSE handler treats `Lagged` as "skip and continue").
        let (tx, _rx) = broadcast::channel(512);
        Self { tx }
    }

    pub fn publish(&self, event: AnalysisEvent) {
        let _ = self.tx.send(event);
    }

    pub fn subscribe(&self) -> broadcast::Receiver<AnalysisEvent> {
        self.tx.subscribe()
    }
}

impl Default for AnalysisEvents {
    fn default() -> Self {
        Self::new()
    }
}

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

    #[test]
    fn started_serializes_kind_and_labels() {
        let ev = AnalysisEvent::Started {
            page_id: Uuid::nil(),
            manga_id: Uuid::nil(),
            manga_title: "Berserk".into(),
            chapter_id: Uuid::nil(),
            chapter_number: 12,
            page_number: 7,
        };
        let v = serde_json::to_value(&ev).unwrap();
        assert_eq!(v["kind"], "started");
        assert_eq!(v["manga_title"], "Berserk");
        assert_eq!(v["chapter_number"], 12);
        assert_eq!(v["page_number"], 7);
    }
}
