'use strict';
// --- Session Integrity: Detection, Recovery, and Telemetry ---
//
// Detects mismatches between CTM's session DB and actual JSONL files on disk,
// attempts safe recovery, and reports issues to telemetry for visibility.
//
// Mismatch types detected:
//   1. Missing file: DB references a JSONL file that doesn't exist on disk
//   2. Stale metadata: DB file_size/modified_at doesn't match actual file stats
//   3. Wrong mapping: CTM tab's agent_session_id points to wrong JSONL file
//   4. Orphan file: JSONL file on disk has no corresponding DB row
//   5. Duplicate claim: Multiple DB rows claim the same agent_session_id
//   6. Timestamp drift: DB created_at is significantly off from JSONL timestamp
//   7. Codex stale resume alias: provider_resume_id points at another CTM tab

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const claudeDesktopSessions = require('./lib/claude-desktop-sessions');
const {
  codexRolloutIdFromPath,
  findCodexSessionFiles,
  findCodexSessionFilesByFilename,
  findCodexStateRolloutPath,
  readCodexRolloutMetadata,
} = require('./lib/session-history');

const CLAUDE_PROJECTS_DIR = path.join(process.env.HOME, '.claude', 'projects');

// --- Detection ---

function sessionFileIdFromPath(filePath) {
  const virtual = claudeDesktopSessions.parseVirtualSessionPath(filePath);
  if (virtual) return virtual.sessionId;
  const codexId = codexRolloutIdFromPath(filePath);
  if (codexId) return codexId;
  const base = path.basename(filePath).replace(/\.jsonl(\.bak)?$/, '');
  if (/^walle-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(base)) {
    return base.toLowerCase();
  }
  const uuid = base.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
  return uuid ? uuid[1].toLowerCase() : base;
}

function fileEntryFromPath(filePath, expectedFileId, projectEntry = '') {
  if (!filePath) return null;
  const actualFileId = sessionFileIdFromPath(filePath);
  if (expectedFileId && actualFileId !== expectedFileId) return null;
  try {
    const sourcePath = claudeDesktopSessions.sourcePathForStat(filePath);
    const stat = fs.statSync(sourcePath);
    if (!stat.isFile()) return null;
    return { filePath, stat, projectEntry };
  } catch {
    return null;
  }
}

function addFileIndexEntry(fileIndex, filePath, projectEntry) {
  const file = fileEntryFromPath(filePath, null, projectEntry);
  if (file) fileIndex[sessionFileIdFromPath(filePath)] = file;
}

function isCodexSessionRow(row) {
  return (row?.provider === 'codex')
    || String(row?.jsonl_path || '').includes(`${path.sep}.codex${path.sep}sessions${path.sep}`);
}

function providerChildThreadFields(row = {}) {
  const threadSource = String(row.thread_source || row.threadSource || '').trim().toLowerCase();
  const parentAgentSessionId = String(
    row.parent_agent_session_id
    || row.parentAgentSessionId
    || row.parent_thread_id
    || row.parentThreadId
    || ''
  ).trim();
  return { threadSource, parentAgentSessionId };
}

function isProviderChildAgentRow(row = {}) {
  const provider = String(row.provider || '').trim().toLowerCase();
  const { threadSource, parentAgentSessionId } = providerChildThreadFields(row);
  return provider === 'codex' && threadSource === 'subagent' && !!parentAgentSessionId;
}

function addAgentRowFileIds(dbFileIds, row = {}) {
  if (row.id) dbFileIds.add(row.id);
  if (row.agent_session_id) dbFileIds.add(row.agent_session_id);
  const jsonlPath = String(row.jsonl_path || '').trim();
  if (jsonlPath) {
    const fileId = sessionFileIdFromPath(jsonlPath);
    if (fileId) dbFileIds.add(fileId);
    const rolloutId = codexRolloutIdFromPath(jsonlPath);
    if (rolloutId) dbFileIds.add(rolloutId);
    if (isCodexSessionRow(row)) {
      try {
        const meta = readCodexRolloutMetadata(jsonlPath) || {};
        if (meta.id) dbFileIds.add(String(meta.id));
      } catch {}
    }
  }
}

function resolveCodexCanonicalFile(expectedFileId, fileIndex) {
  if (!expectedFileId) return null;
  if (fileIndex[expectedFileId]) return fileIndex[expectedFileId];
  try {
    for (const filePath of findCodexSessionFilesByFilename(expectedFileId)) {
      const file = fileEntryFromPath(filePath, expectedFileId);
      if (file) return file;
    }
  } catch {}
  return null;
}

function resolveCodexStateAliasFile(expectedFileId) {
  if (!expectedFileId) return null;
  try {
    const filePath = findCodexStateRolloutPath(expectedFileId);
    const file = fileEntryFromPath(filePath, null);
    if (!file) return null;
    const fileId = sessionFileIdFromPath(file.filePath);
    if (fileId && fileId !== expectedFileId) return file;
  } catch {}
  return null;
}

function resolveDbSessionFile(row, expectedFileId, fileIndex) {
  if (!expectedFileId) return null;

  const isCodex = isCodexSessionRow(row);
  if (isCodex) return resolveCodexCanonicalFile(expectedFileId, fileIndex) || fileEntryFromPath(row?.jsonl_path, expectedFileId);

  if (fileIndex[expectedFileId]) return fileIndex[expectedFileId];

  const stored = fileEntryFromPath(row?.jsonl_path, isCodex ? null : expectedFileId);
  if (stored) return stored;

  return null;
}

function dbTimestampFromIso(value) {
  if (!value) return '';
  const ms = new Date(value).getTime();
  if (!Number.isFinite(ms)) return '';
  return new Date(ms).toISOString().replace('T', ' ').replace(/\.\d+Z$/, '');
}

function parseDbTimestampMs(value) {
  if (!value) return NaN;
  const str = String(value).trim();
  if (!str) return NaN;
  const hasZone = /(?:Z|[+-]\d\d:?\d\d)$/.test(str);
  return new Date(hasZone ? str : str.replace(' ', 'T') + 'Z').getTime();
}

function isLowSignalIntegrityUserText(text) {
  const s = String(text || '').trim();
  if (!s) return true;
  if (/^\[Request interrupted\b/i.test(s)) return true;
  if (/^\[(?:request\s+)?interrupted\b/i.test(s)) return true;
  if (/^<command-(?:message|name)>/i.test(s)) return true;
  // Codex injects these as separate user-role messages before the human prompt;
  // comparing them against db.first_message produces false wrong_mapping flags.
  if (/^<environment_context\b/i.test(s)) return true;
  if (/^<image\b/i.test(s)) return true;
  if (/^<\/image>/i.test(s)) return true;
  if (/^<skill>/i.test(s)) return true;
  return false;
}

function normalizedWordSet(text) {
  return new Set(String(text || '')
    .replace(/<[^>]+>/g, ' ')
    .replace(/[^a-zA-Z0-9\s]/g, ' ')
    .toLowerCase()
    .split(/\s+/)
    .filter(w => w.length > 3));
}

function wordSimilarity(a, b) {
  const aWords = normalizedWordSet(a);
  const bWords = normalizedWordSet(b);
  if (aWords.size <= 3 || bWords.size <= 3) return null;
  let overlap = 0;
  for (const w of aWords) if (bWords.has(w)) overlap++;
  return overlap / Math.min(aWords.size, bWords.size);
}

function textFromUserEntry(entry) {
  if (!entry) return '';
  if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload?.role === 'user') {
    const c = entry.payload.content;
    if (typeof c === 'string') return c;
    if (Array.isArray(c)) {
      return c
        .filter(b => b && (b.type === 'input_text' || b.type === 'text'))
        .map(b => b.text || '')
        .join('\n');
    }
    return '';
  }
  if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
    return String(entry.payload.message || '');
  }
  if (entry.type !== 'user' || entry.message?.role !== 'user') return '';
  const c = entry.message.content;
  if (typeof c === 'string') return c;
  if (Array.isArray(c)) return c.filter(b => b && b.type === 'text').map(b => b.text || '').join('\n');
  return '';
}

function readJsonlHeaderSignals(filePath, stat, {
  maxBytes = 128 * 1024,
  maxUserMessages = 5,
} = {}) {
  const result = { timestamp: '', rawTimestamp: '', userMessages: [] };
  let fallbackTimestamp = '';
  let fd = null;
  try {
    const sourcePath = claudeDesktopSessions.sourcePathForStat(filePath);
    fd = fs.openSync(sourcePath, 'r');
    const buf = Buffer.alloc(Math.min(stat.size || 0, maxBytes));
    const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
    const chunk = buf.toString('utf8', 0, bytesRead);
    const lines = chunk.split('\n');
    for (const line of lines) {
      if (!line.trim()) continue;
      try {
        const entry = JSON.parse(line);
        if (!fallbackTimestamp && entry.timestamp) fallbackTimestamp = entry.timestamp;
        const text = textFromUserEntry(entry);
        if (text && !isLowSignalIntegrityUserText(text)) {
          if (!result.timestamp && entry.timestamp) result.timestamp = entry.timestamp;
          result.userMessages.push(text);
          if (result.userMessages.length >= maxUserMessages && result.timestamp) break;
        }
      } catch {
        if (!fallbackTimestamp) {
          const ts = line.match(/"timestamp"\s*:\s*"([^"]+)"/);
          if (ts) fallbackTimestamp = ts[1];
        }
      }
    }
  } catch {
    // Integrity checks are advisory; unreadable files are handled elsewhere.
  } finally {
    if (fd !== null) {
      try { fs.closeSync(fd); } catch {}
    }
  }
  result.rawTimestamp = fallbackTimestamp;
  if (!result.timestamp) result.timestamp = fallbackTimestamp;
  return result;
}

function getJsonlHeaderSignals(file) {
  if (!file) return { timestamp: '', userMessages: [] };
  if (!file._headerSignals) file._headerSignals = readJsonlHeaderSignals(file.filePath, file.stat);
  return file._headerSignals;
}

function aliasFileMatchesDbFirstMessage(aliasFile, firstMessage) {
  const dbFirstMessage = String(firstMessage || '').trim();
  if (!aliasFile || dbFirstMessage.length <= 20) return false;
  const messages = getJsonlHeaderSignals(aliasFile).userMessages
    .filter(m => m && m.length > 20);
  if (messages.length === 0) return false;
  const normalize = (value) => String(value || '')
    .replace(/<[^>]+>/g, ' ')
    .replace(/[^a-zA-Z0-9\s]/g, ' ')
    .toLowerCase()
    .replace(/\s+/g, ' ')
    .trim();
  const dbNormalized = normalize(dbFirstMessage);
  if (dbNormalized.length > 20 && messages.some(m => {
    const msgNormalized = normalize(m);
    return msgNormalized === dbNormalized || msgNormalized.includes(dbNormalized) || dbNormalized.includes(msgNormalized);
  })) {
    return true;
  }
  const similarities = messages
    .map(m => wordSimilarity(dbFirstMessage, m))
    .filter(score => score !== null);
  return similarities.length > 0 && Math.max(...similarities) >= 0.1;
}

function parseStartupArgs(value) {
  if (Array.isArray(value)) return value;
  try {
    const parsed = JSON.parse(value || '[]');
    return Array.isArray(parsed) ? parsed : [];
  } catch {
    return [];
  }
}

function codexResumeTargetFromArgs(args) {
  const list = parseStartupArgs(args);
  const resumeIdx = list.indexOf('resume');
  if (resumeIdx >= 0 && list[resumeIdx + 1]) {
    return { target: String(list[resumeIdx + 1]), canonicalShape: true };
  }
  if (list.length === 1 && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(list[0] || ''))) {
    return { target: String(list[0]), canonicalShape: false };
  }
  return { target: '', canonicalShape: false };
}

function codexResumeIdForAgentRow(row) {
  return String(row?.provider_resume_id || '').trim() || String(row?.agent_session_id || '').trim();
}

function codexAgentRowHasTranscript(row) {
  return Number(row?.file_size || 0) > 0 || !!String(row?.jsonl_path || '').trim();
}

/**
 * Run a full integrity check between the sessions DB and JSONL files on disk.
 * Returns an array of issue objects, each with { type, severity, sessionId, details, suggestion }.
 */
function detectMismatches(db, getAllSessionFiles) {
  const issues = [];
  // 1. Load all DB sessions (v1 schema: ctm_sessions + agent_sessions)
  let allSessions;
  try {
    // Backward-compat: older DBs (pre-v2) don't have agent_sessions.slug.
    // Check first and select accordingly.
    let hasSlugColumn = false;
    let hasProviderResumeColumn = false;
    let hasThreadSourceColumn = false;
    let hasParentAgentSessionColumn = false;
    let hasAgentNicknameColumn = false;
    let hasAgentRoleColumn = false;
    try {
      const cols = db.prepare('PRAGMA table_info(agent_sessions)').all();
      hasSlugColumn = cols.some((c) => c.name === 'slug');
      hasProviderResumeColumn = cols.some((c) => c.name === 'provider_resume_id');
      hasThreadSourceColumn = cols.some((c) => c.name === 'thread_source');
      hasParentAgentSessionColumn = cols.some((c) => c.name === 'parent_agent_session_id');
      hasAgentNicknameColumn = cols.some((c) => c.name === 'agent_nickname');
      hasAgentRoleColumn = cols.some((c) => c.name === 'agent_role');
    } catch {}
    const slugCol = hasSlugColumn ? 'a.slug' : "'' AS slug";
    const providerResumeCol = hasProviderResumeColumn ? 'a.provider_resume_id' : "'' AS provider_resume_id";
    const threadSourceCol = hasThreadSourceColumn ? 'a.thread_source' : "'' AS thread_source";
    const parentAgentSessionCol = hasParentAgentSessionColumn ? 'a.parent_agent_session_id' : "'' AS parent_agent_session_id";
    const agentNicknameCol = hasAgentNicknameColumn ? 'a.agent_nickname' : "'' AS agent_nickname";
    const agentRoleCol = hasAgentRoleColumn ? 'a.agent_role' : "'' AS agent_role";
    allSessions = db.prepare(`
      SELECT c.id, c.provider, c.title, c.user_renamed, c.starred, c.project_path, c.cwd,
        c.created_at, c.updated_at,
        a.agent_session_id, ${providerResumeCol}, a.jsonl_path, a.file_size, a.first_message,
        a.modified_at, a.hostname, a.model, a.git_branch, a.user_msg_count,
        ${threadSourceCol}, ${parentAgentSessionCol}, ${agentNicknameCol}, ${agentRoleCol},
        ${slugCol}
      FROM ctm_sessions c
      LEFT JOIN agent_sessions a ON a.ctm_session_id = c.id
    `).all();
  } catch (e) {
    issues.push({ type: 'db_error', severity: 'critical', sessionId: null,
      details: { error: e.message }, suggestion: 'Check DB connectivity' });
    return issues;
  }

  // 2. Build file index from disk
  const fileIndex = {}; // uuid -> { filePath, stat, projectEntry }
  try {
    for (const { filePath, projectEntry } of getAllSessionFiles()) {
      addFileIndexEntry(fileIndex, filePath, projectEntry);
    }
  } catch (e) {
    issues.push({ type: 'scan_error', severity: 'warning', sessionId: null,
      details: { error: e.message }, suggestion: 'JSONL directory may be inaccessible' });
  }

  const timestampStartBySession = new Map();
  for (const row of allSessions) {
    const sid = row.id;
    const agentId = row.agent_session_id;
    const expectedFileId = (agentId && agentId !== sid) ? agentId : sid;
    const file = resolveDbSessionFile(row, expectedFileId, fileIndex);
    if (!file) continue;
    const headerSignals = getJsonlHeaderSignals(file);
    if (!headerSignals.timestamp) continue;
    const tsMs = new Date(headerSignals.timestamp).getTime();
    if (!Number.isFinite(tsMs)) continue;
    const prev = timestampStartBySession.get(sid);
    if (!prev || tsMs < prev.tsMs) {
      timestampStartBySession.set(sid, {
        tsMs,
        timestamp: headerSignals.timestamp,
        rawTimestamp: headerSignals.rawTimestamp || '',
        fileId: expectedFileId,
      });
    }
  }

  // 3. Track claims to detect duplicates
  const agentIdClaims = {}; // agent_session_id -> [session ids that claim it]
  const timestampDriftChecked = new Set();
  const agentOwnerById = new Map();
  for (const row of allSessions) {
    if (row.agent_session_id) agentOwnerById.set(row.agent_session_id, row.id);
  }

  for (const row of allSessions) {
    const sid = row.id;
    const agentId = row.agent_session_id;

    // Track duplicate claims
    if (agentId && agentId !== sid) {
      if (!agentIdClaims[agentId]) agentIdClaims[agentId] = [];
      agentIdClaims[agentId].push(sid);
    }

    // Skip DB-only rows with no file expectation (legacy tabs with no agent_session_id)
    const expectedFileId = (agentId && agentId !== sid) ? agentId : sid;
    const file = resolveDbSessionFile(row, expectedFileId, fileIndex);
    let reportedCodexAgentJsonlMismatch = false;

    if (isCodexSessionRow(row) && agentId) {
      const providerResumeId = String(row.provider_resume_id || '').trim();
      if (providerResumeId && providerResumeId !== agentId) {
        const resumeOwner = agentOwnerById.get(providerResumeId);
        if (resumeOwner && resumeOwner !== sid) {
          issues.push({
            type: 'codex_provider_resume_cross_claim', severity: 'warning', sessionId: sid,
            details: {
              agent_session_id: agentId,
              provider_resume_id: providerResumeId,
              provider_resume_owner: resumeOwner,
            },
            suggestion: 'Codex provider_resume_id points at another CTM tab; clear the stale alias and resume this tab by its own agent_session_id.',
          });
        }
      }

      const storedFile = fileEntryFromPath(row.jsonl_path, null);
      const storedFileAgentId = storedFile ? sessionFileIdFromPath(storedFile.filePath) : '';
      const aliasFile = storedFileAgentId && storedFileAgentId !== agentId
        ? storedFile
        : (!file ? resolveCodexStateAliasFile(agentId) : null);
      const fileAgentId = aliasFile ? sessionFileIdFromPath(aliasFile.filePath) : '';
      const detector = storedFileAgentId && storedFileAgentId !== agentId ? 'stored_jsonl_path' : 'codex_state_rollout_path';
      const shouldReportAlias = detector === 'stored_jsonl_path' || aliasFileMatchesDbFirstMessage(aliasFile, row.first_message || '');
      if (fileAgentId && fileAgentId !== agentId && shouldReportAlias) {
        issues.push({
          type: 'codex_agent_jsonl_mismatch', severity: 'error', sessionId: sid,
          details: {
            agent_session_id: agentId,
            file_agent_session_id: fileAgentId,
            provider_resume_id: row.provider_resume_id || '',
            jsonl_path: aliasFile.filePath,
            db_jsonl_path: row.jsonl_path || '',
            db_first_message: row.first_message || '',
            file_size: aliasFile.stat.size,
            modified_at: aliasFile.stat.mtime.toISOString(),
            detector,
          },
          suggestion: 'Codex agent_session_id must match the rollout JSONL filename; store the resume/thread id in provider_resume_id.',
        });
        reportedCodexAgentJsonlMismatch = true;
      }
    }

    // Check 1: Missing file
    if (!file && row.file_size > 0 && !reportedCodexAgentJsonlMismatch) {
      issues.push({
        type: 'missing_file', severity: 'warning', sessionId: sid,
        details: {
          expected_file_id: expectedFileId,
          db_file_size: row.file_size,
          db_jsonl_path: row.jsonl_path || '',
          db_provider: row.provider || '',
          db_title: row.title || '',
        },
        suggestion: 'File may have been deleted or moved. Check .jsonl.bak variant.',
      });
      continue;
    }

    if (!file) continue; // No file expected or found — OK for legacy entries

    // Check 2: Stale metadata (file_size mismatch)
    if (row.file_size > 0 && file.stat.size > 0) {
      const sizeDiff = Math.abs(row.file_size - file.stat.size);
      // Only flag if the DB is significantly behind (file grew) — small diffs from
      // race conditions during active writing are normal
      if (sizeDiff > 1024 && file.stat.size > row.file_size * 1.5) {
        issues.push({
          type: 'stale_metadata', severity: 'info', sessionId: sid,
          details: {
            agent_session_id: agentId || '',
            file_id: expectedFileId,
            db_file_size: row.file_size,
            actual_file_size: file.stat.size,
            size_diff: sizeDiff,
            db_jsonl_path: row.jsonl_path || '',
            db_provider: row.provider || '',
          },
          suggestion: 'DB metadata is stale — will be refreshed on next session list load.',
        });
      }
    }

    // Check 3: Timestamp drift (created_at vs JSONL timestamp)
    // Filesystem birthtime is not reliable for Claude JSONL sessions: Dropbox,
    // restores, rsync/copy, and fresh dev databases can all make birthtime mean
    // "when this file arrived here", not "when the conversation started".
    const headerSignals = timestampStartBySession.get(sid);
    if (!timestampDriftChecked.has(sid) && row.created_at && headerSignals?.timestamp) {
      timestampDriftChecked.add(sid);
      const dbMs = parseDbTimestampMs(row.created_at);
      const fileMs = new Date(headerSignals.timestamp).getTime();
      const rawMs = headerSignals.rawTimestamp ? new Date(headerSignals.rawTimestamp).getTime() : NaN;
      const drift = Math.abs(dbMs - fileMs);
      const rawDrift = Math.abs(dbMs - rawMs);
      const matchesRawStart = Number.isFinite(rawMs) && rawDrift <= 300000;
      if (Number.isFinite(dbMs) && Number.isFinite(fileMs) && drift > 300000 && !matchesRawStart) { // >5 minutes off
        issues.push({
          type: 'timestamp_drift', severity: 'info', sessionId: sid,
          details: {
            file_id: headerSignals.fileId,
            db_created_at: row.created_at,
            file_created_at: dbTimestampFromIso(headerSignals.timestamp),
            file_timestamp: headerSignals.timestamp,
            raw_file_timestamp: headerSignals.rawTimestamp || '',
            drift_seconds: Math.round(drift / 1000),
          },
          suggestion: 'DB created_at differs from the JSONL conversation timestamp.',
        });
      }
    }

    // Check 4a: Wrong mapping via slug mismatch (primary, slug-based detector).
    // Flags rows whose slug differs from the tab's canonical slug AND whose
    // JSONL isn't a prefix-copy continuation of the tab's chain. Backward-compat:
    // if either slug is empty, fall through to the legacy content check.
    try {
      const { canonicalSlugForTab, readFirstSlug, readPrefixSessionIds, knownAgentIdsForTab } = require('./lib/session-lineage');
      const agentSid = row.agent_session_id;
      if (agentSid && agentSid !== row.id && row.slug) {
        const canonical = canonicalSlugForTab(db, row.id);
        if (canonical && canonical !== row.slug) {
          // Prefix-copy rescue: if candidate's JSONL prefix contains a known
          // sibling sessionId, treat as lineage and skip.
          let prefixRescue = false;
          if (row.jsonl_path && !claudeDesktopSessions.isVirtualSessionPath(row.jsonl_path) && fs.existsSync(row.jsonl_path)) {
            const known = knownAgentIdsForTab(db, row.id);
            const prefixIds = readPrefixSessionIds(row.jsonl_path);
            for (const pid of prefixIds) {
              if (pid !== agentSid && known.has(pid)) { prefixRescue = true; break; }
            }
          }
          if (!prefixRescue) {
            issues.push({
              type: 'wrong_mapping', severity: 'error', sessionId: sid,
              details: {
                detector: 'slug_mismatch',
                ctm_session_id: row.id,
                agent_session_id: agentSid,
                canonical_slug: canonical,
                agent_slug: row.slug,
              },
              suggestion: 'Slug mismatch — agent_session belongs to a different conversation.',
            });
          }
        }
      } else if (agentSid && agentSid !== row.id && !row.slug && row.jsonl_path) {
        // Backward-compat: slug empty in DB. Try reading the JSONL directly
        // so we don't miss the detection just because backfill hasn't caught up.
        const canonical = canonicalSlugForTab(db, row.id);
        if (canonical && !claudeDesktopSessions.isVirtualSessionPath(row.jsonl_path) && fs.existsSync(row.jsonl_path)) {
          const hdr = readFirstSlug(row.jsonl_path);
          if (hdr && hdr.slug && hdr.slug !== canonical) {
            const known = knownAgentIdsForTab(db, row.id);
            const prefixIds = readPrefixSessionIds(row.jsonl_path);
            let prefixRescue = false;
            for (const pid of prefixIds) {
              if (pid !== agentSid && known.has(pid)) { prefixRescue = true; break; }
            }
            if (!prefixRescue) {
              issues.push({
                type: 'wrong_mapping', severity: 'error', sessionId: sid,
                details: {
                  detector: 'slug_mismatch_ondisk',
                  ctm_session_id: row.id,
                  agent_session_id: agentSid,
                  canonical_slug: canonical,
                  agent_slug: hdr.slug,
                },
                suggestion: 'Slug mismatch (read from disk) — mapping likely wrong.',
              });
            }
          }
        }
      }
    } catch (e) { /* lineage helpers unavailable — fall through to content check */ }

    // Check 4b: Wrong mapping detection via content fingerprint (legacy).
    // Kept for pre-slug JSONLs and as a defense-in-depth check.
    // If the DB has a first_message and the file's first user message is completely different,
    // the mapping is likely wrong.
    if (row.first_message && row.first_message.length > 20) {
      try {
        const fileUserMessages = getJsonlHeaderSignals(file).userMessages
          .filter(m => m && m.length > 20)
          .slice(0, 5);
        if (fileUserMessages.length > 0) {
          const similarities = fileUserMessages
            .map(m => ({ text: m, score: wordSimilarity(row.first_message, m) }))
            .filter(s => s.score !== null);
          if (similarities.length > 0) {
            similarities.sort((a, b) => b.score - a.score);
            const best = similarities[0];
            if (best.score < 0.1) { // <10% word overlap = likely wrong mapping
              issues.push({
                type: 'wrong_mapping', severity: 'error', sessionId: sid,
                details: {
                  file_id: expectedFileId,
                  db_first_message: row.first_message.slice(0, 100),
                  file_first_message: best.text.slice(0, 100),
                  word_similarity: Math.round(best.score * 100) + '%',
                },
                suggestion: 'Content mismatch — DB row may be linked to wrong JSONL file.',
              });
            }
          }
        }
      } catch {}
    }
  }

  // Check 5: Duplicate claims
  for (const [agentId, claimers] of Object.entries(agentIdClaims)) {
    if (claimers.length > 1) {
      issues.push({
        type: 'duplicate_claim', severity: 'warning', sessionId: agentId,
        details: {
          agent_session_id: agentId,
          claimed_by: claimers,
          claimer_count: claimers.length,
        },
        suggestion: 'Multiple DB rows claim the same JSONL file. Resolve by keeping the most recent.',
      });
    }
  }

  // Check 6: Orphan files (on disk but not in DB)
  const dbFileIds = new Set();
  for (const row of allSessions) {
    addAgentRowFileIds(dbFileIds, row);
    if (isCodexSessionRow(row) && row.agent_session_id) {
      try {
        for (const filePath of findCodexSessionFiles(row.agent_session_id)) {
          const rolloutFileId = sessionFileIdFromPath(filePath);
          if (rolloutFileId) dbFileIds.add(rolloutFileId);
        }
      } catch {}
    }
  }
  try {
    const agentCols = new Set(db.prepare('PRAGMA table_info(agent_sessions)').all().map(c => c.name));
    const threadSourceCol = agentCols.has('thread_source') ? 'thread_source' : "'' AS thread_source";
    const parentAgentSessionCol = agentCols.has('parent_agent_session_id') ? 'parent_agent_session_id' : "'' AS parent_agent_session_id";
    const allAgentRows = db.prepare(`
      SELECT agent_session_id, ctm_session_id, provider, jsonl_path,
        ${threadSourceCol}, ${parentAgentSessionCol}
      FROM agent_sessions
    `).all();
    for (const row of allAgentRows) {
      addAgentRowFileIds(dbFileIds, row);
      if (isCodexSessionRow(row) && row.agent_session_id) {
        try {
          for (const filePath of findCodexSessionFiles(row.agent_session_id)) {
            const rolloutFileId = sessionFileIdFromPath(filePath);
            if (rolloutFileId) dbFileIds.add(rolloutFileId);
          }
        } catch {}
      }
    }
  } catch {}
  for (const issue of issues) {
    if (issue.type === 'codex_agent_jsonl_mismatch' && issue.details?.file_agent_session_id) {
      dbFileIds.add(issue.details.file_agent_session_id);
    }
  }
  let orphanCount = 0;
  for (const [uuid, file] of Object.entries(fileIndex)) {
    if (!dbFileIds.has(uuid)) {
      orphanCount++;
      if (orphanCount <= 10) { // Cap detail reporting to avoid noise
        issues.push({
          type: 'orphan_file', severity: 'info', sessionId: uuid,
          details: {
            file_path: file.filePath,
            file_size: file.stat.size,
            project_entry: file.projectEntry,
            birthtime: file.stat.birthtime.toISOString(),
          },
          suggestion: 'JSONL file exists on disk but has no DB entry. Will be indexed on next session list load.',
        });
      }
    }
  }
  if (orphanCount > 10) {
    issues.push({
      type: 'orphan_file_summary', severity: 'info', sessionId: null,
      details: { total_orphans: orphanCount, reported: 10 },
      suggestion: `${orphanCount} orphan files total. Only first 10 reported.`,
    });
  }

  // Check 6b: Codex startup_tasks must contain provider-native resume args.
  // A CTM tab UUID is a lifecycle id, not a Codex resume id. If a crash restart
  // launches `codex <ctm-id>` or `codex resume <ctm-id>`, Codex will report
  // "No saved session found" even when CTM has the real rollout in agent_sessions.
  try {
    const startupCols = db.prepare('PRAGMA table_info(startup_tasks)').all().map(c => c.name);
    if (startupCols.includes('ctm_session_id') && startupCols.includes('args')) {
      const startupRows = db.prepare('SELECT * FROM startup_tasks').all();
      const ctmProviders = new Map(allSessions.map(row => [row.id, row.provider || '']));
      const agentRowsByCtm = new Map();
      for (const row of allSessions) {
        if (!row.id || !row.agent_session_id) continue;
        if (!agentRowsByCtm.has(row.id)) agentRowsByCtm.set(row.id, []);
        agentRowsByCtm.get(row.id).push(row);
      }
      for (const task of startupRows) {
        const sid = String(task.ctm_session_id || '').trim();
        const cmd = String(task.cmd || '').toLowerCase();
        const isCodexTask = cmd.includes('codex') || ctmProviders.get(sid) === 'codex';
        if (!sid || !isCodexTask) continue;
        const candidates = (agentRowsByCtm.get(sid) || [])
          .filter(row => isCodexSessionRow(row) && row.agent_session_id && codexAgentRowHasTranscript(row))
          .sort((a, b) => {
            const at = parseDbTimestampMs(a.modified_at || a.updated_at || a.created_at) || 0;
            const bt = parseDbTimestampMs(b.modified_at || b.updated_at || b.created_at) || 0;
            return bt - at;
          });
        const canonical = candidates[0] || null;
        const canonicalResumeId = codexResumeIdForAgentRow(canonical);
        if (!canonical?.agent_session_id || !canonicalResumeId) continue;
        const resume = codexResumeTargetFromArgs(task.args);
        if (resume.canonicalShape && resume.target === canonicalResumeId) continue;
        const hasEmptySelfRow = (agentRowsByCtm.get(sid) || []).some(row =>
          row.agent_session_id === sid
          && !codexAgentRowHasTranscript(row)
          && codexResumeIdForAgentRow(row) === sid
        );
        issues.push({
          type: 'codex_startup_resume_target_stale',
          severity: 'warning',
          sessionId: sid,
          details: {
            startup_resume_target: resume.target,
            startup_args: task.args || '[]',
            agent_session_id: canonical.agent_session_id,
            provider_resume_id: canonical.provider_resume_id || '',
            canonical_resume_id: canonicalResumeId,
            jsonl_path: canonical.jsonl_path || '',
            has_empty_self_row: hasEmptySelfRow,
          },
          suggestion: 'Codex startup task must restart with `codex resume <provider-thread-id>` from agent_sessions, not the CTM tab UUID.',
        });
      }
    }
  } catch (e) {
    issues.push({
      type: 'startup_scan_error',
      severity: 'warning',
      sessionId: null,
      details: { error: e.message },
      suggestion: 'Startup task integrity scan failed; check startup_tasks schema.',
    });
  }

  // Check 7: Orphan top-level agent_sessions (no valid ctm_session parent).
  // Provider-native child threads (Codex subagents, etc.) are deliberately
  // indexed as agent identities without top-level CTM tabs. Promoting them here
  // creates the one-message "ghost sessions" that pollute Recent Sessions.
  try {
    const agentCols = new Set(db.prepare('PRAGMA table_info(agent_sessions)').all().map(c => c.name));
    const threadSourceCol = agentCols.has('thread_source') ? 'a.thread_source' : "'' AS thread_source";
    const parentAgentSessionCol = agentCols.has('parent_agent_session_id') ? 'a.parent_agent_session_id' : "'' AS parent_agent_session_id";
    const agentNicknameCol = agentCols.has('agent_nickname') ? 'a.agent_nickname' : "'' AS agent_nickname";
    const agentRoleCol = agentCols.has('agent_role') ? 'a.agent_role' : "'' AS agent_role";
    const orphanAgents = db.prepare(`
      SELECT a.agent_session_id, a.ctm_session_id, a.provider, a.project_path,
        a.jsonl_path, a.file_size,
        ${threadSourceCol}, ${parentAgentSessionCol}, ${agentNicknameCol}, ${agentRoleCol}
      FROM agent_sessions a
      LEFT JOIN ctm_sessions c ON c.id = a.ctm_session_id
      WHERE a.ctm_session_id IS NULL OR c.id IS NULL
    `).all();
    for (const row of orphanAgents) {
      if (isProviderChildAgentRow(row)) continue;
      issues.push({
        type: 'orphan_agent_session', severity: 'warning', sessionId: row.agent_session_id,
        details: {
          agent_session_id: row.agent_session_id,
          stale_ctm_id: row.ctm_session_id || null,
          provider: row.provider || '',
          project_path: row.project_path || '',
          jsonl_path: row.jsonl_path || '',
          file_size: row.file_size || 0,
          thread_source: row.thread_source || '',
          parent_agent_session_id: row.parent_agent_session_id || '',
          agent_nickname: row.agent_nickname || '',
          agent_role: row.agent_role || '',
        },
        suggestion: 'agent_session has no valid ctm_session parent. Will auto-create parent.',
      });
    }
  } catch (e) {
    console.error('[session-integrity] Error checking orphan agent_sessions:', e.message);
  }

  return issues;
}


// --- Recovery ---

/**
 * Attempt to fix detected issues. Only applies safe, non-destructive fixes.
 * Returns { fixed: number, skipped: number, actions: string[] }.
 */
function recoverMismatches(db, issues, getAllSessionFiles) {
  const result = { fixed: 0, skipped: 0, actions: [] };

  // Build file index for recovery lookups
  const fileIndex = {};
  try {
    for (const { filePath, projectEntry } of getAllSessionFiles()) {
      addFileIndexEntry(fileIndex, filePath, projectEntry);
    }
  } catch {}

  for (const issue of issues) {
    switch (issue.type) {
      case 'codex_startup_resume_target_stale': {
        const agentId = String(issue.details?.agent_session_id || '').trim();
        const resumeId = String(issue.details?.canonical_resume_id || issue.details?.provider_resume_id || agentId || '').trim();
        if (!issue.sessionId || !agentId || !resumeId) { result.skipped++; break; }
        try {
          const projectDir = issue.details?.jsonl_path ? path.dirname(issue.details.jsonl_path) : '';
          const cols = new Set(db.prepare('PRAGMA table_info(startup_tasks)').all().map(c => c.name));
          const sets = ['args = ?'];
          const vals = [JSON.stringify(['resume', resumeId])];
          if (cols.has('agent_project_dir')) { sets.push('agent_project_dir = ?'); vals.push(projectDir || null); }
          if (cols.has('claude_project_dir')) { sets.push('claude_project_dir = ?'); vals.push(projectDir || null); }
          vals.push(issue.sessionId);
          const fixed = db.prepare(`UPDATE startup_tasks SET ${sets.join(', ')} WHERE ctm_session_id = ?`).run(...vals).changes || 0;
          let removedSelfRows = 0;
          if (issue.details?.has_empty_self_row) {
            removedSelfRows = db.prepare(`
              DELETE FROM agent_sessions
              WHERE ctm_session_id = ?
                AND agent_session_id = ?
                AND COALESCE(jsonl_path, '') = ''
                AND COALESCE(file_size, 0) <= 0
            `).run(issue.sessionId, issue.sessionId).changes || 0;
          }
          if (fixed > 0 || removedSelfRows > 0) {
            result.fixed++;
            result.actions.push(`Repaired Codex startup resume for ${issue.sessionId.slice(0, 8)} → ${resumeId.slice(0, 8)}${removedSelfRows ? '; removed empty CTM self row' : ''}`);
          } else {
            result.skipped++;
          }
        } catch (e) {
          result.skipped++;
          result.actions.push(`Failed Codex startup resume repair for ${issue.sessionId.slice(0, 8)}: ${e.message}`);
        }
        break;
      }

      case 'codex_provider_resume_cross_claim': {
        const agentId = issue.details?.agent_session_id || '';
        const resumeId = issue.details?.provider_resume_id || '';
        if (!agentId || !resumeId || !issue.sessionId) { result.skipped++; break; }
        try {
          let fixed = 0;
          fixed += db.prepare(`
            UPDATE agent_sessions
            SET provider_resume_id = '',
                updated_at = datetime('now')
            WHERE ctm_session_id = ?
              AND agent_session_id = ?
              AND provider_resume_id = ?
          `).run(issue.sessionId, agentId, resumeId).changes || 0;

          try {
            const task = db.prepare('SELECT args FROM startup_tasks WHERE ctm_session_id = ?').get(issue.sessionId);
            if (task?.args) {
              const args = JSON.parse(task.args);
              if (Array.isArray(args)) {
                let changed = false;
                const healedArgs = args.map((arg) => {
                  if (arg === resumeId) {
                    changed = true;
                    return agentId;
                  }
                  return arg;
                });
                if (changed) {
                  fixed += db.prepare('UPDATE startup_tasks SET args = ? WHERE ctm_session_id = ?')
                    .run(JSON.stringify(healedArgs), issue.sessionId).changes || 0;
                }
              }
            }
          } catch {}

          if (fixed > 0) {
            result.fixed++;
            result.actions.push(`Cleared stale Codex resume alias ${resumeId.slice(0, 8)} from ${issue.sessionId.slice(0, 8)}; restore will use ${agentId.slice(0, 8)}`);
          } else {
            result.skipped++;
          }
        } catch (e) {
          result.skipped++;
          result.actions.push(`Failed stale Codex resume alias cleanup for ${issue.sessionId.slice(0, 8)}: ${e.message}`);
        }
        break;
      }

      case 'codex_agent_jsonl_mismatch': {
        const oldAgentId = issue.details?.agent_session_id || '';
        const newAgentId = issue.details?.file_agent_session_id || '';
        const resumeId = issue.details?.provider_resume_id || oldAgentId;
        if (!oldAgentId || !newAgentId || oldAgentId === newAgentId) { result.skipped++; break; }
        try {
          const existing = db.prepare('SELECT ctm_session_id FROM agent_sessions WHERE agent_session_id = ?').get(newAgentId);
          const dbJsonlPath = issue.details?.db_jsonl_path || '';
          const dbJsonlFileId = sessionFileIdFromPath(dbJsonlPath);
          if (existing && existing.ctm_session_id && existing.ctm_session_id !== issue.sessionId) {
            if (!dbJsonlPath || dbJsonlFileId === newAgentId) {
              db.prepare(`
                UPDATE agent_sessions
                SET jsonl_path = '',
                    file_size = 0,
                    modified_at = '',
                    provider_resume_id = CASE WHEN provider_resume_id = ? THEN '' ELSE provider_resume_id END,
                    updated_at = datetime('now')
                WHERE ctm_session_id = ? AND agent_session_id = ?
              `).run(newAgentId, issue.sessionId, oldAgentId);
              result.fixed++;
              result.actions.push(`Cleared stale Codex JSONL claim for ${issue.sessionId.slice(0, 8)}: ${newAgentId.slice(0, 8)} already belongs to ${existing.ctm_session_id.slice(0, 8)}`);
            } else {
              result.skipped++;
              result.actions.push(`Skipped Codex state alias ${oldAgentId.slice(0, 8)} -> ${newAgentId.slice(0, 8)}; stored JSONL still points at ${dbJsonlFileId.slice(0, 8)}`);
            }
            break;
          }

          const row = db.prepare('SELECT first_message, jsonl_path FROM agent_sessions WHERE ctm_session_id = ? AND agent_session_id = ?')
            .get(issue.sessionId, oldAgentId);
          const dbFirstMessage = row?.first_message || issue.details?.db_first_message || '';
          if (dbFirstMessage && issue.details?.jsonl_path) {
            const candidateSignals = readJsonlHeaderSignals(issue.details.jsonl_path, {
              size: issue.details.file_size || 0,
            });
            const similarities = candidateSignals.userMessages
              .filter(m => m && m.length > 20)
              .map(m => wordSimilarity(dbFirstMessage, m))
              .filter(score => score !== null);
            if (similarities.length > 0 && Math.max(...similarities) < 0.1) {
              if (!dbJsonlPath || dbJsonlFileId === newAgentId) {
                db.prepare(`
                  UPDATE agent_sessions
                  SET jsonl_path = '',
                      file_size = 0,
                      modified_at = '',
                      provider_resume_id = CASE WHEN provider_resume_id = ? THEN '' ELSE provider_resume_id END,
                      updated_at = datetime('now')
                  WHERE ctm_session_id = ? AND agent_session_id = ?
                `).run(newAgentId, issue.sessionId, oldAgentId);
                result.fixed++;
                result.actions.push(`Cleared low-confidence Codex JSONL claim for ${issue.sessionId.slice(0, 8)}; candidate ${newAgentId.slice(0, 8)} did not match first message`);
              } else {
                result.skipped++;
                result.actions.push(`Skipped low-confidence Codex alias ${oldAgentId.slice(0, 8)} -> ${newAgentId.slice(0, 8)}; first message does not match`);
              }
              break;
            }
          }

          db.prepare(`
            UPDATE agent_sessions
            SET agent_session_id = ?,
                provider_resume_id = ?,
                jsonl_path = ?,
                file_size = ?,
                modified_at = ?,
                updated_at = datetime('now')
            WHERE ctm_session_id = ? AND agent_session_id = ?
          `).run(
            newAgentId,
            resumeId !== newAgentId ? resumeId : '',
            issue.details?.jsonl_path || '',
            issue.details?.file_size || 0,
            issue.details?.modified_at || '',
            issue.sessionId,
            oldAgentId
          );
          const hasConversations = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='session_conversations'").get();
          if (hasConversations) {
            db.prepare(`
              UPDATE session_conversations
              SET ctm_session_id = ?
              WHERE ctm_session_id = ?
                AND NOT EXISTS (SELECT 1 FROM session_conversations WHERE ctm_session_id = ?)
            `).run(newAgentId, oldAgentId, newAgentId);
          }
          const hasMessages = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='session_messages'").get();
          if (hasMessages) {
            db.prepare(`
              UPDATE session_messages
              SET ctm_session_id = ?
              WHERE ctm_session_id = ?
                AND NOT EXISTS (SELECT 1 FROM session_messages WHERE ctm_session_id = ?)
            `).run(newAgentId, oldAgentId, newAgentId);
          }
          result.fixed++;
          result.actions.push(`Repaired Codex agent id ${oldAgentId.slice(0, 8)} -> ${newAgentId.slice(0, 8)} and preserved resume id ${resumeId.slice(0, 8)}`);
        } catch (e) {
          result.skipped++;
          result.actions.push(`Failed Codex id repair for ${oldAgentId.slice(0, 8)}: ${e.message}`);
        }
        break;
      }

      case 'missing_file': {
        // Try to find .bak variant
        const fileId = issue.details.expected_file_id;
        if (!fileId) { result.skipped++; break; }
        let found = false;
        try {
          if (!fs.existsSync(CLAUDE_PROJECTS_DIR)) break;
          for (const entry of fs.readdirSync(CLAUDE_PROJECTS_DIR)) {
            const bakPath = path.join(CLAUDE_PROJECTS_DIR, entry, `${fileId}.jsonl.bak`);
            if (fs.existsSync(bakPath)) {
              // Restore .bak to .jsonl
              const jsonlPath = bakPath.replace(/\.bak$/, '');
              fs.copyFileSync(bakPath, jsonlPath);
              const stat = fs.statSync(jsonlPath);
              db.prepare('UPDATE agent_sessions SET jsonl_path = ?, file_size = ?, modified_at = ?, updated_at = datetime(\'now\') WHERE ctm_session_id = ?')
                .run(jsonlPath, stat.size, stat.mtime.toISOString(), issue.sessionId);
              result.fixed++;
              result.actions.push(`Restored ${fileId.slice(0, 8)}.jsonl from .bak`);
              found = true;
              break;
            }
          }
        } catch {}
        if (!found) {
          // File is gone for good. Delete the orphan agent_sessions row so:
          //   (a) future attach/resume calls can't use the stale mapping
          //       (previously this only cleared file_size=0, which let the
          //        spawn path still pass --resume <missing-id> → crash loop)
          //   (b) repeated integrity checks don't re-flag it
          // Scope to the specific (ctm, agent) pair to avoid nuking sibling
          // agent_sessions rows under the same CTM tab.
          try {
            const expectedFileId = issue.details?.expected_file_id;
            if (expectedFileId && expectedFileId !== issue.sessionId) {
              db.prepare("DELETE FROM agent_sessions WHERE ctm_session_id = ? AND agent_session_id = ?")
                .run(issue.sessionId, expectedFileId);
              result.actions.push(`Deleted orphan agent_session ${expectedFileId.slice(0, 8)} for ctm ${issue.sessionId.slice(0, 8)} (file missing)`);
            } else {
              // Legacy case: agent_session_id == ctm_session_id. Don't delete
              // the row — that would break the tab. Just zero file_size.
              db.prepare("UPDATE agent_sessions SET file_size = 0, updated_at = datetime('now') WHERE ctm_session_id = ? AND agent_session_id = ?")
                .run(issue.sessionId, issue.sessionId);
              result.actions.push(`Cleared file_size for ${issue.sessionId.slice(0, 8)} (file deleted, legacy row)`);
            }
            result.fixed++;
          } catch {
            result.skipped++;
          }
        }
        break;
      }

      case 'stale_metadata': {
        // Refresh metadata from actual file
        const fileId = issue.details.file_id;
        const isCodex = issue.details?.db_provider === 'codex';
        const file = (isCodex
          ? resolveCodexCanonicalFile(fileId, fileIndex)
          : null)
          || fileIndex[fileId]
          || fileEntryFromPath(issue.details?.db_jsonl_path, fileId)
          || (!isCodex ? resolveDbSessionFile({ provider: 'codex', jsonl_path: issue.details?.db_jsonl_path || '' }, fileId, fileIndex) : null);
        if (!file) { result.skipped++; break; }
        try {
          const agentSessionId = issue.details?.agent_session_id || '';
          if (agentSessionId) {
            db.prepare('UPDATE agent_sessions SET jsonl_path = ?, file_size = ?, modified_at = ?, updated_at = datetime(\'now\') WHERE ctm_session_id = ? AND agent_session_id = ?')
              .run(file.filePath, file.stat.size, file.stat.mtime.toISOString(), issue.sessionId, agentSessionId);
          } else {
            db.prepare('UPDATE agent_sessions SET jsonl_path = ?, file_size = ?, modified_at = ?, updated_at = datetime(\'now\') WHERE ctm_session_id = ?')
              .run(file.filePath, file.stat.size, file.stat.mtime.toISOString(), issue.sessionId);
          }
          result.fixed++;
          result.actions.push(`Refreshed metadata for ${issue.sessionId.slice(0, 8)} (${issue.details.db_file_size} -> ${file.stat.size})`);
        } catch {
          result.skipped++;
        }
        break;
      }

      case 'timestamp_drift': {
        // Fix timestamp from JSONL timestamp. Do not use filesystem birthtime:
        // it changes under copy/sync/restore and caused fresh dev DBs to
        // rewrite conversation chronology to the wrong date.
        const ts = issue.details?.file_created_at;
        if (!ts) { result.skipped++; break; }
        try {
          db.prepare('UPDATE ctm_sessions SET created_at = ?, updated_at = datetime(\'now\') WHERE id = ?')
            .run(ts, issue.sessionId);
          result.fixed++;
          result.actions.push(`Fixed timestamp for ${issue.sessionId.slice(0, 8)} (drift ${issue.details.drift_seconds}s)`);
        } catch {
          result.skipped++;
        }
        break;
      }

      case 'duplicate_claim': {
        // Keep the row with the most recent updated_at, remove agent_session_id from others
        const claimers = issue.details.claimed_by;
        if (!claimers || claimers.length < 2) { result.skipped++; break; }
        try {
          const rows = claimers.map(id =>
            db.prepare(`
              SELECT c.id, c.updated_at, c.user_renamed, c.starred, a.file_size, a.agent_session_id
              FROM ctm_sessions c LEFT JOIN agent_sessions a ON a.ctm_session_id = c.id
              WHERE c.id = ?
            `).get(id)
          ).filter(Boolean);
          // Prefer: user_renamed > starred > largest file_size > most recent updated_at
          rows.sort((a, b) => {
            if (a.user_renamed !== b.user_renamed) return b.user_renamed - a.user_renamed;
            if (a.starred !== b.starred) return b.starred - a.starred;
            if ((a.file_size || 0) !== (b.file_size || 0)) return (b.file_size || 0) - (a.file_size || 0);
            return (b.updated_at || '').localeCompare(a.updated_at || '');
          });
          const keeper = rows[0];
          const unlinkLosers = db.transaction((losers) => {
            for (const row of losers) {
              // Unlink the agent_session from the losing ctm_session (delete the agent_sessions row for this CTM)
              db.prepare("DELETE FROM agent_sessions WHERE ctm_session_id = ? AND agent_session_id = ?")
                .run(row.id, issue.details.agent_session_id);
            }
          });
          unlinkLosers(rows.slice(1));
          result.fixed++;
          result.actions.push(`Resolved duplicate claim on ${issue.details.agent_session_id.slice(0, 8)}: kept ${keeper.id.slice(0, 8)}, unlinked ${rows.length - 1} others`);
        } catch {
          result.skipped++;
        }
        break;
      }

      case 'orphan_agent_session': {
        // Auto-create a ctm_session parent for orphaned agent_sessions.
        // This restores the invariant for top-level agent_sessions. Provider
        // child threads are intentionally parentless from CTM's tab model.
        const agentId = issue.details.agent_session_id;
        if (!agentId) { result.skipped++; break; }
        if (isProviderChildAgentRow(issue.details || {})) {
          result.skipped++;
          result.actions.push(`Skipped provider child agent ${agentId.slice(0, 8)}; child threads are not CTM tabs`);
          break;
        }
        try {
          const txn = db.transaction(() => {
            // Create a new ctm_session using the agent_session_id as the CTM id
            db.prepare(`
              INSERT OR IGNORE INTO ctm_sessions (id, provider, project_path, cwd, title)
              VALUES (?, 'claude', ?, ?, '')
            `).run(agentId, issue.details.project_path || '', issue.details.project_path || '');
            // Link the agent_session to its new parent
            db.prepare('UPDATE agent_sessions SET ctm_session_id = ? WHERE agent_session_id = ?')
              .run(agentId, agentId);
          });
          txn();
          result.fixed++;
          result.actions.push(`Created ctm_session parent for orphan agent ${agentId.slice(0, 8)}`);
        } catch (e) {
          console.error('[session-integrity] Failed to fix orphan agent_session:', e.message);
          result.skipped++;
        }
        break;
      }

      case 'wrong_mapping': {
        // Fail-safe auto-recovery: only unlink when (a) the flagged row's
        // slug differs from the tab's canonical AND (b) another agent_session
        // row for the same tab has a matching slug (or is the self-ref).
        // Otherwise, keep the legacy [MANUAL] log — we don't touch tabs we
        // can't safely re-home.
        const d = issue.details || {};
        const ctmId = d.ctm_session_id;
        const badAgent = d.agent_session_id;
        const canonical = d.canonical_slug;
        if (!ctmId || !badAgent || !canonical) {
          // Legacy content-mismatch case without slug context — stay conservative.
          result.skipped++;
          result.actions.push(`[MANUAL] Wrong mapping detected for ${issue.sessionId.slice(0, 8)} — needs manual review`);
          break;
        }
        try {
          const rescue = db.prepare(
            "SELECT agent_session_id FROM agent_sessions WHERE ctm_session_id = ? AND agent_session_id != ? AND (slug = ? OR agent_session_id = ?) ORDER BY CASE WHEN agent_session_id = ? THEN 0 ELSE 1 END, created_at ASC LIMIT 1"
          ).get(ctmId, badAgent, canonical, ctmId, ctmId);
          if (!rescue) {
            result.skipped++;
            result.actions.push(`[MANUAL] Wrong mapping for ${issue.sessionId.slice(0, 8)} — no safe replacement; needs manual review`);
            break;
          }
          // Snapshot the row we're about to delete so we can recreate it
          // as a standalone after the DELETE.
          const badRow = db.prepare('SELECT jsonl_path, slug FROM agent_sessions WHERE agent_session_id = ?').get(badAgent);
          const txn = db.transaction(() => {
            db.prepare('DELETE FROM agent_sessions WHERE agent_session_id = ? AND ctm_session_id = ?').run(badAgent, ctmId);
            if (badRow && badRow.jsonl_path && fs.existsSync(claudeDesktopSessions.sourcePathForStat(badRow.jsonl_path))) {
              const existingCtm = db.prepare('SELECT id FROM ctm_sessions WHERE id = ?').get(badAgent);
              if (!existingCtm) {
                db.prepare("INSERT OR IGNORE INTO ctm_sessions (id, provider, created_at, updated_at) VALUES (?, 'claude', datetime('now'), datetime('now'))").run(badAgent);
                db.prepare("INSERT OR IGNORE INTO agent_sessions (agent_session_id, ctm_session_id, jsonl_path, slug, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))").run(badAgent, badAgent, badRow.jsonl_path, badRow.slug || '');
              }
            }
          });
          txn();
          result.fixed++;
          result.actions.push(`[AUTO] Wrong mapping: unlinked ${badAgent.slice(0, 8)} from ${ctmId.slice(0, 8)}, reset to ${rescue.agent_session_id.slice(0, 8)} (canonical=${canonical})`);
          try { telemetry && telemetry.track && telemetry.track('wrong_mapping_auto_recovered', { ctm: ctmId.slice(0, 8), removed: badAgent.slice(0, 8), replacement: rescue.agent_session_id.slice(0, 8), canonical }); } catch {}
        } catch (e) {
          console.error('[session-integrity] wrong_mapping auto-recovery failed:', e.message);
          result.skipped++;
          result.actions.push(`[MANUAL] Wrong mapping for ${issue.sessionId.slice(0, 8)} — recovery error: ${e.message}`);
        }
        break;
      }

      default:
        // orphan_file, scan_error, etc. — handled by normal session list refresh
        result.skipped++;
        break;
    }
  }

  return result;
}

function _tableColumns(db, table) {
  try { return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map(c => c.name)); }
  catch { return new Set(); }
}

function _updateColumns(db, table, keyColumn, keyValue, values) {
  const cols = _tableColumns(db, table);
  const sets = [];
  const args = [];
  for (const [col, value] of Object.entries(values || {})) {
    if (!cols.has(col)) continue;
    sets.push(`${col} = ?`);
    args.push(value);
  }
  if (cols.has('updated_at')) sets.push("updated_at = datetime('now')");
  if (sets.length === 0) return 0;
  args.push(keyValue);
  return db.prepare(`UPDATE ${table} SET ${sets.join(', ')} WHERE ${keyColumn} = ?`).run(...args).changes || 0;
}

function _dirForJsonl(jsonlPath) {
  return jsonlPath ? path.dirname(jsonlPath) : '';
}

/**
 * Repair the exact Codex two-tab swap created when startup_tasks.args still
 * points at one provider session while agent_sessions points at the other tab's
 * provider session. This is intentionally narrow: it only runs for the relink
 * audit's explicit two-way Codex swap verdict and only swaps ownership between
 * the two already-present agent rows.
 */
function repairSafeRelinkSwaps(db, auditResult = null) {
  const result = { fixed: 0, skipped: 0, actions: [] };
  let audit = auditResult;
  try {
    if (!audit) {
      const { auditRelink } = require('./lib/relink-audit');
      audit = auditRelink({ db });
    }
  } catch (e) {
    result.skipped++;
    result.actions.push(`[MANUAL] relink audit failed: ${e.message}`);
    return result;
  }

  const tabs = Array.isArray(audit?.tabs) ? audit.tabs : [];
  const byCtm = new Map(tabs.map(t => [t.ctm_session_id, t]));
  const seenPairs = new Set();

  for (const tab of tabs) {
    const d = tab.details || {};
    if (tab.verdict !== 'cross_linked') continue;
    if (d.reason !== 'codex startup resume target and agent_session_id are swapped with another tab') continue;
    const ctmA = d.ctm_session_id || tab.ctm_session_id;
    const ctmB = d.other_ctm_session_id;
    const desiredA = d.args_resume_id;
    const desiredB = d.startup_agent_id;
    if (!ctmA || !ctmB || !desiredA || !desiredB) { result.skipped++; continue; }
    const pairKey = [ctmA, ctmB].sort().join(':');
    if (seenPairs.has(pairKey)) continue;
    seenPairs.add(pairKey);

    const other = byCtm.get(ctmB);
    if (!other || other.details?.args_resume_id !== desiredB || other.details?.startup_agent_id !== desiredA) {
      result.skipped++;
      result.actions.push(`[MANUAL] Codex swap for ${ctmA.slice(0, 8)} is not symmetric; skipped`);
      continue;
    }

    try {
      const rowA = db.prepare('SELECT * FROM agent_sessions WHERE agent_session_id = ?').get(desiredA);
      const rowB = db.prepare('SELECT * FROM agent_sessions WHERE agent_session_id = ?').get(desiredB);
      const stA = db.prepare('SELECT * FROM startup_tasks WHERE ctm_session_id = ?').get(ctmA);
      const stB = db.prepare('SELECT * FROM startup_tasks WHERE ctm_session_id = ?').get(ctmB);
      const ctmRowA = db.prepare('SELECT * FROM ctm_sessions WHERE id = ?').get(ctmA) || {};
      const ctmRowB = db.prepare('SELECT * FROM ctm_sessions WHERE id = ?').get(ctmB) || {};
      if (!rowA || !rowB || !stA || !stB) {
        result.skipped++;
        result.actions.push(`[MANUAL] Codex swap for ${ctmA.slice(0, 8)} missing DB rows; skipped`);
        continue;
      }
      if (rowA.ctm_session_id !== ctmB || rowB.ctm_session_id !== ctmA) {
        result.skipped++;
        result.actions.push(`[MANUAL] Codex swap for ${ctmA.slice(0, 8)} changed since audit; skipped`);
        continue;
      }
      if ((rowA.jsonl_path && !fs.existsSync(rowA.jsonl_path)) || (rowB.jsonl_path && !fs.existsSync(rowB.jsonl_path))) {
        result.skipped++;
        result.actions.push(`[MANUAL] Codex swap for ${ctmA.slice(0, 8)} has missing JSONL; skipped`);
        continue;
      }

      const txn = db.transaction(() => {
        db.prepare(`
          UPDATE agent_sessions
          SET ctm_session_id = ?,
              provider_resume_id = CASE WHEN provider_resume_id = ? THEN '' ELSE provider_resume_id END,
              updated_at = datetime('now')
          WHERE agent_session_id = ?
        `).run(ctmA, desiredB, desiredA);
        db.prepare(`
          UPDATE agent_sessions
          SET ctm_session_id = ?,
              provider_resume_id = CASE WHEN provider_resume_id = ? THEN '' ELSE provider_resume_id END,
              updated_at = datetime('now')
          WHERE agent_session_id = ?
        `).run(ctmB, desiredA, desiredB);

        const projectA = rowA.project_path || stA.cwd || ctmRowA.cwd || '';
        const projectB = rowB.project_path || stB.cwd || ctmRowB.cwd || '';
        _updateColumns(db, 'startup_tasks', 'ctm_session_id', ctmA, {
          agent_session_id: desiredA,
          claude_session_id: desiredA,
          agent_project_dir: _dirForJsonl(rowA.jsonl_path),
          claude_project_dir: _dirForJsonl(rowA.jsonl_path),
          cwd: projectA,
          worktree_path: /\/\.(?:claude|walle)\/worktrees\//.test(projectA) ? projectA : null,
        });
        _updateColumns(db, 'startup_tasks', 'ctm_session_id', ctmB, {
          agent_session_id: desiredB,
          claude_session_id: desiredB,
          agent_project_dir: _dirForJsonl(rowB.jsonl_path),
          claude_project_dir: _dirForJsonl(rowB.jsonl_path),
          cwd: projectB,
          worktree_path: /\/\.(?:claude|walle)\/worktrees\//.test(projectB) ? projectB : null,
        });
        _updateColumns(db, 'ctm_sessions', 'id', ctmA, {
          provider: 'codex',
          project_path: projectA,
          cwd: projectA,
          title: ctmRowA.user_renamed ? ctmRowA.title : (stA.label || rowA.first_message || ctmRowA.title || ''),
        });
        _updateColumns(db, 'ctm_sessions', 'id', ctmB, {
          provider: 'codex',
          project_path: projectB,
          cwd: projectB,
          title: ctmRowB.user_renamed ? ctmRowB.title : (stB.label || rowB.first_message || ctmRowB.title || ''),
        });
      });
      txn();
      result.fixed++;
      result.actions.push(`[AUTO] Codex swap: ${ctmA.slice(0, 8)} now owns ${desiredA.slice(0, 8)}, ${ctmB.slice(0, 8)} now owns ${desiredB.slice(0, 8)}`);
    } catch (e) {
      result.skipped++;
      result.actions.push(`[MANUAL] Codex swap for ${ctmA.slice(0, 8)} recovery error: ${e.message}`);
    }
  }

  return result;
}


// --- Telemetry Reporting ---

const INTEGRITY_ISSUE_TELEMETRY_TTL_MS = 6 * 60 * 60 * 1000;
const INTEGRITY_ISSUE_TELEMETRY_DETAIL_CAP = 5;
const _lastIntegrityIssueTelemetry = new Map();

function integrityDetailTelemetryEnabled() {
  const value = String(process.env.CTM_SESSION_INTEGRITY_DETAIL_TELEMETRY || '').toLowerCase();
  return value === '1' || value === 'true' || value === 'yes';
}

function integrityIssueTelemetryKey(issue) {
  const details = issue && issue.details && typeof issue.details === 'object'
    ? Object.keys(issue.details).sort().join('|')
    : typeof issue?.details;
  return [
    issue?.type || 'unknown',
    issue?.severity || 'unknown',
    details,
  ].join(':');
}

function shouldReportIntegrityIssue(issue, now = Date.now()) {
  const key = integrityIssueTelemetryKey(issue);
  const last = _lastIntegrityIssueTelemetry.get(key) || 0;
  if (now - last < INTEGRITY_ISSUE_TELEMETRY_TTL_MS) return false;
  _lastIntegrityIssueTelemetry.set(key, now);
  if (_lastIntegrityIssueTelemetry.size > 1000) {
    for (const [k, ts] of _lastIntegrityIssueTelemetry) {
      if (now - ts > INTEGRITY_ISSUE_TELEMETRY_TTL_MS) _lastIntegrityIssueTelemetry.delete(k);
    }
  }
  return true;
}

function integrityIssueTelemetryDetails(issue) {
  const details = issue && issue.details && typeof issue.details === 'object'
    ? issue.details
    : {};
  const detailKeys = Object.keys(details).sort();
  const detailsHash = crypto
    .createHash('sha256')
    .update(JSON.stringify(details))
    .digest('hex')
    .slice(0, 16);
  return {
    detail_keys: detailKeys.join(','),
    details_hash: detailsHash,
  };
}

function integrityIssueTelemetrySummary(issues) {
  const byType = {};
  const bySeverity = {};
  const sampleHashes = [];
  const seenHashes = new Set();
  for (const issue of issues || []) {
    const type = issue?.type || 'unknown';
    const severity = issue?.severity || 'unknown';
    byType[type] = (byType[type] || 0) + 1;
    bySeverity[severity] = (bySeverity[severity] || 0) + 1;
    if (sampleHashes.length < 10) {
      const details = integrityIssueTelemetryDetails(issue);
      const key = `${type}:${severity}:${details.details_hash}`;
      if (!seenHashes.has(key)) {
        seenHashes.add(key);
        sampleHashes.push({
          type,
          severity,
          detail_keys: details.detail_keys,
          details_hash: details.details_hash,
        });
      }
    }
  }
  return {
    issue_count: (issues || []).length,
    by_type: JSON.stringify(byType),
    by_severity: JSON.stringify(bySeverity),
    sample_hashes: JSON.stringify(sampleHashes),
  };
}

/**
 * Report integrity check results to telemetry with bounded debug context.
 */
function reportToTelemetry(telemetry, issues, recovery) {
  if (!telemetry || typeof telemetry.track !== 'function') return;

  // Summary event
  const bySeverity = { critical: 0, error: 0, warning: 0, info: 0 };
  const byType = {};
  for (const issue of issues) {
    bySeverity[issue.severity] = (bySeverity[issue.severity] || 0) + 1;
    byType[issue.type] = (byType[issue.type] || 0) + 1;
  }

  telemetry.track('session_integrity_check', {
    total_issues: issues.length,
    ...bySeverity,
    by_type: JSON.stringify(byType),
    recovery_fixed: recovery ? recovery.fixed : 0,
    recovery_skipped: recovery ? recovery.skipped : 0,
  });

  // One low-cardinality issue summary keeps fleet visibility without emitting
  // one telemetry row per mismatched session. Detailed issue rows are opt-in for
  // local debugging because old fleets made this event type very noisy.
  const serious = issues.filter(i => i.severity === 'error' || i.severity === 'critical');
  if (serious.length > 0) {
    telemetry.track('session_integrity_issue_summary', integrityIssueTelemetrySummary(serious));
  }

  if (integrityDetailTelemetryEnabled()) {
    for (const issue of serious.filter(issue => shouldReportIntegrityIssue(issue)).slice(0, INTEGRITY_ISSUE_TELEMETRY_DETAIL_CAP)) {
      const detailMeta = integrityIssueTelemetryDetails(issue);
      telemetry.track('session_integrity_issue', {
        type: issue.type,
        severity: issue.severity,
        session_id: issue.sessionId ? issue.sessionId.slice(0, 8) : null,
        ...detailMeta,
      });
    }
  }

  // Recovery actions
  if (recovery && recovery.actions.length > 0) {
    telemetry.track('session_integrity_recovery', {
      fixed: recovery.fixed,
      skipped: recovery.skipped,
      actions: JSON.stringify(recovery.actions.slice(0, 20)),
    });
  }
}


// --- Console Report ---

/**
 * Print a human-readable integrity report to console.
 */
function printReport(issues, recovery) {
  if (issues.length === 0) {
    console.log('[session-integrity] All clear — no mismatches detected.');
    return;
  }

  const bySeverity = { critical: [], error: [], warning: [], info: [] };
  for (const issue of issues) {
    (bySeverity[issue.severity] || bySeverity.info).push(issue);
  }

  console.log(`[session-integrity] Found ${issues.length} issue(s):`);
  for (const [severity, items] of Object.entries(bySeverity)) {
    if (items.length === 0) continue;
    const icon = severity === 'critical' ? '!!!' : severity === 'error' ? '!!' : severity === 'warning' ? '!' : '.';
    console.log(`  ${icon} ${severity}: ${items.length}`);
    for (const item of items.slice(0, 5)) {
      const sid = item.sessionId ? item.sessionId.slice(0, 8) + '...' : 'N/A';
      console.log(`    [${item.type}] ${sid}: ${item.suggestion}`);
    }
    if (items.length > 5) console.log(`    ... and ${items.length - 5} more`);
  }

  if (recovery) {
    console.log(`  Recovery: ${recovery.fixed} fixed, ${recovery.skipped} skipped`);
    for (const action of recovery.actions.slice(0, 5)) {
      console.log(`    ${action}`);
    }
  }
}


// --- Orchestrator (call from server.js) ---

/**
 * Run detection + recovery + telemetry reporting.
 * Called periodically or on demand via API.
 * @param {object} opts - { db, getAllSessionFiles, telemetry, autoRecover, broadcastToAll }
 * @returns {{ issues, recovery }}
 */
function runIntegrityCheck(opts) {
  const { db, getAllSessionFiles, telemetry: telem, autoRecover = true } = opts;

  const issues = detectMismatches(db, getAllSessionFiles);

  let recovery = null;
  if (autoRecover) {
    const recoverable = issues.filter(i =>
      i.severity !== 'info' || i.type === 'stale_metadata' || i.type === 'timestamp_drift'
    );
    if (recoverable.length > 0) {
      recovery = recoverMismatches(db, recoverable, getAllSessionFiles);
    }
  }

  // Additionally run the relink audit to surface tab↔JSONL cross-contamination.
  // This is READ-ONLY — never auto-recovers, since the repair is ambiguous
  // (user may have recent work in the wrongly-linked JSONL).
  let crossLinks = [];
  let relinkRecovery = null;
  try {
    // Lazy-require to avoid circular init
    const { auditRelink } = require('./lib/relink-audit');
    const audit = auditRelink({ db });
    crossLinks = audit.tabs
      .filter(t => t.verdict === 'cross_linked')
      .map(t => ({
        ctm_session_id: t.ctm_session_id,
        label: t.label,
        startup_target: t.startup_agent_id,
        reason: t.details?.reason || 'cross_linked',
        suggested_fix: t.details?.suggested_fix || null,
      }));
    if (crossLinks.length > 0 && telem && telem.track) {
      try { telem.track('session_integrity_cross_links', { count: crossLinks.length }); } catch {}
    }
    if (autoRecover && crossLinks.length > 0) {
      relinkRecovery = repairSafeRelinkSwaps(db, audit);
    }
  } catch (e) {
    console.error('[integrity] relink audit error:', e.message);
  }

  if (relinkRecovery && (relinkRecovery.fixed > 0 || relinkRecovery.skipped > 0)) {
    if (!recovery) recovery = { fixed: 0, skipped: 0, actions: [] };
    recovery.fixed += relinkRecovery.fixed;
    recovery.skipped += relinkRecovery.skipped;
    recovery.actions.push(...relinkRecovery.actions);
  }

  reportToTelemetry(telem, issues, recovery);
  printReport(issues, recovery);

  // Toast notifications disabled — these confuse end users who can't action them.
  // Detection + telemetry still runs; issues are available via GET /api/sessions/integrity.

  return { issues, recovery, crossLinks, relinkRecovery };
}


module.exports = {
  detectMismatches,
  recoverMismatches,
  reportToTelemetry,
  printReport,
  runIntegrityCheck,
  repairSafeRelinkSwaps,
  _test: {
    integrityIssueTelemetryKey,
    shouldReportIntegrityIssue,
    integrityIssueTelemetryDetails,
    integrityIssueTelemetrySummary,
    integrityDetailTelemetryEnabled,
    resetIntegrityIssueTelemetry: () => _lastIntegrityIssueTelemetry.clear(),
  },
};
