//! Read/write helpers for the `llm_sessions` LWW-map collection, including the
//! atomic `assemble_and_advance_session` helper used by every chat transport.
//!
//! LLM sessions are keyed by **persona name** (e.g. `"timmy"` for the
//! gateway-level bot) and track per-sled high-water marks so that
//! `assemble_and_advance_session` can inject only events the LLM has not yet
//! seen and advance the marks atomically within a single CRDT lock acquisition.

use std::collections::{BTreeMap, BTreeSet};

use bft_json_crdt::json_crdt::{JsonValue, *};
use bft_json_crdt::op::ROOT_ID;
use serde_json::json;

use super::super::state::{apply_and_persist, get_crdt, rebuild_llm_session_index};
use super::super::types::{LlmSessionCrdt, LlmSessionView, ScopeFilter};
use super::event_log::GAP_PIPELINE_EVENT;

/// Write or upsert an LLM session entry keyed by `persona`.
///
/// Creates a new entry if `persona` is not yet present; updates `scope` on an
/// existing entry.  The `high_water` register is not touched by this function —
/// use `assemble_and_advance_session` to advance it atomically.
///
/// The `scope` string must be in wire form: `"all"` for [`ScopeFilter::All`]
/// or `"sleds:hex1,hex2"` for [`ScopeFilter::Sleds`].
pub fn write_llm_session(persona: &str, scope: &str) {
    let Some(state_mutex) = get_crdt() else {
        return;
    };
    let Ok(mut state) = state_mutex.lock() else {
        return;
    };

    if let Some(&idx) = state.llm_session_index.get(persona) {
        apply_and_persist(&mut state, |s| {
            s.crdt.doc.llm_sessions[idx]
                .persona_name
                .set(persona.to_string())
        });
        apply_and_persist(&mut state, |s| {
            s.crdt.doc.llm_sessions[idx].scope.set(scope.to_string())
        });
    } else {
        let entry: JsonValue = json!({
            "session_id": persona,
            "persona_name": persona,
            "scope": scope,
            "high_water": "{}",
        })
        .into();
        apply_and_persist(&mut state, |s| {
            s.crdt.doc.llm_sessions.insert(ROOT_ID, entry)
        });
        state.llm_session_index = rebuild_llm_session_index(&state.crdt);
    }
}

/// Read a single LLM session entry by persona name.
pub fn read_llm_session(persona: &str) -> Option<LlmSessionView> {
    let state_mutex = get_crdt()?;
    let state = state_mutex.lock().ok()?;
    let &idx = state.llm_session_index.get(persona)?;
    // Compute the local sled id from the guard we already hold — calling
    // our_node_id() here would re-lock the same non-reentrant mutex and
    // self-deadlock the thread while it holds the lock, wedging every other
    // CRDT user behind it (bug 1170: repeated full-sled freezes).
    let local_sled_id = crate::crdt_state::hex::encode(&state.crdt.id);
    extract_llm_session_view(&state.crdt.doc.llm_sessions[idx], &local_sled_id)
}

/// Atomically read new event-log entries for `persona` past the stored
/// high-water marks, render them as a block of audit lines, and advance the
/// marks to prevent double-injection on the next call.
///
/// The set of sleds whose events are collected is determined by the persona's
/// [`ScopeFilter`]:
/// - [`ScopeFilter::All`]: events from every sled present in the event log are
///   included — this is the gateway-level persona default that gives a full
///   cross-sled view.
/// - [`ScopeFilter::Sleds`]: only events whose `sled_id` is in the stored set
///   are included.  When the stored set is empty (legacy `"single-sled"` rows or
///   freshly created sessions with no explicit scope), the local node's sled ID
///   is used as the sole member, preserving prior single-sled behaviour.
///
/// Returns an empty `Vec` when there are no new events or the CRDT is not
/// initialised.
pub fn assemble_and_advance_session(persona: &str) -> Vec<String> {
    let local_sled_id = crate::crdt_state::our_node_id().unwrap_or_default();

    let Some(state_mutex) = get_crdt() else {
        return Vec::new();
    };
    let Ok(mut state) = state_mutex.lock() else {
        return Vec::new();
    };

    // Determine the persona's scope filter and current high-water map.
    let (scope_filter, current_high_water) = match state.llm_session_index.get(persona).copied() {
        Some(idx) => {
            let filter = parse_scope(&state.crdt.doc.llm_sessions[idx], &local_sled_id);
            let hw = parse_high_water(&state.crdt.doc.llm_sessions[idx]);
            (filter, hw)
        }
        None => {
            // New session with no stored entry: default to local sled only.
            let mut ids = BTreeSet::new();
            if !local_sled_id.is_empty() {
                ids.insert(local_sled_id.clone());
            }
            (ScopeFilter::Sleds(ids), BTreeMap::new())
        }
    };

    // Build the set of sled IDs to collect events from.
    let target_sleds: BTreeSet<String> = match &scope_filter {
        ScopeFilter::All => {
            // Collect every unique sled_id present in the event log at this moment
            // (live, not snapshotted — picks up newly adopted sleds automatically).
            state
                .crdt
                .doc
                .event_log
                .iter()
                .filter_map(|e| match e.sled_id.view() {
                    JsonValue::String(s) if !s.is_empty() => Some(s),
                    _ => None,
                })
                .collect()
        }
        ScopeFilter::Sleds(ids) if ids.is_empty() => {
            // Empty set → legacy fallback: local sled only.
            if local_sled_id.is_empty() {
                return Vec::new();
            }
            std::iter::once(local_sled_id.clone()).collect()
        }
        ScopeFilter::Sleds(ids) => ids.clone(),
    };

    if target_sleds.is_empty() {
        return Vec::new();
    }

    // Collect new events from each target sled past its high-water mark.
    let mut new_events: Vec<(f64, String, String, String, String, String)> = state
        .crdt
        .doc
        .event_log
        .iter()
        .filter_map(|e| extract_new_event_multi(e, &target_sleds, &current_high_water))
        .collect();

    if new_events.is_empty() {
        return Vec::new();
    }

    // Sort by (sled_id, event_seq) for deterministic ordering.
    new_events.sort_by(|a, b| {
        a.1.cmp(&b.1)
            .then(a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))
    });

    // Advance the high-water mark for each sled that had new events.
    let mut new_high_water = current_high_water;
    for (seq, sled_id, ..) in &new_events {
        let entry = new_high_water.entry(sled_id.clone()).or_insert(0);
        if *seq as u64 > *entry {
            *entry = *seq as u64;
        }
    }
    let new_hw_json = serde_json::to_string(&new_high_water).unwrap_or_else(|_| "{}".to_string());

    // Upsert the persona entry with the new high-water value.
    let idx_opt = state.llm_session_index.get(persona).copied();
    if let Some(idx) = idx_opt {
        apply_and_persist(&mut state, |s| {
            s.crdt.doc.llm_sessions[idx]
                .high_water
                .set(new_hw_json.clone())
        });
    } else {
        let scope_str = scope_filter.to_scope_str();
        let entry: JsonValue = json!({
            "session_id": persona,
            "persona_name": persona,
            "scope": scope_str,
            "high_water": new_hw_json,
        })
        .into();
        apply_and_persist(&mut state, |s| {
            s.crdt.doc.llm_sessions.insert(ROOT_ID, entry)
        });
        state.llm_session_index = rebuild_llm_session_index(&state.crdt);
    }

    // Observability: log event-log size and gap count across the persona's
    // target sleds (the scope actually assembled for this persona).
    let total_entries = state
        .crdt
        .doc
        .event_log
        .iter()
        .filter(|e| matches!(e.sled_id.view(), JsonValue::String(s) if target_sleds.contains(&s)))
        .count();
    let gap_count = state
        .crdt
        .doc
        .event_log
        .iter()
        .filter(|e| {
            matches!(e.sled_id.view(), JsonValue::String(s) if target_sleds.contains(&s))
                && matches!(e.pipeline_event.view(), JsonValue::String(s) if s == GAP_PIPELINE_EVENT)
        })
        .count();
    crate::slog!(
        "[event-log] assemble persona={persona} sled_entries={total_entries} gap_count={gap_count}"
    );

    // Render each new event as a compact audit line; gap sentinels get a
    // human-readable message so the LLM is never presented with raw field data.
    new_events
        .into_iter()
        .map(
            |(_, sled_id, story_id, from_stage, to_stage, pipeline_event)| {
                if pipeline_event == GAP_PIPELINE_EVENT {
                    format!("events between {from_stage} and {to_stage} were dropped")
                } else {
                    format!(
                        "pipeline_event sled_id=\"{sled_id}\" story_id=\"{story_id}\" \
                         from=\"{from_stage}\" to=\"{to_stage}\" event=\"{pipeline_event}\""
                    )
                }
            },
        )
        .collect()
}

/// Decode the high-water JSON string from an `LlmSessionCrdt` entry.
fn parse_high_water(entry: &LlmSessionCrdt) -> BTreeMap<String, u64> {
    match entry.high_water.view() {
        JsonValue::String(s) if !s.is_empty() && s != "{}" => {
            serde_json::from_str(&s).unwrap_or_default()
        }
        _ => BTreeMap::new(),
    }
}

/// Parse the scope filter from an `LlmSessionCrdt` entry, falling back to
/// a single-element set containing `local_sled_id` for legacy / empty scope strings.
fn parse_scope(entry: &LlmSessionCrdt, local_sled_id: &str) -> ScopeFilter {
    let raw = match entry.scope.view() {
        JsonValue::String(s) => s,
        _ => String::new(),
    };
    let filter = ScopeFilter::from_scope_str(&raw);
    // For a Sleds filter with an empty set (legacy "single-sled" or ""),
    // fall back to the local sled.
    if let ScopeFilter::Sleds(ref ids) = filter
        && ids.is_empty()
        && !local_sled_id.is_empty()
    {
        let mut fallback = BTreeSet::new();
        fallback.insert(local_sled_id.to_string());
        return ScopeFilter::Sleds(fallback);
    }
    filter
}

/// Extract one event log entry if its `sled_id` is in `target_sleds` and its
/// `event_seq` is strictly greater than the matching high-water value (or no
/// high-water has been recorded yet for that sled).
///
/// Returns `(event_seq, sled_id, story_id, from_stage, to_stage, pipeline_event)`.
fn extract_new_event_multi(
    e: &crate::crdt_state::types::EventLogEntryCrdt,
    target_sleds: &BTreeSet<String>,
    high_water: &BTreeMap<String, u64>,
) -> Option<(f64, String, String, String, String, String)> {
    let sled_id = match e.sled_id.view() {
        JsonValue::String(s) if !s.is_empty() && target_sleds.contains(&s) => s,
        _ => return None,
    };
    let event_seq = match e.event_seq.view() {
        JsonValue::Number(n) => n,
        _ => return None,
    };
    let last_seen = high_water.get(&sled_id).copied();
    if last_seen.is_some_and(|last| event_seq as u64 <= last) {
        return None;
    }
    let story_id = match e.story_id.view() {
        JsonValue::String(s) => s,
        _ => String::new(),
    };
    let from_stage = match e.from_stage.view() {
        JsonValue::String(s) => s,
        _ => String::new(),
    };
    let to_stage = match e.to_stage.view() {
        JsonValue::String(s) => s,
        _ => String::new(),
    };
    let pipeline_event = match e.pipeline_event.view() {
        JsonValue::String(s) => s,
        _ => String::new(),
    };
    Some((
        event_seq,
        sled_id,
        story_id,
        from_stage,
        to_stage,
        pipeline_event,
    ))
}

/// Convert a CRDT LLM session entry into its read-only view representation.
///
/// `local_sled_id` must be supplied by the caller: this function runs while
/// the CRDT state lock is held, so it must NOT call `our_node_id()` (which
/// acquires that same lock — see bug 1170).
pub(super) fn extract_llm_session_view(
    entry: &LlmSessionCrdt,
    local_sled_id: &str,
) -> Option<LlmSessionView> {
    let session_id = match entry.session_id.view() {
        JsonValue::String(s) if !s.is_empty() => s,
        _ => return None,
    };
    let persona_name = match entry.persona_name.view() {
        JsonValue::String(s) => s,
        _ => String::new(),
    };
    let scope_filter = parse_scope(entry, local_sled_id);
    let high_water = parse_high_water(entry);
    Some(LlmSessionView {
        session_id,
        persona_name,
        scope_filter,
        high_water,
    })
}
