//! CRDT document types, read-side view types, and CRDT-state events.

#![allow(unused_imports, dead_code)]
use bft_json_crdt::json_crdt::*;
use bft_json_crdt::list_crdt::ListCrdt;
use bft_json_crdt::lww_crdt::LwwRegisterCrdt;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;

/// An event emitted when a pipeline item's stage changes in the CRDT document.
#[derive(Clone, Debug)]
pub struct CrdtEvent {
    /// Work item ID (e.g. `"42_story_my_feature"`).
    pub story_id: String,
    /// The stage the item was in before this transition, or `None` for new items.
    pub from_stage: Option<crate::pipeline_state::Stage>,
    /// The stage the item is now in.
    pub to_stage: crate::pipeline_state::Stage,
    /// Human-readable story name from the CRDT document (empty string when unset).
    pub name: String,
}

// ── CRDT document types ──────────────────────────────────────────────

/// CRDT sub-document holding gateway-level configuration.
///
/// Stored as a nested node in [`PipelineDoc`] so that gateway settings are
/// replicated across all connected nodes.  LWW semantics ensure the last
/// writer wins on concurrent updates.
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug, Serialize, Deserialize)]
pub struct GatewayConfigCrdt {
    /// The currently active project name (empty string = unset / use default).
    pub active_project: LwwRegisterCrdt<String>,
}

/// Top-level CRDT document holding all replicated pipeline state (items, nodes, jobs, etc.).
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug, Serialize, Deserialize)]
pub struct PipelineDoc {
    pub items: ListCrdt<PipelineItemCrdt>,
    pub nodes: ListCrdt<NodePresenceCrdt>,
    pub tokens: ListCrdt<TokenUsageCrdt>,
    pub merge_jobs: ListCrdt<MergeJobCrdt>,
    pub active_agents: ListCrdt<ActiveAgentCrdt>,
    pub test_jobs: ListCrdt<TestJobCrdt>,
    pub agent_throttle: ListCrdt<AgentThrottleCrdt>,
    pub gateway_projects: ListCrdt<GatewayProjectCrdt>,
    pub gateway_config: GatewayConfigCrdt,
    /// Append-only log of every pipeline transition, persisted as CRDT ops.
    pub event_log: ListCrdt<EventLogEntryCrdt>,
    /// Per-session LLM context state (high-water marks for event log injection).
    pub llm_sessions: ListCrdt<LlmSessionCrdt>,
}

/// CRDT entry representing a single persisted pipeline stage-transition event.
///
/// Entries are append-only; once written they are never updated or tombstoned.
/// The `event_seq` field is a per-sled monotonic counter computed at write time
/// (count of existing entries for that sled), giving deterministic ordering for
/// all transitions recorded by a single node even after CRDT replay on restart.
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug, Serialize, Deserialize)]
pub struct EventLogEntryCrdt {
    /// Monotonic sequence number for this sled (0, 1, 2, …).  Stored as `f64`
    /// because all CRDT scalar registers use JSON numbers.
    pub event_seq: LwwRegisterCrdt<f64>,
    /// Hex-encoded Ed25519 public key of the sled that recorded this event.
    pub sled_id: LwwRegisterCrdt<String>,
    /// Unix timestamp (seconds) when the transition fired.
    pub timestamp: LwwRegisterCrdt<f64>,
    /// Story ID of the work item that transitioned (e.g. `"42_story_foo"`).
    pub story_id: LwwRegisterCrdt<String>,
    /// Human-readable label of the stage before the transition.
    pub from_stage: LwwRegisterCrdt<String>,
    /// Human-readable label of the stage after the transition.
    pub to_stage: LwwRegisterCrdt<String>,
    /// String label of the `PipelineEvent` variant that triggered the transition.
    pub pipeline_event: LwwRegisterCrdt<String>,
}

/// CRDT entry tracking an LLM session's event-log injection state.
///
/// Each session (keyed by `session_id`, typically a Matrix room ID) records the
/// per-sled high-water marks so that `assemble_prompt_context` can inject only
/// events the LLM has not yet seen and then advance the marks atomically.
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug, Serialize, Deserialize)]
pub struct LlmSessionCrdt {
    /// Stable session identifier (e.g. Matrix room ID).
    pub session_id: LwwRegisterCrdt<String>,
    /// Human-readable persona name (e.g. `"Timmy"`).
    pub persona_name: LwwRegisterCrdt<String>,
    /// Scope wire string parsed by [`ScopeFilter::from_scope_str`]: `"all"`,
    /// `"sleds:hex1,hex2"`, or legacy `"single-sled"` / empty (→ local sled).
    pub scope: LwwRegisterCrdt<String>,
    /// JSON-serialised `BTreeMap<sled_id, last_seen_event_seq>` tracking how far
    /// each sled's event stream has been injected into this session's prompts.
    pub high_water: LwwRegisterCrdt<String>,
}

/// Which sleds' events an LLM session may see.
///
/// Stored as a compact string in the CRDT register and parsed at read time.
/// The default for a freshly-created session with no stored scope is
/// [`ScopeFilter::LocalOnly`], which preserves prior single-sled behaviour.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ScopeFilter {
    /// Include events from every sled present in the CRDT event log.
    ///
    /// Default for gateway-level personas (e.g. Timmy in multi-project mode).
    All,
    /// Include only events whose `sled_id` is in the given set.
    ///
    /// Default for sled-level personas: the set contains only the sled's own ID.
    Sleds(std::collections::BTreeSet<String>),
}

impl ScopeFilter {
    /// Parse a wire-form scope string stored in the CRDT register.
    ///
    /// Recognised forms:
    /// - `"all"` → [`ScopeFilter::All`]
    /// - `"sleds:hex1,hex2,…"` → [`ScopeFilter::Sleds`]
    /// - Anything else (including legacy `"single-sled"` and empty) →
    ///   [`ScopeFilter::Sleds`] with an empty set; callers should fall back
    ///   to the local sled ID in that case.
    pub fn from_scope_str(s: &str) -> Self {
        if s == "all" {
            return ScopeFilter::All;
        }
        if let Some(rest) = s.strip_prefix("sleds:") {
            let ids = rest
                .split(',')
                .filter(|id| !id.is_empty())
                .map(|id| id.to_string())
                .collect();
            return ScopeFilter::Sleds(ids);
        }
        ScopeFilter::Sleds(std::collections::BTreeSet::new())
    }

    /// Encode this filter as the compact wire string stored in the CRDT.
    pub fn to_scope_str(&self) -> String {
        match self {
            ScopeFilter::All => "all".to_string(),
            ScopeFilter::Sleds(ids) => {
                let joined = ids.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(",");
                format!("sleds:{joined}")
            }
        }
    }
}

/// Read-side snapshot of a single LLM session entry.
pub struct LlmSessionView {
    /// Stable session identifier.
    pub session_id: String,
    /// Persona name for the bot in this session.
    pub persona_name: String,
    /// Parsed event-scope filter derived from the `scope` CRDT register.
    pub scope_filter: ScopeFilter,
    /// Decoded high-water map: sled_id → last seen event_seq.
    pub high_water: std::collections::BTreeMap<String, u64>,
}

/// CRDT sub-document representing a single pipeline work item with LWW fields for stage, agent, etc.
///
/// Story 945: the `blocked`, `review_hold`, `frozen`, and
/// `mergemaster_attempted` boolean flags have been deleted.  Each of the
/// states those flags encoded is represented as a `Stage` variant, so the
/// stage register is the single source of truth.  A sibling `resume_to`
/// register stores the `Stage::Frozen { resume_to }` / `Stage::ReviewHold
/// { resume_to, .. }` resume target as a clean wire-form stage name.
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug, Serialize, Deserialize)]
pub struct PipelineItemCrdt {
    pub story_id: LwwRegisterCrdt<String>,
    pub stage: LwwRegisterCrdt<String>,
    pub name: LwwRegisterCrdt<String>,
    pub agent: LwwRegisterCrdt<String>,
    pub retry_count: LwwRegisterCrdt<f64>,
    pub depends_on: LwwRegisterCrdt<String>,
    /// Name of the agent (e.g. `"coder-1"`) that has claimed this item.
    /// Empty string means the item is unclaimed.  Replaces the legacy
    /// `claimed_by` node-hex register (story 1009).
    pub claim_agent: LwwRegisterCrdt<String>,
    /// Unix timestamp (seconds) when the claim was written.
    /// Zero means no active claim.  Previously named `claimed_at`.
    pub claim_ts: LwwRegisterCrdt<f64>,
    /// Unix timestamp (seconds) when the item was merged to master.
    /// Written once when the item transitions to `5_done`.  Used by the
    /// sweep loop to determine when to promote to `6_archived`.
    pub merged_at: LwwRegisterCrdt<f64>,
    /// QA mode override for this item: `"server"`, `"agent"`, or `"human"`.
    /// Empty string means "use the project default".
    pub qa_mode: LwwRegisterCrdt<String>,
    /// Item type: `"story"`, `"bug"`, `"spike"`, `"refactor"`, or `"epic"`.
    /// Empty string means "infer from story_id slug or default to story".
    /// Sub-story 933; replaces the legacy `type:` YAML front-matter field.
    pub item_type: LwwRegisterCrdt<String>,
    /// Epic ID this item belongs to, or empty string when the item is not a
    /// member of any epic.  Sub-story 933; replaces the legacy `epic:` YAML
    /// front-matter field that linked member work items to their epic.
    pub epic: LwwRegisterCrdt<String>,
    /// Story 945: resume-target stage for `Stage::Frozen` and
    /// `Stage::ReviewHold` variants.  Stored as a clean wire-form stage
    /// name (e.g. `"coding"`, `"qa"`).  Empty string means "no resume target
    /// stored" (defaults to `Coding` on read).
    /// Story 984: also reused to carry `superseded_by` (story ID) when the
    /// stage is `"superseded"`, and the rejection `reason` when the stage is
    /// `"rejected"`.  These stages never have a resume target, so the
    /// register is exclusively available for their metadata.
    pub resume_to: LwwRegisterCrdt<String>,
    /// Story 1010: lifecycle state of `PLAN.md` in the coding worktree.
    /// Wire values: `"missing"` (default/empty), `"drafted"`, `"confirmed"`.
    /// Updated by the filesystem watcher on PLAN.md create/modify/remove events.
    pub plan_state: LwwRegisterCrdt<String>,
    /// Story 1036: Unix timestamp (f64 seconds) of the server process that
    /// started the currently active merge task for this item.  Zero / absent
    /// means no merge task is in flight.  Projected into `Stage::Merge {
    /// server_start_time }` so callers never read this register directly.
    pub merge_server_start: LwwRegisterCrdt<f64>,
    /// Story 1086: kebab-case wire form of the [`crate::pipeline_state::Pipeline`]
    /// projection of the current `stage`.  Written by `write_item` alongside
    /// `stage` so display/scan code on remote peers can route by pipeline column
    /// without re-deriving from the stage string.  Empty string means "use the
    /// value derived from `stage`" (legacy items predating 1086).
    pub pipeline: LwwRegisterCrdt<String>,
    /// Story 1086: kebab-case wire form of the [`crate::pipeline_state::Status`]
    /// projection of the current `stage`.  Written alongside `stage` so badge
    /// renderers can read the status directly without re-projecting from the
    /// stage string.  Empty string means "use the value derived from `stage`"
    /// (legacy items predating 1086).
    pub status: LwwRegisterCrdt<String>,
    /// Story 1088: origin of the work item — who or what created it.
    ///
    /// Stored as a compact JSON string, e.g.
    /// `{"kind":"user","id":"","ts":1716768000.0}` or
    /// `{"kind":"agent","id":"coder-1","ts":1716768000.0}`.
    /// Empty string on older items that pre-date this register; the typed
    /// read path surfaces those as `None`, which the UI renders as `"unknown"`.
    pub origin: LwwRegisterCrdt<String>,
}

/// CRDT node that holds a single peer's presence entry.
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug, Serialize, Deserialize)]
pub struct NodePresenceCrdt {
    /// Hex-encoded Ed25519 public key — stable identity across restarts.
    pub node_id: LwwRegisterCrdt<String>,
    /// WebSocket URL this peer advertises, e.g. `ws://192.168.1.10:3001/crdt-sync`.
    pub address: LwwRegisterCrdt<String>,
    /// Unix timestamp (seconds) of the last heartbeat written by this node.
    pub last_seen: LwwRegisterCrdt<f64>,
    /// `false` once a stale-detection pass has tombstoned this node.
    pub alive: LwwRegisterCrdt<bool>,
    /// Human-readable display name for this node, e.g. `"builder-1"`.
    pub label: LwwRegisterCrdt<String>,
    /// Project slug this node is assigned to, or empty string if unassigned.
    pub assigned_project: LwwRegisterCrdt<String>,
    /// Unix timestamp in **milliseconds** of the last heartbeat (higher precision than `last_seen`).
    pub last_seen_ms: LwwRegisterCrdt<f64>,
}

// ── Read-side view types ─────────────────────────────────────────────

/// Numeric identifier for an epic work item.
///
/// The numeric prefix of the epic's story_id (e.g. `EpicId(9990)` for the
/// epic whose CRDT story_id register holds `"9990"`).  Epics are always
/// created with a pure-numeric story_id by `create_epic_file`.
///
/// Serialises as a decimal string (`"9990"`) so JSON consumers see a stable
/// string identifier regardless of whether the inner value fits in a JS integer.
/// Deserialises from a decimal string.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct EpicId(pub u32);

impl EpicId {
    /// Parse a CRDT epic string (e.g. `"9990"`) into an `EpicId`.
    ///
    /// Accepts both pure-numeric strings (`"9990"`) and slug-prefixed strings
    /// (`"9990_epic_foo"`) by stripping any non-digit suffix.
    pub fn from_crdt_str(s: &str) -> Option<Self> {
        // Try pure-numeric parse first.
        if let Ok(n) = s.parse::<u32>() {
            return Some(Self(n));
        }
        // Fall back: take the leading numeric prefix before the first `_`.
        s.split('_').next()?.parse::<u32>().ok().map(Self)
    }
}

impl std::fmt::Display for EpicId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

impl serde::Serialize for EpicId {
    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        serializer.serialize_str(&self.0.to_string())
    }
}

impl<'de> serde::Deserialize<'de> for EpicId {
    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        let s = String::deserialize(deserializer)?;
        s.parse::<u32>()
            .map(EpicId)
            .map_err(serde::de::Error::custom)
    }
}

/// A typed snapshot of a single pipeline work item derived from the CRDT document.
///
/// Access fields exclusively through the typed accessor methods — raw field access is
/// restricted to the `crdt_state` module tree.  All `JsonValue` interpretation is
/// confined to `crdt_state::read::extract_item_view`, so no `JsonValue` escapes into
/// the public API.
///
/// Adding a new field here without also reading it in an accessor produces an
/// `unused field` compiler warning, enforcing the read-side contract at compile time.
#[derive(Clone, Debug)]
pub struct WorkItem {
    pub(super) story_id: String,
    pub(super) stage: crate::pipeline_state::Stage,
    /// Human-readable name. `extract_item_view` returns `None` (filtering the item
    /// out of all read paths) when this register is empty — a nameless item is
    /// treated as malformed, not surfaced with an empty string.
    pub(super) name: String,
    pub(super) agent: Option<crate::config::AgentName>,
    /// Dependency story numbers — empty `Vec` when the register is unset.
    pub(super) depends_on: Vec<u32>,
    /// QA mode override. `None` means "use the project default".
    pub(super) qa_mode: Option<crate::io::story_metadata::QaMode>,
    /// Item type. `None` means "infer from the story_id slug prefix".
    pub(super) item_type: Option<crate::io::story_metadata::ItemType>,
    /// Epic this item belongs to. `None` when the item has no parent epic.
    pub(super) epic: Option<EpicId>,
    /// Origin of the work item (story 1088).  `None` for items created before
    /// the origin register was introduced; those display as `"unknown"`.
    pub(super) origin: Option<String>,
}

impl WorkItem {
    /// The story identifier (e.g. `"42"` or `"42_story_my_feature"`).
    pub fn story_id(&self) -> &str {
        &self.story_id
    }

    /// Pipeline stage as a typed [`crate::pipeline_state::Stage`].
    pub fn stage(&self) -> &crate::pipeline_state::Stage {
        &self.stage
    }

    /// Human-readable story name.
    ///
    /// Items without a name never reach callers — `extract_item_view` returns
    /// `None` for nameless items so they are filtered out of all read paths.
    pub fn name(&self) -> &str {
        &self.name
    }

    /// Typed agent pinned to this item, or `None` when unset.
    pub fn agent(&self) -> Option<crate::config::AgentName> {
        self.agent
    }

    /// Retry counter projected from the Stage variant.
    ///
    /// Returns `retries` from `Stage::Coding` or `Stage::Merge`; `0` for all
    /// other stages (QA, Backlog, Done, etc. carry no retry state).
    pub fn retry_count(&self) -> u32 {
        match &self.stage {
            crate::pipeline_state::Stage::Coding { retries, .. } => *retries,
            crate::pipeline_state::Stage::Merge { retries, .. } => *retries,
            _ => 0,
        }
    }

    /// Dependency story numbers. Returns an empty slice when unset.
    pub fn depends_on(&self) -> &[u32] {
        &self.depends_on
    }

    /// QA mode override, or `None` when the register is unset (use project default).
    pub fn qa_mode(&self) -> Option<crate::io::story_metadata::QaMode> {
        self.qa_mode
    }

    /// Item type, or `None` when the register is unset (infer from story_id prefix).
    pub fn item_type(&self) -> Option<crate::io::story_metadata::ItemType> {
        self.item_type
    }

    /// Epic this item belongs to, or `None` when unset.
    pub fn epic(&self) -> Option<EpicId> {
        self.epic
    }

    /// Origin of the work item (story 1088), or `None` for items created before
    /// the origin register was introduced.
    pub fn origin(&self) -> Option<&str> {
        self.origin.as_deref()
    }

    /// Construct a `WorkItem` for use in tests outside `crdt_state::*`.
    ///
    /// Within `crdt_state` use a struct literal directly (fields are `pub(super)`).
    /// Each field must be supplied — adding a new field to `WorkItem` without updating
    /// this constructor produces a compile error, enforcing the read-side contract.
    #[allow(clippy::too_many_arguments)]
    pub fn for_test(
        story_id: impl Into<String>,
        stage: crate::pipeline_state::Stage,
        name: impl Into<String>,
        agent: Option<crate::config::AgentName>,
        depends_on: Vec<u32>,
        qa_mode: Option<crate::io::story_metadata::QaMode>,
        item_type: Option<crate::io::story_metadata::ItemType>,
        epic: Option<EpicId>,
    ) -> Self {
        Self {
            story_id: story_id.into(),
            stage,
            name: name.into(),
            agent,
            depends_on,
            qa_mode,
            item_type,
            epic,
            origin: None,
        }
    }
}

/// Backward-compatibility alias; prefer [`WorkItem`].
pub type PipelineItemView = WorkItem;

/// A snapshot of a single node presence entry derived from the CRDT document.
#[derive(Clone, Debug, serde::Serialize)]
pub struct NodePresenceView {
    pub node_id: String,
    pub address: String,
    /// Unix timestamp (seconds).
    pub last_seen: f64,
    pub alive: bool,
    /// Human-readable display name; `None` if not yet set.
    pub label: Option<String>,
    /// Project slug this node is assigned to; `None` if unassigned.
    pub assigned_project: Option<String>,
    /// Unix timestamp in milliseconds; `None` if not yet set.
    pub last_seen_ms: Option<f64>,
}

// ── LWW-map CRDT types ───────────────────────────────────────────────
// Each collection is a `ListCrdt` with a primary-key field.  A secondary
// index in `CrdtState` provides O(1) lookup by that key.

/// CRDT entry holding per-agent token-usage metrics.
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug, Serialize, Deserialize)]
pub struct TokenUsageCrdt {
    /// Unique key (e.g. `"coder-1:42_story_foo"`).
    pub agent_id: LwwRegisterCrdt<String>,
    pub story_id: LwwRegisterCrdt<String>,
    pub input_tokens: LwwRegisterCrdt<f64>,
    pub output_tokens: LwwRegisterCrdt<f64>,
    /// Unix timestamp (seconds) of this record.
    pub timestamp: LwwRegisterCrdt<f64>,
}

/// CRDT entry describing a merge job.
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug, Serialize, Deserialize)]
pub struct MergeJobCrdt {
    /// Unique key: the story being merged.
    pub story_id: LwwRegisterCrdt<String>,
    /// Status: `"pending"`, `"running"`, `"done"`, or `"failed"`.
    pub status: LwwRegisterCrdt<String>,
    pub started_at: LwwRegisterCrdt<f64>,
    pub finished_at: LwwRegisterCrdt<f64>,
    pub error: LwwRegisterCrdt<String>,
}

/// CRDT entry for a currently-running agent instance.
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug, Serialize, Deserialize)]
pub struct ActiveAgentCrdt {
    /// Unique key (e.g. `"coder-1"`).
    pub agent_id: LwwRegisterCrdt<String>,
    pub story_id: LwwRegisterCrdt<String>,
    pub node_id: LwwRegisterCrdt<String>,
    pub started_at: LwwRegisterCrdt<f64>,
}

/// CRDT entry describing a test job.
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug, Serialize, Deserialize)]
pub struct TestJobCrdt {
    /// Unique key: the story under test.
    pub story_id: LwwRegisterCrdt<String>,
    /// Status: `"pending"`, `"running"`, `"pass"`, or `"fail"`.
    pub status: LwwRegisterCrdt<String>,
    pub started_at: LwwRegisterCrdt<f64>,
    pub finished_at: LwwRegisterCrdt<f64>,
    pub output: LwwRegisterCrdt<String>,
}

/// CRDT entry holding per-node agent-throttle state.
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug, Serialize, Deserialize)]
pub struct AgentThrottleCrdt {
    /// Unique key: the node whose throttle this tracks.
    pub node_id: LwwRegisterCrdt<String>,
    /// Unix timestamp (seconds) of the throttle window start.
    pub window_start: LwwRegisterCrdt<f64>,
    /// Agent starts recorded in the current window.
    pub count: LwwRegisterCrdt<f64>,
    /// Maximum starts allowed per window.
    pub limit: LwwRegisterCrdt<f64>,
}

/// CRDT entry for a gateway project registered in `gateway_config.projects`.
#[add_crdt_fields]
#[derive(Clone, CrdtNode, Debug, Serialize, Deserialize)]
pub struct GatewayProjectCrdt {
    /// Unique key: project name (e.g. `"huskies"`).
    pub name: LwwRegisterCrdt<String>,
    /// Container base URL (e.g. `"http://huskies:3001"`).
    pub url: LwwRegisterCrdt<String>,
}

// ── LWW-map view types ───────────────────────────────────────────────

/// Snapshot of a single token-usage entry.
#[derive(Clone, Debug)]
pub struct TokenUsageView {
    pub agent_id: String,
    pub story_id: Option<String>,
    pub input_tokens: f64,
    pub output_tokens: f64,
    /// Unix timestamp (seconds).
    pub timestamp: f64,
}

/// Snapshot of a single merge-job entry.
#[derive(Clone, Debug)]
pub struct MergeJobView {
    pub story_id: String,
    pub status: String,
    pub started_at: f64,
    pub finished_at: Option<f64>,
    pub error: Option<String>,
}

/// Snapshot of a single active-agent entry.
#[derive(Clone, Debug)]
pub struct ActiveAgentView {
    pub agent_id: String,
    pub story_id: Option<String>,
    pub node_id: Option<String>,
    pub started_at: f64,
}

/// Snapshot of a single test-job entry.
#[derive(Clone, Debug)]
pub struct TestJobView {
    pub story_id: String,
    pub status: String,
    pub started_at: f64,
    pub finished_at: Option<f64>,
    pub output: Option<String>,
}

/// Snapshot of a single agent-throttle entry.
#[derive(Clone, Debug)]
pub struct AgentThrottleView {
    pub node_id: String,
    pub window_start: f64,
    pub count: f64,
    pub limit: f64,
}

/// Snapshot of a single gateway-project entry.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct GatewayProjectView {
    pub name: String,
    pub url: String,
}

#[cfg(test)]
mod tests {
    use super::super::state::emit_event;
    use super::super::state::init_for_test;
    use super::super::write::write_item;
    use super::*;
    use bft_json_crdt::json_crdt::OpState;
    use bft_json_crdt::keypair::make_keypair;
    use bft_json_crdt::op::ROOT_ID;
    use serde_json::json;

    #[test]
    fn crdt_doc_insert_and_view() {
        let kp = make_keypair();
        let mut crdt = BaseCrdt::<PipelineDoc>::new(&kp);

        let item_json: JsonValue = json!({
            "story_id": "10_story_test",
            "stage": "2_current",
            "name": "Test Story",
            "agent": "coder-opus",
            "retry_count": 0.0,
            "blocked": false,
            "depends_on": "",
            "claim_agent": "",
            "claim_ts": 0.0,
        })
        .into();

        let op = crdt.doc.items.insert(ROOT_ID, item_json).sign(&kp);
        assert_eq!(crdt.apply(op), OpState::Ok);

        let view = crdt.doc.items.view();
        assert_eq!(view.len(), 1);

        let item = &crdt.doc.items[0];
        assert_eq!(
            item.story_id.view(),
            JsonValue::String("10_story_test".to_string())
        );
        assert_eq!(
            item.stage.view(),
            JsonValue::String("2_current".to_string())
        );
    }

    #[test]
    fn crdt_doc_update_stage() {
        let kp = make_keypair();
        let mut crdt = BaseCrdt::<PipelineDoc>::new(&kp);

        let item_json: JsonValue = json!({
            "story_id": "20_story_move",
            "stage": "1_backlog",
            "name": "Move Me",
            "agent": "",
            "retry_count": 0.0,
            "blocked": false,
            "depends_on": "",
            "claim_agent": "",
            "claim_ts": 0.0,
        })
        .into();

        let insert_op = crdt.doc.items.insert(ROOT_ID, item_json).sign(&kp);
        crdt.apply(insert_op);

        // Update stage
        let stage_op = crdt.doc.items[0]
            .stage
            .set("2_current".to_string())
            .sign(&kp);
        crdt.apply(stage_op);

        assert_eq!(
            crdt.doc.items[0].stage.view(),
            JsonValue::String("2_current".to_string())
        );
    }

    #[test]
    fn crdt_event_has_expected_fields() {
        let evt = CrdtEvent {
            story_id: "42_story_foo".to_string(),
            from_stage: Some(crate::pipeline_state::Stage::Backlog),
            to_stage: crate::pipeline_state::Stage::Coding {
                claim: None,
                plan: crate::pipeline_state::PlanState::Missing,
                retries: 0,
            },
            name: "Foo Feature".to_string(),
        };
        assert_eq!(evt.story_id, "42_story_foo");
        assert!(matches!(
            evt.from_stage,
            Some(crate::pipeline_state::Stage::Backlog)
        ));
        assert!(matches!(
            evt.to_stage,
            crate::pipeline_state::Stage::Coding { .. }
        ));
        assert_eq!(evt.name, "Foo Feature");
    }

    #[test]
    fn crdt_event_clone_preserves_data() {
        let evt = CrdtEvent {
            story_id: "10_story_bar".to_string(),
            from_stage: None,
            to_stage: crate::pipeline_state::Stage::Backlog,
            name: String::new(),
        };
        let cloned = evt.clone();
        assert_eq!(cloned.story_id, "10_story_bar");
        assert!(cloned.from_stage.is_none());
        assert!(cloned.name.is_empty());
    }

    #[test]
    fn emit_event_is_noop_when_channel_not_initialised() {
        // Before CRDT_EVENT_TX is set, emit_event should not panic.
        // This test verifies the guard clause works. In test binaries the
        // OnceLock may already be set by another test, so we just verify
        // the function doesn't panic regardless.
        emit_event(CrdtEvent {
            story_id: "99_story_noop".to_string(),
            from_stage: None,
            to_stage: crate::pipeline_state::Stage::Backlog,
            name: String::new(),
        });
    }

    #[test]
    fn node_metadata_fields_replicate_to_peer() {
        // Create two independent CRDT instances simulating two peers.
        let kp_a = make_keypair();
        let kp_b = make_keypair();
        let mut crdt_a = BaseCrdt::<PipelineDoc>::new(&kp_a);
        let mut crdt_b = BaseCrdt::<PipelineDoc>::new(&kp_b);

        // Peer A inserts a node entry with the new metadata fields.
        let node_json: JsonValue = serde_json::json!({
            "node_id": "aabbccdd",
            "address": "ws://192.168.1.10:3001/crdt-sync",
            "last_seen": 1_000_000.0_f64,
            "alive": true,
            "label": "builder-1",
            "assigned_project": "my-project",
            "last_seen_ms": 1_000_000_123.0_f64,
        })
        .into();

        let insert_op = crdt_a.doc.nodes.insert(ROOT_ID, node_json).sign(&kp_a);
        assert_eq!(crdt_a.apply(insert_op.clone()), OpState::Ok);

        // Replicate the insert op to peer B.
        assert_eq!(crdt_b.apply(insert_op), OpState::Ok);

        // Assert the node appeared on peer B.
        assert_eq!(crdt_b.doc.nodes.view().len(), 1);

        let node = &crdt_b.doc.nodes[0];
        assert_eq!(
            node.node_id.view(),
            JsonValue::String("aabbccdd".to_string())
        );
        assert_eq!(
            node.label.view(),
            JsonValue::String("builder-1".to_string())
        );
        assert_eq!(
            node.assigned_project.view(),
            JsonValue::String("my-project".to_string())
        );
        assert_eq!(node.last_seen_ms.view(), JsonValue::Number(1_000_000_123.0));

        // Peer A updates label and last_seen_ms via separate ops.
        let label_op = crdt_a.doc.nodes[0]
            .label
            .set("builder-1-renamed".to_string())
            .sign(&kp_a);
        let ms_op = crdt_a.doc.nodes[0]
            .last_seen_ms
            .set(2_000_000_000.0_f64)
            .sign(&kp_a);
        assert_eq!(crdt_a.apply(label_op.clone()), OpState::Ok);
        assert_eq!(crdt_a.apply(ms_op.clone()), OpState::Ok);

        // Replicate updates to peer B.
        assert_eq!(crdt_b.apply(label_op), OpState::Ok);
        assert_eq!(crdt_b.apply(ms_op), OpState::Ok);

        // Both peers converge to the same state.
        assert_eq!(
            crdt_b.doc.nodes[0].label.view(),
            JsonValue::String("builder-1-renamed".to_string())
        );
        assert_eq!(
            crdt_b.doc.nodes[0].last_seen_ms.view(),
            JsonValue::Number(2_000_000_000.0)
        );
        assert_eq!(
            crdt_a.doc.nodes[0].label.view(),
            crdt_b.doc.nodes[0].label.view()
        );
        assert_eq!(
            crdt_a.doc.nodes[0].last_seen_ms.view(),
            crdt_b.doc.nodes[0].last_seen_ms.view()
        );
    }

    #[test]
    fn node_metadata_deserializes_with_defaults_for_pre_existing_entries() {
        // Pre-existing entries without new fields should deserialize with sensible defaults.
        let kp = make_keypair();
        let mut crdt = BaseCrdt::<PipelineDoc>::new(&kp);

        // Insert a legacy node without the new fields.
        let legacy_json: JsonValue = serde_json::json!({
            "node_id": "legacy-node",
            "address": "ws://10.0.0.1:3001/crdt-sync",
            "last_seen": 500_000.0_f64,
            "alive": true,
        })
        .into();

        let op = crdt.doc.nodes.insert(ROOT_ID, legacy_json).sign(&kp);
        assert_eq!(crdt.apply(op), OpState::Ok);

        let node = &crdt.doc.nodes[0];
        // New fields default to Null (not set).
        assert_eq!(node.label.view(), JsonValue::Null);
        assert_eq!(node.assigned_project.view(), JsonValue::Null);
        assert_eq!(node.last_seen_ms.view(), JsonValue::Null);
        // Existing fields still work.
        assert_eq!(
            node.node_id.view(),
            JsonValue::String("legacy-node".to_string())
        );
        assert_eq!(node.alive.view(), JsonValue::Bool(true));
    }

    #[test]
    fn crdt_event_broadcast_channel_round_trip() {
        use crate::pipeline_state::Stage;
        let (tx, mut rx) = broadcast::channel::<CrdtEvent>(16);
        let evt = CrdtEvent {
            story_id: "70_story_broadcast".to_string(),
            from_stage: Some(Stage::Backlog),
            to_stage: Stage::Coding {
                claim: None,
                plan: crate::pipeline_state::PlanState::Missing,
                retries: 0,
            },
            name: "Broadcast Test".to_string(),
        };
        tx.send(evt).unwrap();

        let received = rx.try_recv().unwrap();
        assert_eq!(received.story_id, "70_story_broadcast");
        assert!(matches!(received.from_stage, Some(Stage::Backlog)));
        assert!(matches!(received.to_stage, Stage::Coding { .. }));
        assert_eq!(received.name, "Broadcast Test");
    }
}
