'use strict';
/**
 * Session management scheduler jobs for CTM.
 * Registers background jobs with the scheduler to replace ad-hoc setInterval timers.
 *
 * Jobs:
 *   jsonl-reconciliation   — safety-net scan to catch what fs.watch missed
 *   session-metadata-sync  — update file_size/modified_at for active sessions
 *   auto-title             — AI-generated titles for untitled sessions
 *   session-healer         — deprecated no-op for legacy startup identity columns
 *   session-integrity      — detect and auto-recover session mismatches
 *   session-relink         — detect continuation files for active sessions
 */
const fs = require('fs');
const path = require('path');
const os = require('os');
const { verifyCandidateForTab, readFirstUserMessage, wordOverlap } = require('./session-verify.js');
const { verifyLineage } = require('./session-lineage.js');
const claudeDesktopSessions = require('./claude-desktop-sessions');
const { detectAgentType, normalizeAgentType } = require('./agent-capabilities');
const {
  resolveRestoreArgsFromAgentSessions,
  resolveRestoreCwdFromAgentSession,
} = require('./session-restore');
const { findCodexSessionFilesByFilename } = require('./session-history');
const { runSync } = require('./perf-tracker');

const HOSTNAME = os.hostname();
const fsp = fs.promises;

function yieldToEventLoop() {
  return new Promise((resolve) => setImmediate(resolve));
}

function positiveInteger(value, fallback) {
  const parsed = Number(value);
  return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
}

function tableHasColumn(db, tableName, columnName) {
  try {
    return db.prepare(`PRAGMA table_info(${tableName})`).all()
      .some((col) => col.name === columnName);
  } catch {
    return false;
  }
}

function providerSelectExpr(db, tableName, alias) {
  return tableHasColumn(db, tableName, 'provider')
    ? `${alias}.provider AS provider`
    : "'' AS provider";
}

function statFileIfReadable(filePath) {
  if (!filePath) return null;
  try {
    const stat = fs.statSync(filePath);
    return stat.isFile() ? stat : null;
  } catch {
    return null;
  }
}

// Short-TTL readdir cache: the session-relink scan readdir's a Claude project dir per STALE
// session, so multiple stale sessions sharing a project dir each re-listed it within one tick.
// Coalesce those (1.5s TTL) to one readdirSync per dir per scan. Project-dir contents change
// slowly (a new JSONL appears on /resume), so a brief TTL is safe.
const _relinkReaddirCache = new Map(); // dir -> { at, files }
const _RELINK_READDIR_TTL_MS = Math.max(0, Number(process.env.CTM_RELINK_READDIR_TTL_MS) || 1500);
function _cachedRelinkReaddir(dir) {
  const now = Date.now();
  if (_RELINK_READDIR_TTL_MS > 0) {
    const hit = _relinkReaddirCache.get(dir);
    if (hit && (now - hit.at) < _RELINK_READDIR_TTL_MS) return hit.files;
  }
  const files = fs.readdirSync(dir);
  if (_RELINK_READDIR_TTL_MS > 0) {
    _relinkReaddirCache.set(dir, { at: now, files });
    if (_relinkReaddirCache.size > 256) _relinkReaddirCache.delete(_relinkReaddirCache.keys().next().value);
  }
  return files;
}

function nextCursorAfterBatch(start, batchLength, totalRows, deletedRows = 0) {
  if (batchLength <= 0 || start + batchLength >= totalRows) return 0;
  return Math.max(0, start + batchLength - Math.max(0, deletedRows));
}

function resolveAgentSessionJsonlInfo(row, opts = {}) {
  const provider = normalizeAgentType(row?.provider || '') || row?.provider || '';
  const storedPath = row?.jsonl_path || '';
  const statPath = claudeDesktopSessions.sourcePathForStat(storedPath);
  const stat = statFileIfReadable(statPath);
  if (stat) return { filePath: storedPath, stat };

  if (opts.scanCodexFallback !== false && provider === 'codex' && row?.agent_session_id) {
    try {
      for (const filePath of findCodexSessionFilesByFilename(row.agent_session_id)) {
        const codexStat = statFileIfReadable(filePath);
        if (codexStat) return { filePath, stat: codexStat };
      }
    } catch {}
  }

  return null;
}

// Same resolution as resolveAgentSessionJsonlInfo, but the primary-path stat comes from a
// pre-computed off-thread `statFiles` result (Map<statPath,{exists,size,mtimeMs}>) instead of a
// synchronous fs.statSync on the main loop. Only the rare codex readdir fallback (primary path
// missing on a codex row) drops to main. The synthetic stat carries the {size, mtime} the caller
// reads (mtime is a Date so `.toISOString()` matches the live-stat path).
function resolveJsonlInfoFromStatMap(row, statMap) {
  const storedPath = row?.jsonl_path || '';
  const statPath = claudeDesktopSessions.sourcePathForStat(storedPath);
  const st = statPath ? statMap.get(statPath) : null;
  if (st && st.exists) {
    return { filePath: storedPath, stat: { size: st.size, mtime: new Date(st.mtimeMs) } };
  }
  const provider = normalizeAgentType(row?.provider || '') || row?.provider || '';
  if (provider === 'codex' && row?.agent_session_id) {
    // Primary path absent on a codex row → the only alternate location is a readdir scan; do it
    // on main (rare). Skip the redundant primary statSync we already know missed.
    return resolveAgentSessionJsonlInfo(row);
  }
  return null; // non-codex miss → no file (matches resolveAgentSessionJsonlInfo → null)
}

function parsedThreadLineage(parsed = {}) {
  return {
    threadSource: String(parsed.threadSource || parsed.thread_source || '').trim().toLowerCase(),
    parentAgentSessionId: String(
      parsed.parentAgentSessionId
      || parsed.parent_agent_session_id
      || parsed.parentThreadId
      || parsed.parent_thread_id
      || ''
    ).trim(),
    agentNickname: String(parsed.agentNickname || parsed.agent_nickname || '').trim(),
    agentRole: String(parsed.agentRole || parsed.agent_role || '').trim(),
  };
}

function isProviderChildParsedSession(parsed = {}) {
  if (normalizeAgentType(parsed.agent || parsed.provider || '') !== 'codex') return false;
  const lineage = parsedThreadLineage(parsed);
  return lineage.threadSource === 'subagent' && !!lineage.parentAgentSessionId;
}

/**
 * Register all session-related scheduler jobs.
 * @param {import('./scheduler').Scheduler} scheduler
 * @param {Object} deps - Dependencies injected from server.js
 * @param {Object} deps.dbModule
 * @param {Function} deps.parseSessionFile
 * @param {Function} deps.getAllSessionFilesAsync
 * @param {Function} deps.encodeProjectEntry
 * @param {Function} deps.decodeProjectEntry
 * @param {Map} deps.sessions - In-memory sessions map
 * @param {Function} deps.broadcastSessionList
 * @param {Function} deps.runAutoTitleGeneration
 * @param {Function} deps.healStartupTaskSessionIds
 * @param {Object} deps.sessionIntegrity
 * @param {Function} deps.getAllSessionFiles
 * @param {Object} deps.telemetry
 * @param {Function} [deps.syncCodingSessionModels]
 */
function registerSessionJobs(scheduler, deps) {
  const {
    dbModule, parseSessionFile, getAllSessionFilesAsync,
    encodeProjectEntry, decodeProjectEntry,
    sessions, broadcastSessionList,
    runAutoTitleGeneration, healStartupTaskSessionIds,
    sessionIntegrity, getAllSessionFiles, telemetry,
    syncCodingSessionModels,
    statFilesOffThread,
    isRestoreInProgress, getServerStartedAt, getRestoreCompletedAt, isDbWarming, isDbWorkerDegraded,
    getLastUserInteractiveInputAt,
  } = deps;

  // Grace window for session-relink: the job is disabled during startup restore
  // AND for 5 min after server start. Mass-spawn creates multiple continuation
  // JSONLs in the same project dir within seconds — the "pick-latest-mtime in
  // project dir" heuristic can't tell them apart. Watcher + handleCreate claim
  // paths handle legit mappings in real time; the periodic relink is only a
  // safety net for context-overflow continuations, which can wait 5 minutes.
  const RELINK_GRACE_MS = 5 * 60 * 1000;
  const STARTUP_MAINTENANCE_GRACE_MS = positiveInteger(process.env.CTM_STARTUP_MAINTENANCE_GRACE_MS, 5 * 60 * 1000);
  function _isInRelinkGraceWindow() {
    if (typeof isRestoreInProgress === 'function' && isRestoreInProgress()) return true;
    if (typeof getServerStartedAt === 'function') {
      const startedAt = getServerStartedAt();
      if (Number.isFinite(startedAt) && (Date.now() - startedAt) < RELINK_GRACE_MS) return true;
    }
    return false;
  }
  function _isInStartupMaintenanceQuietWindow() {
    if (typeof isRestoreInProgress === 'function' && isRestoreInProgress()) return true;
    // Defer while the single serial db-owner worker is degraded under load: piling more
    // maintenance onto a struggling worker deepens the contention cascade that wedges the
    // cold-boot storm. The scheduler re-runs the job once the worker recovers.
    if (typeof isDbWorkerDegraded === 'function' && isDbWorkerDegraded()) return true;
    // isDbWarming() is currently a retired compatibility gate; keep honoring it
    // here so any future SQLite-owned warmup can defer maintenance safely. The
    // fixed startup grace below remains the active maintenance quiet window.
    if (typeof isDbWarming === 'function' && isDbWarming()) return true;
    if (typeof getServerStartedAt === 'function') {
      const startedAt = Number(getServerStartedAt());
      if (Number.isFinite(startedAt) && startedAt > 0 && (Date.now() - startedAt) < STARTUP_MAINTENANCE_GRACE_MS) {
        return true;
      }
    }
    if (typeof getRestoreCompletedAt === 'function') {
      const completedAt = Number(getRestoreCompletedAt());
      if (Number.isFinite(completedAt) && completedAt > 0 && (Date.now() - completedAt) < STARTUP_MAINTENANCE_GRACE_MS) {
        return true;
      }
    }
    return false;
  }
  // Interactive guard: heavy maintenance jobs (the io-pool scans) must not START
  // their synchronous DB/fs work within INTERACTIVE_DEFER_MS of a user keystroke.
  // Those scans block the event loop, and a keystroke that lands while one runs
  // stalls until the loop frees — the measured cause of per-keystroke lag. The
  // jobs are safety nets, so deferring a few seconds is harmless. INTERACTIVE_MAX_
  // DEFER_MS caps the defer so continuous typing can't starve maintenance forever.
  const INTERACTIVE_DEFER_MS = positiveInteger(process.env.CTM_MAINT_INTERACTIVE_DEFER_MS, 1500);
  const INTERACTIVE_MAX_DEFER_MS = positiveInteger(process.env.CTM_MAINT_INTERACTIVE_MAX_DEFER_MS, 20000);
  let _interactiveDeferSince = 0;
  function _userInteractingNow() {
    if (typeof getLastUserInteractiveInputAt !== 'function') return false;
    const last = Number(getLastUserInteractiveInputAt()) || 0;
    return last > 0 && (Date.now() - last) < INTERACTIVE_DEFER_MS;
  }
  function _canRunStartupMaintenance() {
    if (_isInStartupMaintenanceQuietWindow()) return false;
    if (_userInteractingNow()) {
      const now = Date.now();
      if (!_interactiveDeferSince) _interactiveDeferSince = now;
      // Starvation cap: once we've been deferring for MAX_DEFER, let a run through
      // (then re-arm the window so we keep yielding to bursts of typing).
      if (now - _interactiveDeferSince < INTERACTIVE_MAX_DEFER_MS) return false;
    }
    _interactiveDeferSince = 0;
    return true;
  }

  let sessionMetadataSyncCursor = 0;
  let ghostReaperLegacyCursor = 0;
  let ghostReaperAgentCursor = 0;

  // --- jsonl-reconciliation: safety-net full scan (catches fs.watch misses) ---
  // Runs immediately, then every 10min. fs.watch is the primary detector; this
  // is the reliability guarantee. The work itself is bounded and yields between
  // batches, so freshness is not traded for arbitrary startup quiet time.
  scheduler.register({
    name: 'jsonl-reconciliation',
    heavy: true, // long synchronous DB-map build + per-file parse; cap to 1 heavy/tick
    intervalMs: 10 * 60 * 1000,
    startDelayMs: 0,
    priority: 4,
    pool: 'io',
    shouldRun: _canRunStartupMaintenance,
    run: async () => {
      const files = await getAllSessionFilesAsync();
      const db = dbModule.getDb();
      let upserted = 0;
      let lastYieldAt = Date.now();
      const pendingMetadataUpdates = [];
      const pendingUpserts = [];

      async function maybeYield(force = false) {
        if (!force && Date.now() - lastYieldAt < 25) return;
        await yieldToEventLoop();
        lastYieldAt = Date.now();
      }

      const claimedMap = {};
      const existingMap = new Map();
      try {
        // This loads EVERY agent_sessions row in one synchronous span; it grows
        // with session count and is a prime event-loop-block suspect. Wrap it so
        // a [perf-lag] block here is attributed by name instead of `(unknown)`.
        runSync('jsonl-reconciliation:loadAgentSessionsMap', () => {
          for (const r of db.prepare("SELECT agent_session_id, ctm_session_id, file_size, modified_at FROM agent_sessions").all()) {
            existingMap.set(r.agent_session_id, r);
            if (r.ctm_session_id) claimedMap[r.agent_session_id] = r.ctm_session_id;
          }
        });
      } catch (e) { console.error('[jsonl-reconciliation] metadata map build error:', e.message); }

      const updateMetadata = db.prepare("UPDATE agent_sessions SET file_size = ?, modified_at = ?, updated_at = datetime('now') WHERE agent_session_id = ?");
      const runMetadataBatch = db.transaction((batch) => {
        for (const item of batch) updateMetadata.run(item.fileSize, item.modifiedAt, item.sessionId);
      });
      const runUpsertBatch = db.transaction((batch) => {
        for (const item of batch) dbModule.upsertSession(item.id, item.data);
      });

      function flushMetadataUpdates() {
        if (!pendingMetadataUpdates.length) return;
        const batch = pendingMetadataUpdates.splice(0, pendingMetadataUpdates.length);
        runMetadataBatch(batch);
      }

      function flushUpserts() {
        if (!pendingUpserts.length) return;
        const batch = pendingUpserts.splice(0, pendingUpserts.length);
        try {
          runUpsertBatch(batch);
        } catch (e) {
          console.error('[jsonl-reconciliation] batch upsert error:', e.message);
          for (const item of batch) {
            try { dbModule.upsertSession(item.id, item.data); } catch (singleErr) {
              console.error(`[jsonl-reconciliation] upsert error for ${item.id}:`, singleErr.message);
            }
          }
        }
      }

      for (let index = 0; index < files.length; index++) {
        const { filePath, projectPath, projectEntry, sessionId: discoveredSessionId } = files[index];
        try {
          const virtualSession = claudeDesktopSessions.parseVirtualSessionPath(filePath);
          const fileSessionId = virtualSession ? virtualSession.sessionId : path.basename(filePath).replace(/\.jsonl(\.bak)?$/, '');
          let agentSessionId = String(discoveredSessionId || fileSessionId || '').trim();
          const statPath = claudeDesktopSessions.sourcePathForStat(filePath);
          const stat = await fsp.stat(statPath);
          const modifiedAt = stat.mtime.toISOString();
          let existing = existingMap.get(agentSessionId) || existingMap.get(fileSessionId);

          if (existing) {
            // Update metadata if stale
            if (existing.file_size !== stat.size || existing.modified_at !== modifiedAt) {
              pendingMetadataUpdates.push({ sessionId: existing.agent_session_id || agentSessionId, fileSize: stat.size, modifiedAt });
              existing.file_size = stat.size;
              existing.modified_at = modifiedAt;
              upserted++;
              if (pendingMetadataUpdates.length >= 25) flushMetadataUpdates();
            }
            await maybeYield(index % 20 === 19);
            continue;
          }

          // New file not in DB — parse and insert. parseSessionFile reads a
          // bounded head/tail slice synchronously, so yield around it during
          // startup scans rather than wrapping hundreds of parses in one turn.
          // Wrap the sync parse so a [perf-lag] block lands as a named span.
          await maybeYield();
          const parsed = runSync('jsonl-reconciliation:parseSessionFile', () => parseSessionFile(filePath, projectPath, projectEntry));
          agentSessionId = String(parsed.sessionId || agentSessionId || fileSessionId || '').trim();
          existing = existingMap.get(agentSessionId);
          if (existing) {
            if (existing.file_size !== stat.size || existing.modified_at !== modifiedAt) {
              pendingMetadataUpdates.push({ sessionId: agentSessionId, fileSize: stat.size, modifiedAt });
              existing.file_size = stat.size;
              existing.modified_at = modifiedAt;
              upserted++;
              if (pendingMetadataUpdates.length >= 25) flushMetadataUpdates();
            }
            await maybeYield(index % 20 === 19);
            continue;
          }

          // Skip ephemeral/aborted sessions (tiny file, no content) — prevents ghost rows
          if (parsed.fileSize < 1024 && !parsed.userMsgCount && !parsed.title && !parsed.firstMessage) {
            await maybeYield(index % 10 === 9);
            continue;
          }

          const lineage = parsedThreadLineage(parsed);
          const parsedProvider = normalizeAgentType(parsed.agent || '') || parsed.agent || 'claude';
          if (isProviderChildParsedSession(parsed)) {
            try {
              dbModule.upsertAgentSessionIdentity(agentSessionId, {
                provider: parsedProvider,
                providerResumeId: agentSessionId,
                projectEntry: parsed.projectEntry || projectEntry,
                projectPath: parsed.project || parsed.cwd || projectPath,
                cwd: parsed.cwd || parsed.project || projectPath,
                title: parsed.title || '',
                firstMessage: parsed.firstMessage || '',
                jsonlPath: filePath,
                fileSize: parsed.fileSize || stat.size,
                modifiedAt: parsed.modifiedAt || modifiedAt,
                createdAt: parsed.timestamp || '',
                model: parsed.modelId || parsed.model || '',
                gitBranch: parsed.gitBranch || '',
                slug: parsed.slug || '',
                userMsgCount: parsed.userMsgCount || 0,
                hostname: HOSTNAME,
                ...lineage,
              });
              existingMap.set(agentSessionId, {
                agent_session_id: agentSessionId,
                ctm_session_id: null,
                file_size: parsed.fileSize || stat.size,
                modified_at: parsed.modifiedAt || modifiedAt,
              });
              upserted++;
            } catch (e) {
              console.error(`[jsonl-reconciliation] child identity upsert error for ${agentSessionId}:`, e.message);
            }
            await maybeYield(index % 10 === 9);
            continue;
          }

          const ctmTabId = claimedMap[agentSessionId];
          const parsedProjectEntry = parsed.projectEntry || projectEntry;
          const parsedProjectPath = parsed.project || parsed.cwd || projectPath;

          if (ctmTabId) {
            // Already claimed by a CTM tab — update metadata
            pendingUpserts.push({ id: ctmTabId, data: {
              agentSessionId,
              projectEntry: parsedProjectEntry,
              projectPath: parsedProjectPath,
              cwd: parsed.cwd || parsedProjectPath,
              jsonlPath: filePath,
              fileSize: parsed.fileSize,
              modifiedAt: parsed.modifiedAt,
              firstMessage: parsed.firstMessage || '',
              createdAt: parsed.timestamp || '',
              provider: parsedProvider,
              model: parsed.modelId || parsed.model || '',
              gitBranch: parsed.gitBranch || '',
              slug: parsed.slug || '',
              userMsgCount: parsed.userMsgCount || 0,
              hostname: HOSTNAME,
              ...lineage,
            } });
          } else {
            // Standalone session — auto-create ctm_sessions + agent_sessions
            pendingUpserts.push({ id: agentSessionId, data: {
              agentSessionId,
              projectEntry: parsedProjectEntry,
              projectPath: parsedProjectPath,
              cwd: parsed.cwd || projectPath,
              title: parsed.title || '',
              jsonlPath: filePath,
              fileSize: parsed.fileSize,
              modifiedAt: parsed.modifiedAt,
              firstMessage: parsed.firstMessage || '',
              createdAt: parsed.timestamp || '',
              provider: parsedProvider,
              model: parsed.modelId || parsed.model || '',
              gitBranch: parsed.gitBranch || '',
              slug: parsed.slug || '',
              userMsgCount: parsed.userMsgCount || 0,
              hostname: HOSTNAME,
              ...lineage,
            } });
          }
          if (pendingUpserts.length >= 3) {
            flushUpserts();
            await maybeYield(true);
          }
          upserted++;
        } catch (e) { console.error(`[jsonl-reconciliation] error processing ${filePath}:`, e.message); }
        await maybeYield(index % 10 === 9);
      }
      flushMetadataUpdates();
      flushUpserts();
      return { upserted, totalFiles: files.length };
    },
  });

  // --- ghost-reaper: remove DB rows whose JSONL files no longer exist ---
  // Checks a bounded round-robin batch. The old full-table scan could spend
  // minutes walking Codex history paths; batching is the protection, not a long
  // startup delay.
  scheduler.register({
    name: 'ghost-reaper',
    heavy: true,
    intervalMs: 30 * 60 * 1000,
    startDelayMs: 0,
    priority: 8,
    pool: 'io',
    shouldRun: _canRunStartupMaintenance,
    run: async () => {
      const db = dbModule.getDb();
      let reaped = 0;
      let checked = 0;
      let partial = false;
      const maxRows = positiveInteger(process.env.CTM_GHOST_REAPER_ROWS_PER_RUN, 50);
      // Query both table schemas — legacy `sessions` and new `agent_sessions`
      const tables = [];
      try { db.prepare("SELECT 1 FROM sessions LIMIT 1").get(); tables.push('sessions'); } catch {}
      try { db.prepare("SELECT 1 FROM agent_sessions LIMIT 1").get(); tables.push('agent_sessions'); } catch {}

      if (tables.includes('sessions')) {
        const rows = db.prepare(
          "SELECT id, jsonl_path FROM sessions WHERE jsonl_path != '' AND jsonl_path IS NOT NULL AND (user_msg_count = 0 OR user_msg_count IS NULL) ORDER BY id"
        ).all();
        const start = Math.min(ghostReaperLegacyCursor, Math.max(0, rows.length - 1));
        const batch = rows.slice(start, Math.min(start + maxRows, rows.length));
        if (start + batch.length < rows.length) partial = true;
        checked += batch.length;
        const toDelete = batch.filter(r => !fs.existsSync(claudeDesktopSessions.sourcePathForStat(r.jsonl_path))).map(r => r.id);
        if (toDelete.length > 0) {
          const txn = db.transaction(() => {
            const stmt = db.prepare("DELETE FROM sessions WHERE id = ?");
            for (const id of toDelete) stmt.run(id);
          });
          txn();
          reaped += toDelete.length;
        }
        ghostReaperLegacyCursor = nextCursorAfterBatch(start, batch.length, rows.length, toDelete.length);
      }

      if (tables.includes('agent_sessions')) {
        const providerExpr = providerSelectExpr(db, 'agent_sessions', 'a');
        const rows = db.prepare(
          `SELECT a.agent_session_id, a.jsonl_path, a.ctm_session_id, ${providerExpr} FROM agent_sessions a WHERE a.jsonl_path != '' AND a.jsonl_path IS NOT NULL ORDER BY a.agent_session_id`
        ).all();
        const start = Math.min(ghostReaperAgentCursor, Math.max(0, rows.length - 1));
        const batch = rows.slice(start, Math.min(start + maxRows, rows.length));
        if (start + batch.length < rows.length) partial = true;
        checked += batch.length;
        const missingFiles = batch.filter(r => !resolveAgentSessionJsonlInfo(r, { scanCodexFallback: false }));
        if (missingFiles.length > 0) {
          const txn = db.transaction(() => {
            const delAgent = db.prepare("DELETE FROM agent_sessions WHERE agent_session_id = ?");
            for (const r of missingFiles) delAgent.run(r.agent_session_id);
            // Clean up orphaned ctm_sessions with no remaining agent_sessions
            db.prepare(
              "DELETE FROM ctm_sessions WHERE id NOT IN (SELECT DISTINCT ctm_session_id FROM agent_sessions WHERE ctm_session_id IS NOT NULL) AND id NOT IN (SELECT DISTINCT id FROM ctm_sessions WHERE id IN (SELECT ctm_session_id FROM agent_sessions))"
            ).run();
          });
          txn();
          reaped += missingFiles.length;
        }
        ghostReaperAgentCursor = nextCursorAfterBatch(start, batch.length, rows.length, missingFiles.length);
      }

      return {
        reaped,
        checked,
        tables,
        partial: partial || ghostReaperLegacyCursor !== 0 || ghostReaperAgentCursor !== 0,
      };
    },
  });

  // --- session-metadata-sync: update file metadata for active sessions ---
  scheduler.register({
    name: 'session-metadata-sync',
    heavy: true,
    intervalMs: 30 * 1000,
    startDelayMs: 0,
    priority: 5,
    pool: 'io',
    shouldRun: _canRunStartupMaintenance,
    run: async () => {
      if (typeof deps.isConversationImportRunning === 'function' && deps.isConversationImportRunning()) {
        return { updated: 0, skipped: 'conversation_import_running' };
      }
      const db = dbModule.getDb();
      let updated = 0;
      const providerExpr = providerSelectExpr(db, 'agent_sessions', 'agent_sessions');
      const hasProviderColumn = tableHasColumn(db, 'agent_sessions', 'provider');
      const whereClause = hasProviderColumn
        ? "(jsonl_path != '' OR provider = 'codex')"
        : "jsonl_path != ''";
      const maxRows = positiveInteger(process.env.CTM_METADATA_SYNC_ROWS_PER_RUN, 50);
      // Load only this run's batch via SQL LIMIT/OFFSET. The previous `.all()`
      // pulled EVERY agent_sessions row into memory each tick (then sliced 50) —
      // a synchronous main-thread load that grew with session count and was a
      // top event-loop-block offender. Keyset over the stable agent_session_id
      // order; wrap to the start when a short page signals the end.
      const batch = db.prepare(
        `SELECT agent_session_id, jsonl_path, file_size, modified_at, ${providerExpr} FROM agent_sessions WHERE ${whereClause} ORDER BY agent_session_id LIMIT ? OFFSET ?`
      ).all(maxRows, sessionMetadataSyncCursor);
      sessionMetadataSyncCursor = batch.length < maxRows ? 0 : sessionMetadataSyncCursor + maxRows;
      const updateMetadata = db.prepare("UPDATE agent_sessions SET jsonl_path = ?, file_size = ?, modified_at = ?, updated_at = datetime('now') WHERE agent_session_id = ?");
      let lastYieldAt = Date.now();

      async function maybeYield(force = false) {
        if (!force && Date.now() - lastYieldAt < 25) return;
        await yieldToEventLoop();
        lastYieldAt = Date.now();
      }

      // Offload the per-row fs.statSync to the read-pool as ONE batched `statFiles` call instead
      // of a synchronous stat loop on the main event loop (the freeze). On pool unavailable/timeout
      // statMap is null → fall back to the on-main resolver (only 50 rows). CTM_METADATA_SYNC_OFFLOAD=0 opts out.
      let statMap = null;
      if (process.env.CTM_METADATA_SYNC_OFFLOAD !== '0' && typeof statFilesOffThread === 'function') {
        const statPaths = [...new Set(batch
          .map((r) => claudeDesktopSessions.sourcePathForStat(r.jsonl_path || ''))
          .filter(Boolean))];
        try {
          statMap = await statFilesOffThread(statPaths, positiveInteger(process.env.CTM_METADATA_SYNC_READPOOL_TIMEOUT_MS, 10000));
        } catch { statMap = null; }
      }

      for (let index = 0; index < batch.length; index++) {
        const row = batch[index];
        try {
          const fileInfo = statMap ? resolveJsonlInfoFromStatMap(row, statMap) : resolveAgentSessionJsonlInfo(row);
          if (!fileInfo) continue;
          const stat = fileInfo.stat;
          const newMtime = stat.mtime.toISOString();
          if (row.jsonl_path !== fileInfo.filePath || row.file_size !== stat.size || row.modified_at !== newMtime) {
            updateMetadata.run(fileInfo.filePath, stat.size, newMtime, row.agent_session_id);
            updated++;
          }
        } catch (e) { console.error(`[session-metadata-sync] error for ${row.agent_session_id}:`, e.message); }
        await maybeYield(index % 25 === 24);
      }
      return { updated, checked: batch.length, partial: sessionMetadataSyncCursor !== 0 };
    },
  });

  // --- auto-title: AI-generated titles ---
  scheduler.register({
    name: 'auto-title',
    intervalMs: 5 * 60 * 1000,
    startDelayMs: 30 * 1000,
    priority: 7,
    pool: 'llm',
    shouldRun: _canRunStartupMaintenance,
    run: async () => {
      await runAutoTitleGeneration();
      return { done: true };
    },
  });

  // --- session-healer: legacy no-op ---
  // Provider identity is owned by agent_sessions. This hook remains registered
  // so old deployments that inject the dependency keep working, but it must not
  // synthesize startup_tasks mappings from live runtime state.
  scheduler.register({
    name: 'session-healer',
    intervalMs: 5 * 60 * 1000,
    startDelayMs: 30 * 1000,
    priority: 6,
    pool: 'io',
    shouldRun: _canRunStartupMaintenance,
    run: async () => {
      await healStartupTaskSessionIds();
      return { done: true };
    },
  });

  // --- session-integrity: detect and auto-recover mismatches ---
  scheduler.register({
    name: 'session-integrity',
    intervalMs: 30 * 60 * 1000,
    startDelayMs: 60 * 1000,
    priority: 8,
    shouldRun: _canRunStartupMaintenance,
    run: async () => {
      const result = deps.runSessionIntegrity
        ? await deps.runSessionIntegrity()
        : sessionIntegrity.runIntegrityCheck({
          db: dbModule.getDb(), getAllSessionFiles, telemetry, autoRecover: true,
        });
      return result || { checked: true };
    },
  });

  // --- approval-self-adapt: keep the learned approval-detection rule set healthy
  // (retire rules whose patterns no longer compile/are unsafe). The behavioral
  // learning signals fire inline on user input; this is the periodic GC. ---
  if (deps.approvalSelfAdapt && deps.approvalAiRefinement) {
    scheduler.register({
      name: 'approval-self-adapt',
      intervalMs: 45 * 60 * 1000,
      startDelayMs: 90 * 1000,
      priority: 4,
      pool: 'io',
      // runSelfAdaptMaintenance is synchronous and has been seen blocking the
      // event loop for several seconds (recompiles/validates the learned rule
      // set). Defer it out of the startup-quiet window and away from active
      // typing — the interactive guard's starvation cap still forces it to run.
      shouldRun: _canRunStartupMaintenance,
      run: async () => {
        const result = deps.approvalSelfAdapt.runSelfAdaptMaintenance({
          dbModule,
          refinement: deps.approvalAiRefinement,
        });
        if (result && result.disabled) {
          console.log(`[approval-self-adapt] maintenance: checked=${result.checked} retired=${result.disabled} kept=${result.kept}`);
        }
        return result || { checked: 0 };
      },
    });
  }

  // --- walle-health-watchdog: detect unrecoverable Wall-E failures and raise
  // a repair incident (notify + offer full-auto coding-agent repair). ---
  if (deps.walleRepair) {
    scheduler.register({
      name: 'walle-health-watchdog',
      intervalMs: 20000,
      startDelayMs: 45000,
      priority: 7,
      pool: 'io',
      // Health detection a few seconds late is fine; never let the 20s watchdog
      // tick block keystroke echo. Defer during the startup-quiet window (Wall-E
      // is still booting → avoids false-positive failure detection) and while
      // the user is typing. Starvation cap bounds the defer to ~20s.
      shouldRun: () => process.env.WALLE_REPAIR_DISABLED !== '1' && _canRunStartupMaintenance(),
      run: async () => {
        const result = await deps.walleRepair.onWatchdogTick();
        return result || { ticked: true };
      },
    });
  }

  // --- session-relink: detect continuation files for active sessions ---
  scheduler.register({
    name: 'session-relink',
    intervalMs: 30000,
    startDelayMs: 20000,
    priority: 5,
    pool: 'io',
    run: () => {
      // Grace window: skip while startup restore is in progress OR within 5min
      // of server start. Mass-spawn cross-contamination lives in this window.
      if (_isInRelinkGraceWindow()) return { relinked: 0, skipped: 'grace_window' };
      let relinked = 0;
      for (const [id, session] of sessions) {
        if (!String(session.cmd || '').toLowerCase().includes('claude')) continue;
        if (!session._claudeSessionId || !session._claudeProjectDir) continue;
        if (session.type === 'walle') continue;
        try {
          const currentJsonl = path.join(session._claudeProjectDir, `${session._claudeSessionId}.jsonl`);
          let currentMtime = 0;
          try { currentMtime = fs.statSync(currentJsonl).mtimeMs; } catch { continue; }
          if (Date.now() - currentMtime < 60000) continue;

          let newerMatch = null;
          let newerMtime = 0;
          for (const file of _cachedRelinkReaddir(session._claudeProjectDir)) {
            if (!file.endsWith('.jsonl')) continue;
            const csId = file.replace(/\.jsonl$/, '');
            if (csId === session._claudeSessionId) continue;
            const alreadyClaimed = [...sessions.values()].some(
              s => s !== session && s._claudeSessionId === csId
            );
            if (alreadyClaimed) continue;
            const fstat = fs.statSync(path.join(session._claudeProjectDir, file));
            if (fstat.birthtimeMs < session.createdAt - 5000) continue;
            if (Date.now() - fstat.mtimeMs > 120000) continue;
            if (fstat.mtimeMs > newerMtime) {
              newerMatch = csId;
              newerMtime = fstat.mtimeMs;
            }
          }

          if (newerMatch) {
            const oldId = session._claudeSessionId;
            const newerMatchPath = path.join(session._claudeProjectDir, `${newerMatch}.jsonl`);
            // Slug-first lineage verification. Primary: slug of candidate
            // matches the tab's canonical slug. Secondary: prefix-copy
            // sessionId belongs to this tab's chain (--resume lineage).
            // Backward-compat: if the candidate JSONL has no slug (older
            // Claude Code), verifyLineage refuses rather than cross-link.
            const lineage = verifyLineage(newerMatchPath, {
              ctmSessionId: id,
              cwd: session.cwd,
              createdAt: session.createdAt,
            }, dbModule.getDb());
            if (!lineage.ok) {
              console.log(`[session-relink] BLOCKED: ${id.slice(0,8)} → ${newerMatch.slice(0,8)} (${lineage.reason})`);
              try { telemetry && telemetry.track && telemetry.track('session_relink_blocked_lineage', { tab: id.slice(0,8), target: newerMatch.slice(0,8), reason: lineage.reason }); } catch {}
              continue;
            }
            // Guard: reject if the target is another CTM tab's own session ID.
            // (Classic cross-link signature — the relink would steal another tab's JSONL.)
            let skipCrossLink = false;
            try {
              const db = dbModule.getDb();
              const existing = db.prepare(
                'SELECT ctm_session_id FROM agent_sessions WHERE agent_session_id = ?'
              ).get(newerMatch);
              if (existing && existing.ctm_session_id && existing.ctm_session_id !== id) {
                console.log(`[session-relink] BLOCKED: ${id.slice(0,8)} → ${newerMatch.slice(0,8)} (already owned by ${existing.ctm_session_id.slice(0,8)})`);
                try { telemetry && telemetry.track && telemetry.track('session_relink_blocked_cross_tab', { tab: id.slice(0,8), target: newerMatch.slice(0,8), existing: existing.ctm_session_id.slice(0,8) }); } catch {}
                skipCrossLink = true;
              }
            } catch (e) { console.error('[session-relink] claim-check error:', e.message); }
            if (skipCrossLink) continue;
            session._claudeSessionId = newerMatch;
            console.log(`[session-relink] ${id}: ${oldId} → ${newerMatch} (detected newer active JSONL)`);
            try { dbModule.updateStartupTaskClaudeSession(id, newerMatch, session._claudeProjectDir); } catch (e) { console.error('[session-relink] updateStartupTask error:', e.message); }
            try {
              dbModule.upsertSession(id, {
                agentSessionId: newerMatch,
                jsonlPath: path.join(session._claudeProjectDir, `${newerMatch}.jsonl`),
              });
            } catch (e) {
              if (e.code === 'E_CROSS_TAB_CLAIM') {
                // Claimed between our check and the upsert — another path won the race.
                console.log(`[session-relink] RACED: ${id.slice(0,8)} → ${newerMatch.slice(0,8)} claimed by ${e.existingCtmSessionId.slice(0,8)}`);
                // Roll back our in-memory mutation so we don't mis-resume on next restart
                session._claudeSessionId = oldId;
                try { dbModule.updateStartupTaskClaudeSession(id, oldId, session._claudeProjectDir); } catch {}
                continue;
              }
              console.error('[session-relink] upsertSession error:', e.message);
            }
            try {
              const titleRow = dbModule.getSessionTitleNew(id);
              if (titleRow && titleRow.title) {
                dbModule.setSessionTitleNew(newerMatch, titleRow.title, titleRow.userRenamed);
              }
            } catch (e) { console.error('[session-relink] title transfer error:', e.message); }
            relinked++;
          }
        } catch (e) { console.error(`[session-relink] error for session ${id}:`, e.message); }
      }
      return { relinked };
    },
  });

  // --- model-sync: sync agent model info for active sessions ---
  if (syncCodingSessionModels) {
    scheduler.register({
      name: 'model-sync',
      intervalMs: Math.max(3000, Number(process.env.CTM_MODEL_SYNC_INTERVAL_MS) || 5000),
      startDelayMs: 5000,
      priority: 9,
      shouldRun: _canRunStartupMaintenance,
      run: async () => {
        // Prefer the off-thread variant: the claude JSONL scan + Codex state-DB reads run on the
        // read-pool worker; only the small apply (model→registry, title decision, session mutate)
        // stays on main. Falls back to the synchronous version when offload is disabled.
        if (process.env.CTM_MODEL_SYNC_OFFLOAD !== '0' && typeof deps.syncCodingSessionModelsOffThread === 'function') {
          await deps.syncCodingSessionModelsOffThread();
        } else {
          syncCodingSessionModels();
        }
        return { done: true };
      },
    });
  }

  // --- conversation-import: incremental JSONL → session_conversations import ---
  if (deps.runIncrementalConversationImport) {
    scheduler.register({
      name: 'conversation-import',
      heavy: true,
      intervalMs: 10 * 60 * 1000,
      startDelayMs: 0,
      priority: 6,
      pool: 'io',
      shouldRun: _canRunStartupMaintenance,
      run: async () => {
        return await deps.runIncrementalConversationImport();
      },
    });
  }

  // --- cursor-conversation-import: import Cursor blob-graph stores → session_conversations ---
  // Cursor conversations aren't JSONL; they're a content-addressed blob graph that is
  // expensive to reconstruct. Import them on a cadence (off the main thread, on the db-owner
  // worker) so the cache read path serves them instead of reconstructing on every UI poll.
  // Signature-gated inside the job, so idle stores cost ~nothing; runs even when there are no
  // cursor sessions (then it's a no-op query).
  if (deps.runCursorConversationImport) {
    scheduler.register({
      name: 'cursor-conversation-import',
      heavy: true,
      intervalMs: Math.max(60 * 1000, Number(process.env.CTM_CURSOR_IMPORT_INTERVAL_MS) || 3 * 60 * 1000),
      startDelayMs: 15 * 1000,
      priority: 7,
      pool: 'io',
      shouldRun: _canRunStartupMaintenance,
      run: async () => {
        return await deps.runCursorConversationImport();
      },
    });
  }

  // --- message-backfill: extract session_messages from session_conversations for FTS ---
  if (deps.backfillSessionMessages) {
    scheduler.register({
      name: 'message-backfill',
      heavy: true,
      intervalMs: 10 * 60 * 1000,
      startDelayMs: 0,
      dependsOn: ['conversation-import'],
      cascadeFrom: ['conversation-import'],
      // Only cascade when conversation-import actually imported a session. The default
      // cascade check fires on ANY numeric > 0 in the import result — including `total`
      // (sessions on disk) and `scanned` — so an idle import scan that imported nothing
      // still triggered a backfill on nearly every 5s tick (347 runs / 33 min in one
      // sample, churning the db-owner worker + write lock). Gate on real new work.
      cascadeCheck: (result) => Number(result && result.imported) > 0,
      priority: 8,
      pool: 'io',
      shouldRun: _canRunStartupMaintenance,
      run: async () => {
        const result = deps.backfillSessionMessagesAsync
          ? await deps.backfillSessionMessagesAsync({ limit: 1, chunkSize: 10 })
          : deps.backfillSessionMessages();
        return result && typeof result === 'object' ? result : { extracted: Number(result || 0) };
      },
    });
  }

  // --- persist-agent-session-id: canonicalize startup resume cache ---
  // startup_tasks is lifecycle/restore state only. This job may refresh its
  // cached resume columns/args, but the provider id must be selected through
  // agent_sessions. In-memory fields are not durable ownership.
  scheduler.register({
    name: 'persist-agent-session-id',
    intervalMs: 60 * 1000,
    startDelayMs: 45 * 1000,
    priority: 6,
    pool: 'io',
    shouldRun: _canRunStartupMaintenance,
    run: async () => {
      let synced = 0;
      let skippedNoMapping = 0;
      try {
        const cols = new Set(dbModule.getDb().prepare('PRAGMA table_info(startup_tasks)').all().map(c => c.name));
        const rows = dbModule.getDb().prepare(
          `SELECT * FROM startup_tasks WHERE (cmd LIKE '%claude%' OR cmd LIKE '%codex%' OR cmd LIKE '%gemini%')`
        ).all();
        // Per-row resolution does several DB queries + a cwd fs check; over many rows this
        // is an unbounded synchronous main-thread scan. Yield to the loop every ~25ms so a
        // large startup_tasks table can't stall keystroke echo (matches session-metadata-sync).
        let _lastYield = Date.now();
        for (const r of rows) {
          if (Date.now() - _lastYield > 25) { await yieldToEventLoop(); _lastYield = Date.now(); }
          const ctmSessionId = r.ctm_session_id;
          const session = sessions.get(ctmSessionId);
          const agentType = detectAgentType(r.cmd || session?.cmd || '') || normalizeAgentType(r.type || session?.type || '');
          if (!ctmSessionId || !agentType) continue;
          let choice = null;
          try {
            choice = resolveRestoreArgsFromAgentSessions({
              db: dbModule.getDb(),
              task: { ...r, ctm_session_id: ctmSessionId, session_id: ctmSessionId },
              agentType,
              fsExists: fs.existsSync,
            });
          } catch (e) {
            console.error('[persist-agent-session-id] agent_sessions resolve error:', e.message);
          }
          if (choice?.source !== 'agent_sessions' || !choice.agentSessionId) {
            skippedNoMapping++;
            continue;
          }
          const currentArgs = (() => {
            try { return JSON.stringify(JSON.parse(r.args || '[]')); } catch { return r.args || ''; }
          })();
          const nextArgs = JSON.stringify(choice.args || []);
          const projectDir = choice.row?.jsonl_path ? path.dirname(choice.row.jsonl_path) : (r.agent_project_dir || r.claude_project_dir || '');
          const currentProjectDir = r.agent_project_dir || r.claude_project_dir || '';
          const hasLegacyProjectDir = cols.has('agent_project_dir') || cols.has('claude_project_dir');
          const cwdChoice = resolveRestoreCwdFromAgentSession({
            task: { ...r, cwd: r.cwd, ctm_session_id: ctmSessionId, session_id: ctmSessionId },
            row: choice.row,
            agentType,
            fsExists: fs.existsSync,
          });
          const needsCwdHeal = !!(cwdChoice.changed && cwdChoice.cwd && cwdChoice.cwd !== r.cwd);
          if (currentArgs === nextArgs && (!hasLegacyProjectDir || currentProjectDir === projectDir) && !needsCwdHeal) continue;
          try {
            dbModule.updateStartupTaskAgentSession(
              ctmSessionId,
              choice.agentSessionId,
              projectDir,
              choice.args
            );
            if (needsCwdHeal && typeof dbModule.updateStartupTaskCwd === 'function') {
              dbModule.updateStartupTaskCwd(ctmSessionId, cwdChoice.cwd);
            }
            synced++;
          } catch (e) { console.error('[persist-agent-session-id] update error:', e.message); }
        }
      } catch (e) { console.error('[persist-agent-session-id] scan error:', e.message); }
      return { synced, skippedNoMapping };
    },
  });
}

/**
 * Register session stream scheduler jobs (cleanup + reconciliation).
 * @param {import('./scheduler').Scheduler} scheduler
 * @param {Object} deps
 * @param {import('./session-stream').SessionStream} deps.sessionStream
 */
function registerStreamJobs(scheduler, deps) {
  const { sessionStream, isRestoreInProgress, getServerStartedAt, getRestoreCompletedAt, isDbWarming, isDbWorkerDegraded } = deps;
  if (!sessionStream) return;
  const STARTUP_MAINTENANCE_GRACE_MS = positiveInteger(process.env.CTM_STARTUP_MAINTENANCE_GRACE_MS, 5 * 60 * 1000);
  function _canRunStartupMaintenance() {
    if (typeof isRestoreInProgress === 'function' && isRestoreInProgress()) return false;
    // Defer while the serial db-owner worker is degraded under load (cold-boot contention cascade).
    if (typeof isDbWorkerDegraded === 'function' && isDbWorkerDegraded()) return false;
    if (typeof isDbWarming === 'function' && isDbWarming()) return false;
    if (typeof getServerStartedAt === 'function') {
      const startedAt = Number(getServerStartedAt());
      if (Number.isFinite(startedAt) && startedAt > 0 && (Date.now() - startedAt) < STARTUP_MAINTENANCE_GRACE_MS) {
        return false;
      }
    }
    if (typeof getRestoreCompletedAt === 'function') {
      const completedAt = Number(getRestoreCompletedAt());
      if (Number.isFinite(completedAt) && completedAt > 0 && (Date.now() - completedAt) < STARTUP_MAINTENANCE_GRACE_MS) {
        return false;
      }
    }
    return true;
  }

  // stream-cleanup: remove stale non-CTM sessions inactive >30min
  scheduler.register({
    name: 'stream-cleanup',
    intervalMs: 10 * 60 * 1000,
    startDelayMs: 30 * 1000,
    priority: 9,
    shouldRun: _canRunStartupMaintenance,
    run: () => {
      const cleaned = sessionStream.cleanStale();
      return { cleaned };
    },
  });

  // stream-reconciliation: stat all tracked files, trigger incremental read if changed
  scheduler.register({
    name: 'stream-reconciliation',
    heavy: true,
    intervalMs: 60 * 1000,
    startDelayMs: 30 * 1000,
    priority: 5,
    pool: 'io',
    shouldRun: _canRunStartupMaintenance,
    run: async () => {
      return await sessionStream.reconcile({ maxSessions: 12, maxChunksPerSession: 4 });
    },
  });
}

module.exports = { registerSessionJobs, registerStreamJobs };
