//! Concrete subscriber stubs for the event bus, plus the production audit-log subscriber.

use super::Stage;
use super::events::{TransitionFired, TransitionSubscriber};
#[allow(unused_imports)]
use super::{event_label, stage_dir_name, stage_label};
use crate::service::status::{StatusBroadcaster, StatusEvent};
use std::sync::Arc;

// ── Audit log subscriber ─────────────────────────────────────────────────────

/// Format a `TransitionFired` event as a structured one-line audit log entry.
///
/// Fields are in stable `key=value` order separated by spaces:
/// `audit ts=<ISO8601> id=<story_id> from=<from_stage> to=<to_stage> event=<event_label>`
pub fn format_audit_entry(f: &TransitionFired) -> String {
    format!(
        "audit ts={} id={} from={} to={} event={}",
        f.at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
        f.story_id,
        stage_label(&f.before),
        stage_label(&f.after),
        event_label(&f.event),
    )
}

/// Subscriber that writes structured one-line audit entries for every stage transition.
pub struct AuditLogSubscriber;

impl TransitionSubscriber for AuditLogSubscriber {
    fn name(&self) -> &'static str {
        "audit-log"
    }

    fn on_transition(&self, f: &TransitionFired) {
        crate::slog!("{}", format_audit_entry(f));
    }
}

/// Reconcile: no-op for the audit log subscriber.
///
/// The audit log records live transitions only.  Replaying historical CRDT state at
/// reconcile time would produce misleading entries (wrong timestamps, duplicate lines).
/// Eventual consistency of the audit log is not required — missed events are simply
/// absent from the log, which is acceptable.
pub(crate) fn reconcile_audit_log() {}

/// Spawn a background task that writes a structured audit log entry for every pipeline transition.
///
/// Subscribes to the transition broadcast channel.  Every `TransitionFired` event produces
/// one line via [`format_audit_entry`] and writes it to the shared log buffer.
pub fn spawn_audit_log_subscriber() {
    let sub = AuditLogSubscriber;
    let mut rx = super::events::subscribe_transitions();
    tokio::spawn(async move {
        loop {
            match rx.recv().await {
                Ok(fired) => sub.on_transition(&fired),
                Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
                    crate::slog_warn!(
                        "[audit-log] Subscriber lagged, skipped {n} event(s); \
                         some transitions may be absent from the audit log."
                    );
                }
                Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
            }
        }
    });
}

/// Subscriber that publishes a [`StatusEvent::StageTransition`] to a
/// [`StatusBroadcaster`] for every pipeline transition.
///
/// `story_name` is looked up from the CRDT at publish time and defaults to
/// an empty string when the item has no name set.
pub struct StatusBroadcastSubscriber {
    status: Arc<StatusBroadcaster>,
}

impl TransitionSubscriber for StatusBroadcastSubscriber {
    fn name(&self) -> &'static str {
        "status-broadcast"
    }

    fn on_transition(&self, f: &TransitionFired) {
        self.status.publish(StatusEvent::StageTransition {
            story_id: f.story_id.0.clone(),
            story_name: crate::crdt_state::read_item(&f.story_id.0)
                .map(|v| v.name().to_string())
                .unwrap_or_default(),
            from_stage: stage_dir_name(&f.before).to_string(),
            to_stage: stage_dir_name(&f.after).to_string(),
        });
    }
}

/// Spawn a background task that publishes a [`StatusEvent::StageTransition`] to
/// `status` for every pipeline transition.
///
/// Subscribes to the transition broadcast channel — the same channel
/// [`spawn_audit_log_subscriber`] uses — and forwards each fired transition via
/// [`StatusBroadcastSubscriber::on_transition`] to the given [`StatusBroadcaster`],
/// which fans it out to the gateway relay and any other status consumer.
pub fn spawn_status_broadcast_subscriber(status: Arc<StatusBroadcaster>) {
    let sub = StatusBroadcastSubscriber { status };
    let mut rx = super::events::subscribe_transitions();
    tokio::spawn(async move {
        loop {
            match rx.recv().await {
                Ok(fired) => sub.on_transition(&fired),
                Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
                    crate::slog_warn!(
                        "[status-broadcast] Subscriber lagged, skipped {n} event(s); \
                         some transitions may be missing from the status broadcast."
                    );
                }
                Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
            }
        }
    });
}

// ── Subscriber stubs (real dispatch uses these as the interface) ─────────────
//
// These are ready to wire into the event bus but not yet connected to the
// actual subsystems. Suppress dead_code until consumers are migrated.

/// Subscriber that logs pipeline transitions to the Matrix bot channel.
#[allow(dead_code)]
pub struct MatrixBotSubscriber;
#[allow(dead_code)]
impl TransitionSubscriber for MatrixBotSubscriber {
    fn name(&self) -> &'static str {
        "matrix-bot"
    }
    fn on_transition(&self, f: &TransitionFired) {
        crate::slog!(
            "[pipeline/matrix-bot] #{}: {} → {}",
            f.story_id,
            stage_label(&f.before),
            stage_label(&f.after)
        );
    }
}

/// Subscriber that re-renders the filesystem `work/` directory on stage transitions.
#[allow(dead_code)]
pub struct FileRendererSubscriber;
#[allow(dead_code)]
impl TransitionSubscriber for FileRendererSubscriber {
    fn name(&self) -> &'static str {
        "filesystem"
    }
    fn on_transition(&self, f: &TransitionFired) {
        crate::slog!(
            "[pipeline/filesystem] re-rendering work/{}/{}",
            stage_dir_name(&f.after),
            f.story_id
        );
    }
}

/// Subscriber that writes stage updates to the pipeline-items data store.
#[allow(dead_code)]
pub struct PipelineItemsSubscriber;
#[allow(dead_code)]
impl TransitionSubscriber for PipelineItemsSubscriber {
    fn name(&self) -> &'static str {
        "pipeline-items"
    }
    fn on_transition(&self, f: &TransitionFired) {
        crate::slog!(
            "[pipeline/items] UPDATE stage = '{}' WHERE id = '{}'",
            stage_dir_name(&f.after),
            f.story_id
        );
    }
}

/// Subscriber that promotes eligible backlog items when a story completes or is archived.
#[allow(dead_code)]
pub struct AutoAssignSubscriber;
#[allow(dead_code)]
impl TransitionSubscriber for AutoAssignSubscriber {
    fn name(&self) -> &'static str {
        "auto-assign"
    }
    fn on_transition(&self, f: &TransitionFired) {
        if matches!(
            f.after,
            Stage::Done { .. }
                | Stage::Archived { .. }
                | Stage::Abandoned { .. }
                | Stage::Superseded { .. }
                | Stage::Rejected { .. }
        ) {
            crate::slog!(
                "[pipeline/auto-assign] story {} reached {}; checking for promotable backlog items",
                f.story_id,
                stage_label(&f.after)
            );
        }
    }
}

/// Subscriber that broadcasts stage transitions to all connected WebSocket clients.
#[allow(dead_code)]
pub struct WebUiBroadcastSubscriber;
#[allow(dead_code)]
impl TransitionSubscriber for WebUiBroadcastSubscriber {
    fn name(&self) -> &'static str {
        "web-ui-broadcast"
    }
    fn on_transition(&self, f: &TransitionFired) {
        crate::slog!(
            "[pipeline/web-ui] broadcasting #{} transition to connected clients",
            f.story_id
        );
    }
}

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

    /// Build a `TransitionFired` for a Backlog → Coding transition of `story_id`.
    fn fired_backlog_to_coding(story_id: &str) -> TransitionFired {
        TransitionFired {
            story_id: crate::pipeline_state::StoryId(story_id.to_string()),
            before: Stage::Backlog,
            after: Stage::Coding {
                claim: None,
                plan: crate::pipeline_state::PlanState::Missing,
                retries: 0,
            },
            event: PipelineEvent::DepsMet,
            at: chrono::Utc::now(),
        }
    }

    /// Calling `on_transition` directly must publish a matching
    /// `StatusEvent::StageTransition` on the given `StatusBroadcaster`, with
    /// `story_name` populated from the CRDT.
    ///
    /// This drives the subscriber directly (rather than through
    /// `spawn_status_broadcast_subscriber` + the process-wide transition
    /// broadcast channel) so the test is deterministic: the global channel is
    /// shared by every test in the binary, and unrelated concurrently-running
    /// tests firing their own transitions would otherwise race with this one.
    #[tokio::test]
    async fn on_transition_publishes_stage_transition_with_story_name() {
        crate::db::ensure_content_store();

        let story_id = "99184_story_status_broadcast";
        crate::db::write_item_with_content(
            story_id,
            "1_backlog",
            "---\nname: Status Broadcast Test\n---\n# Story\n",
            crate::db::ItemMeta::named("Status Broadcast Test"),
        );

        let status = Arc::new(StatusBroadcaster::new());
        let mut sub = status.subscribe();
        let subscriber = StatusBroadcastSubscriber {
            status: status.clone(),
        };

        subscriber.on_transition(&fired_backlog_to_coding(story_id));

        let event = sub.recv().await.expect("channel should not be closed");
        match event {
            StatusEvent::StageTransition {
                story_id: sid,
                story_name,
                from_stage,
                to_stage,
            } => {
                assert_eq!(sid, story_id);
                assert_eq!(story_name, "Status Broadcast Test");
                assert_eq!(from_stage, "backlog");
                assert_eq!(to_stage, "coding");
            }
            other => panic!("expected StatusEvent::StageTransition, got: {other:?}"),
        }
    }

    /// When the CRDT has no item for the transitioning story — e.g. it was
    /// never written, or was evicted before the subscriber processed the
    /// event — `story_name` defaults to an empty string.
    #[tokio::test]
    async fn on_transition_defaults_story_name_to_empty_for_unknown_story() {
        let story_id = "99184_story_status_broadcast_noname";

        let status = Arc::new(StatusBroadcaster::new());
        let mut sub = status.subscribe();
        let subscriber = StatusBroadcastSubscriber {
            status: status.clone(),
        };

        subscriber.on_transition(&fired_backlog_to_coding(story_id));

        let event = sub.recv().await.expect("channel should not be closed");
        match event {
            StatusEvent::StageTransition { story_name, .. } => {
                assert_eq!(story_name, "", "story_name should default to empty string");
            }
            other => panic!("expected StatusEvent::StageTransition, got: {other:?}"),
        }
    }

    /// `spawn_status_broadcast_subscriber` wires a `StatusBroadcastSubscriber`
    /// to the real transition broadcast channel: firing a transition through
    /// `super::events::try_broadcast` must reach the given `StatusBroadcaster`.
    /// Uses a story_id unique enough that no other concurrently-running test
    /// will coincidentally publish a matching one.
    #[tokio::test]
    async fn spawn_status_broadcast_subscriber_wires_the_real_channel() {
        let story_id = "99184_story_status_broadcast_spawn_wiring_check";

        let status = Arc::new(StatusBroadcaster::new());
        let mut sub = status.subscribe();
        spawn_status_broadcast_subscriber(status.clone());

        crate::pipeline_state::events::try_broadcast(&fired_backlog_to_coding(story_id));

        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
        loop {
            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
            let event = tokio::time::timeout(remaining, sub.recv())
                .await
                .expect("timed out waiting for spawned subscriber to forward the event")
                .expect("channel should not be closed");
            // Ignore unrelated transitions fired by other concurrently-running
            // tests on the shared global broadcast channel.
            if let StatusEvent::StageTransition { story_id: sid, .. } = &event
                && sid == story_id
            {
                break;
            }
        }
    }
}
