//! LLM session management — CRDT-backed context assembly for bot prompts.
//!
//! The central export is [`assemble_prompt_context`], which reads new pipeline
//! transition events from the CRDT event log past the persona's stored high-water
//! marks, wraps them in a `<system-reminder>` block for injection at the head of
//! the next LLM prompt, and atomically advances the marks so a mid-turn crash
//! cannot double-inject the same events.

/// Assemble a `<system-reminder>` block containing new pipeline-transition events
/// for `persona` and atomically advance the high-water marks.
///
/// All chat transports call this with the same persona name (e.g. `"timmy"`)
/// so that events are visible to whichever transport handles the next turn,
/// regardless of transport-specific session identifiers.  Returns an empty
/// string when there are no new events or the CRDT is not yet initialised.
pub fn assemble_prompt_context(persona: &str) -> String {
    let lines = crate::crdt_state::assemble_and_advance_session(persona);
    let event_count = lines.len();
    crate::slog!(
        "[llm-session] assemble_prompt_context persona={persona} new_events={event_count}"
    );
    if lines.is_empty() {
        return String::new();
    }
    let body = lines.join("\n");
    format!("<system-reminder>\n{body}\n</system-reminder>\n")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::pipeline_state::{PipelineEvent, PlanState, Stage, StoryId, TransitionFired};

    fn make_fired(story_id: &str) -> TransitionFired {
        TransitionFired {
            story_id: StoryId(story_id.to_string()),
            before: Stage::Backlog,
            after: Stage::Coding {
                claim: None,
                plan: PlanState::Missing,
                retries: 0,
            },
            event: PipelineEvent::DepsMet,
            at: chrono::Utc::now(),
        }
    }

    /// AC 4: fire a `TransitionFired` event, call `assemble_prompt_context` via
    /// the session helper, assert the rendered output contains the event details.
    /// A second call must return empty because the high-water was advanced.
    #[test]
    fn assemble_prompt_context_includes_new_events_and_advances_high_water() {
        crate::crdt_state::init_for_test();

        // Log two transition events for different stories.
        crate::event_log::log_transition_event(&make_fired("42_story_foo"));
        crate::event_log::log_transition_event(&make_fired("99_story_bar"));

        let ctx = assemble_prompt_context("room-test-1");

        // Must be wrapped in a <system-reminder> block.
        assert!(
            ctx.starts_with("<system-reminder>\n"),
            "missing opening tag; got: {ctx}"
        );
        assert!(
            ctx.ends_with("</system-reminder>\n"),
            "missing closing tag; got: {ctx}"
        );

        // Both story IDs must appear in the rendered block.
        assert!(
            ctx.contains("42_story_foo"),
            "first story missing; got: {ctx}"
        );
        assert!(
            ctx.contains("99_story_bar"),
            "second story missing; got: {ctx}"
        );

        // The pipeline_event label must appear.
        assert!(ctx.contains("DepsMet"), "event label missing; got: {ctx}");

        // Second call: high-water was advanced — no new events, returns empty.
        let ctx2 = assemble_prompt_context("room-test-1");
        assert!(
            ctx2.is_empty(),
            "second call must be empty after high-water advance; got: {ctx2}"
        );
    }

    /// Different session IDs have independent high-water marks.
    #[test]
    fn assemble_prompt_context_sessions_are_independent() {
        crate::crdt_state::init_for_test();

        crate::event_log::log_transition_event(&make_fired("77_story_x"));

        // Session A sees the event.
        let ctx_a = assemble_prompt_context("room-session-a");
        assert!(
            ctx_a.contains("77_story_x"),
            "session A must see the event; got: {ctx_a}"
        );

        // Session B also sees it (independent high-water).
        let ctx_b = assemble_prompt_context("room-session-b");
        assert!(
            ctx_b.contains("77_story_x"),
            "session B must see the event; got: {ctx_b}"
        );

        // Second call on A: already advanced.
        let ctx_a2 = assemble_prompt_context("room-session-a");
        assert!(
            ctx_a2.is_empty(),
            "session A second call must be empty; got: {ctx_a2}"
        );

        // But B's second call is also empty.
        let ctx_b2 = assemble_prompt_context("room-session-b");
        assert!(
            ctx_b2.is_empty(),
            "session B second call must be empty; got: {ctx_b2}"
        );
    }

    /// Events logged after a prior advance are included in the next call.
    #[test]
    fn assemble_prompt_context_includes_events_logged_after_advance() {
        crate::crdt_state::init_for_test();

        crate::event_log::log_transition_event(&make_fired("10_story_old"));
        // First call drains and advances.
        let ctx1 = assemble_prompt_context("room-incremental");
        assert!(ctx1.contains("10_story_old"), "got: {ctx1}");

        // Log a new event after the advance.
        crate::event_log::log_transition_event(&make_fired("20_story_new"));
        let ctx2 = assemble_prompt_context("room-incremental");
        assert!(
            ctx2.contains("20_story_new"),
            "new event must appear; got: {ctx2}"
        );
        assert!(
            !ctx2.contains("10_story_old"),
            "old event must not reappear; got: {ctx2}"
        );
    }

    /// `assemble_prompt_context` returns empty string when there are no events.
    #[test]
    fn assemble_prompt_context_empty_when_no_events() {
        crate::crdt_state::init_for_test();
        let ctx = assemble_prompt_context("room-empty");
        assert!(ctx.is_empty(), "must be empty with no events; got: {ctx}");
    }

    /// AC 4: two sleds each fire one transition; a session scoped `All` sees
    /// both events; a session scoped `Sleds([sled-A])` sees only sled-A's event.
    ///
    /// Simulates the gateway aggregate view by directly calling
    /// `append_event_log_entry` with two distinct sled IDs, then asserting
    /// scope-filtered assembly behaves correctly.
    #[test]
    fn scope_filter_all_sees_both_sleds_filter_sees_one() {
        crate::crdt_state::init_for_test();

        let sled_a = "aaaaaaaaaaaaaaaa";
        let sled_b = "bbbbbbbbbbbbbbbb";

        // Each sled fires one pipeline transition.
        crate::crdt_state::append_event_log_entry(
            sled_a,
            1_000_000.0,
            "10_story_alpha",
            "1_backlog",
            "2_current",
            "DepsMet",
        );
        crate::crdt_state::append_event_log_entry(
            sled_b,
            1_000_001.0,
            "20_story_beta",
            "2_current",
            "3_qa",
            "AgentCompleted",
        );

        // Set up a persona scoped to ALL sleds.
        crate::crdt_state::write_llm_session("timmy", "all");
        // Set up a persona scoped to sled-A only.
        let sled_a_scope = format!("sleds:{sled_a}");
        crate::crdt_state::write_llm_session("sally", &sled_a_scope);

        // All-scope persona: both events must appear.
        let ctx_all = assemble_prompt_context("timmy");
        assert!(
            ctx_all.contains("10_story_alpha"),
            "All scope must contain sled-A event; got: {ctx_all}"
        );
        assert!(
            ctx_all.contains("20_story_beta"),
            "All scope must contain sled-B event; got: {ctx_all}"
        );

        // Sled-A-only persona: only sled-A's event visible.
        let ctx_a = assemble_prompt_context("sally");
        assert!(
            ctx_a.contains("10_story_alpha"),
            "Sleds filter must contain sled-A event; got: {ctx_a}"
        );
        assert!(
            !ctx_a.contains("20_story_beta"),
            "Sleds filter must NOT contain sled-B event; got: {ctx_a}"
        );

        // Second call on both personas: nothing new (high-water advanced).
        let ctx_all2 = assemble_prompt_context("timmy");
        assert!(
            ctx_all2.is_empty(),
            "All scope second call must be empty; got: {ctx_all2}"
        );
        let ctx_a2 = assemble_prompt_context("sally");
        assert!(
            ctx_a2.is_empty(),
            "Sleds filter second call must be empty; got: {ctx_a2}"
        );
    }

    /// AC 5 e2e: fire a pipeline transition, then verify that calling
    /// `assemble_prompt_context` with the same persona key from any "transport"
    /// (simulated by different caller labels) sees the event.  The persona is
    /// transport-agnostic; subsequent transports sharing the persona see their
    /// own new events independently via independent calls (each drains a fresh
    /// batch).
    #[test]
    fn persona_key_is_transport_agnostic() {
        crate::crdt_state::init_for_test();
        crate::crdt_state::write_llm_session("timmy", "all");

        // Fire event 1.
        crate::event_log::log_transition_event(&make_fired("e2e_story_1"));

        // Matrix turn: see event 1.
        let matrix_ctx = assemble_prompt_context("timmy");
        assert!(
            matrix_ctx.contains("e2e_story_1"),
            "Matrix turn must see event 1; got: {matrix_ctx}"
        );

        // Fire event 2.
        crate::event_log::log_transition_event(&make_fired("e2e_story_2"));

        // Web-UI turn (same persona): see event 2 only (event 1 high-water already advanced).
        let web_ui_ctx = assemble_prompt_context("timmy");
        assert!(
            web_ui_ctx.contains("e2e_story_2"),
            "Web-UI turn must see event 2; got: {web_ui_ctx}"
        );
        assert!(
            !web_ui_ctx.contains("e2e_story_1"),
            "Web-UI turn must NOT re-see event 1; got: {web_ui_ctx}"
        );

        // Fire event 3.
        crate::event_log::log_transition_event(&make_fired("e2e_story_3"));

        // CLI turn (same persona): see event 3 only.
        let cli_ctx = assemble_prompt_context("timmy");
        assert!(
            cli_ctx.contains("e2e_story_3"),
            "CLI turn must see event 3; got: {cli_ctx}"
        );
        assert!(
            !cli_ctx.contains("e2e_story_1"),
            "CLI turn must NOT re-see event 1; got: {cli_ctx}"
        );
        assert!(
            !cli_ctx.contains("e2e_story_2"),
            "CLI turn must NOT re-see event 2; got: {cli_ctx}"
        );
    }

    /// Newly-added sled events appear in an All-scope session without
    /// restarting (AC 5 runtime pickup).
    #[test]
    fn scope_filter_all_picks_up_new_sled_at_runtime() {
        crate::crdt_state::init_for_test();

        let sled_a = "cccccccccccccccc";
        let sled_new = "dddddddddddddddd";

        // Only sled-A exists initially.
        crate::crdt_state::append_event_log_entry(
            sled_a,
            2_000_000.0,
            "30_story_first",
            "1_backlog",
            "2_current",
            "DepsMet",
        );
        crate::crdt_state::write_llm_session("timmy", "all");

        let ctx1 = assemble_prompt_context("timmy");
        assert!(
            ctx1.contains("30_story_first"),
            "first event must appear; got: {ctx1}"
        );

        // sled_new is adopted at runtime — its event is appended without restart.
        crate::crdt_state::append_event_log_entry(
            sled_new,
            2_000_001.0,
            "40_story_second",
            "2_current",
            "3_qa",
            "AgentCompleted",
        );

        let ctx2 = assemble_prompt_context("timmy");
        assert!(
            ctx2.contains("40_story_second"),
            "newly adopted sled event must appear; got: {ctx2}"
        );
        assert!(
            !ctx2.contains("30_story_first"),
            "old event must not reappear; got: {ctx2}"
        );
    }
}
