using System;
using System.Collections.Generic;
using System.Threading;
namespace AcDream.Core.Chat;
///
/// One visible SpewBox line: display text plus the caller-clock timestamp
/// (seconds) at which it should be pruned.
///
public readonly record struct SpewBoxEntry(string Text, double ExpiresAtSeconds);
///
/// Retail's transient on-screen "interface text" — a direct port of
/// gmSpewBoxUI's pending/visible split
/// (docs/research/2026-08-09-chat-retail-interface-text.md §1.1/§3.1).
/// Pure state, no presentation.
///
///
/// Placement note (deviation from the CH2 brief): the brief names
/// AcDream.Runtime as this type's home. It lives in
/// AcDream.Core.Chat instead, directly beside ,
/// because AcDream.UI.Abstractions (Code Structure Rule 3 — panels/
/// ViewModels target UI.Abstractions only) references AcDream.Core
/// but NOT AcDream.Runtime, and the UI.Abstractions
/// SpewBoxVM needs to wrap this type directly — exactly the same
/// constraint already satisfies by wrapping
/// (also Core, not Runtime). RuntimeCommunicationState
/// still owns the canonical instance and is still the sole writer via its
/// AddText router, matching every other J4-era Runtime-owns/App-or-
/// UI.Abstractions-borrows pattern in this codebase.
///
///
///
/// Retail decouples enqueue (RecvNotice_DisplayFinalStringInfo
/// @0x004D60A0, type-filtered to 0x1A only) from display
/// (Update @0x004D5DF0, driven once per UI tick by global message
/// 3) — NOT by exactly one frame (fixed 2026-08-09, CH2 REJECT-review
/// rework NIT 5: the earlier wording overstated this). A message that
/// arrives just before the tick fires waits ~0 frames; one that arrives
/// just after waits nearly a full frame — retail's own gap is 0-1 frames,
/// bounded by tick cadence, not a fixed one-frame delay.
/// reproduces the SAME-CALL shape: it drains whatever is pending into the
/// visible list (applying retail's dedupe-against-index-0 and
/// MaxConcurrentItems overflow rules) and prunes expired entries in
/// one call, so a caller invoking then immediately
/// and in the same frame sees the
/// line SAME-frame — the decoupling only shows up when the caller's own
/// tick cadence spans multiple frames, exactly like retail's.
///
///
public sealed class SpewBoxState
{
///
/// The shipped LayoutDesc's AUTHORED value — no longer a placeholder.
/// CH2 REJECT-review rework, NIT 3
/// (docs/research/2026-08-09-ch2-review-findings.md), wording
/// corrected at the CH2 re-review nits pass
/// (docs/plans/2026-08-09-chat-parity-campaign.md, nit 6): the
/// original C.7 sweep's dats.Portal pass was not a meaningful
/// search — its id source was DatCollection's top-level
/// AGGREGATE GetAllIdsOfType<LayoutDesc>(), not
/// dats.Portal's own (which reports a count of ZERO for this
/// type), so querying those ids against dats.Portal.TryGet
/// established nothing about Portal either way. Extending the sweep
/// to dats.Local (client_local_English.dat) — this time
/// with a correctly-paired id source — found it: LayoutDesc
/// 0x21000011, element 0x10000048, whose sole child
/// (ListBox 0x10000049, matching gmSpewBoxUI::PostInit's
/// GetChildRecursive(0x10000049) call verbatim) carries ListBox
/// property 0x10000028 = the integer 4. Retail's own
/// code default (gmSpewBoxUI::PostInit @0x004D5AB0), used only
/// when this property is absent or unreadable, was 1 — the
/// shipped layout overrides it with 4. See
/// SpewBoxLayoutDumpDiagnostic's own corrected RESULT comment
/// for the full id-source analysis.
///
public const int MaxConcurrentItems = 4;
///
/// Retail's own client never raises the expiry element message
/// (0x10000003) anywhere in the Sept 2013 EoR build — the real
/// per-line timeout is owned by keystone.dll's authored behaviour for
/// layout 0x10000012 element 0x1000004A and was not
/// measured in this slice (§3.2.1 of the research doc). This is an
/// INVENTED placeholder, not a retail-measured value — see the
/// divergence register.
///
public static readonly TimeSpan DefaultLifetime = TimeSpan.FromSeconds(5);
private readonly object _gate = new();
private readonly Queue _pending = new();
private readonly List _visible = new();
private long _revision;
/// Monotonic content revision — advances on any Tick that changes the visible set, and on Reset.
public long Revision => Interlocked.Read(ref _revision);
/// Number of currently visible lines (0..).
public int Count
{
get { lock (_gate) return _visible.Count; }
}
///
/// Enqueue text for display — retail's
/// RecvNotice_DisplayFinalStringInfo type-0x1A branch.
/// Does not itself become visible until the next .
///
public void Enqueue(string text)
{
lock (_gate)
_pending.Enqueue(text);
}
///
/// Drain any pending text into the visible list and prune expired
/// entries. Call once per UI frame/tick (retail's global message
/// 3, gmSpewBoxUI::Update).
///
///
/// Caller's own monotonic clock, in seconds. Only used to stamp new
/// entries' expiry and to prune old ones — never compared across
/// different clock sources.
///
public void Tick(double nowSeconds)
{
bool changed;
lock (_gate)
{
changed = _pending.Count > 0;
while (_pending.Count > 0)
{
string text = _pending.Dequeue();
// Dedupe against index 0 ONLY (retail: 0x004D5EF6-0x004D5F91)
// — an identical repeat refreshes the newest line in place
// instead of stacking a duplicate.
if (_visible.Count > 0 && _visible[0].Text == text)
_visible.RemoveAt(0);
// Newest at the top — retail's InsertItem(item, 0).
_visible.Insert(0, new SpewBoxEntry(text, nowSeconds + DefaultLifetime.TotalSeconds));
// Overflow drops the OLDEST (highest index) entry — retail's
// DeleteItem(count - 1) when count > m_maxConcurrentItems.
while (_visible.Count > MaxConcurrentItems)
_visible.RemoveAt(_visible.Count - 1);
}
int removed = _visible.RemoveAll(e => e.ExpiresAtSeconds <= nowSeconds);
changed |= removed > 0;
}
if (changed)
Interlocked.Increment(ref _revision);
}
/// Snapshot of currently visible lines, newest first.
public SpewBoxEntry[] Snapshot()
{
lock (_gate)
return _visible.ToArray();
}
/// Clear all pending and visible state — session/generation reset.
public void Reset()
{
lock (_gate)
{
_pending.Clear();
_visible.Clear();
}
Interlocked.Increment(ref _revision);
}
}