//! Live-state write/read use cases (programme live-state, lot LS2).
//!
//! The **live-state** is the durable-but-volatile "who is doing what right now"
//! projection: one [`LiveEntry`] per agent, persisted through the
//! [`LiveStateStore`] port (keyed last-writer-wins, never a journal).
//!
//! This module owns the two use cases over that port:
//! - [`UpdateLiveState`] — publish/replace an agent's current row (write side),
//!   stamping `updated_at_ms` from the injected [`Clock`] and enforcing the
//!   domain field bounds via [`LiveEntry::new`].
//! - [`GetLiveStateLean`] — prune-on-read then return a **distilled** snapshot
//!   ([`LeanLiveState`]) for agent context injection and the future MCP tools.
//!
//! ## Boundary (programme invariant)
//!
//! The live-state stores **only what cannot be re-derived**: an agent's declared
//! `intent`/`progress`, its current `ticket` and its `last_delegation`. Busy
//! state, live sessions and the delegation queue are **computed elsewhere**
//! ([`crate::workstate::GetProjectWorkState`]) and are deliberately *not* copied
//! here. The lean DTO is also distinct from that rich, human-facing read model.

use std::sync::Arc;

use serde::{Deserialize, Serialize};

use domain::live_state::{LiveEntry, LiveState, WorkStatus};
use domain::ports::{Clock, LiveStateStore};
use domain::{AgentId, TicketId};

use crate::error::AppError;

/// Default TTL for a live-state row, in milliseconds: **6 hours**.
///
/// An entry not refreshed within this window is considered stale and pruned on
/// the next read. Six hours comfortably spans a normal working session (an agent
/// that has not published anything for that long is effectively offline), while
/// still bounding the file so a forgotten/abandoned project does not accumulate
/// dead rows indefinitely.
pub const LIVE_STATE_TTL_MS: u64 = 6 * 60 * 60 * 1_000;

/// Default cardinality bound applied after the TTL sweep: keep at most this many
/// most-recently-updated rows.
///
/// A project runs a handful of agents; `64` is a generous ceiling that still
/// absorbs transient fan-out (an orchestrator delegating to many agents at once)
/// while keeping the snapshot small and the injected context bounded.
pub const LIVE_STATE_MAX_ENTRIES: usize = 64;

/// Input for [`UpdateLiveState::execute`] — the elements of one live-state
/// transition. `updated_at_ms` is **not** part of the input: it is stamped by the
/// use case from the injected [`Clock`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateLiveStateInput {
    /// The agent publishing the transition (the keyed identity).
    pub agent_id: AgentId,
    /// The ticket it is currently handling, if any.
    pub ticket: Option<TicketId>,
    /// Short description of the current intent (domain-bounded on construction).
    pub intent: String,
    /// Coarse status at a glance.
    pub status: WorkStatus,
    /// Optional finer-grained progress note (domain-bounded on construction).
    pub progress: Option<String>,
    /// The ticket of the most recent delegation this agent issued, if any.
    pub last_delegation: Option<TicketId>,
}

/// Publishes (inserts or replaces) an agent's live-state row.
pub struct UpdateLiveState {
    store: Arc<dyn LiveStateStore>,
    clock: Arc<dyn Clock>,
}

impl UpdateLiveState {
    /// Builds the use case from the injected store and clock ports.
    #[must_use]
    pub fn new(store: Arc<dyn LiveStateStore>, clock: Arc<dyn Clock>) -> Self {
        Self { store, clock }
    }

    /// Stamps `updated_at_ms` from the clock, builds a validated [`LiveEntry`]
    /// (applying the soft truncation / hard rejection bounds) and upserts it.
    ///
    /// # Errors
    /// [`AppError::Invalid`] if a text field exceeds the domain hard threshold;
    /// [`AppError::Store`] on a persistence failure.
    pub async fn execute(&self, input: UpdateLiveStateInput) -> Result<(), AppError> {
        let now_ms = u64::try_from(self.clock.now_millis()).unwrap_or(0);
        let entry = LiveEntry::new(
            input.agent_id,
            input.ticket,
            input.intent,
            input.status,
            input.progress,
            input.last_delegation,
            now_ms,
        )
        .map_err(|e| AppError::Invalid(e.to_string()))?;
        self.store.upsert(entry).await?;
        Ok(())
    }
}

/// A single distilled live-state row for injection / MCP — the **lean** DTO,
/// deliberately narrower than the rich human read model (no `progress` free-text,
/// no timestamp). All free-text is already domain-bounded.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LeanLiveEntry {
    /// The agent this row describes.
    pub agent_id: AgentId,
    /// Short description of what the agent is doing (domain-bounded).
    pub intent: String,
    /// Coarse status at a glance.
    pub status: WorkStatus,
    /// The ticket it is currently handling, if any.
    pub ticket: Option<TicketId>,
    /// The ticket of the most recent delegation it issued, if any.
    pub last_delegation: Option<TicketId>,
}

impl From<LiveEntry> for LeanLiveEntry {
    fn from(e: LiveEntry) -> Self {
        Self {
            agent_id: e.agent_id,
            intent: e.intent,
            status: e.status,
            ticket: e.ticket,
            last_delegation: e.last_delegation,
        }
    }
}

/// The distilled live-state snapshot returned by [`GetLiveStateLean`].
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LeanLiveState {
    /// The current rows (already pruned), one per agent.
    pub entries: Vec<LeanLiveEntry>,
}

impl From<LiveState> for LeanLiveState {
    fn from(state: LiveState) -> Self {
        Self {
            entries: state.entries.into_iter().map(LeanLiveEntry::from).collect(),
        }
    }
}

/// Reads the live-state, pruning stale/excess rows first, and returns the lean
/// snapshot.
pub struct GetLiveStateLean {
    store: Arc<dyn LiveStateStore>,
    clock: Arc<dyn Clock>,
}

impl GetLiveStateLean {
    /// Builds the use case from the injected store and clock ports.
    #[must_use]
    pub fn new(store: Arc<dyn LiveStateStore>, clock: Arc<dyn Clock>) -> Self {
        Self { store, clock }
    }

    /// Applies the TTL + cardinality policy ([`LIVE_STATE_TTL_MS`],
    /// [`LIVE_STATE_MAX_ENTRIES`]) at read time, then returns the distilled
    /// snapshot.
    ///
    /// # Errors
    /// [`AppError::Store`] on a persistence failure.
    pub async fn execute(&self) -> Result<LeanLiveState, AppError> {
        let now_ms = u64::try_from(self.clock.now_millis()).unwrap_or(0);
        self.store
            .prune(now_ms, LIVE_STATE_TTL_MS, LIVE_STATE_MAX_ENTRIES)
            .await?;
        let state = self.store.load().await?;
        Ok(LeanLiveState::from(state))
    }
}

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

    use std::sync::Mutex;

    use domain::live_state::FIELD_MAX_BYTES;
    use domain::ports::StoreError;

    /// In-memory [`LiveStateStore`] double mirroring the FS adapter's pure
    /// semantics (keyed upsert + prune), with no I/O.
    #[derive(Default)]
    struct InMemoryLiveStateStore {
        state: Mutex<LiveState>,
    }

    #[async_trait::async_trait]
    impl LiveStateStore for InMemoryLiveStateStore {
        async fn load(&self) -> Result<LiveState, StoreError> {
            Ok(self.state.lock().unwrap().clone())
        }
        async fn upsert(&self, entry: LiveEntry) -> Result<(), StoreError> {
            self.state.lock().unwrap().upsert(entry);
            Ok(())
        }
        async fn prune(&self, now_ms: u64, ttl_ms: u64, max_n: usize) -> Result<(), StoreError> {
            self.state.lock().unwrap().prune(now_ms, ttl_ms, max_n);
            Ok(())
        }
    }

    /// A fixed clock returning a preset epoch-ms value.
    struct FixedClock(i64);
    impl Clock for FixedClock {
        fn now_millis(&self) -> i64 {
            self.0
        }
    }

    fn aid(n: u128) -> AgentId {
        AgentId::from_uuid(uuid::Uuid::from_u128(n))
    }

    #[tokio::test]
    async fn update_stamps_clock_and_persists() {
        let store = Arc::new(InMemoryLiveStateStore::default());
        let clock = Arc::new(FixedClock(4_242));
        let uc = UpdateLiveState::new(store.clone(), clock);

        uc.execute(UpdateLiveStateInput {
            agent_id: aid(1),
            ticket: None,
            intent: "shipping".to_owned(),
            status: WorkStatus::Working,
            progress: None,
            last_delegation: None,
        })
        .await
        .unwrap();

        let state = store.load().await.unwrap();
        assert_eq!(state.entries.len(), 1);
        assert_eq!(state.entries[0].intent, "shipping");
        assert_eq!(
            state.entries[0].updated_at_ms, 4_242,
            "updated_at_ms stamped from the clock"
        );
    }

    #[tokio::test]
    async fn update_applies_domain_bounds_truncation() {
        let store = Arc::new(InMemoryLiveStateStore::default());
        let uc = UpdateLiveState::new(store.clone(), Arc::new(FixedClock(1)));

        let long = "x".repeat(500);
        uc.execute(UpdateLiveStateInput {
            agent_id: aid(1),
            ticket: None,
            intent: long,
            status: WorkStatus::Working,
            progress: None,
            last_delegation: None,
        })
        .await
        .unwrap();

        let state = store.load().await.unwrap();
        assert!(
            state.entries[0].intent.chars().count() <= 200,
            "soft bound truncated the over-long intent"
        );
    }

    #[tokio::test]
    async fn update_rejects_oversize_field() {
        let store = Arc::new(InMemoryLiveStateStore::default());
        let uc = UpdateLiveState::new(store, Arc::new(FixedClock(1)));

        let huge = "x".repeat(FIELD_MAX_BYTES + 1);
        let err = uc
            .execute(UpdateLiveStateInput {
                agent_id: aid(1),
                ticket: None,
                intent: huge,
                status: WorkStatus::Working,
                progress: None,
                last_delegation: None,
            })
            .await
            .expect_err("oversize intent must be rejected");
        assert!(matches!(err, AppError::Invalid(_)));
    }

    #[tokio::test]
    async fn get_lean_prunes_on_read_and_returns_lean_dto() {
        let store = Arc::new(InMemoryLiveStateStore::default());
        // One fresh row (now-ish) and one ancient row (well beyond the TTL).
        let now: i64 = 10 * 60 * 60 * 1_000; // 10h in ms
        store
            .upsert(
                LiveEntry::new(
                    aid(1),
                    None,
                    "fresh",
                    WorkStatus::Working,
                    Some("detailed progress note".to_owned()),
                    None,
                    u64::try_from(now).unwrap(),
                )
                .unwrap(),
            )
            .await
            .unwrap();
        store
            .upsert(LiveEntry::new(aid(2), None, "stale", WorkStatus::Idle, None, None, 0).unwrap())
            .await
            .unwrap();

        let lean = GetLiveStateLean::new(store.clone(), Arc::new(FixedClock(now)))
            .execute()
            .await
            .unwrap();

        // The ancient row (age 10h > 6h TTL) was pruned on read.
        assert_eq!(lean.entries.len(), 1);
        let entry = &lean.entries[0];
        assert_eq!(entry.agent_id, aid(1));
        assert_eq!(entry.intent, "fresh");
        assert_eq!(entry.status, WorkStatus::Working);

        // Lean DTO is narrow: no `progress` free-text leaks through. The rich
        // serialized form must not carry it.
        let json = serde_json::to_string(&lean).unwrap();
        assert!(
            !json.contains("progress"),
            "lean DTO drops progress: {json}"
        );
        assert!(!json.contains("detailed progress note"));
        assert!(json.contains("\"agentId\""), "camelCase lean DTO");

        // Prune persisted: the store no longer holds the stale row either.
        assert_eq!(store.load().await.unwrap().entries.len(), 1);
    }
}
