1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import {
3 generateSpreadPinOrderKeys,
4 pinOrderKeyBetween,
5 planPinnedReorder,
6} from "@t3tools/client-runtime/state/thread-sort";
7import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
8import type { EnvironmentId } from "@t3tools/contracts";
9
10export type ThreadMoveDestination =
11 | "up"
12 | "down"
13 | {
14 readonly targetId: string | null;
15 readonly section?: "pinned" | "active" | "settled";
16 readonly placement: "before" | "after";
17 };
18
19/** Resolve against stable row identities, including rows hidden by a filter. */
20export function threadOrderAfterMove(
21 orderedIds: readonly string[],
22 movedId: string,
23 destination: ThreadMoveDestination,
24): string[] | null {
25 if (typeof destination === "object" && destination.section === "settled") return null;
26 const from = orderedIds.indexOf(movedId);
27 if (from < 0 && (typeof destination === "string" || destination.section === undefined))
28 return null;
29 const result = orderedIds.filter((id) => id !== movedId);
30 let to: number;
31 if (typeof destination === "string") {
32 to = from + (destination === "up" ? -1 : 1);
33 if (to < 0 || to >= orderedIds.length) return null;
34 } else {
35 if (destination.targetId === null) {
36 if (destination.section === undefined) return null;
37 to = destination.placement === "before" ? 0 : result.length;
38 } else {
39 const target = result.indexOf(destination.targetId);
40 if (target < 0) return null;
41 to = target + (destination.placement === "after" ? 1 : 0);
42 }
43 }
44 if (to === from) return null;
45 result.splice(to, 0, movedId);
46 return result;
47}
48
49export type OrderRow = Pick<
50 EnvironmentThreadShell,
51 | "id"
52 | "environmentId"
53 | "pinOrderKey"
54 | "activeOrderKey"
55 | "createdAt"
56 | "unsettledAt"
57 | "pinnedAt"
58>;
59
60export interface PendingThreadOrder {
61 readonly section: "pinned" | "active";
62 readonly orderedIds: readonly string[];
63 readonly before: ReadonlyMap<string, { readonly key: string | null; readonly anchor: string }>;
64 readonly assignments: ReadonlyMap<string, string>;
65 readonly confirmed: ReadonlySet<string>;
66 readonly commandsComplete: boolean;
67}
68
69function rowId(row: OrderRow): string {
70 return `${row.environmentId}:${row.id}`;
71}
72
73function rowOrder(row: OrderRow, section: PendingThreadOrder["section"]) {
74 return {
75 key: (section === "pinned" ? row.pinOrderKey : row.activeOrderKey) ?? null,
76 anchor: section === "pinned" ? (row.pinnedAt ?? "") : (row.unsettledAt ?? row.createdAt),
77 };
78}
79
80/** Keep every visible row as an anchor, but only offer plans whose key writes
81 * are supported. Menu availability and execution use this same planner. */
82export function createThreadMovePlanner(input: {
83 readonly ordered: readonly OrderRow[];
84 readonly allThreads?: readonly OrderRow[];
85 readonly section: PendingThreadOrder["section"];
86 readonly reorderableEnvironmentIds: ReadonlySet<EnvironmentId>;
87}) {
88 const orderedIds = input.ordered.map(rowId);
89 const keysById = new Map(
90 (input.allThreads ?? input.ordered).map((row) => [
91 rowId(row),
92 rowOrder(row, input.section).key,
93 ]),
94 );
95 const writableIds = new Set(
96 (input.allThreads ?? input.ordered)
97 .filter((row) => input.reorderableEnvironmentIds.has(row.environmentId))
98 .map(rowId),
99 );
100 return (movedId: string, direction: ThreadMoveDestination) => {
101 if (!writableIds.has(movedId)) return null;
102 const nextIds = threadOrderAfterMove(orderedIds, movedId, direction);
103 if (nextIds === null) return null;
104 const assignments = planPinnedReorder({ orderedIds: nextIds, keysById, movedId });
105 return assignments === null ||
106 assignments.length === 0 ||
107 assignments.some((assignment) => !writableIds.has(assignment.id))
108 ? null
109 : assignments;
110 };
111}
112
113export interface ThreadMoveAvailability {
114 readonly canMoveUp: boolean;
115 readonly canMoveDown: boolean;
116}
117
118/**
119 * Batch form of "call `createThreadMovePlanner` once per card": one pass over
120 * the section answers up/down availability for every ordered row, so list
121 * construction stays linear instead of one full planner probe (array copies,
122 * hidden-key rescans) per row on the minute-tick rebuild path. The mirror of
123 * the planner's plan rules lives in the body below; the reference-parity test
124 * pins them row-by-row, including adversarial keys and hidden reservations.
125 */
126export function computeThreadMoveAvailability(input: {
127 readonly ordered: readonly OrderRow[];
128 readonly allThreads?: readonly OrderRow[];
129 readonly section: PendingThreadOrder["section"];
130 readonly reorderableEnvironmentIds: ReadonlySet<EnvironmentId>;
131 readonly pendingOrder?: PendingThreadOrder | null;
132}): Map<string, ThreadMoveAvailability> {
133 const result = new Map<string, ThreadMoveAvailability>();
134 // A reorder in flight locks the whole list until its receipt lands.
135 if (input.pendingOrder != null) return result;
136 const rows = input.ordered;
137 // With no neighbor there is no up/down move, so skip the allThreads indexes.
138 if (rows.length < 2) {
139 for (const row of rows) result.set(rowId(row), { canMoveUp: false, canMoveDown: false });
140 return result;
141 }
142 // Plain loops: this runs per section on every Home rebuild over all shells,
143 // and the map/filter/spread chains it replaces dominated it with garbage.
144 const orderedIds: string[] = [];
145 const indexById = new Map<string, number>();
146 for (const row of rows) {
147 const id = rowId(row);
148 indexById.set(id, orderedIds.length);
149 orderedIds.push(id);
150 }
151 const keysById = new Map<string, string | null>();
152 const writableIds = new Set<string>();
153 for (const row of input.allThreads ?? input.ordered) {
154 const id = rowId(row);
155 keysById.set(id, rowOrder(row, input.section).key);
156 if (input.reorderableEnvironmentIds.has(row.environmentId)) writableIds.add(id);
157 }
158 // Read after the loop so a duplicate id reserves only its final key.
159 const reservedKeys = new Set<string>();
160 keysById.forEach((key, id) => {
161 if (key != null && !indexById.has(id)) reservedKeys.add(key);
162 });
163 // Mirror of `planPinnedReorder` for adjacent swaps, hoisted so every row is
164 // answered in O(1) amortized instead of one planner probe per row:
165 //
166 // Fast path (both neighbors keyed): the fresh key between the landing
167 // neighbors, walking forward while hidden reserved keys block it. The walk
168 // depends only on the neighbor key pair - each adjacency is probed by at
169 // most two rows (down of the left member, up of the right member) - so the
170 // memo keeps even a fully adversarial reserved-key layout linear per build.
171 //
172 // Rewrite path (keyless/unusable neighbor, or key space exhausted): fresh
173 // spread keys for every row; only positions whose current key differs get
174 // written. A swap permutes two rows without changing the multiset, so the
175 // assignment set differs from the unswapped baseline at at most those two
176 // positions, and mismatch/writability tallies computed once per section
177 // answer each row with a constant-size delta.
178 const midpoints = new Map<string, string | null>();
179 const fastPathKey = (beforeKey: string | null, afterKey: string | null): string | null => {
180 const memoKey = `${beforeKey ?? ""}\u0000${afterKey ?? ""}`;
181 const cached = midpoints.get(memoKey);
182 if (cached !== undefined) return cached;
183 let key = pinOrderKeyBetween(beforeKey, afterKey);
184 while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey);
185 midpoints.set(memoKey, key);
186 return key;
187 };
188 const spreadKeys = generateSpreadPinOrderKeys(orderedIds.length + reservedKeys.size)
189 .filter((key) => !reservedKeys.has(key))
190 .slice(0, orderedIds.length);
191 const currentKeys = orderedIds.map((id) => keysById.get(id) ?? null);
192 const writableRow = orderedIds.map((id) => writableIds.has(id));
193 let baselineWrites = 0;
194 let baselineUnwritableWrites = 0;
195 for (let position = 0; position < orderedIds.length; position += 1) {
196 if (currentKeys[position] === spreadKeys[position]) continue;
197 baselineWrites += 1;
198 if (!writableRow[position]) baselineUnwritableWrites += 1;
199 }
200 // `movedId` swaps with its neighbor; ids at the two swapped positions change,
201 // so only their tally contributions are recomputed.
202 const rewriteViable = (index: number): boolean => {
203 let writes = baselineWrites;
204 let unwritableWrites = baselineUnwritableWrites;
205 for (const position of [index, index + 1]) {
206 const other = position === index ? index + 1 : index;
207 if (currentKeys[position] !== spreadKeys[position]) {
208 writes -= 1;
209 if (!writableRow[position]) unwritableWrites -= 1;
210 }
211 // After the swap this position holds the row that was at `other`.
212 if (currentKeys[other] !== spreadKeys[position]) {
213 writes += 1;
214 if (!writableRow[other]) unwritableWrites += 1;
215 }
216 }
217 return writes > 0 && unwritableWrites === 0;
218 };
219 for (const row of rows) {
220 const movedId = rowId(row);
221 const denied = { canMoveUp: false, canMoveDown: false };
222 if (!writableIds.has(movedId)) {
223 result.set(movedId, denied);
224 continue;
225 }
226 const index = indexById.get(movedId);
227 if (index === undefined) {
228 result.set(movedId, denied);
229 continue;
230 }
231 const adjacentAvailable = (towardUp: boolean): boolean => {
232 const shifted = index + (towardUp ? -1 : 1);
233 if (shifted < 0 || shifted >= orderedIds.length) return false;
234 // The swap exchanges the row with its neighbor; afterwards the moved row
235 // sits at `shifted` between `beforeIndex` and `afterIndex` of the OLD
236 // order: moving up it lands between old(index-2) and old(index-1),
237 // moving down between old(index+1) and old(index+2).
238 const beforeIndex = towardUp ? index - 2 : index + 1;
239 const afterIndex = towardUp ? index - 1 : index + 2;
240 const beforeId = beforeIndex < 0 ? null : (orderedIds[beforeIndex] ?? null);
241 const afterId = afterIndex >= orderedIds.length ? null : (orderedIds[afterIndex] ?? null);
242 const beforeKey = beforeId === null ? null : (keysById.get(beforeId) ?? null);
243 const afterKey = afterId === null ? null : (keysById.get(afterId) ?? null);
244 if ((beforeId === null || beforeKey != null) && (afterId === null || afterKey != null)) {
245 const key = fastPathKey(beforeKey, afterKey);
246 // A fresh key is a single-write plan for the (writable) moved row.
247 if (key !== null) return true;
248 }
249 // Keyless neighbor or exhausted key space: the section rewrite runs.
250 return rewriteViable(Math.min(index, shifted));
251 };
252 result.set(movedId, {
253 canMoveUp: adjacentAvailable(true),
254 canMoveDown: adjacentAvailable(false),
255 });
256 }
257 return result;
258}
259
260export function createPendingThreadOrder(input: {
261 readonly section: PendingThreadOrder["section"];
262 readonly ordered: readonly OrderRow[];
263 readonly movedId: string;
264 readonly direction: ThreadMoveDestination;
265 readonly assignments: readonly { readonly id: string; readonly orderKey: string }[];
266}): PendingThreadOrder {
267 const orderedIds = threadOrderAfterMove(input.ordered.map(rowId), input.movedId, input.direction);
268 if (orderedIds === null) throw new Error("Cannot begin an invalid thread move");
269 return {
270 section: input.section,
271 orderedIds,
272 before: new Map(input.ordered.map((row) => [rowId(row), rowOrder(row, input.section)])),
273 assignments: new Map(input.assignments.map(({ id, orderKey }) => [id, orderKey])),
274 confirmed: new Set(),
275 commandsComplete: false,
276 };
277}
278
279/** Receipts and shell updates arrive independently. Only our own key writes
280 * may pass through the hold; membership and other arrangement changes win. */
281export function reconcilePendingThreadOrder(
282 pending: PendingThreadOrder,
283 ordered: readonly OrderRow[],
284): PendingThreadOrder | null {
285 if (ordered.length !== pending.before.size) return null;
286 const confirmed = new Set(pending.confirmed);
287 for (const row of ordered) {
288 const id = rowId(row);
289 const before = pending.before.get(id);
290 const current = rowOrder(row, pending.section);
291 if (before === undefined || current.anchor !== before.anchor) return null;
292 const assigned = pending.assignments.get(id);
293 if (assigned !== undefined && current.key === assigned) confirmed.add(id);
294 else if (current.key !== before.key || confirmed.has(id)) return null;
295 }
296 if (pending.commandsComplete && confirmed.size === pending.assignments.size) return null;
297 return confirmed.size === pending.confirmed.size ? pending : { ...pending, confirmed };
298}
299
300/** Apply the full section's pending order after search/environment filtering. */
301export function applyPendingThreadOrder<T extends OrderRow>(
302 rows: readonly T[],
303 section: PendingThreadOrder["section"],
304 pending: PendingThreadOrder | null | undefined,
305): T[] {
306 if (pending == null || pending.section !== section) return [...rows];
307 const rank = new Map(pending.orderedIds.map((id, index) => [id, index]));
308 return [...rows].sort(
309 (left, right) => (rank.get(rowId(left)) ?? Infinity) - (rank.get(rowId(right)) ?? Infinity),
310 );
311}
312
313/** Match desktop re-entry: a pin wakes the thread on the server; Active clears
314 * each underlying parked state before assigning its destination order key. */
315export function threadDropLifecycle(
316 thread: EnvironmentThreadShell,
317 section: "pinned" | "active",
318 now: string,
319) {
320 if (section === "pinned") return { pin: true, unpin: false, unsettle: false, unsnooze: false };
321 return {
322 pin: false,
323 unpin: thread.pinnedAt != null,
324 unsettle: thread.settledOverride === "settled",
325 unsnooze: effectiveSnoozed(thread, { now }),
326 };
327}
328
329export type ThreadDragSection = "pinned" | "active" | "snoozed" | "settled";
330
331/** The action shown during hover describes the lifecycle change made on drop. */
332export function threadDragAction(source: ThreadDragSection, destination: ThreadDragSection) {
333 if (destination === "snoozed") return null;
334 if (destination === "settled") return source === "settled" ? null : "Settle";
335 if (source === destination) return "Reorder";
336 if (destination === "pinned") return "Pin";
337 if (source === "pinned") return "Unpin";
338 if (source === "settled") return "Unsettle";
339 return "Unsnooze";
340}