// --- Prompt Queue Engine ---
// In-memory, session-scoped queue state management with completion detection
// Persists to SQLite via db.js
const crypto = require('crypto');
const db = require('./db');

// Map<sessionId, QueueState>
const queues = new Map();

// Lifecycle tracing for the "queued message stuck / sent all at once /
// disappeared" investigation, tagged [queue-trace] so it's greppable in ctm.log.
// Each queue object gets a stable _uid so we can SEE when the live object is
// swapped out from under an in-flight completion (the completeAsyncSend
// `live !== q` drop). Anomalies (_qlog) are always on; verbose per-op trace
// (_qtrace) is gated behind CTM_DEBUG_QUEUE.
let _queueUidSeq = 0;
// Anomaly logs (drops, dedupe, queue-object replacement, recovery) are always on
// — they are rare and each one flags a real problem worth seeing in prod.
function _qlog(msg) { try { console.log(`[queue-trace] ${msg}`); } catch {} }
// Verbose per-operation trace (every wake/dispatch/complete) is gated behind an
// env flag so it doesn't spam prod; enable with CTM_DEBUG_QUEUE=1 when diagnosing.
const _QUEUE_TRACE = !!process.env.CTM_DEBUG_QUEUE;
function _qtrace(msg) { if (_QUEUE_TRACE) { try { console.log(`[queue-trace] ${msg}`); } catch {} } }
function _itemSummary(q) {
  try {
    return (q.items || []).map((it, i) => `${i}${i === q.currentIndex ? '*' : ''}:${it.status}`).join(',');
  } catch { return '?'; }
}

// Hook called when a new queue is created (set by server.js)
let _onQueueCreated = null;
function setOnQueueCreated(fn) { _onQueueCreated = fn; }

// Default prompt patterns that indicate Claude Code is waiting for input
const DEFAULT_PROMPT_PATTERNS = [
  /[❯>$#]\s*$/,
  /^\s*>\s*$/m,
  /waiting for input/i,
  /\?\s*$/,
];

function imageLabel(img, idx) {
  return String(img && img.label || `[Image #${idx + 1}]`);
}

function appendMissingImageLabels(text, images) {
  let out = String(text || '');
  const list = Array.isArray(images) ? images : [];
  const missing = list
    .map((img, idx) => imageLabel(img, idx))
    .filter(label => label && !out.includes(label));
  if (missing.length) out = `${out} ${missing.join(' ')}`.trim();
  return out;
}

const ITEM_STATUSES = new Set(['pending', 'sending', 'sent', 'skipped', 'failed']);

function normalizeQueueItem(item = {}, options = {}) {
  const text = item.text || item.body || item.prompt || '';
  const status = options.preserveStatus && ITEM_STATUSES.has(String(item.status || '').toLowerCase())
    ? String(item.status || '').toLowerCase()
    : 'pending';
  return {
    id: item.id || crypto.randomUUID(),
    type: item.type || 'inline',
    promptId: item.promptId || item.prompt_id || null,
    title: item.title || String(text).slice(0, 60) || 'Untitled',
    text,
    images: Array.isArray(item.images) ? item.images : [],
    status,
  };
}

function normalizeReadiness(value) {
  if (value === false) {
    return {
      ok: false,
      code: 'target_not_ready',
      reason: 'target_not_ready',
      message: 'The target session is not ready for another prompt.',
    };
  }
  if (value && typeof value === 'object' && value.ok === false) {
    return {
      ok: false,
      code: value.code || value.reason || 'target_not_ready',
      reason: value.reason || value.code || 'target_not_ready',
      message: value.message || value.reason || 'The target session is not ready for another prompt.',
    };
  }
  return { ok: true };
}

function setWaiting(q, readiness) {
  const code = readiness.code || readiness.reason || 'target_not_ready';
  const reason = readiness.reason || code;
  const message = readiness.message || reason;
  const changed = q.waitingCode !== code || q.waitingReason !== reason || q.waitingMessage !== message;
  q.waitingCode = code;
  q.waitingReason = reason;
  q.waitingMessage = message;
  return changed;
}

function clearWaiting(q) {
  const changed = !!(q.waitingCode || q.waitingReason || q.waitingMessage);
  q.waitingCode = '';
  q.waitingReason = '';
  q.waitingMessage = '';
  return changed;
}

function canDispatch(q, item) {
  if (!q._canSendFn) return { ok: true };
  try {
    return normalizeReadiness(q._canSendFn({
      sessionId: q.sessionId,
      item: { ...item },
      queue: getState(q.sessionId),
    }));
  } catch (err) {
    return {
      ok: false,
      code: 'readiness_check_failed',
      reason: 'readiness_check_failed',
      message: err && err.message ? err.message : 'Could not verify session readiness.',
    };
  }
}

function canRecoverSending(q, item, reason) {
  if (!q._canRecoverSendingFn) {
    return {
      ok: false,
      code: 'sending_in_flight',
      reason: 'sending_in_flight',
      message: 'The current queued item is still marked as in flight.',
    };
  }
  try {
    return normalizeReadiness(q._canRecoverSendingFn({
      sessionId: q.sessionId,
      item: { ...item },
      queue: getState(q.sessionId),
      reason,
    }));
  } catch (err) {
    return {
      ok: false,
      code: 'recovery_check_failed',
      reason: 'recovery_check_failed',
      message: err && err.message ? err.message : 'Could not verify whether the queued turn is still running.',
    };
  }
}

// --- Persistence (SQLite) ---

function saveToDb(q) {
  try {
    db.saveQueue(q.sessionId, {
      mode: q.mode,
      status: q.status,
      currentIndex: q.currentIndex,
      items: q.items.map(i => ({
        id: i.id,
        type: i.type,
        promptId: i.promptId,
        title: i.title,
        text: i.text,
        images: Array.isArray(i.images) ? i.images : undefined,
        status: i.status,
      })),
      idleTimeoutMs: q.idleTimeoutMs,
    });
  } catch (e) {
    console.error('Failed to save queue to db:', e.message);
  }
}

function loadFromDb() {
  try {
    const rows = db.loadAllQueues();
    for (const saved of rows) {
      // Pause any running queues on restore (PTY may have moved on). This is a
      // SYSTEM pause, not a user pause — flag it so wake() can auto-resume the
      // queue once the session signals readiness again, instead of stranding
      // still-pending items forever (the "I queued it but it never sent after a
      // restart" bug). A user-initiated pause does NOT set this flag.
      const wasRunning = saved.status === 'running';
      // An item left in 'sending' was mid-dispatch when the previous process
      // exited. The turn that owned it — and would have resolved it to 'sent'
      // (completeAsyncSend) or rolled it back to 'pending' (failAsyncSend) — died
      // with that process, so it can NEVER leave 'sending' on its own. Both
      // sendNext() and wake() bail on a 'sending' item at currentIndex, so an
      // orphaned 'sending' permanently wedges the queue: the item shows "sending"
      // forever and no further queued message can dispatch ("I can't send my
      // queued message"). Requeue it so it re-dispatches once the session is
      // ready again. This is distinct from the restart-pause auto-resume below:
      // that unblocks a still-PENDING item; this unblocks a SENDING one.
      let earliestRequeued = -1;
      const items = saved.items.map((item, idx) => {
        const norm = normalizeQueueItem(item, { preserveStatus: true });
        if (norm.status === 'sending') {
          norm.status = 'pending';
          if (earliestRequeued === -1) earliestRequeued = idx;
        }
        return norm;
      });
      // sendNext()/wake() only scan for the next pending AFTER currentIndex, so
      // move the cursor just before the recovered item; otherwise the re-dispatch
      // scan skips right over it and the message is silently dropped.
      let currentIndex = saved.currentIndex;
      if (earliestRequeued !== -1 && earliestRequeued <= currentIndex) {
        currentIndex = earliestRequeued - 1;
      }
      if (earliestRequeued !== -1) {
        console.log(`[queue] recovered orphaned 'sending' item on restore session=${saved.sessionId} (mid-turn restart) → requeued for re-dispatch`);
      }
      const prior = queues.get(saved.sessionId);
      if (prior) {
        _qlog(`loadFromDb REPLACING live queue session=${saved.sessionId} olduid=${prior._uid} oldStatus=${prior.status} oldItems=[${_itemSummary(prior)}] — any in-flight completion bound to olduid will be DROPPED (live!==q)`);
      }
      const queue = {
        sessionId: saved.sessionId,
        _uid: ++_queueUidSeq,
        mode: saved.mode,
        status: wasRunning ? 'paused' : saved.status,
        _restorePaused: wasRunning,
        currentIndex,
        items,
        idleTimeoutMs: saved.idleTimeoutMs || 10000,
        promptPatterns: DEFAULT_PROMPT_PATTERNS,
        _idleTimer: null,
        _recentOutput: '',
        _outputSinceLastSend: '',
        _lastOutputTime: 0,
        _onStateChange: null,
        _sendFn: null,
        _canSendFn: null,
        _canRecoverSendingFn: null,
        revision: 0,
        updatedAt: Date.now(),
        waitingCode: '',
        waitingReason: '',
        waitingMessage: '',
      };
      queues.set(saved.sessionId, queue);
      _qlog(`loadFromDb created uid=${queue._uid} session=${saved.sessionId} status=${queue.status} items=[${_itemSummary(queue)}]`);
    }
    if (queues.size > 0) {
      console.log(`  Restored ${queues.size} queue(s) from database`);
    }
  } catch (e) {
    console.error('Failed to load queues from db:', e.message);
  }
}

// NOTE: loadFromDb() is called by init(), which must be called after db.initDb()

function init() {
  loadFromDb();
}

function createQueue(sessionId, { mode = 'manual', items = [], idleTimeoutMs = 10000, promptPatterns } = {}) {
  const prior = queues.get(sessionId);
  if (prior) {
    _qlog(`createQueue REPLACING live queue session=${sessionId} olduid=${prior._uid} oldStatus=${prior.status} oldItems=[${_itemSummary(prior)}] — in-flight completion on olduid will be DROPPED`);
  }
  const queue = {
    sessionId,
    _uid: ++_queueUidSeq,
    mode,
    status: 'idle',
    currentIndex: -1,
    items: items.map(item => normalizeQueueItem(item)),
    idleTimeoutMs: idleTimeoutMs || 10000,
    promptPatterns: promptPatterns || DEFAULT_PROMPT_PATTERNS,
    _idleTimer: null,
    _recentOutput: '',
    _outputSinceLastSend: '',
    _lastOutputTime: 0,
    _onStateChange: null,
    _sendFn: null,
    _canSendFn: null,
    _canRecoverSendingFn: null,
    revision: 0,
    updatedAt: Date.now(),
    waitingCode: '',
    waitingReason: '',
    waitingMessage: '',
  };
  queues.set(sessionId, queue);
  saveToDb(queue);
  if (_onQueueCreated) _onQueueCreated(sessionId);
  return getState(sessionId);
}

function getQueue(sessionId) {
  return queues.get(sessionId) || null;
}

function getState(sessionId) {
  const q = queues.get(sessionId);
  if (!q) return null;
  return {
    sessionId: q.sessionId,
    mode: q.mode,
    status: q.status,
    currentIndex: q.currentIndex,
    items: q.items.map(i => ({
      id: i.id,
      type: i.type,
      promptId: i.promptId,
      title: i.title,
      text: i.text,
      images: Array.isArray(i.images) ? i.images : [],
      status: i.status,
    })),
    idleTimeoutMs: q.idleTimeoutMs,
    revision: q.revision || 0,
    updatedAt: q.updatedAt || 0,
    error: q.error || null,
    waitingCode: q.waitingCode || '',
    waitingReason: q.waitingReason || '',
    waitingMessage: q.waitingMessage || '',
  };
}

function stateFromSavedQueue(saved) {
  if (!saved || !saved.sessionId) return null;
  const currentIndex = Number(saved.currentIndex);
  return {
    sessionId: saved.sessionId,
    mode: saved.mode || 'manual',
    status: saved.status || 'idle',
    currentIndex: Number.isFinite(currentIndex) ? currentIndex : -1,
    items: (Array.isArray(saved.items) ? saved.items : []).map(item => {
      const normalized = normalizeQueueItem(item, { preserveStatus: true });
      return {
        id: normalized.id,
        type: normalized.type,
        promptId: normalized.promptId,
        title: normalized.title,
        text: normalized.text,
        images: Array.isArray(normalized.images) ? normalized.images : [],
        status: normalized.status,
      };
    }),
    idleTimeoutMs: saved.idleTimeoutMs || 10000,
    revision: 0,
    updatedAt: saved.updatedAt || 0,
    error: null,
    waitingCode: '',
    waitingReason: '',
    waitingMessage: '',
    persisted: true,
  };
}

function getPersistedState(sessionId) {
  const live = getState(sessionId);
  if (live) return live;
  try {
    return stateFromSavedQueue(db.loadQueue(sessionId));
  } catch (err) {
    console.error('[queue] failed to load persisted queue state:', err && err.message ? err.message : err);
    return null;
  }
}

function getAllStates() {
  const result = {};
  for (const [sessionId] of queues) {
    result[sessionId] = getState(sessionId);
  }
  return result;
}

function deleteQueue(sessionId) {
  const q = queues.get(sessionId);
  if (q) {
    clearIdleTimer(q);
    queues.delete(sessionId);
    try { db.deleteQueueDb(sessionId); } catch (e) { /* ignore */ }
  }
}

function appendItems(sessionId, { mode, items = [], idleTimeoutMs, autoStart = false } = {}) {
  const normalized = Array.isArray(items) ? items.map(item => normalizeQueueItem(item)) : [];
  if (normalized.length === 0) return getState(sessionId);

  let q = queues.get(sessionId);
  if (!q || q.status === 'done') {
    const state = createQueue(sessionId, {
      mode: mode || 'manual',
      items: normalized,
      idleTimeoutMs,
    });
    return autoStart ? start(sessionId) || state : state;
  }

  // Reconcile an incoming post against the existing queue, by id. The Wall-E
  // "Send Queue" path re-posts items, so the same id can arrive again for three
  // distinct reasons — each needs different handling:
  //
  //   1. New id                → append (genuinely new message).
  //   2. Existing is 'pending'  → refresh text/title/images in place (honor an
  //                               EDIT re-send) but do NOT push a duplicate.
  //   3. Existing is 'sending'  → leave it; re-dispatching would double-send the
  //      or 'queued' (in flight)  same turn. (This is the duplicate-suppression
  //                               that stops "sent all at once" on a double-click.)
  //   4. Existing is DONE        → a genuine RE-SEND ("Re-send this item" / a
  //      (sent/skipped/failed)    re-post after the queue restored 'paused' on a
  //                               CTM restart). Reset it to 'pending', refresh its
  //                               content, and re-dispatch. The OLD behavior only
  //                               handled case 2 and silently swallowed this one,
  //                               so the re-sent message never reached Wall-E.
  const DONE_STATUSES = new Set(['sent', 'skipped', 'failed']);
  const fresh = [];
  let deduped = 0;
  let resent = 0;
  let earliestResent = -1;
  for (const it of normalized) {
    const idx = q.items.findIndex(x => x && x.id === it.id);
    if (idx === -1) { fresh.push(it); continue; }
    const existing = q.items[idx];
    if (DONE_STATUSES.has(existing.status)) {
      existing.status = 'pending';
      existing.statusUpdatedAt = Date.now();
      existing.text = it.text;
      existing.title = it.title;
      existing.images = it.images;
      existing.promptId = it.promptId;
      resent++;
      if (earliestResent === -1 || idx < earliestResent) earliestResent = idx;
    } else {
      deduped++;
      if (existing.status === 'pending') {
        existing.text = it.text;
        existing.title = it.title;
        existing.images = it.images;
        existing.promptId = it.promptId;
      }
    }
  }
  if (deduped > 0) {
    _qlog(`appendItems deduped ${deduped} already-queued item(s) session=${sessionId} uid=${q._uid} — re-send did NOT duplicate-dispatch`);
  }
  if (resent > 0) {
    _qlog(`appendItems re-sent ${resent} finished item(s) session=${sessionId} uid=${q._uid} — reset to 'pending' for re-dispatch`);
    // sendNext()/wake() only scan for the next pending AFTER currentIndex, so a
    // re-sent item sitting BEHIND the cursor would be skipped. Roll the cursor to
    // just before the earliest re-sent item (sent items in between are skipped by
    // the pending-only scan, so they are NOT re-dispatched). Mirrors the
    // orphaned-'sending' recovery in loadFromDb().
    if (earliestResent <= q.currentIndex) q.currentIndex = earliestResent - 1;
  }

  if (fresh.length > 0) q.items.push(...fresh);
  if (mode) q.mode = mode;
  if (idleTimeoutMs) q.idleTimeoutMs = idleTimeoutMs;

  const hasNewWork = fresh.length > 0 || resent > 0;
  if (hasNewWork) {
    q.error = null;
    if (autoStart && (q.status === 'idle' || q.status === 'paused')) {
      q.status = 'running';
      q._restorePaused = false;
    }
    notifyChange(q);
    if (autoStart && q.status === 'running') return wake(sessionId, 'append');
    return getState(sessionId);
  }

  // Nothing new to dispatch (every incoming id was an in-flight or pending
  // duplicate). Still honor autoStart so a re-click can resume/kick a stalled
  // queue, and persist any in-place edits we just applied — but never duplicate.
  if (autoStart && (q.status === 'idle' || q.status === 'paused')) {
    q.status = 'running';
    notifyChange(q);
    return wake(sessionId, 'append');
  }
  if (deduped > 0) notifyChange(q); // persist edited-in-place text
  return getState(sessionId);
}

function notifyChange(q) {
  q.revision = (Number(q.revision) || 0) + 1;
  q.updatedAt = Date.now();
  saveToDb(q);
  if (q._onStateChange) {
    q._onStateChange(getState(q.sessionId));
  }
}

// --- Execution Control ---

function start(sessionId) {
  const q = queues.get(sessionId);
  if (!q) return null;
  if (q.items.length === 0) return getState(sessionId);

  q.status = 'running';
  q._restorePaused = false;
  q.currentIndex = -1;
  sendNext(sessionId);
  return getState(sessionId);
}

function sendNext(sessionId) {
  const q = queues.get(sessionId);
  if (!q || q.status !== 'running') return null;
  const current = q.items[q.currentIndex];
  if (current && current.status === 'sending') return getState(sessionId);

  // Find next pending item
  let nextIdx = -1;
  for (let i = q.currentIndex + 1; i < q.items.length; i++) {
    if (q.items[i].status === 'pending') {
      nextIdx = i;
      break;
    }
  }

  if (nextIdx === -1) {
    // All done
    q.status = 'done';
    clearWaiting(q);
    clearIdleTimer(q);
    notifyChange(q);
    return getState(sessionId);
  }

  const item = q.items[nextIdx];
  const readiness = canDispatch(q, item);
  if (!readiness.ok) {
    console.log(`[queue-diag] sendNext DEFERRED session=${sessionId} reason=${readiness.code || readiness.reason || 'not-ready'} item="${String(item.text || '').slice(0, 50)}"`);
    if (setWaiting(q, readiness)) notifyChange(q);
    return getState(sessionId);
  }
  clearWaiting(q);
  console.log(`[queue-diag] sendNext DISPATCHING session=${sessionId} mode=${q.mode} item="${String(item.text || '').slice(0, 50)}"`);
  _qtrace(`sendNext DISPATCH uid=${q._uid} session=${sessionId} idx=${nextIdx} item=${item.id} mode=${q.mode} items_before=[${_itemSummary(q)}] — completion will bind to uid=${q._uid}`);

  q.currentIndex = nextIdx;
  item.status = 'sending';
  q.error = null;
  q._outputSinceLastSend = '';
  notifyChange(q);

  const textToSend = appendMissingImageLabels(item.text, item.images);

  // Send the prompt through the session's transport. PTY sessions complete
  // synchronously; provider-backed sessions can return a Promise for turn end.
  let sendResult;
  if (q._sendFn) {
    try {
      sendResult = q._sendFn(textToSend, {
        sessionId,
        item: { ...item },
        queue: getState(sessionId),
      });
    } catch (err) {
      failAsyncSend(q, item, err);
      return getState(sessionId);
    }
  }

  if (sendResult && typeof sendResult.then === 'function') {
    Promise.resolve(sendResult)
      .then(() => completeAsyncSend(q, item))
      .catch((err) => failAsyncSend(q, item, err));
    return getState(sessionId);
  }

  item.status = 'sent';
  notifyChange(q);

  // Start idle detection for auto mode
  if (q.mode === 'auto') {
    startIdleTimer(q);
  }

  return getState(sessionId);
}

function completeAsyncSend(q, item) {
  const live = queues.get(q.sessionId);
  if (live !== q) {
    _qlog(`completeAsyncSend DROPPED (queue swapped) session=${q.sessionId} bounduid=${q._uid} liveuid=${live ? live._uid : 'none'} item=${item.id} → item NEVER marked 'sent' (point #1)`);
    return;
  }
  const current = q.items[q.currentIndex];
  if (!current || current.id !== item.id || current.status !== 'sending') {
    _qlog(`completeAsyncSend DROPPED (cursor/status moved) session=${q.sessionId} uid=${q._uid} item=${item.id} currentIdx=${q.currentIndex} currentId=${current ? current.id : 'none'} currentStatus=${current ? current.status : 'none'} items=[${_itemSummary(q)}]`);
    return;
  }
  current.status = 'sent';
  _qtrace(`completeAsyncSend OK session=${q.sessionId} uid=${q._uid} item=${item.id} → sent items=[${_itemSummary(q)}]`);
  clearWaiting(q);
  notifyChange(q);
  if (q.mode === 'auto' && q.status === 'running') {
    setTimeout(() => {
      if (q.status === 'running') sendNext(q.sessionId);
    }, 0);
  }
}

// A "busy"/not-ready failure is transient: the dispatch lost the race against
// an in-flight turn (the server throws "Wall-E is still thinking") or the target
// briefly wasn't ready. These must NOT dead-pause the queue — that strands the
// item until a manual resume. Keep it running + waiting so the next wake (turn
// finished) retries. Genuine errors still pause so we don't hammer a broken
// provider in a loop.
const RETRYABLE_SEND_FAILURE = /still thinking|walle[_ ]busy|not ready|not connected|queued until|finishes the current turn/i;

function failAsyncSend(q, item, err) {
  const live = queues.get(q.sessionId);
  if (live !== q) {
    _qlog(`failAsyncSend DROPPED (queue swapped) session=${q.sessionId} bounduid=${q._uid} liveuid=${live ? live._uid : 'none'} item=${item.id}`);
    return;
  }
  const current = q.items[q.currentIndex];
  if (!current || current.id !== item.id) {
    _qlog(`failAsyncSend DROPPED (cursor moved) session=${q.sessionId} uid=${q._uid} item=${item.id} currentIdx=${q.currentIndex} currentId=${current ? current.id : 'none'}`);
    return;
  }
  current.status = 'pending';
  const message = err && err.message ? err.message : String(err || 'Queue send failed');
  _qlog(`failAsyncSend session=${q.sessionId} uid=${q._uid} item=${item.id} err="${String(message).slice(0, 80)}" retryable=${RETRYABLE_SEND_FAILURE.test(message)}`);
  clearIdleTimer(q);
  if (q.status === 'running' && RETRYABLE_SEND_FAILURE.test(message)) {
    // Transient — stay running and surface as "waiting" so wake() retries.
    // sendNext advanced currentIndex onto this (now-failed) item before the
    // send threw; roll it back one so the retry's "next pending after
    // currentIndex" scan re-finds it instead of skipping it.
    q.currentIndex = Math.max(-1, q.currentIndex - 1);
    q.error = null;
    setWaiting(q, { code: 'walle_busy', reason: 'walle_busy', message });
    notifyChange(q);
    return;
  }
  q.status = 'paused';
  q.error = message;
  clearWaiting(q);
  notifyChange(q);
}

// Reasons that prove no turn is running for this session, so a still-'sending'
// item is definitionally orphaned and must be recovered (not bailed on). The
// server fires wake() with 'walle-turn-finished' from handleWalleMessage's
// finally — i.e. AFTER the turn that owned the in-flight item has ended.
const TURN_FINISHED_WAKE_REASONS = new Set(['walle-turn-finished']);

function wake(sessionId, reason) {
  const q = queues.get(sessionId);
  if (!q) return getState(sessionId);
  _qtrace(`wake reason=${reason || '(none)'} uid=${q._uid} session=${sessionId} status=${q.status} items=[${_itemSummary(q)}]`);
  // A queue auto-paused by the system (a CTM restart restore) should resume the
  // moment the session is ready again — otherwise queued messages sit forever.
  // Only auto-resume SYSTEM pauses with work left; a user pause stays paused.
  if (q.status === 'paused' && q._restorePaused && hasPendingItems(q)) {
    q._restorePaused = false;
    q.status = 'running';
    notifyChange(q);
  }
  if (q.status !== 'running') return getState(sessionId);
  const current = q.items[q.currentIndex];
  if (current && current.status === 'sending') {
    // An item is held 'sending' for the entire turn and has exactly ONE
    // completion path: the _sendFn promise's .then(completeAsyncSend) /
    // .catch(failAsyncSend). If that single callback is ever missed or its guard
    // drops (queue object swapped, currentIndex moved, status raced under it),
    // the item is stranded 'sending' and every later wake()/sendNext() bails on
    // it — wedging the queue in-process until a restart ("my queued message is
    // stuck sending, I can't send"). The restart-restore recovery in loadFromDb
    // only heals this across a restart; this heals it live.
    //
    // A turn-finished wake is the server's authoritative "no turn is running"
    // signal, so a still-'sending' item is orphaned: resolve it (the message WAS
    // delivered — the turn ran and ended) and let the queue advance. In the
    // normal case completeAsyncSend (a microtask) has already flipped the item to
    // 'sent' before this macrotask wake runs, so this branch is a no-op safety
    // net; it only fires when the promise completion was genuinely lost. Any
    // other wake reason keeps bailing, since a real turn may be in flight.
    const recovery = TURN_FINISHED_WAKE_REASONS.has(reason)
      ? { ok: true, code: 'turn_finished' }
      : canRecoverSending(q, current, reason);
    if (recovery.ok) {
      console.log(`[queue] recovered orphaned 'sending' item on ${reason || 'manual'} wake session=${sessionId} (${recovery.code || 'target idle'}) → resolved`);
      current.status = 'sent';
      clearWaiting(q);
      notifyChange(q);
      return sendNext(sessionId);
    }
    if (reason === 'manual-next' || reason === 'manual-recover') {
      if (setWaiting(q, recovery)) notifyChange(q);
    }
    return getState(sessionId);
  }
  return sendNext(sessionId);
}

function hasPendingItems(q) {
  return Array.isArray(q.items) && q.items.some(it => it && it.status === 'pending');
}

function skip(sessionId) {
  const q = queues.get(sessionId);
  if (!q || q.status !== 'running') return null;

  // Find next pending item and skip it
  for (let i = q.currentIndex + 1; i < q.items.length; i++) {
    if (q.items[i].status === 'pending') {
      q.items[i].status = 'skipped';
      break;
    }
  }

  notifyChange(q);
  return getState(sessionId);
}

// Remove a single queued item by id (used by the per-bubble ✕/edit actions).
// Only pending/failed items can be pulled; an item mid-send is left alone.
function removeItem(sessionId, itemId) {
  const q = queues.get(sessionId);
  if (!q) return null;
  const idx = q.items.findIndex(it => it && it.id === itemId);
  if (idx === -1) return getState(sessionId);
  if (q.items[idx].status === 'sending') return getState(sessionId);
  q.items.splice(idx, 1);
  if (idx <= q.currentIndex) q.currentIndex -= 1;
  const anyActive = q.items.some(it => it && (it.status === 'pending' || it.status === 'sending'));
  if (!anyActive && (q.status === 'running' || q.status === 'paused')) q.status = 'done';
  notifyChange(q);
  return getState(sessionId);
}

// Reorder the PENDING items to match `orderedIds` (drag-to-reorder). Sent/
// sending/skipped items keep their slots; any pending id not listed is appended.
function reorderItems(sessionId, orderedIds) {
  const q = queues.get(sessionId);
  if (!q) return null;
  if (!Array.isArray(orderedIds)) return getState(sessionId);
  const byId = new Map(q.items.map(it => [it.id, it]));
  const pending = q.items.filter(it => it && it.status === 'pending');
  const pendingIds = new Set(pending.map(it => it.id));
  const seen = new Set();
  const newPending = [];
  for (const idv of orderedIds) {
    if (pendingIds.has(idv) && !seen.has(idv)) { newPending.push(byId.get(idv)); seen.add(idv); }
  }
  for (const it of pending) if (!seen.has(it.id)) newPending.push(it);
  let pi = 0;
  q.items = q.items.map(it => (it && it.status === 'pending') ? newPending[pi++] : it);
  notifyChange(q);
  return getState(sessionId);
}

function pause(sessionId) {
  const q = queues.get(sessionId);
  if (!q || q.status !== 'running') return null;

  q.status = 'paused';
  q._restorePaused = false; // deliberate user pause — wake() must respect it
  clearWaiting(q);
  clearIdleTimer(q);
  notifyChange(q);
  return getState(sessionId);
}

function resume(sessionId) {
  const q = queues.get(sessionId);
  if (!q || q.status !== 'paused') return null;

  q.status = 'running';
  q._restorePaused = false;

  // If current item is already sent, check if we should auto-advance
  const currentItem = q.items[q.currentIndex];
  if (!currentItem || currentItem.status === 'sent') {
    if (q.mode === 'auto') {
      startIdleTimer(q);
    }
  }

  notifyChange(q);
  wake(sessionId);
  return getState(sessionId);
}

function stop(sessionId) {
  const q = queues.get(sessionId);
  if (!q) return null;

  q.status = 'done';
  clearWaiting(q);
  clearIdleTimer(q);
  notifyChange(q);
  return getState(sessionId);
}

function setMode(sessionId, mode) {
  const q = queues.get(sessionId);
  if (!q) return null;

  q.mode = mode;
  if (mode === 'auto' && q.status === 'running') {
    const currentItem = q.items[q.currentIndex];
    if (currentItem && currentItem.status === 'sent') {
      startIdleTimer(q);
    }
  } else if (mode === 'manual') {
    clearIdleTimer(q);
  }

  notifyChange(q);
  return getState(sessionId);
}

// --- Completion Detection ---

function feedOutput(sessionId, data) {
  const q = queues.get(sessionId);
  if (!q || q.status !== 'running') return;

  q._lastOutputTime = Date.now();
  q._outputSinceLastSend += data;
  if (q._outputSinceLastSend.length > 2048) {
    q._outputSinceLastSend = q._outputSinceLastSend.slice(-2048);
  }

  if (q.mode === 'auto') {
    // Throttle expensive stripAnsi + regex to every 300ms instead of per-chunk
    const now = Date.now();
    if (!q._lastFeedCheck) q._lastFeedCheck = 0;
    if (now - q._lastFeedCheck < 300) return;
    q._lastFeedCheck = now;

    const clean = stripAnsi(q._outputSinceLastSend);
    const lastLines = clean.split('\n').slice(-5).join('\n');

    for (const pattern of q.promptPatterns) {
      if (pattern.test(lastLines)) {
        clearIdleTimer(q);
        setTimeout(() => {
          if (q.status === 'running') {
            sendNext(sessionId);
          }
        }, 500);
        return;
      }
    }

    resetIdleTimer(q);
  }
}

function stripAnsi(str) {
  return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
    .replace(/\x1b\][^\x07]*\x07/g, '')
    .replace(/\x1b[()][AB012]/g, '')
    .replace(/\x1b[\[\]()#;?]*[0-9;]*[a-zA-Z@`]/g, '');
}

function startIdleTimer(q) {
  clearIdleTimer(q);
  q._idleTimer = setTimeout(() => {
    if (q.status === 'running' && q.mode === 'auto') {
      sendNext(q.sessionId);
    }
  }, q.idleTimeoutMs);
}

function resetIdleTimer(q) {
  if (q._idleTimer) {
    startIdleTimer(q);
  }
}

function clearIdleTimer(q) {
  if (q._idleTimer) {
    clearTimeout(q._idleTimer);
    q._idleTimer = null;
  }
}

// --- Hooks ---

function setOnStateChange(sessionId, fn) {
  const q = queues.get(sessionId);
  if (q) q._onStateChange = fn;
}

function setSendFn(sessionId, fn) {
  const q = queues.get(sessionId);
  if (q) q._sendFn = fn;
}

function setCanSendFn(sessionId, fn) {
  const q = queues.get(sessionId);
  if (q) q._canSendFn = typeof fn === 'function' ? fn : null;
}

function setCanRecoverSendingFn(sessionId, fn) {
  const q = queues.get(sessionId);
  if (q) q._canRecoverSendingFn = typeof fn === 'function' ? fn : null;
}

function onSessionExit(sessionId) {
  const q = queues.get(sessionId);
  // Preserve queues that have unsent items — they'll be restored on next startup.
  // Only delete queues that are fully done or empty.
  if (q) {
    const hasPending = q.items.some(i => i.status === 'pending');
    if (hasPending) {
      // Pause in-memory state but keep DB row intact for restore
      clearIdleTimer(q);
      q.status = 'paused';
      saveToDb(q);
      queues.delete(sessionId);
      return;
    }
  }
  deleteQueue(sessionId);
}

module.exports = {
  init,
  createQueue,
  appendItems,
  getQueue,
  getState,
  getPersistedState,
  getAllStates,
  deleteQueue,
  start,
  sendNext,
  skip,
  removeItem,
  reorderItems,
  pause,
  resume,
  stop,
  wake,
  setMode,
  feedOutput,
  setOnStateChange,
  setSendFn,
  setCanSendFn,
  setCanRecoverSendingFn,
  onSessionExit,
  setOnQueueCreated,
};
