'use strict';

const { normalizeAttention } = require('./standup-attention');
const {
  latestObservedTimeMs,
  normalizeObservedTimeMs,
  parseTimeMs,
} = require('./activity-time');

const LANE_DEFS = [
  {
    id: 'running',
    title: 'Running',
    description: 'Active sessions that are still producing work.',
  },
  {
    id: 'ready_review',
    title: 'Ready Review',
    description: 'Sessions with reviewable work, finished changes, or worktree cleanup to handle.',
  },
  {
    id: 'needs_user',
    title: 'Needs You',
    description: 'Approvals, questions, blockers, or failed work that need operator attention.',
  },
  {
    id: 'continue_later',
    title: 'Ready',
    description: 'Idle sessions ready to resume when this preserved context is useful.',
  },
];

const LANE_ORDER = new Map(LANE_DEFS.map((lane, index) => [lane.id, index]));
const STALE_AFTER_MS = 30 * 60 * 1000;
const WAITING_INPUT_STALE_MS = STALE_AFTER_MS;
const SEARCH_TEXT_LIMIT = 8000;
const SEARCH_SNIPPET_LIMIT = 12;
const SEARCH_SNIPPET_TEXT_LIMIT = 240;
const SEARCH_RECENT_TURN_LIMIT = 6;

function truncateText(value, max = 180) {
  const text = String(value || '').replace(/\s+/g, ' ').trim();
  if (!text) return '';
  if (text.length <= max) return text;
  return `${text.slice(0, Math.max(0, max - 1)).trim()}...`;
}

function valueText(value) {
  if (!value) return '';
  if (typeof value === 'string') return value;
  if (typeof value === 'object') {
    return value.text || value.summary || value.phase || value.next || '';
  }
  return String(value);
}

function cleanGoalText(value) {
  return String(value || '')
    .replace(/^\s*(?:please\s+)?summari[sz]e\s+(?:(?:the|this|current)\s+)?tasks?\s*:\s*/i, '')
    .trim();
}

function progressText(progress) {
  if (!progress) return '';
  if (typeof progress === 'string') return progress;
  if (typeof progress === 'object') {
    if (progress.summary) return progress.summary;
    if (Array.isArray(progress.bullets) && progress.bullets.length) {
      return progress.bullets.filter(Boolean).slice(-1)[0] || '';
    }
    return progress.next || progress.phase || '';
  }
  return String(progress);
}

function sourceForValue(value, fallback = '') {
  return value && typeof value === 'object' && value.source
    ? String(value.source)
    : fallback;
}

function freshnessForValue(value, fallback = '') {
  return value && typeof value === 'object' && value.freshness
    ? String(value.freshness)
    : fallback;
}

function confidenceForValue(value, fallback = '') {
  return value && typeof value === 'object' && value.confidence
    ? String(value.confidence)
    : fallback;
}

function timestampForValue(value) {
  if (!value || typeof value !== 'object') return 0;
  return value.updatedAt || value.timestamp || value.promptTimestamp || value.aiUpdatedAt || 0;
}

function goalForSummary(session, summary = {}) {
  const candidates = [
    {
      value: summary?.currentTask,
      source: sourceForValue(summary?.currentTask, 'current-task'),
      freshness: freshnessForValue(summary?.currentTask, ''),
      confidence: confidenceForValue(summary?.currentTask, ''),
      updatedAt: timestampForValue(summary?.currentTask),
    },
    {
      value: summary?.intent,
      source: sourceForValue(summary?.intent, 'intent'),
      freshness: freshnessForValue(summary?.intent, ''),
      confidence: confidenceForValue(summary?.intent, ''),
      updatedAt: timestampForValue(summary?.intent),
    },
    {
      value: summary?.summary,
      source: summary?.aiSummary?.source || (summary?.summary ? 'ai-summary' : ''),
      freshness: summary?.aiSummary?.status || '',
      confidence: summary?.summary ? 'medium' : '',
      updatedAt: summary?.aiSummary?.updatedAt || 0,
    },
    {
      value: summary?.displayPrompt,
      source: 'prompt-fallback',
      freshness: 'fallback',
      confidence: 'medium',
      updatedAt: summary?.displayPrompt?.timestamp || summary?.displayPrompt?.updatedAt || 0,
    },
    {
      value: summary?.lastPrompt,
      source: 'prompt-fallback',
      freshness: 'fallback',
      confidence: 'medium',
      updatedAt: summary?.lastPrompt?.timestamp || summary?.lastPrompt?.updatedAt || 0,
    },
    {
      value: session?.label,
      source: 'title-fallback',
      freshness: 'fallback',
      confidence: 'low',
      updatedAt: session?.lastActivity || session?.modifiedAt || session?.createdAt || 0,
    },
  ];

  for (const candidate of candidates) {
    const text = truncateText(cleanGoalText(valueText(candidate.value)), 170);
    if (!text) continue;
    return {
      text,
      source: candidate.source || 'unknown',
      freshness: candidate.freshness || '',
      confidence: candidate.confidence || '',
      updatedAt: candidate.updatedAt || 0,
    };
  }

  return {
    text: '',
    source: 'missing',
    freshness: 'missing',
    confidence: 'low',
    updatedAt: 0,
  };
}

function progressForSummary(summary = {}) {
  const progress = summary?.progress || '';
  const text = truncateText(progressText(progress), 190);
  if (!progress || typeof progress !== 'object') {
    return {
      text,
      source: progress ? 'text' : 'missing',
      phase: '',
      next: '',
      updatedAt: 0,
    };
  }

  return {
    text,
    source: progress.source || (Array.isArray(progress.bullets) && progress.bullets.length ? 'assistant-events' : 'progress'),
    phase: progress.phase || '',
    next: truncateText(progress.next || '', 160),
    updatedAt: progress.updatedAt || 0,
  };
}

function toMs(value) {
  return parseTimeMs(value);
}

function toIso(value, now = Date.now()) {
  const ms = normalizeObservedTimeMs(value, now);
  return ms ? new Date(ms).toISOString() : null;
}

function ageLabel(now, value) {
  const ms = normalizeObservedTimeMs(value, now);
  if (!ms) return '';
  const age = Math.max(0, now - ms);
  if (age < 60 * 1000) return 'just now';
  const minutes = Math.round(age / (60 * 1000));
  if (minutes < 60) return `${minutes}m ago`;
  const hours = Math.round(minutes / 60);
  if (hours < 48) return `${hours}h ago`;
  const days = Math.round(hours / 24);
  return `${days}d ago`;
}

function normalizeStatus(status) {
  const text = String(status || '').toLowerCase().replace(/[-\s]+/g, '_');
  if (!text) return 'unknown';
  if (text === 'busy' || text === 'active' || text === 'working' || text === 'thinking') return 'running';
  if (text === 'restoring' || text === 'resuming' || text === 'restore_starting' || text === 'restarting') return 'resuming';
  if (text === 'waiting_input') return 'waiting_input';
  if (text === 'waiting_for_input') return 'waiting_input';
  if (['running', 'resuming', 'waiting', 'idle', 'exited', 'unknown'].includes(text)) return text;
  return text;
}

function normalizeProvider(provider) {
  const text = String(provider || '').trim().toLowerCase().replace(/_/g, '-');
  if (!text) return '';
  if (['anthropic', 'claude', 'claude-code'].includes(text)) return 'anthropic';
  if (['openai', 'codex', 'chatgpt'].includes(text)) return 'openai';
  if (['google', 'gemini', 'gemini-cli', 'vertex', 'vertex-ai'].includes(text)) return 'google';
  if (['deepseek', 'deep-seek'].includes(text)) return 'deepseek';
  if (['moonshot', 'moonshot-ai', 'kimi', 'kimi-k2'].includes(text)) return 'moonshot';
  if (['ollama', 'mlx', 'openrouter', 'xai'].includes(text)) return text;
  if (['walle', 'wall-e'].includes(text)) return 'walle';
  if (['shell', 'terminal', 'local'].includes(text)) return 'shell';
  return text.replace(/[^a-z0-9-]/g, '');
}

function inferProviderFromModel(model) {
  const text = String(model || '').trim().toLowerCase();
  if (!text) return '';
  const prefix = text.match(/^([a-z][a-z0-9_-]+)[/:]/);
  if (prefix) {
    const normalizedPrefix = normalizeProvider(prefix[1]);
    if (normalizedPrefix) return normalizedPrefix;
  }
  if (/^(claude-|opus\b|sonnet\b|haiku\b)/.test(text)) return 'anthropic';
  if (/^(gpt-|o[1-9]|chatgpt|codex)/.test(text)) return 'openai';
  if (/^(gemini-|text-embedding-)/.test(text)) return 'google';
  if (/^deepseek-/.test(text)) return 'deepseek';
  if (/^(kimi-|moonshot-)/.test(text)) return 'moonshot';
  if (/^(ollama:|llama|mistral|qwen|phi|gemma)/.test(text)) return 'ollama';
  if (/^mlx[:/-]/.test(text)) return 'mlx';
  if (/^grok[-/]/.test(text)) return 'xai';
  return '';
}

function inferProviderFromAgent(agent) {
  const text = String(agent || '').trim().toLowerCase().replace(/_/g, '-');
  if (!text) return '';
  if (text === 'codex') return 'openai';
  if (text === 'claude' || text === 'claude-code' || text === 'claude-desktop') return 'anthropic';
  if (text === 'gemini' || text === 'gemini-cli') return 'google';
  if (text === 'walle' || text === 'wall-e') return 'walle';
  if (text === 'shell') return 'shell';
  return normalizeProvider(text);
}

function isWalleOwnedSession(session) {
  if (!session || typeof session !== 'object') return false;
  if (session.walle_owned === true || session.walleOwned === true) return true;
  if (session.native_walle_session === true || session.nativeWalleSession === true) return true;
  const explicitProvider = normalizeProvider(session.provider || session.providerId || session._providerId);
  if (explicitProvider === 'walle') return true;
  const explicitAgent = inferProviderFromAgent(session.agentType || session.agent || session.type);
  if (explicitAgent === 'walle') return true;

  // Labels, branches, and worktree names describe the task, not the runtime owner.
  const cmd = String(session.cmd || '').trim().toLowerCase();
  if (cmd === 'walle' || cmd === 'walle coding' || cmd.startsWith('walle coding ')) return true;
  return false;
}

function inferSessionModelProvider(session) {
  if (isWalleOwnedSession(session)) {
    const walleProvider = normalizeProvider(session?.walle_model_provider || session?.walleModelProvider);
    if (walleProvider) return walleProvider;
  }
  const explicitModelProvider = normalizeProvider(session?.model_provider || session?.modelProvider);
  if (explicitModelProvider) return explicitModelProvider;
  return inferProviderFromModel(session?.model_id || session?.model);
}

function inferSessionModel(session) {
  if (isWalleOwnedSession(session)) {
    const walleModel = session?.walle_model_id || session?.walleModelId || session?.walle_model || session?.walleModel;
    if (walleModel) return walleModel;
  }
  return session?.model_id || session?.model || '';
}

function inferSessionProvider(session) {
  if (isWalleOwnedSession(session)) return 'walle';
  const explicitModelProvider = normalizeProvider(session?.model_provider || session?.modelProvider);
  if (explicitModelProvider) return explicitModelProvider;
  const explicitProvider = normalizeProvider(session?.provider);
  if (explicitProvider && !['walle', 'shell'].includes(explicitProvider)) return explicitProvider;
  const fromModel = inferProviderFromModel(session?.model_id || session?.model);
  if (fromModel) return fromModel;
  if (explicitProvider) return explicitProvider;
  const providerId = normalizeProvider(session?.providerId || session?._providerId);
  if (providerId) return providerId;
  return inferProviderFromAgent(session?.agentType || session?.agent || session?.type);
}

function addIndexEntries(map, item) {
  if (!item || typeof item !== 'object') return;
  for (const key of ['id', 'sessionId', 'ctmSessionId', 'agentSessionId', 'claudeSessionId']) {
    const value = item[key];
    if (value) map.set(String(value), item);
  }
}

function buildIndex(items) {
  const map = new Map();
  for (const item of items || []) addIndexEntries(map, item);
  return map;
}

function lookupForSession(index, session) {
  if (!index || !session) return null;
  const ids = [
    session.id,
    session.sessionId,
    session.ctmSessionId,
    session.agentSessionId,
    session.claudeSessionId,
  ];
  for (const id of ids) {
    if (id && index.has(String(id))) return index.get(String(id));
  }
  return null;
}

function isBlockingWaitingReason(reason) {
  const text = String(reason || '').toLowerCase();
  return text === 'approval' ||
    text === 'choice' ||
    /\bapproval\b/.test(text) ||
    /\bpermission\b/.test(text);
}

function hasUnsubmittedWaitingInput(session) {
  return !!(session?.lastNonResolvingWaitingInputAt &&
    (!session?.lastWaitingResolveInputAt ||
      session.lastNonResolvingWaitingInputAt > session.lastWaitingResolveInputAt));
}

function isStaleNonBlockingWaiting(session, status, summary, now = Date.now()) {
  const reason = String(session?.waitingReason || status?.reason || summary?.statusEvidence?.[0] || '').toLowerCase();
  if (isBlockingWaitingReason(reason)) return false;
  if (hasUnsubmittedWaitingInput(session)) return false;
  const waitingAt = normalizeObservedTimeMs(
    session?.waitingForInputAt || session?.waitingAt || status?.waitingForInputAt || status?.updatedAt,
    now
  );
  return !!(waitingAt && (now - waitingAt) > WAITING_INPUT_STALE_MS);
}

function attentionDismissalForSession(session, now = Date.now()) {
  const dismissedAt = normalizeObservedTimeMs(
    session?.attentionDismissedAt ?? session?.attention_dismissed_at,
    now
  );
  const dismissedInputAt = normalizeObservedTimeMs(
    session?.attentionDismissedInputAt ?? session?.attention_dismissed_input_at,
    now
  );
  const lastUserPromptInputAt = normalizeObservedTimeMs(
    session?.lastUserPromptInputAt ?? session?.last_user_prompt_input_at,
    now
  );
  const active = dismissedAt > 0 && (!lastUserPromptInputAt || lastUserPromptInputAt <= dismissedInputAt);
  return { dismissedAt, dismissedInputAt, lastUserPromptInputAt, active };
}

function statusForSession(session, status, summary, now = Date.now()) {
  if (session?.waitingForInput && !isStaleNonBlockingWaiting(session, status, summary, now)) return 'waiting_input';
  const candidates = [
    session?.standupStatus,
    session?.liveStatus,
    session?.computedStatus,
    session?.state,
    status?.captureStatus,
    status?.status,
    summary?.captureStatus,
    summary?.status,
    session?.serverState,
    session?.status,
  ];
  for (const candidate of candidates) {
    const normalized = normalizeStatus(candidate);
    if (normalized && normalized !== 'unknown') return normalized;
  }
  return 'unknown';
}

function isWalleSession(session) {
  return session?.type === 'walle' || session?.agentType === 'walle' || session?.agent === 'walle';
}

function standupStatusForSession(session, status, summary, now = Date.now()) {
  const normalized = statusForSession(session, status, summary, now);
  const waitingReason = String(session?.waitingReason || status?.reason || summary?.statusEvidence?.[0] || '').toLowerCase();
  // A quiet Wall-E chat is available for the next message, not blocked on
  // operator input. Older live projections used "waiting" for that idle state.
  // Some restored Wall-E tabs also arrive as "waiting-for-input" even though
  // no permission prompt or form input is actually active; keep those out of
  // the needs-user lane unless an explicit waiting flag/reason is present.
  if ((normalized === 'waiting' || normalized === 'waiting_input') && isWalleSession(session) && !session?.waitingForInput && !waitingReason) return 'idle';
  if ((normalized === 'waiting' || normalized === 'waiting_input') && isStaleNonBlockingWaiting(session, status, summary, now)) return 'idle';
  if (normalized === 'waiting' && (!isWalleSession(session) || /approval|choice|input/.test(waitingReason))) return 'waiting_input';
  return normalized;
}

function worktreeEvidence(worktree) {
  if (!worktree) return '';
  const dirtyFiles = Number(worktree.dirtyFiles || 0);
  const trackedDirtyFiles = worktree.trackedDirtyFiles != null
    ? Number(worktree.trackedDirtyFiles || 0)
    : Math.max(0, dirtyFiles - Number(worktree.untrackedFiles || 0));
  const untrackedFiles = Number(worktree.untrackedFiles || 0);
  const localCommits = worktree.isMain
    ? Number(worktree.unpushedCommits || worktree.ahead || worktree.unmergedCommits || 0)
    : Number(worktree.unmergedCommits || 0);
  const parts = [];
  if (trackedDirtyFiles) parts.push(`${trackedDirtyFiles} dirty`);
  if (untrackedFiles) parts.push(`${untrackedFiles} untracked`);
  if (localCommits) parts.push(worktree.isMain ? `${localCommits} local` : `${localCommits} unmerged`);
  if (!parts.length && worktree.summary) parts.push(worktree.summary);
  return parts.length ? `worktree ${parts.join(', ')}` : '';
}

function hasReviewableWork(session, summaryText, progress) {
  const worktree = session?.worktreeStatus || session?.worktree || null;
  if (worktree) {
    if (worktree.needsAttention) return true;
    const trackedDirtyFiles = worktree.trackedDirtyFiles != null
      ? Number(worktree.trackedDirtyFiles || 0)
      : Math.max(0, Number(worktree.dirtyFiles || 0) - Number(worktree.untrackedFiles || 0));
    if (trackedDirtyFiles > 0) return true;
    if (Number(worktree.unmergedCommits || 0) > 0) return true;
    if (Number(worktree.unpushedCommits || 0) > 0) return true;
  }
  const text = `${summaryText || ''} ${progressText(progress)}`.toLowerCase();
  return /\b(done|completed|complete|verified|passed|ready for review|all work done)\b/.test(text);
}

function hasFailureSignal(summaryText, progress) {
  const text = `${summaryText || ''} ${progressText(progress)}`.toLowerCase();
  return /\b(cannot proceed|blocked|blocker|stuck|permission denied|fatal|panic|segmentation fault|uncaught|unhandled exception|traceback|command failed|build failed|tests? failed|npm err!)\b/.test(text);
}

function attentionForSession(session, summary) {
  return normalizeAttention(summary?.attention || session?.attention || null);
}

function prependEvidence(evidence, items) {
  const out = [];
  for (const item of items || []) {
    const text = truncateText(item, 90);
    if (text && !out.some(existing => existing.toLowerCase() === text.toLowerCase())) out.push(text);
  }
  for (const item of evidence || []) {
    const text = truncateText(item, 90);
    if (text && !out.some(existing => existing.toLowerCase() === text.toLowerCase())) out.push(text);
  }
  return out.slice(0, 5);
}

function compactSearchText(value, max = SEARCH_SNIPPET_TEXT_LIMIT) {
  if (value == null) return '';
  const raw = Array.isArray(value)
    ? value.filter(Boolean).join(' ')
    : (typeof value === 'object' ? valueText(value) : String(value));
  const text = truncateText(raw, max);
  if (!max || text.length <= max) return text;
  return `${text.slice(0, Math.max(0, max - 3)).trimEnd()}...`;
}

function addSearchPart(parts, value, max = SEARCH_SNIPPET_TEXT_LIMIT) {
  const text = compactSearchText(value, max);
  if (!text) return;
  const lower = text.toLowerCase();
  if (parts.some(item => item.toLowerCase() === lower)) return;
  parts.push(text);
}

function addSearchSnippet(snippets, field, value, max = SEARCH_SNIPPET_TEXT_LIMIT) {
  const text = compactSearchText(value, max);
  if (!text) return;
  const lowerField = String(field || '').toLowerCase();
  const lowerText = text.toLowerCase();
  if (snippets.some(item => item.field.toLowerCase() === lowerField && item.text.toLowerCase() === lowerText)) return;
  snippets.push({ field: String(field || 'Match'), text });
}

function buildCardSearchPayload({ session, summary = {}, card }) {
  const parts = [];
  const snippets = [];
  const add = (value, max) => addSearchPart(parts, value, max);
  const snippet = (field, value, max) => addSearchSnippet(snippets, field, value, max);

  [
    card.title,
    card.agent,
    card.provider,
    card.model,
    card.cwd,
    card.project,
    card.branch,
    card.status,
    card.lane,
    card.actionKind,
    card.actionLabel,
    card.recommendation,
    card.goal,
    card.intent,
    card.progressSummary,
    card.progressNext,
    card.progressPhase,
    session?.label,
    session?.cmd,
    summary?.summary,
    summary?.currentTask,
    summary?.intent,
    summary?.displayPrompt,
    summary?.lastPrompt,
    summary?.latestPrompt,
    summary?.aiSummary,
    summary?.progress?.summary,
    summary?.progress?.next,
    ...(card.evidence || []),
  ].forEach(value => add(value));

  snippet('Title', card.title);
  snippet('Goal', card.goal || card.intent);
  snippet('Progress', card.progressSummary || card.progress);
  snippet('Next', card.progressNext);
  snippet('Recommendation', card.recommendation);

  const recentTurns = Array.isArray(summary?.recentTurns)
    ? summary.recentTurns.slice(-SEARCH_RECENT_TURN_LIMIT)
    : [];
  for (const turn of recentTurns) {
    const role = turn?.role === 'assistant' ? 'Assistant' : turn?.role === 'user' ? 'User' : '';
    if (!role) continue;
    snippet(role, turn.text);
    add(`${role}: ${compactSearchText(turn.text, 500)}`, 560);
  }

  const searchText = compactSearchText(parts.join(' '), SEARCH_TEXT_LIMIT);
  return {
    searchText,
    searchSnippets: snippets.slice(0, SEARCH_SNIPPET_LIMIT),
  };
}

function baseEvidence({ session, status, summary, now, intentText, progress, goalSource }) {
  const evidence = [];
  if (status === 'waiting_input') evidence.push('waiting input');
  else if (status === 'waiting') evidence.push('waiting');
  else if (status === 'resuming') evidence.push('resuming');
  else if (status === 'running') evidence.push('running');
  const branch = session?.branch || session?.gitBranch || session?.worktreeStatus?.branch || '';
  if (branch) evidence.push(`branch ${branch}`);
  const wtEvidence = worktreeEvidence(session?.worktreeStatus || session?.worktree);
  if (wtEvidence) evidence.push(wtEvidence);
  const age = ageLabel(now, latestActivity(session, summary, now));
  if (age) evidence.push(`last activity ${age}`);
  const promptBacked = [goalSource, summary?.currentTask?.source, summary?.intent?.source]
    .some(source => ['latest-prompt', 'prompt-fallback'].includes(source));
  if (intentText && promptBacked) {
    evidence.push(`prompt: ${truncateText(intentText, 80)}`);
  }
  const progressPhase = progress && typeof progress === 'object' ? progress.phase : '';
  if (progressPhase && !['unknown', status].includes(String(progressPhase))) {
    evidence.push(`phase ${progressPhase}`);
  }
  return evidence.slice(0, 5);
}

function latestActivity(session, summary, now = Date.now()) {
  const values = [
    summary?.lastActivity,
    summary?.progress?.updatedAt,
    summary?.currentTask?.updatedAt,
    summary?.currentTask?.timestamp,
    summary?.currentTask?.promptTimestamp,
    summary?.intent?.updatedAt,
    summary?.aiSummary?.updatedAt,
    session?.lastPtyActivity,
    session?.lastActivity,
    session?.modifiedAt,
    session?.fileModifiedAt,
    session?.createdAt,
  ];
  return latestObservedTimeMs(values, now);
}

function latestAgentOutput(session, summary, status, now = Date.now()) {
  const recentAssistantTurns = Array.isArray(summary?.recentTurns)
    ? summary.recentTurns
        .filter(turn => turn?.role === 'assistant')
        .map(turn => turn.timestamp || turn.updatedAt || turn.createdAt)
    : [];
  const values = [
    summary?.progress?.updatedAt,
    summary?.progress?.timestamp,
    session?.lastAgentOutputAt,
    session?.lastPtyActivity,
    status?.lastAgentOutputAt,
    status?.lastPtyActivity,
    ...(String(status?.status || '').toLowerCase() === 'running' ? [status?.lastActivity] : []),
    ...recentAssistantTurns,
  ];
  return latestObservedTimeMs(values, now);
}

function classifySessionStandup(session, signals = {}, now = Date.now()) {
  const summary = signals.summary || {};
  const streamStatus = signals.status || {};
  let status = standupStatusForSession(session, streamStatus, summary, now);
  const goal = goalForSummary(session, summary);
  const intentString = goal.text;
  const progress = summary.progress || '';
  const progressInfo = progressForSummary(summary);
  const progressString = progressInfo.text;
  const summaryText = valueText(summary.summary) || intentString;
  const lastActivityMs = latestActivity(session, summary, now);
  const lastAgentOutputMs = latestAgentOutput(session, summary, streamStatus, now);
  const ageMs = lastActivityMs ? Math.max(0, now - lastActivityMs) : null;
  const waitingReason = session?.waitingReason || streamStatus?.reason || summary?.statusEvidence?.[0] || '';
  const reviewable = hasReviewableWork(session, summaryText, progress);
  const attention = attentionForSession(session, summary);
  const hasStructuredAttention = !!(summary?.attention || session?.attention);
  const attentionSeverity = attention?.severity || 'none';
  const failed = attentionSeverity === 'failure'
    || (!hasStructuredAttention && hasFailureSignal(summaryText, progress));
  const warning = attentionSeverity === 'warning';
  const isWaiting = status === 'waiting_input' || status === 'waiting';
  const isRunning = status === 'running' || status === 'busy';
  const isResuming = status === 'resuming';

  let lane = 'continue_later';
  let actionKind = 'resume';
  let actionLabel = 'Continue';
  let recommendation = 'Session is idle; reuse this context when you have the next instruction.';
  let confidence = 'medium';

  if (isWaiting) {
    lane = 'needs_user';
    actionKind = status === 'waiting_input' || /approval|choice|input/i.test(waitingReason)
      ? 'approval_needed'
      : 'needs_input';
    actionLabel = 'Respond';
    recommendation = actionKind === 'approval_needed'
      ? 'Approval or input is waiting in the terminal.'
      : 'The session appears to need user input before it can continue.';
    confidence = 'high';
  } else if (isRunning) {
    lane = 'running';
    actionKind = 'watch';
    actionLabel = 'Open';
    recommendation = 'Work is still running; no operator action is needed yet.';
    confidence = 'high';
  } else if (isResuming) {
    lane = 'continue_later';
    actionKind = 'watch';
    actionLabel = 'Open';
    recommendation = 'CTM is restoring this session after restart; it will return to idle unless new provider output arrives.';
    confidence = 'medium';
  } else if (failed) {
    lane = 'needs_user';
    actionKind = 'investigate';
    actionLabel = 'Inspect';
    recommendation = attention?.recommendation || 'A current blocker or failure appears to need attention.';
    confidence = attention?.confidence || 'medium';
  } else if (warning) {
    lane = 'needs_user';
    actionKind = 'warning';
    actionLabel = 'Review';
    recommendation = attention?.recommendation || 'A current warning appears to need attention.';
    confidence = attention?.confidence || 'medium';
  } else if (reviewable) {
    lane = 'ready_review';
    actionKind = 'review';
    actionLabel = 'Review';
    recommendation = 'Review the session output or finish the worktree changes.';
    confidence = session?.worktreeStatus ? 'high' : 'medium';
  } else if (ageMs != null && ageMs > STALE_AFTER_MS) {
    lane = 'continue_later';
    actionKind = 'resume';
    actionLabel = 'Resume';
    recommendation = 'Session is quiet; resume it only if this preserved context is still useful.';
    confidence = 'medium';
  }

  let evidence = baseEvidence({ session, status, summary, now, intentText: intentString, progress, goalSource: goal.source });
  if ((failed || warning) && attention?.evidence?.length) {
    evidence = prependEvidence(evidence, attention.evidence);
  }
  if (failed && !isRunning && !evidence.some(item => /failed|blocked|blocker|error|failure/i.test(item))) {
    evidence.unshift('possible blocker');
  }
  if (warning && !isRunning && !evidence.some(item => /warn|caution|risk|attention/i.test(item))) {
    evidence.unshift('warning');
  }

  const dismissal = attentionDismissalForSession(session, now);
  let dismissedToIdle = false;
  if (dismissal.active && lane === 'needs_user') {
    dismissedToIdle = true;
    lane = 'continue_later';
    status = 'idle';
    actionKind = 'resume';
    actionLabel = 'Open';
    recommendation = 'Dismissed from Needs You; open when ready or send a new prompt to continue.';
    confidence = confidence === 'high' ? 'medium' : confidence;
    if (!evidence.some(item => String(item || '').toLowerCase() === 'dismissed')) {
      evidence.unshift('dismissed');
    }
  }

  const title = truncateText(
    session?.label || summary?.displayPrompt || summary?.lastPrompt || intentString || session?.id || 'Session',
    90
  );
  const provider = inferSessionProvider(session);
  const modelProvider = inferSessionModelProvider(session);
  const nativeWalleSession = session?.native_walle_session === true
    || session?.nativeWalleSession === true
    || session?.type === 'walle';
  const walleOwned = session?.walle_owned === true
    || session?.walleOwned === true
    || isWalleOwnedSession(session);
  const sessionType = session?.session_type || session?.sessionType || session?.runtime_type || session?.runtimeType || session?.type || '';

  const card = {
    id: session?.id || session?.sessionId || '',
    agentSessionId: session?.agentSessionId || session?.claudeSessionId || null,
    title,
    agent: session?.agentType || session?.agent || session?.type || 'session',
    provider,
    type: session?.type || sessionType || '',
    session_type: sessionType || '',
    runtime_type: session?.runtime_type || session?.runtimeType || session?.type || '',
    walle_owned: !!walleOwned,
    walleOwned: !!walleOwned,
    native_walle_session: !!nativeWalleSession,
    nativeWalleSession: !!nativeWalleSession,
    agentMode: session?.agentMode || session?.agent_mode || null,
    agentKind: session?.agentKind || session?.agent_kind || (walleOwned && !nativeWalleSession ? 'walle-coding' : null),
    taskType: session?.taskType || session?.task_type || (walleOwned && !nativeWalleSession ? 'coding' : null),
    modelProvider,
    model: inferSessionModel(session),
    cwd: session?.cwd || '',
    project: session?.project || session?.cwd || '',
    branch: session?.branch || session?.gitBranch || session?.worktreeStatus?.branch || '',
    status,
    lane,
    actionKind,
    actionLabel,
    recommendation,
    confidence,
    evidence,
    attention: attention && attention.severity !== 'none' ? attention : null,
    goal: goal.text,
    goalSource: goal.source,
    goalFreshness: goal.freshness,
    goalConfidence: goal.confidence,
    intent: intentString,
    progressSummary: progressString,
    progressSource: progressInfo.source,
    progressPhase: progressInfo.phase,
    progressNext: progressInfo.next,
    progress: progressString,
    createdAt: toIso(session?.createdAt, now),
    lastActivity: toIso(lastActivityMs, now),
    lastAgentOutputAt: toIso(lastAgentOutputMs, now),
    attentionDismissedAt: toIso(dismissal.dismissedAt, now),
    attentionDismissedInputAt: toIso(dismissal.dismissedInputAt, now),
    lastUserPromptInputAt: toIso(dismissal.lastUserPromptInputAt, now),
    attentionDismissedActive: dismissal.active,
    dismissedToIdle,
    dismissedToReady: dismissedToIdle,
    ageMs,
    worktree: session?.worktreeStatus || session?.worktree || null,
    worktreeStatus: session?.worktreeStatus || session?.worktree || null,
    capabilities: session?.agentCapabilities || session?.capabilities || {},
    priority: LANE_ORDER.get(lane) ?? 99,
  };
  return {
    ...card,
    ...buildCardSearchPayload({ session, summary, card }),
  };
}

function sortCards(a, b) {
  if (a.priority !== b.priority) return a.priority - b.priority;
  const agentA = toMs(a.lastAgentOutputAt);
  const agentB = toMs(b.lastAgentOutputAt);
  if (agentA !== agentB) return (agentB || 0) - (agentA || 0);
  const actionOrder = {
    approval_needed: 0,
    needs_input: 1,
    investigate: 2,
    warning: 3,
    review: 4,
    watch: 5,
    resume: 6,
    archive: 7,
  };
  const ao = actionOrder[a.actionKind] ?? 99;
  const bo = actionOrder[b.actionKind] ?? 99;
  if (ao !== bo) return ao - bo;
  const createdA = toMs(a.createdAt);
  const createdB = toMs(b.createdAt);
  if (createdA || createdB) {
    if (createdA !== createdB) return (createdA || Number.MAX_SAFE_INTEGER) - (createdB || Number.MAX_SAFE_INTEGER);
  }
  const titleCmp = String(a.title || '').localeCompare(String(b.title || ''), undefined, { sensitivity: 'base' });
  if (titleCmp) return titleCmp;
  return String(a.id || '').localeCompare(String(b.id || ''));
}

function buildSessionStandupSnapshot({ sessions = [], summaries = [], statuses = [], now = Date.now() } = {}) {
  const summaryIndex = buildIndex(summaries);
  const statusIndex = buildIndex(statuses);
  const cards = sessions
    .map(session => classifySessionStandup(session, {
      summary: lookupForSession(summaryIndex, session),
      status: lookupForSession(statusIndex, session),
    }, now))
    .filter(card => card.id)
    .sort(sortCards);

  const counts = { total: cards.length };
  for (const lane of LANE_DEFS) counts[lane.id] = 0;
  for (const card of cards) counts[card.lane] = (counts[card.lane] || 0) + 1;

  const lanes = LANE_DEFS.map(lane => ({
    ...lane,
    count: counts[lane.id] || 0,
    sessions: cards.filter(card => card.lane === lane.id),
  }));

  return {
    generatedAt: new Date(now).toISOString(),
    counts,
    lanes,
    sessions: cards,
  };
}

module.exports = {
  LANE_DEFS,
  classifySessionStandup,
  buildSessionStandupSnapshot,
  isWalleOwnedSession,
  _private: {
    normalizeStatus,
    normalizeProvider,
    inferProviderFromModel,
    inferSessionModel,
    inferSessionModelProvider,
    inferSessionProvider,
    isWalleOwnedSession,
    standupStatusForSession,
    latestActivity,
    latestAgentOutput,
    truncateText,
    hasFailureSignal,
    attentionDismissalForSession,
  },
};
