using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Net.Messages;
using AcDream.Core.Quests;
using AcDream.Core.Ui;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.UI.Layout;
///
/// The Journal panel's Contracts page — retail gmContractsUI
/// (element type 0x1000004B, page 0x100005D4).
///
///
///
/// A two-column list (Contract / Status) over a detail pane. Rows come from
/// — live server state — joined to the
/// authored for every word the player reads;
/// the wire itself carries only an id, a stage and two timers.
///
///
/// Rebuilds are revision-gated. The tracker changes rarely (accepting or
/// advancing a quest) while the panel ticks every frame, so polling
/// is what keeps this from
/// rebuilding a template list continuously.
///
///
public sealed class JournalContractsPageController
{
/// The layout the row template lives in — authored property
/// 0x63 on the list's template entry.
public const uint RowTemplateLayoutId = 0x21000069u;
/// The row template element — authored 0x62, and the id
/// retail passes to AddItemFromTemplateListByID @0x00499747.
public const uint RowTemplateElementId = 0x100005D7u;
private const uint ListId = 0x100005CFu;
private const uint RowNameId = 0x100005D1u;
private const uint RowStatusId = 0x100005D2u;
private const uint StatusValueId = 0x100005DFu;
private const uint ContactValueId = 0x100005E0u;
private const uint ContactLocationValueId = 0x100005E1u;
private const uint QuestLocationValueId = 0x100005E2u;
private const uint DescriptionId = 0x100005DEu;
private const uint TimedValueId = 0x100005E3u;
private const uint AbandonButtonId = 0x100005DCu;
/// Live state and services the page reads.
/// The canonical tracker view.
/// The authored contract text.
///
/// The clock the repeat countdown is measured against. Injected rather
/// than read from so the countdown is
/// testable without waiting for it.
///
/// Builds one row from the authored template.
///
/// Sends the abandon action for one contract. The row disappears only when
/// the SERVER answers with its own delete, so a refused abandon leaves the
/// quest exactly where it was.
///
public sealed record Bindings(
IRuntimeContractView Contracts,
Func Catalog,
Func Now,
Func TemplateResolver,
Action? Abandon = null);
private readonly Bindings _bindings;
private readonly UiTemplateListBox? _list;
private readonly UiText? _statusValue;
private readonly UiText? _contactValue;
private readonly UiText? _contactLocationValue;
private readonly UiText? _questLocationValue;
private readonly UiText? _description;
private readonly UiText? _timedValue;
///
/// Retail's own selection highlight for a listbox row. UiTemplateListBox
/// has no generic selection mechanism (its class doc), so the page opts in
/// directly — the same precedent
/// set.
///
private static readonly Vector4 SelectedNameColor = Vector4.One;
private readonly List _rowContractIds = [];
private readonly List<(uint ContractId, UiText? Name, Vector4 Unselected)> _rows = [];
private long _renderedRevision = -1;
private uint _selectedContractId;
public JournalContractsPageController(UiElement page, Bindings bindings)
{
ArgumentNullException.ThrowIfNull(page);
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
_list = UiElement.FindDescendant(page, ListId) as UiTemplateListBox;
if (_list is not null)
_list.TemplateResolver = bindings.TemplateResolver;
_statusValue = UiElement.FindDescendant(page, StatusValueId) as UiText;
_contactValue = UiElement.FindDescendant(page, ContactValueId) as UiText;
_contactLocationValue =
UiElement.FindDescendant(page, ContactLocationValueId) as UiText;
_questLocationValue =
UiElement.FindDescendant(page, QuestLocationValueId) as UiText;
_description = UiElement.FindDescendant(page, DescriptionId) as UiText;
_timedValue = UiElement.FindDescendant(page, TimedValueId) as UiText;
if (UiElement.FindDescendant(page, AbandonButtonId) is UiButton abandon)
abandon.OnClick = AbandonSelected;
Refresh();
}
/// The contract the detail pane is showing, or 0.
public uint SelectedContractId => _selectedContractId;
/// Contract ids in list order, for tests.
public IReadOnlyList RowContractIds => _rowContractIds;
public void Tick()
{
// Cheap every frame; a rebuild only when the tracker actually moved.
if (_bindings.Contracts.Snapshot.Revision != _renderedRevision)
Refresh();
else
RefreshDetail(); // the repeat countdown ticks without a rebuild
}
public void Refresh()
{
RuntimeContractsSnapshot snapshot = _bindings.Contracts.Snapshot;
_renderedRevision = snapshot.Revision;
IReadOnlyList contracts = _bindings.Contracts.GetContracts();
ContractCatalog catalog = _bindings.Catalog();
DateTime now = _bindings.Now();
// Retail's own default: the server nominates a display contract, and
// otherwise the first row stands.
if (snapshot.DisplayContractId != 0u)
_selectedContractId = snapshot.DisplayContractId;
if (_selectedContractId == 0u && contracts.Count != 0)
_selectedContractId = contracts[0].ContractId;
if (contracts.Count == 0)
_selectedContractId = 0u;
_rowContractIds.Clear();
_rows.Clear();
_list?.FlushPreservingScroll();
foreach (ContractTracker tracker in contracts)
{
_rowContractIds.Add(tracker.ContractId);
if (_list is null)
continue;
UiElement? row = _list.AddItemFromTemplateList(0);
if (row is null)
continue;
ContractEntry entry = catalog.Lookup(tracker.ContractId);
var name = UiElement.FindDescendant(row, RowNameId) as UiText;
if (name is not null)
SetText(name, entry.ContractName);
if (UiElement.FindDescendant(row, RowStatusId) is UiText status)
{
SetText(status, ContractProgressText.Build(
(uint)tracker.Stage, tracker.TimeWhenRepeats,
tracker.ReceivedAt, entry, now));
}
// Captured AFTER SetText, which never touches DefaultColor (it is
// read lazily inside the provider closure), so this is the row's
// own authored colour to restore on deselect.
_rows.Add((tracker.ContractId, name, name?.DefaultColor ?? Vector4.One));
uint captured = tracker.ContractId;
if (row is UiDatElement clickable)
{
// A generic Type-3 container is click-THROUGH by default, so
// without this the handler below never fires and the list looks
// dead. UiDatElement carries the opt-in seam for exactly this.
clickable.ClickThrough = false;
clickable.OnClick = () => Select(captured);
}
}
ApplySelectionHighlight();
RefreshDetail();
}
///
/// Abandons the selected contract — game action 0x0316.
///
///
/// Nothing is removed locally. The server replies with a 0x0315
/// carrying DeleteContract and the tracker drops the row then, so a
/// refusal leaves the quest visibly intact rather than vanishing it
/// optimistically and having it reappear.
///
public void AbandonSelected()
{
if (_selectedContractId == 0u)
return;
_bindings.Abandon?.Invoke(_selectedContractId);
}
/// Points the detail pane at one contract.
public void Select(uint contractId)
{
_selectedContractId = contractId;
ApplySelectionHighlight();
RefreshDetail();
}
///
/// Re-painted rather than merely remembered: a rebuild discards the old row
/// objects, so a preserved selection has to be applied to the new ones.
///
private void ApplySelectionHighlight()
{
foreach ((uint contractId, UiText? name, Vector4 unselected) in _rows)
{
if (name is not null)
{
name.DefaultColor =
contractId == _selectedContractId ? SelectedNameColor : unselected;
}
}
}
private void RefreshDetail()
{
ContractCatalog catalog = _bindings.Catalog();
DateTime now = _bindings.Now();
if (_selectedContractId == 0u
|| !_bindings.Contracts.TryGetContract(_selectedContractId, out ContractTracker tracker))
{
SetText(_statusValue, string.Empty);
SetText(_contactValue, string.Empty);
SetText(_contactLocationValue, string.Empty);
SetText(_questLocationValue, string.Empty);
SetText(_description, string.Empty);
SetText(_timedValue, string.Empty);
return;
}
ContractEntry entry = catalog.Lookup(_selectedContractId);
SetText(_statusValue, ContractProgressText.Build(
(uint)tracker.Stage, tracker.TimeWhenRepeats, tracker.ReceivedAt, entry, now));
SetText(_contactValue, entry.NameNpcStart);
SetText(_contactLocationValue, LocationText(entry.LocationNpcStartCell));
SetText(_questLocationValue, LocationText(entry.LocationQuestAreaCell));
SetText(_description, entry.Description);
// "Timed:" is the other wire timer — the one FillProgressString never
// reads. It belongs here, not in the Status column.
SetText(_timedValue, tracker.TimeWhenDone > 0d
? RetailDurationText.Format(
Math.Max(0d, tracker.TimeWhenDone - (now - tracker.ReceivedAt).TotalSeconds))
: string.Empty);
}
///
/// Coordinates, or retail's literal "Indoors" when the cell has none
/// (LandDefs::gid_to_lcoord failing, @0x0049937F).
///
private static string LocationText(uint cellId)
{
if (cellId == 0u) return string.Empty;
return RetailPositionFormatter.FormatOutdoorCell(cellId) ?? "Indoors";
}
private static void SetText(UiText? text, string value)
{
if (text is null) return;
text.LinesProvider = () => [new UiText.Line(value, text.DefaultColor)];
}
}