// Deterministic turn coalescer.
//
// OpenClaw calls the Fabric `deliver` callback once per assistant text
// segment; a thinking/tool block between two text blocks is a delivery
// boundary, so one agent turn of `text → thinking/tool → text` arrives as
// multiple deliver() calls. There is no turn id on the delivery, so we
// BUFFER segments by Fabric channelId and post the merged message when the
// turn truly ends. The flush is driven by inbound.ts right after
// `dispatchInboundReplyWithBase` resolves — that only happens AFTER every
// deliver() of the turn, a deterministic boundary (NOT a timer, NOT the
// agent_end hook, which fires before deliver()). `coalesce=false` posts
// each segment immediately.

const SAFETY_FLUSH_MS = 120_000; // leak-guard only; not the flush mechanism

export function normChannelId(x: string | null | undefined): string {
  const s = String(x ?? '');
  return s.startsWith('fabric:') ? s.slice('fabric:'.length) : s;
}

type Pending = {
  parts: string[];
  post: (text: string) => Promise<void>;
  log?: (m: string) => void;
  safety: ReturnType<typeof setTimeout>;
};
const pendingByChannel = new Map<string, Pending>();

async function flushChannel(channelId: string, reason: string): Promise<void> {
  const p = pendingByChannel.get(channelId);
  if (!p) return;
  pendingByChannel.delete(channelId);
  clearTimeout(p.safety);
  const text = p.parts.join('\n\n').trim();
  if (!text) return;
  try {
    await p.post(text);
    p.log?.(`fabric: flushed ${p.parts.length} segment(s) channel=${channelId} (${reason})`);
  } catch (e) {
    p.log?.(`fabric: flush FAILED channel=${channelId} (${reason}): ${String(e)}`);
  }
}

// Buffer one delivered segment (or send immediately when coalesce=false).
// `post` performs the real Fabric postMessage with the caller's already
// resolved guild/token; on flush it is called once with the merged text.
export async function enqueueDelivery(params: {
  channelId: string;
  text: string;
  coalesce: boolean;
  post: (text: string) => Promise<void>;
  log?: (m: string) => void;
}): Promise<void> {
  const cid = normChannelId(params.channelId);
  const text = (params.text ?? '').trim();
  if (!text) return;
  if (!params.coalesce) {
    await params.post(text);
    return;
  }
  const existing = pendingByChannel.get(cid);
  if (existing) {
    existing.parts.push(text);
    existing.post = params.post; // freshest guild/token closure
    existing.log = params.log;
  } else {
    pendingByChannel.set(cid, {
      parts: [text],
      post: params.post,
      log: params.log,
      safety: setTimeout(
        () => void flushChannel(cid, 'safety-timeout'),
        SAFETY_FLUSH_MS,
      ),
    });
  }
}

// Called by the agent_end hook with the hook ctx's channelId (bare or
// fabric:-prefixed). Deterministic per-turn boundary.
export async function flushFabricForChannel(
  rawChannelId: string | null | undefined,
): Promise<void> {
  const cid = normChannelId(rawChannelId);
  if (cid) await flushChannel(cid, 'dispatch-end');
}

// gateway_stop: flush anything still buffered.
export async function flushAllFabric(): Promise<void> {
  for (const cid of [...pendingByChannel.keys()]) {
    await flushChannel(cid, 'gateway_stop');
  }
}
