#!/usr/bin/env node
'use strict';
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');

const brain = require(path.resolve(__dirname, '..', '..', '..', 'brain'));
const slackMcp = require(path.resolve(__dirname, '..', '..', '..', 'tools', 'slack-mcp'));
const { ReplyDispatcher } = require(path.resolve(__dirname, '..', '..', '..', 'channels', 'reply-dispatcher'));
const channelMessageLedger = require(path.resolve(__dirname, '..', '..', '..', 'channels', 'message-ledger'));
const {
  SILENT_REPLY_TOKEN,
  isSilentReply,
  stripSilentReply,
} = require(path.resolve(__dirname, '..', '..', '..', 'loops', 'silent-reply-tokens'));
const {
  cleanWalleAddressText,
  hasWalleActivation,
  normalizeWalleAddress,
} = require(path.resolve(__dirname, '..', '..', '..', 'slack', 'activation'));
const {
  isCurrentLocationQuestion,
  shouldUseLocationForMessage,
} = require(path.resolve(__dirname, '..', '..', '..', 'location-awareness'));
const {
  parseDsmlToolCallBlocks,
} = require(path.resolve(__dirname, '..', '..', '..', 'llm', 'text-tool-calls'));
const ctmOperationalContext = require(path.resolve(__dirname, '..', '..', '..', 'memory', 'ctm-operational-context'));
let telemetry;
try { telemetry = require(path.resolve(__dirname, '..', '..', '..', 'telemetry')); } catch { telemetry = { track() {} }; }

const WATERMARK_FILE = path.join(__dirname, '.watermark.json');
const OWNER_HANDLE = process.env.SLACK_OWNER_HANDLE || '';
const OWNER_NAME = process.env.WALLE_OWNER_NAME || '';
const PAUSE_MS = 800;
const WATCH_DURATION_MS = 2 * 60 * 60 * 1000; // 2 hours
const SLACK_CHAT_TIMEOUT_MS = 240000;
const SLACK_CHAT_FINALIZATION_TIMEOUT_MS = 90000;
// Home channels: direct-polled every cycle for reliable mention detection (iPhone-proof).
// Comma-separated channel IDs, e.g. "D06ABC123,C06XYZ456"
// Auto-discovered from brain DB if not set in env.
function isExplicitCalendarRequest(text) {
  return /\b(calendar|meeting|schedule|appointment|1[:-]1|standup|sync|free|busy|available|availability|do\s+i\s+have|am\s+i\s+free)\b|会议|日程/i.test(String(text || ''));
}

function discoverHomeChannels() {
  const envChannels = (process.env.SLACK_HOME_CHANNELS || '').split(',').map(s => s.trim()).filter(Boolean);
  if (envChannels.length > 0) return envChannels;

  // Auto-discover: find channels where Wall-E was activated recently.
  try {
    const db = brain.getDb();
    const rows = db.prepare(`
      SELECT DISTINCT json_extract(metadata, '$.channel_id') as cid, count(*) as cnt
      FROM memories
      WHERE source='slack'
      AND (
        content LIKE '%@walle%'
        OR content LIKE '%@wall-e%'
        OR lower(content) LIKE 'hi walle%'
        OR lower(content) LIKE 'hi wall-e%'
      )
      AND timestamp > datetime('now', '-30 days')
      AND json_extract(metadata, '$.channel_id') IS NOT NULL
      GROUP BY cid ORDER BY cnt DESC LIMIT 5
    `).all();
    const discovered = rows.map(r => r.cid).filter(Boolean);
    if (discovered.length > 0) {
      console.log(`[slack-mentions] Auto-discovered ${discovered.length} home channel(s): ${discovered.join(', ')}`);
    }
    return discovered;
  } catch {
    return [];
  }
}
let HOME_CHANNELS = [];

const slackReplyDispatcher = new ReplyDispatcher({
  send: async (reply) => slackMcp.callSlackMcp('slack_send_message', {
    channel_id: reply.target,
    thread_ts: reply.threadTs,
    message: reply.message,
  }),
});

// ── Watermark helpers ──

function loadWatermark() {
  try {
    const data = JSON.parse(fs.readFileSync(WATERMARK_FILE, 'utf8'));
    return data.last_ts || null;
  } catch {
    return null;
  }
}

function saveWatermark(ts) {
  fs.writeFileSync(WATERMARK_FILE, JSON.stringify({ last_ts: ts, updated_at: new Date().toISOString() }, null, 2));
}

// ── Helpers ──

function extractText(result) {
  const raw = (result?.content || []).filter(c => c.type === 'text').map(c => c.text).join('\n');
  if (raw.startsWith('{')) {
    try {
      const parsed = JSON.parse(raw);
      return parsed.results || parsed.messages || parsed.text || raw;
    } catch {}
  }
  return raw;
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

function cleanWalleAddress(text, opts = {}) {
  return cleanWalleAddressText(text, opts);
}

function actorLabel(msg) {
  return msg.fromName || msg.fromUserId || getConfiguredOwnerName() || 'A Slack user';
}

function compactSlackTs(ts) {
  return String(ts || '').replace('.', '');
}

function safeSlackIdPart(value) {
  return String(value || 'unknown').replace(/[^A-Za-z0-9_-]/g, '_');
}

function buildSlackThreadSessionId(channelId, threadTs) {
  return `slack-thread-${safeSlackIdPart(channelId)}-${compactSlackTs(threadTs || 'unknown')}`;
}

function buildSlackDeliveryId(kind, channelId, threadTs, messageTs) {
  return `slack:${kind}:${channelId || 'unknown'}:${threadTs || 'unknown'}:${messageTs || 'unknown'}`;
}

function parseSlackAppAttributionLine(line) {
  const trimmed = String(line || '').trim();
  if (!trimmed) return null;
  const match = trimmed.match(/^\*?Sent using\*?\s+(?:@?([A-Za-z][\w .-]{0,80})|<@[A-Z0-9]+(?:\|([^>]+))?>)\s*$/i);
  const appName = (match?.[1] || match?.[2] || '').trim();
  return appName ? appName.replace(/^@/, '').trim() : null;
}

function stripSlackAppAttribution(content) {
  const lines = String(content || '').split('\n');
  let appAttribution = null;
  const kept = [];
  for (const line of lines) {
    const appName = parseSlackAppAttributionLine(line);
    if (appName) {
      appAttribution = appAttribution || appName;
      continue;
    }
    kept.push(line);
  }
  return { content: kept.join('\n').trim(), appAttribution };
}

function isAssistantSlackAppAttribution(appAttribution) {
  return channelMessageLedger.isAssistantAppAttribution(appAttribution, ['claude', 'walle', 'wall-e']);
}

function slackTsFromPermalink(value) {
  const match = String(value || '').match(/\/p(\d{10})(\d{6,})/);
  if (!match) return '';
  return `${match[1]}.${match[2].slice(0, 6)}`;
}

function extractSlackMessageTs(value, seen = new Set()) {
  if (value == null) return '';
  if (typeof value === 'string') {
    const direct = value.match(/\bmessage[_-]?ts\b["':=\s]+(\d{10}\.\d{6})/i);
    if (direct) return direct[1];
    const ts = value.match(/\bts\b["':=\s]+(\d{10}\.\d{6})/i);
    if (ts) return ts[1];
    return slackTsFromPermalink(value);
  }
  if (typeof value !== 'object') return '';
  if (seen.has(value)) return '';
  seen.add(value);

  const direct = value.message_ts || value.messageTs || value.ts;
  if (typeof direct === 'string' && /^\d{10}\.\d{6}$/.test(direct)) return direct;
  const permalink = value.message_link || value.messageLink || value.permalink || value.url;
  const fromLink = slackTsFromPermalink(permalink);
  if (fromLink) return fromLink;

  if (Array.isArray(value)) {
    for (const item of value) {
      const found = extractSlackMessageTs(item, seen);
      if (found) return found;
    }
    return '';
  }

  for (const item of Object.values(value)) {
    const found = extractSlackMessageTs(item, seen);
    if (found) return found;
  }
  return '';
}

function getLegacySlackInboundEvent(channelId, messageTs) {
  if (!channelId || !messageTs) return null;
  if (typeof brain.getSlackInboundEvent === 'function') {
    try {
      return brain.getSlackInboundEvent(channelId, messageTs);
    } catch (err) {
      console.warn(`[slack-mentions] Could not read legacy Slack event ledger ${channelId}:${messageTs}: ${err.message}`);
      return null;
    }
  }
  try {
    return brain.getDb().prepare('SELECT * FROM slack_inbound_events WHERE id = ?').get(`${channelId}:${messageTs}`) || null;
  } catch (err) {
    console.warn(`[slack-mentions] Could not read legacy Slack event ledger ${channelId}:${messageTs}: ${err.message}`);
    return null;
  }
}

function isSlackLegacyOutboundEvent(event) {
  const status = String(event?.status || '').toLowerCase();
  const source = String(event?.source || '').toLowerCase();
  return status === 'outbound' || source === 'wall_e_outbound' || source.startsWith('wall_e_outbound:');
}

function markSlackOutboundDelivery({ channelId, threadTs, messageTs, deliveryId } = {}) {
  if (!channelId || !messageTs) return false;
  const legacyEvent = getLegacySlackInboundEvent(channelId, messageTs);
  if (legacyEvent && !isSlackLegacyOutboundEvent(legacyEvent)) {
    return false;
  }
  let recorded = false;
  try {
    const channelEvent = channelMessageLedger.recordOutboundDelivery({
      channel: 'slack',
      conversationId: channelId,
      threadId: threadTs || messageTs,
      messageId: messageTs,
      source: 'wall_e_outbound',
      deliveryId,
      metadata: { slackChannelId: channelId, slackThreadTs: threadTs || messageTs },
    });
    recorded = channelEvent.recorded || channelMessageLedger.isOutboundEvent(channelEvent.event);
  } catch (err) {
    console.warn(`[slack-mentions] Could not mark channel outbound Slack reply ${channelId}:${messageTs}: ${err.message}`);
  }
  if (typeof brain.claimSlackInboundEvent !== 'function') return recorded;
  try {
    const claim = brain.claimSlackInboundEvent({
      channelId,
      messageTs,
      threadTs: threadTs || messageTs,
      source: 'wall_e_outbound',
      payloadHash: hashPayload(deliveryId || ''),
    });
    if (!claim.claimed) return isSlackLegacyOutboundEvent(claim.event);
    brain.completeSlackInboundEvent(claim.id, { status: 'outbound' });
    return true;
  } catch (err) {
    console.warn(`[slack-mentions] Could not mark outbound Slack reply ${channelId}:${messageTs}: ${err.message}`);
    return recorded;
  }
}

function getSlackLedgerEvent(channelId, messageTs) {
  if (!channelId || !messageTs) return null;
  try {
    const channelEvent = channelMessageLedger.getMessageEvent({
      channel: 'slack',
      conversationId: channelId,
      messageId: messageTs,
    });
    if (channelEvent) return channelEvent;
  } catch (err) {
    console.warn(`[slack-mentions] Could not read channel Slack event ledger ${channelId}:${messageTs}: ${err.message}`);
  }
  return getLegacySlackInboundEvent(channelId, messageTs);
}

function isSlackOutboundLedgerEvent(event) {
  if (channelMessageLedger.isOutboundEvent(event)) return true;
  return isSlackLegacyOutboundEvent(event);
}

function classifySlackOutboundReply(reply, { channelId, threadTs } = {}) {
  const event = getSlackLedgerEvent(channelId, reply?.ts);
  if (isSlackOutboundLedgerEvent(event)) return { outbound: true, reason: 'ledger' };

  const classification = channelMessageLedger.classifyInboundOrigin({
    channel: 'slack',
    conversationId: channelId,
    threadId: threadTs,
    messageId: reply?.ts,
  }, {
    appAttribution: reply?.appAttribution,
    assistantAppNames: ['claude', 'walle', 'wall-e'],
    deliveryId: buildSlackDeliveryId(`attributed-${String(reply?.appAttribution || '').toLowerCase()}`, channelId, threadTs, reply?.ts),
    metadata: { slackChannelId: channelId, slackThreadTs: threadTs },
  });
  if (classification.outbound) {
    if (classification.reason.startsWith('app_attribution:')) markSlackOutboundDelivery({
      channelId,
      threadTs,
      messageTs: reply.ts,
      deliveryId: buildSlackDeliveryId(`attributed-${String(reply.appAttribution).toLowerCase()}`, channelId, threadTs, reply.ts),
    });
    return { outbound: true, reason: classification.reason };
  }
  return { outbound: false, reason: 'unknown' };
}

function filterSlackOutboundReplies(replies = [], opts = {}) {
  const skipped = [];
  const kept = [];
  for (const reply of replies || []) {
    const classification = classifySlackOutboundReply(reply, opts);
    if (classification.outbound) {
      reply.skipReason = classification.reason;
      skipped.push(reply);
    } else {
      kept.push(reply);
    }
  }
  return { replies: kept, skipped };
}

function hashPayload(value) {
  return crypto.createHash('sha1').update(String(value || '')).digest('hex');
}

function claimSlackMessage(msg, source = 'poll') {
  if (!msg?.channelId || !msg?.ts) {
    return { claimed: true, id: null };
  }
  let channelClaim = null;
  try {
    channelClaim = channelMessageLedger.claimInboundMessage({
      channel: 'slack',
      conversationId: msg.channelId,
      threadId: msg.threadTs || msg.ts,
      messageId: msg.ts,
      source,
      content: msg.content || '',
      metadata: { slackChannelId: msg.channelId, slackThreadTs: msg.threadTs || msg.ts },
    });
    if (!channelClaim.claimed) return { ...channelClaim, channelEventId: channelClaim.id };
  } catch (err) {
    console.warn(`[slack-mentions] Could not claim channel Slack event ${msg.channelId}:${msg.ts}: ${err.message}`);
  }
  if (typeof brain.claimSlackInboundEvent !== 'function') {
    return channelClaim ? { ...channelClaim, channelEventId: channelClaim.id } : { claimed: true, id: null };
  }
  try {
    const legacyClaim = brain.claimSlackInboundEvent({
      channelId: msg.channelId,
      messageTs: msg.ts,
      threadTs: msg.threadTs || msg.ts,
      source,
      payloadHash: hashPayload(msg.content || ''),
    });
    if (!legacyClaim.claimed && channelClaim?.claimed) {
      channelMessageLedger.completeMessageEvent(channelClaim.id, { status: 'skipped_legacy_duplicate' });
      return { ...legacyClaim, channelEventId: channelClaim.id, legacyId: legacyClaim.id };
    }
    return {
      ...(channelClaim || legacyClaim),
      id: channelClaim?.id || legacyClaim.id,
      channelEventId: channelClaim?.id || null,
      legacyId: legacyClaim.id,
    };
  } catch (err) {
    console.warn(`[slack-mentions] Could not claim Slack inbound event ${msg.channelId}:${msg.ts}: ${err.message}`);
    return channelClaim ? { ...channelClaim, channelEventId: channelClaim.id } : { claimed: true, id: null };
  }
}

function completeSlackMessage(claim, status = 'completed', error = null) {
  if (claim?.channelEventId && typeof channelMessageLedger.completeMessageEvent === 'function') {
    try {
      channelMessageLedger.completeMessageEvent(claim.channelEventId, { status, error });
    } catch (err) {
      console.warn(`[slack-mentions] Could not complete channel Slack event ${claim.channelEventId}: ${err.message}`);
    }
  }
  if (!claim?.legacyId && claim?.id && String(claim.id).startsWith('slack:')) return;
  const legacyId = claim?.legacyId || claim?.id;
  if (!legacyId || typeof brain.completeSlackInboundEvent !== 'function') return;
  try {
    brain.completeSlackInboundEvent(legacyId, { status, error });
  } catch (err) {
    console.warn(`[slack-mentions] Could not complete Slack inbound event ${legacyId}: ${err.message}`);
  }
}

function sourceRefMatchesMessage(sourceRef, msg) {
  if (!sourceRef || !msg) return false;
  const ref = String(sourceRef).split('?')[0];
  const permalink = msg.permalink ? String(msg.permalink).split('?')[0] : '';
  if (permalink && ref === permalink) return true;
  if (msg.channelId && msg.ts && ref === `${msg.channelId}:${msg.ts}`) return true;
  if (msg.channelId && msg.ts && ref.includes(`/archives/${msg.channelId}/p${compactSlackTs(msg.ts)}`)) return true;
  return false;
}

function compareSlackTs(a, b) {
  const an = Number(a);
  const bn = Number(b);
  if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn;
  return String(a || '').localeCompare(String(b || ''));
}

function slackTsAfter(a, b) {
  if (!a) return false;
  if (!b) return true;
  return compareSlackTs(a, b) > 0;
}

function latestReplyTs(replies = [], baseline = null) {
  return (replies || []).reduce((latest, reply) => slackTsAfter(reply.ts, latest) ? reply.ts : latest, baseline);
}

function advanceThreadLastSeenForReplies(thread, replies) {
  const latestTs = latestReplyTs(replies, thread?.last_seen_ts || thread?.thread_ts);
  if (!thread?.id || !slackTsAfter(latestTs, thread.last_seen_ts || thread.thread_ts)) return false;
  brain.updateSlackThread(thread.id, {
    last_seen_ts: latestTs,
    last_activity: new Date().toISOString(),
  });
  return true;
}

function getConfiguredOwnerName() {
  if (OWNER_NAME) return OWNER_NAME;
  try {
    return brain.getOwnerName ? (brain.getOwnerName() || '') : '';
  } catch {
    return '';
  }
}

function isOwnerName(name, ownerHandle = OWNER_HANDLE, ownerName) {
  const normalizedName = String(name || '').toLowerCase().trim();
  if (!normalizedName) return false;

  const normalizedHandle = String(ownerHandle || '').toLowerCase().trim().replace(/^@/, '');
  if (normalizedHandle && normalizedName.includes(normalizedHandle)) return true;

  const effectiveOwnerName = ownerName === undefined ? getConfiguredOwnerName() : ownerName;
  const normalizedOwner = String(effectiveOwnerName || '').toLowerCase().trim();
  if (normalizedOwner && normalizedName.includes(normalizedOwner)) return true;

  const firstName = normalizedOwner.split(/\s+/)[0];
  return Boolean(firstName && firstName.length >= 2 && normalizedName.includes(firstName));
}

function isOwnerReply(reply, opts = {}) {
  return Boolean(reply?.ts) && isOwnerName(reply.from, opts.ownerHandle, opts.ownerName);
}

function isMessageDraftRequest(text) {
  const t = String(text || '').toLowerCase();
  return /\b(write|draft|compose|wordsmith|rewrite)\b/.test(t)
    && /\b(message|note|reply|response|email|slack|dm)\b/.test(t);
}

function slackResponseInstruction(text) {
  const standalone = 'Your Slack reply must stand alone in the visible Slack thread. Never refer to an answer above, previous response, hidden context, tool output, or anything the Slack user cannot see.';
  const noReply = `If you cannot produce a useful final answer from the available context, reply with ONLY: ${SILENT_REPLY_TOKEN}. Do not ask the Slack user for more context and do not narrate searches or attempts.`;
  if (isMessageDraftRequest(text)) {
    return [
      'The user wants a message draft.',
      'Use the full Slack thread context and memories to infer the situation.',
      'Return exactly one ready-to-copy draft in first person for the user to review and send.',
      'Do not include analysis, tool narration, caveats, or follow-up questions.',
      'Do not claim you actually sent anything or that you are the user.',
      'If context is incomplete but enough signal exists, write the safest concise draft from the available context.',
      standalone,
      noReply,
    ].join(' ');
  }
  return `Give a concise, helpful answer (2-6 sentences). Do NOT mention any tool failures, search steps, or technical issues in your response. ${standalone} ${noReply}`;
}

function looksLikeSlackToolError(answer) {
  const text = String(answer || '');
  return /\b(authentication failed|auth.?required|tool.?fail|error.*MCP|MCP.*error|OAuth.*expired)\b/i.test(text)
    || /\b(I encountered|I was unable|I couldn't)\b.*\b(authenticate|connect|access|reach|call the tool)\b/i.test(text);
}

function looksLikeInternalFinalizationFallback(answer) {
  const text = String(answer || '');
  return /\b(final answer generation|progress\/tool-control|already collected tool results|summary generation failed|AI provider timed out|couldn'?t generate a summary)\b/i.test(text)
    || /\bsetup page for API key configuration\b/i.test(text)
    || (/\bdetailed tool outputs\b/i.test(text) && /\bactivity panel\b/i.test(text));
}

function looksLikeToolControlMarkup(answer) {
  const text = String(answer || '');
  if (!text.trim()) return false;
  try {
    if (parseDsmlToolCallBlocks(text).toolCalls.length > 0) return true;
  } catch {}
  return /<\s*(?:[|｜]\s*){1,2}DSML\s*(?:[|｜]\s*){1,2}\s*(?:tool_calls|invoke|parameter)\b/i.test(text)
    || /<\/\s*(?:[|｜]\s*){1,2}DSML\s*(?:[|｜]\s*){1,2}\s*(?:tool_calls|invoke|parameter)\s*>/i.test(text);
}

function looksLikeInvisiblePriorAnswerReference(answer) {
  const text = String(answer || '');
  return /\b(?:answer|response|reply)\s+(?:above|earlier|previously)\b/i.test(text)
    || /\b(?:above|earlier|previous)\s+(?:answer|response|reply)\b/i.test(text)
    || /\b(?:as|like)\s+I\s+(?:said|mentioned|explained|covered)\s+(?:above|earlier|previously)\b/i.test(text)
    || /\bI\s+already\s+(?:covered|answered|explained|responded)\b/i.test(text)
    || /\bno\s+further\s+lookups?\s+needed\b/i.test(text);
}

function validateSlackAnswer(answer) {
  const raw = String(answer || '').trim();
  if (isSilentReply(raw)) return { ok: false, reason: 'silent_reply' };
  const stripped = stripSilentReply(raw);
  if (stripped.hadToken && !stripped.text.trim()) return { ok: false, reason: 'silent_reply' };
  const text = stripped.text.trim();
  if (text.length < 5) return { ok: false, reason: 'empty' };
  if (looksLikeToolControlMarkup(text)) return { ok: false, reason: 'tool_control_markup' };
  if (looksLikeInternalFinalizationFallback(text)) return { ok: false, reason: 'finalization_fallback' };
  if (looksLikeInvisiblePriorAnswerReference(text)) return { ok: false, reason: 'invisible_prior_answer_reference' };
  if (looksLikeSlackToolError(text)) return { ok: false, reason: 'tool_error' };
  return { ok: true, text };
}

function buildSlackChatOptions(overrides = {}) {
  return {
    ...overrides,
    timeoutMs: overrides.timeoutMs || SLACK_CHAT_TIMEOUT_MS,
    finalizationTimeoutMs: overrides.finalizationTimeoutMs || SLACK_CHAT_FINALIZATION_TIMEOUT_MS,
    finalizationFailureReply: overrides.finalizationFailureReply || SILENT_REPLY_TOKEN,
  };
}

async function buildSlackCtmContextBlock(text, opts = {}) {
  const classification = ctmOperationalContext.classifyCtmOperationalQuery(text);
  if (!classification.required) {
    return { required: false, ok: true, block: '', result: null };
  }

  let result;
  try {
    result = await ctmOperationalContext.lookupCtmOperationalContext({
      query: text,
      limit: opts.limit || 5,
      force: true,
    }, {
      timeoutMs: opts.timeoutMs || 6000,
      cooldownMs: opts.cooldownMs ?? 0,
    });
  } catch (err) {
    result = {
      required: true,
      ok: false,
      must_not_guess: true,
      reason: err.message,
      context_text: 'CTM operational context lookup failed. Do not guess; reply ONLY NO_REPLY.',
      sources: {
        remote_access: { searched: true, ok: false, reason: err.message },
        ctm_sessions: { searched: true, ok: false, reason: err.message },
      },
    };
  }

  telemetry.track('slack_ctm_context_preflight', {
    required: true,
    ok: Boolean(result.ok),
    remote_ok: Boolean(result.sources?.remote_access?.ok),
    session_ok: Boolean(result.sources?.ctm_sessions?.ok),
    session_count: Number(result.sources?.ctm_sessions?.count || 0),
  });

  if (!result.ok) {
    console.warn(`[slack-mentions] Required CTM context unavailable; suppressing guess (${result.sources?.remote_access?.reason || result.sources?.ctm_sessions?.reason || result.reason || 'unknown'})`);
    return {
      required: true,
      ok: false,
      block: '\n\nRequired CTM operational/session-memory context was unavailable. Reply ONLY NO_REPLY. Do not guess from generic memories, DDEV hosts, localhost, or local IP heuristics.',
      result,
    };
  }

  return {
    required: true,
    ok: true,
    block: `\n\n${result.context_text}`,
    result,
  };
}

function selectThreadMessagesToProcess(replies, thread, opts = {}) {
  const lastSeenTs = thread?.last_seen_ts || thread?.thread_ts || null;
  const ownerReplies = (replies || []).filter(reply => isOwnerReply(reply, opts));
  if (ownerReplies.length === 0) {
    return { messages: [], latestTs: lastSeenTs, reason: 'no_owner_replies' };
  }

  const newReplies = ownerReplies.filter(reply => slackTsAfter(reply.ts, lastSeenTs));
  if (newReplies.length === 0) {
    return { messages: [], latestTs: lastSeenTs, reason: 'no_new_owner_replies' };
  }

  const latestTs = newReplies[newReplies.length - 1].ts;
  return { messages: newReplies, latestTs, reason: 'new_owner_replies' };
}

/**
 * Generate an answer using chat and send it directly to Slack.
 * Returns the reply text on success, or null on failure.
 *
 * Chat has access to search_memories, think, lookup_person, and mcp_call
 * (for live data like Google Calendar). We send the reply ourselves via
 * callSlackMcp rather than relying on the model to call slack_send_message.
 */
async function deliverSlackReply({ channelId, threadTs, message, deliveryId }) {
  if (!channelId) return { ok: true, status: 'no_channel' };
  const status = await slackReplyDispatcher.dispatch({
    channel: 'slack',
    target: channelId,
    threadTs,
    message,
    deliveryId,
    ledger: {
      channel: 'slack',
      conversationId: channelId,
      threadId: threadTs,
      source: 'wall_e_outbound',
      extractMessageId: (result) => extractSlackMessageTs(result),
      metadata: { slackChannelId: channelId, slackThreadTs: threadTs },
    },
  });
  if (status.status === 'sent') {
    const messageTs = extractSlackMessageTs(status.result);
    if (messageTs) markSlackOutboundDelivery({ channelId, threadTs, messageTs, deliveryId });
    console.log(`[slack-mentions] Reply sent to Slack thread ${threadTs}`);
    return { ok: true, status: status.status };
  }
  if (status.status === 'skipped' && status.reason === 'duplicate') {
    console.log(`[slack-mentions] Duplicate Slack delivery skipped: ${deliveryId}`);
    return { ok: false, status: status.status, reason: status.reason };
  }
  console.error(`[slack-mentions] Failed to send reply to Slack: ${status.reason || 'unknown error'}`);
  return { ok: false, status: status.status, reason: status.reason };
}

async function generateAndSendReply(msg, threadTs, contextBlock, kind, opts = {}) {
  const chatModule = require(path.resolve(__dirname, '..', '..', '..', 'chat'));
  const question = cleanWalleAddress(msg.content);

  // Step 1: Generate the answer with limited tools
  const isGreeting = /\b(welcome|hello|hi|hey|greet|introduce|join|new member|new hire)\b/i.test(question);
  let answer;
  try {
    const today = new Date().toISOString().split('T')[0];
    const instruction = slackResponseInstruction(question);
    const ctmContext = await buildSlackCtmContextBlock(question);
    if (ctmContext.required && !ctmContext.ok) return null;
    const effectiveContextBlock = `${contextBlock || ''}${ctmContext.block || ''}`;
    const locationAware = shouldUseLocationForMessage(question);
    const explicitCalendar = isExplicitCalendarRequest(question);
    const locationInstruction = locationAware
      ? 'For physical location/local weather questions, use Wall-E current-location context only. Do not infer location from calendar, timezone, Slack history, or memories.'
      : ctmContext.required
      ? 'Use the CTM operational context block as authoritative. Do not answer from generic memories if they conflict. Do not guess DDEV, localhost, or local-IP answers unless the CTM context explicitly says so.'
      : 'Search your memories for relevant info.';
    // Build a context-aware prompt: greetings/welcomes get a warmer, more proactive prompt
    let prompt;
    if (isGreeting) {
      prompt = `[SLACK GREETING] ${actorLabel(msg)} said in Slack:\n\n"${question}"${effectiveContextBlock}\n\nToday is ${today}. This appears to be a greeting or welcome message. Search your memories (e.g. lookup_person) to learn about the people mentioned. Respond warmly — welcome them, share any relevant context you know about them, and proactively offer to help. Be genuine and concise (2-4 sentences). Do NOT mention any tool failures or technical issues in your response.`;
    } else {
      prompt = `[SLACK ${kind.toUpperCase()}] ${actorLabel(msg)} asked in Slack:\n\n${question}${effectiveContextBlock}\n\nToday is ${today}. ${locationInstruction} For schedule/calendar questions, search with the specific date (e.g. "calendar 2026-04-13") since calendar events are stored as memories with source=calendar. Use ctm_context for CTM operational/session-memory questions. Use mcp_call only if memories don't have what you need. ${instruction}`;
    }

    const allowedTools = ['search_memories', 'ctm_context', 'ctm_remote_access_status', 'ctm_session_search', 'think', 'lookup_person', 'mcp_call', 'calendar_events', 'mail_messages', 'mail_read', 'mail_search']
      .filter(tool => !(locationAware && !explicitCalendar && tool === 'calendar_events'));
    const result = await chatModule.chat(prompt, buildSlackChatOptions({
      channel: 'task',
      session_id: opts.sessionId || buildSlackThreadSessionId(msg.channelId, threadTs),
      routingMessage: question,
      allowNetworkLocationInference: locationAware || isCurrentLocationQuestion(question),
      allowedTools,
      maxToolCalls: 6,
    }));
    // Prefer lastTurnReply to avoid error explanations from intermediate turns
    answer = (result.lastTurnReply || result.reply || '').trim();
    console.log(`[slack-mentions] Generated answer (${answer.length} chars): ${answer.slice(0, 150)}`);
  } catch (err) {
    console.error(`[slack-mentions] Failed to generate answer: ${err.message}`);
    answer = null;
  }

  // Step 2: Send the reply directly via Slack MCP
  // Guard: never send error/progress/uncertainty text to Slack.
  const validation = validateSlackAnswer(answer);
  if (!validation.ok) {
    if (answer) console.warn(`[slack-mentions] Suppressed ${validation.reason} answer: ${answer.slice(0, 200)}`);
    if (!isGreeting) return null;
    answer = isGreeting
      ? `Welcome! Great to have you here. Let me know if there's anything I can help with!`
      : null;
  } else {
    answer = validation.text;
  }
  if (!answer) return null;

  // Truncate for Slack (4000 char limit)
  if (answer.length > 3500) answer = answer.slice(0, 3500) + '...';

  const delivery = await deliverSlackReply({
    channelId: msg.channelId,
    threadTs,
    message: answer,
    deliveryId: opts.deliveryId || buildSlackDeliveryId('reply', msg.channelId, threadTs, msg.ts),
  });
  return delivery.ok ? answer : null;
}

/**
 * Generate and send a follow-up reply in a watched thread.
 * Mirrors generateAndSendReply: we control the chat call and send the reply
 * ourselves, rather than letting the model call slack_send_message directly.
 */
async function generateAndSendFollowUp(combinedMessage, thread, threadContext, opts = {}) {
  const chatModule = require(path.resolve(__dirname, '..', '..', '..', 'chat'));

  let answer;
  try {
    const today = new Date().toISOString().split('T')[0];
    const contextBlock = threadContext ? `\n\nFull Slack thread context:\n${threadContext}` : '';
    const instruction = slackResponseInstruction(combinedMessage);
    const ctmContext = await buildSlackCtmContextBlock(`${combinedMessage}\n${threadContext || ''}`);
    if (ctmContext.required && !ctmContext.ok) return null;
    const effectiveContextBlock = `${contextBlock}${ctmContext.block || ''}`;
    const locationAware = shouldUseLocationForMessage(combinedMessage);
    const explicitCalendar = isExplicitCalendarRequest(combinedMessage);
    const locationInstruction = locationAware
      ? 'For physical location/local weather questions, use Wall-E current-location context only. Do not infer location from calendar, timezone, Slack history, or memories.'
      : ctmContext.required
      ? 'Use the CTM operational context block as authoritative. Do not answer from generic memories if they conflict. Do not guess DDEV, localhost, or local-IP answers unless the CTM context explicitly says so.'
      : 'Search your memories for relevant info.';
    const prompt = `[SLACK FOLLOW-UP] ${getConfiguredOwnerName() || 'The owner'} replied in a Slack thread:\n\n${combinedMessage}${effectiveContextBlock}\n\nToday is ${today}. ${locationInstruction} Use ctm_context for CTM operational/session-memory questions. ${instruction}`;

    const allowedTools = ['search_memories', 'ctm_context', 'ctm_remote_access_status', 'ctm_session_search', 'think', 'lookup_person', 'mcp_call', 'calendar_events', 'mail_messages', 'mail_read', 'mail_search']
      .filter(tool => !(locationAware && !explicitCalendar && tool === 'calendar_events'));
    const result = await chatModule.chat(prompt, buildSlackChatOptions({
      channel: 'task',
      session_id: thread.session_id || buildSlackThreadSessionId(thread.channel_id, thread.thread_ts),
      routingMessage: combinedMessage,
      allowNetworkLocationInference: locationAware || isCurrentLocationQuestion(combinedMessage),
      allowedTools,
      maxToolCalls: 6,
    }));
    answer = (result.lastTurnReply || result.reply || '').trim();
    console.log(`[slack-mentions] Generated follow-up (${answer.length} chars): ${answer.slice(0, 150)}`);
  } catch (err) {
    console.error(`[slack-mentions] Failed to generate follow-up: ${err.message}`);
    return null;
  }

  // Guard: never send error/progress/uncertainty text to Slack.
  const validation = validateSlackAnswer(answer);
  if (!validation.ok) {
    if (answer) console.warn(`[slack-mentions] Suppressed ${validation.reason} follow-up: ${answer.slice(0, 200)}`);
    return null;
  }
  answer = validation.text;

  if (answer.length > 3500) answer = answer.slice(0, 3500) + '...';

  const delivery = await deliverSlackReply({
    channelId: thread.channel_id,
    threadTs: thread.thread_ts,
    message: answer,
    deliveryId: opts.deliveryId || buildSlackDeliveryId('followup', thread.channel_id, thread.thread_ts, opts.messageTs),
  });
  return delivery.ok ? answer : null;
}

function classifyMention(text) {
  const trimmed = (text || '').trim();
  const cleaned = cleanWalleAddress(trimmed);
  if (/\?\s*$/.test(cleaned)) return 'question';
  if (/^(what|who|where|when|why|how|is|are|do|does|did|can|could|would|should|will)\b/i.test(cleaned)) return 'question';

  // Coding requests: action verbs + code concepts
  const hasActionVerb = /\b(add|fix|build|create|implement|refactor|update|remove|delete|write|change|move|rename|extract|optimize)\b/i.test(cleaned);
  const hasCodeConcept = /\b(api|endpoint|function|module|class|component|middleware|route|handler|test|database|schema|migration|config|server|client|ui|page|service|model)\b/i.test(cleaned);
  if (hasActionVerb && hasCodeConcept) return 'coding';

  return 'task';
}

function extractTaskTitle(text) {
  const cleaned = cleanWalleAddress(text);
  if (cleaned.length <= 120) return cleaned;
  return cleaned.slice(0, 120).replace(/\s+\S*$/, '') + '...';
}

async function fetchThreadContext(channelId, threadTs) {
  try {
    const result = await slackMcp.callSlackMcp('slack_read_thread', {
      channel_id: channelId,
      message_ts: threadTs,
      limit: 50,
    });
    return extractText(result);
  } catch (err) {
    console.warn(`[slack-mentions] Could not fetch thread context: ${err.message}`);
    return null;
  }
}

function parseSearchResults(rawText) {
  const messages = [];
  const blocks = rawText.split(/(?=### Result \d)/);

  for (const block of blocks) {
    if (!block.trim() || !block.includes('Result')) continue;

    const channelMatch = block.match(/Channel:.*?\(ID:\s*(\w+)\)/);
    let channelId = channelMatch ? channelMatch[1] : null;

    const fromMatch = block.match(/From:\s*(.+?)\s*\(ID:\s*(\w+)\)/);
    const fromName = fromMatch ? fromMatch[1].trim() : null;
    const fromUserId = fromMatch ? fromMatch[2] : null;

    const timeMatch = block.match(/Time:\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/);
    const isoTimestamp = timeMatch ? timeMatch[1] : null;

    const msgTsMatch = block.match(/Message_ts:\s*(\d+\.\d+)/);
    const msgTs = msgTsMatch ? msgTsMatch[1] : null;

    const threadMatch = block.match(/Thread_ts:\s*(\d+\.\d+)/i);
    const threadTs = threadMatch ? threadMatch[1] : null;

    const linkMatch = block.match(/Permalink:\s*\[.*?\]\((https?:\/\/[^\s)]+)\)/);
    // Slack MCP returns {workspace}.slack.com but enterprise workspaces use {workspace}.enterprise.slack.com
    let permalink = linkMatch ? linkMatch[1] : null;
    if (permalink) permalink = permalink.replace(/^(https:\/\/\w+)\.slack\.com/, '$1.enterprise.slack.com');

    // Fallback: extract channel ID from permalink if Channel field didn't match (e.g. DMs)
    if (!channelId && permalink) {
      const archiveMatch = permalink.match(/\/archives\/([A-Z0-9]+)\//);
      if (archiveMatch) channelId = archiveMatch[1];
    }

    const textMatch = block.match(/Text:\s*\n([\s\S]*?)(?:\n---|\n###|$)/);
    const content = textMatch ? textMatch[1].trim() : '';

    if (hasWalleActivation(content) && content.length > 0) {
      messages.push({
        ts: msgTs, isoTimestamp, channelId, threadTs,
        fromName, fromUserId, permalink, content, raw: block.trim(),
      });
    }
  }

  return messages;
}

function parseThreadReplies(rawText) {
  let text = rawText;
  if (text.startsWith('{')) {
    try {
      const parsed = JSON.parse(text);
      text = parsed.messages || parsed.results || parsed.text || text;
    } catch {}
  }

  const replies = [];
  const blocks = text.split(/(?=--- Reply \d)/);

  for (const block of blocks) {
    if (!block.trim() || !block.includes('Reply')) continue;

    const fromMatch = block.match(/From:\s*(.+?)(?:\s*\(|\n)/);
    const tsMatch = block.match(/Message TS:\s*(\d+\.\d+)/);
    const from = fromMatch ? fromMatch[1].trim() : null;
    const ts = tsMatch ? tsMatch[1] : null;

    const lines = block.split('\n');
    const metaEnd = lines.findIndex(l => /^Message TS:/.test(l));
    const rawContent = metaEnd >= 0 ? lines.slice(metaEnd + 1).join('\n').trim() : '';
    const { content, appAttribution } = stripSlackAppAttribution(rawContent);

    if ((content || appAttribution) && ts) {
      replies.push({ ts, from, content, appAttribution });
    }
  }

  return replies;
}

// ── Watched thread polling (DB-backed) ──

async function pollWatchedThreads() {
  // Clean up expired threads
  const expired = brain.deleteExpiredSlackThreads(WATCH_DURATION_MS);
  if (expired > 0) console.log(`[slack-mentions] Expired ${expired} watched thread(s)`);

  const activeThreads = brain.listActiveSlackThreads(WATCH_DURATION_MS);
  if (activeThreads.length === 0) return;

  for (const thread of activeThreads) {
    try {
      const rawThread = await fetchThreadContext(thread.channel_id, thread.thread_ts);
      if (!rawThread) continue;

      const allReplies = parseThreadReplies(rawThread);
      const { replies, skipped: skippedSelfReplies } = filterSlackOutboundReplies(allReplies, {
        channelId: thread.channel_id,
        threadTs: thread.thread_ts,
      });
      if (skippedSelfReplies.length > 0) {
        const reasons = [...new Set(skippedSelfReplies.map(reply => reply.skipReason).filter(Boolean))].join(', ');
        console.log(`[slack-mentions] Ignored ${skippedSelfReplies.length} outbound Slack repl${skippedSelfReplies.length === 1 ? 'y' : 'ies'} in thread ${thread.id}${reasons ? ` (${reasons})` : ''}`);
      }
      if (replies.length === 0) {
        advanceThreadLastSeenForReplies(thread, skippedSelfReplies);
        continue;
      }

      const selection = selectThreadMessagesToProcess(replies, thread);
      const messagesToProcess = selection.messages;
      if (messagesToProcess.length === 0) {
        advanceThreadLastSeenForReplies(thread, skippedSelfReplies);
        continue;
      }

      console.log(`[slack-mentions] ${messagesToProcess.length} message(s) to process in watched thread ${thread.id}`);

      const latestTs = selection.latestTs || messagesToProcess[messagesToProcess.length - 1].ts;
      const claimed = [];
      for (const reply of messagesToProcess) {
        const claim = claimSlackMessage({
          channelId: thread.channel_id,
          threadTs: thread.thread_ts,
          ts: reply.ts,
          content: reply.content,
        }, 'thread_poll');
        if (!claim.claimed) {
          console.log(`[slack-mentions] Skipping already-claimed thread reply ${thread.channel_id}:${reply.ts}`);
          continue;
        }
        claimed.push({ reply, claim });
      }

      if (claimed.length === 0) {
        brain.updateSlackThread(thread.id, {
          last_seen_ts: latestTs || thread.last_seen_ts,
          last_activity: new Date().toISOString(),
        });
        continue;
      }

      const combinedMessage = claimed.map(item => item.reply.content).join('\n\n');
      const deliveryMessageTs = claimed[claimed.length - 1]?.reply?.ts || latestTs;

      let replySucceeded = false;
      let claimStatus = 'completed';
      let claimError = null;
      try {
        const reply = await generateAndSendFollowUp(combinedMessage, thread, rawThread, {
          messageTs: deliveryMessageTs,
          deliveryId: buildSlackDeliveryId('followup', thread.channel_id, thread.thread_ts, deliveryMessageTs),
        });
        replySucceeded = !!reply;
        if (reply) {
          console.log(`[slack-mentions] Follow-up reply sent for thread ${thread.id}: ${reply.slice(0, 100)}`);
        } else {
          claimStatus = 'no_reply';
        }
      } catch (chatErr) {
        console.error(`[slack-mentions] Failed to process follow-up: ${chatErr.message}`);
        claimStatus = 'failed';
        claimError = chatErr.message;
      } finally {
        for (const item of claimed) completeSlackMessage(item.claim, claimStatus, claimError);
      }

      // Advance even when the reply is suppressed or sending fails. Retrying the
      // same Slack message every 10s is worse than missing one transient reply.
      brain.updateSlackThread(thread.id, {
        last_seen_ts: latestTs || thread.last_seen_ts,
        last_activity: new Date().toISOString(),
      });
      if (!replySucceeded) {
        console.warn(`[slack-mentions] Marked thread ${thread.id} seen without sending a follow-up`);
      }

      await sleep(PAUSE_MS);
    } catch (err) {
      console.error(`[slack-mentions] Error polling thread ${thread.id}: ${err.message}`);
    }
  }
}

// ── Home channel direct polling (Phase 1 — most reliable, iPhone-proof) ──

function parseChannelMessages(rawText, channelId) {
  const messages = [];
  const text = typeof rawText === 'string' ? rawText : '';
  // Slack MCP read_channel format: "=== Message from Name (UXXXX) at YYYY-MM-DD HH:MM:SS TZ ==="
  const blocks = text.split(/(?====\s*Message from)/);

  for (const block of blocks) {
    if (!block.trim()) continue;

    const headerMatch = block.match(/^=== Message from (.+?) \(([A-Z0-9]+)\) at (.+?) ===/);
    if (!headerMatch) continue;

    const fromName = headerMatch[1].trim();
    const fromUserId = headerMatch[2];
    const timestamp = headerMatch[3].trim();

    const msgTsMatch = block.match(/Message[ _]TS:\s*(\d+\.\d+)/);
    const msgTs = msgTsMatch ? msgTsMatch[1] : null;

    const threadTsMatch = block.match(/Thread[ _]TS:\s*(\d+\.\d+)/i);
    const threadTs = threadTsMatch ? threadTsMatch[1] : null;

    const linkMatch = block.match(/Permalink:\s*\[.*?\]\((https?:\/\/[^\s)]+)\)/);
    let permalink = linkMatch ? linkMatch[1] : null;
    if (permalink) permalink = permalink.replace(/^(https:\/\/\w+)\.slack\.com/, '$1.enterprise.slack.com');

    // Extract content: everything after metadata lines, before next message
    const lines = block.split('\n');
    const metaEnd = lines.reduce((last, l, i) =>
      /^(?:Message[ _]TS|Thread[ _]TS|Reactions|Permalink|Channel):/.test(l) ? i : last, 0);
    const content = lines.slice(metaEnd + 1).join('\n').trim();

    if (msgTs && content && hasWalleActivation(content)) {
      messages.push({
        ts: msgTs, threadTs: threadTs || msgTs, channelId,
        fromName, fromUserId, permalink, content,
        isoTimestamp: timestamp,
      });
    }
  }
  return messages;
}

async function pollHomeChannels(lastTs, seenTs, stats) {
  if (HOME_CHANNELS.length === 0) return;

  console.log(`[slack-mentions] Phase 1: Polling ${HOME_CHANNELS.length} home channel(s) directly`);

  for (const channelId of HOME_CHANNELS) {
    try {
      const result = await slackMcp.callSlackMcp('slack_read_channel', {
        channel_id: channelId,
        limit: 20,
      });
      const rawText = extractText(result);
      if (!rawText || rawText.length < 10) continue;

      const messages = parseChannelMessages(rawText, channelId);
      console.log(`[slack-mentions] Home channel ${channelId}: ${messages.length} mention(s) found`);

      for (const msg of messages) {
        if (lastTs && msg.ts && msg.ts <= lastTs) continue;

        // Check sender is owner
        const isFromOwner = isOwnerName(msg.fromName);
        if (!isFromOwner) continue;

        await _processMention(msg, seenTs, stats, { source: 'home_poll' });
      }

      await sleep(PAUSE_MS);
    } catch (err) {
      console.warn(`[slack-mentions] Home channel ${channelId} poll error: ${err.message}`);
    }
  }
}

// ── New mention processing ──

// Shared per-message processor - creates tasks/questions from a mention.
async function _processMention(msg, seenTs, stats, opts = {}) {
  const dedupeKey = msg.channelId && msg.ts ? `${msg.channelId}:${msg.ts}` : (msg.ts || msg.permalink || msg.content);
  if (seenTs.has(dedupeKey)) return;
  seenTs.add(dedupeKey);

  const now = new Date();
  const effectiveTs = msg.ts || msg.isoTimestamp || now.toISOString();
  const threadTs = msg.threadTs || msg.ts;
  const sessionId = buildSlackThreadSessionId(msg.channelId, threadTs);
  const sourceRef = (msg.permalink || (msg.channelId ? `${msg.channelId}:${msg.ts || 'unknown'}` : `unknown:${msg.ts}`)).split('?')[0];
  const claim = claimSlackMessage({ ...msg, threadTs }, opts.source || 'mention_poll');

  if (effectiveTs > (stats.maxTs || '')) stats.maxTs = effectiveTs;
  if (!claim.claimed) {
    console.log(`[slack-mentions] Skipping already-claimed mention: ${sourceRef}`);
    return;
  }

  let claimStatus = 'completed';
  let claimError = null;

  try {
    const existing = brain.listTasks({ source: 'slack' }).find(t => sourceRefMatchesMessage(t.source_ref, msg));
    if (existing) {
      console.log(`[slack-mentions] Skipping already-processed mention: ${sourceRef}`);
      claimStatus = 'skipped_existing_task';
      return;
    }

    stats.newMentions++;
    const kind = classifyMention(msg.content);

    // Fetch full thread context
    let threadContext = null;
    if (msg.channelId && threadTs) {
      threadContext = await fetchThreadContext(msg.channelId, threadTs);
      await sleep(PAUSE_MS);
    }
    const contextBlock = threadContext ? `\n\nFull thread context:\n${threadContext}` : '';

    if (kind === 'coding') {
      const title = extractTaskTitle(msg.content);
      try {
        const { id } = brain.insertTask({
          title: title.slice(0, 100),
          description: `Coding request from Slack:\n\n${msg.content}`,
          priority: 'normal',
          type: 'once',
          execution: 'skill',
          skill: 'coding-agent',
          skill_config: JSON.stringify({
            request: cleanWalleAddress(msg.content),
            cwd: process.env.HOME || '/tmp',
            options: { delivery: 'commit' },
          }),
          source: 'slack',
          source_ref: sourceRef,
        });
        stats.tasksCreated++;
        console.log(`[slack-mentions] Coding task created: ${id} - ${title}`);

        try {
          const threadId = brain.upsertSlackThread({ channelId: msg.channelId, threadTs, taskId: id, sessionId });
          if (msg.ts) brain.updateSlackThread(threadId, { last_seen_ts: msg.ts, last_activity: new Date().toISOString() });
        } catch (upsertErr) {
          console.error(`[slack-mentions] FAILED to upsert watched thread: ${upsertErr.message}`);
        }

        if (msg.channelId) {
          const delivery = await deliverSlackReply({
            channelId: msg.channelId,
            threadTs,
            message: `On it - planning the implementation now. I'll update this thread when done.`,
            deliveryId: buildSlackDeliveryId('coding-ack', msg.channelId, threadTs, msg.ts),
          });
          if (!delivery.ok) claimStatus = 'ack_failed';
          await sleep(PAUSE_MS);
        }
      } catch (taskErr) {
        claimStatus = 'failed';
        claimError = taskErr.message;
        console.error(`[slack-mentions] Failed to create coding task: ${taskErr.message}`);
      }
      return;
    }

    if (kind === 'task' || kind === 'question') {
      stats.questionsFound += kind === 'question' ? 1 : 0;
      stats.tasksCreated += kind === 'task' ? 1 : 0;
      const title = extractTaskTitle(msg.content);
      console.log(`[slack-mentions] ${kind} detected: ${title.slice(0, 80)}`);

      // Generate the response and route delivery through the channel dispatcher.
      const reply = await generateAndSendReply(msg, threadTs, contextBlock, kind, {
        sessionId,
        deliveryId: buildSlackDeliveryId('reply', msg.channelId, threadTs, msg.ts),
      });
      if (!reply) claimStatus = 'no_reply';

      // Track as a completed task for history/dedup.
      try {
        const { id } = brain.insertTask({
          title: kind === 'question' ? `Answer Slack question: ${title}` : title,
          description: `${kind === 'question' ? 'Question' : 'Task'} from ${getConfiguredOwnerName() || 'Slack'} in Slack:\n\n${msg.content}${contextBlock}`,
          priority: kind === 'question' ? 'high' : 'normal',
          type: 'once',
          execution: 'chat',
          source: 'slack',
          source_ref: sourceRef,
        });
        brain.updateTask(id, {
          status: reply ? 'completed' : 'failed',
          result: (reply || 'No Slack reply sent').slice(0, 10000),
          completed_at: new Date().toISOString(),
          run_count: 1,
        });
        try {
          const threadId = brain.upsertSlackThread({ channelId: msg.channelId, threadTs, taskId: id, sessionId });
          if (msg.ts) {
            brain.updateSlackThread(threadId, { last_seen_ts: msg.ts, last_activity: new Date().toISOString() });
          }
        } catch (upsertErr) {
          console.error(`[slack-mentions] FAILED to upsert watched thread: ${upsertErr.message}`);
        }
      } catch (taskErr) {
        claimStatus = 'failed';
        claimError = taskErr.message;
        console.error(`[slack-mentions] Failed to create task record: ${taskErr.message}`);
      }
    }
  } catch (err) {
    claimStatus = 'failed';
    claimError = err.message;
    console.error(`[slack-mentions] Failed to process mention ${sourceRef}: ${err.message}`);
  } finally {
    completeSlackMessage(claim, claimStatus, claimError);
  }
}

function normalizeEventText(event) {
  if (!event || typeof event.text !== 'string') return '';
  if (event.type === 'app_mention') return normalizeWalleAddress(event.text, { allowSlackMentions: true });
  return event.text;
}

function shouldAcceptSocketEvent(event) {
  if (!event || !event.type) return false;
  if (event.bot_id || event.subtype) return false;
  if (event.type === 'app_mention') return true;
  if (event.type === 'message') {
    return event.channel_type === 'im' || event.channel_type === 'mpim' || hasWalleActivation(event.text);
  }
  return false;
}

async function processIncomingSlackEvent(event, opts = {}) {
  if (!shouldAcceptSocketEvent(event)) {
    return { processed: false, reason: 'ignored_event_type' };
  }
  brain.initDb();
  const stats = { newMentions: 0, tasksCreated: 0, questionsFound: 0, maxTs: null };
  const seenTs = new Set();
  const threadTs = event.thread_ts || event.ts;
  const msg = {
    ts: event.ts,
    threadTs,
    channelId: event.channel,
    fromUserId: event.user,
    fromName: opts.fromName || event.user,
    permalink: opts.permalink || null,
    content: normalizeEventText(event),
    isoTimestamp: event.event_ts || event.ts || new Date().toISOString(),
  };
  await _processMention(msg, seenTs, stats, { source: 'socket' });
  return {
    processed: stats.newMentions > 0,
    tasksCreated: stats.tasksCreated,
    questionsFound: stats.questionsFound,
  };
}

async function processNewMentions() {
  // Pre-flight: verify Slack token is available before making any API calls
  const tokenData = slackMcp.loadToken ? slackMcp.loadToken() : null;
  if (!tokenData || !tokenData.access_token) {
    const errMsg = 'Slack token expired or missing. Reconnect Slack from Settings or run a Slack MCP tool in Claude Code.';
    console.error(`[slack-mentions] ${errMsg}`);
    throw new Error(errMsg);
  }

  const lastTs = loadWatermark();
  const now = new Date();
  // Use 2 days ago to avoid timezone edge cases — Slack's after: filter
  // uses UTC dates, so a message at 6pm PDT on April 5 won't match
  // "after:2026-04-05" because that's already April 6 UTC.
  const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 3600 * 1000);
  const afterStr = twoDaysAgo.toISOString().split('T')[0];

  console.log(`[slack-mentions] Checking for Wall-E activations (watermark: ${lastTs || 'none'})`);

  const stats = { newMentions: 0, tasksCreated: 0, questionsFound: 0, maxTs: lastTs };
  const seenTs = new Set();

  // Phase 1: Direct channel polling (most reliable — works for iPhone, desktop, any client)
  await pollHomeChannels(lastTs, seenTs, stats);

  // Phase 2a: Search-based discovery (catches mentions in channels not directly polled)
  const queries = ['"@walle"', '"@wall-e"', '"hi walle"', '"hi wall-e"', 'walle'];
  for (const query of queries) {
    try {
      const result = await slackMcp.callSlackMcp('slack_search_public_and_private', {
        query: `${query} after:${afterStr}`,
        sort: 'timestamp',
        sort_dir: 'desc',
        limit: 10,
      });

      const rawText = extractText(result);
      console.log(`[slack-mentions] Search "${query}": raw length=${rawText?.length || 0}`);
      if (!rawText || rawText.length < 10) continue;

      const messages = parseSearchResults(rawText);
      console.log(`[slack-mentions] Parsed ${messages.length} message(s) from "${query}" search`);

      for (const msg of messages) {
        if (lastTs && msg.ts && msg.ts <= lastTs) continue;
        if (lastTs && msg.isoTimestamp && msg.isoTimestamp <= lastTs) continue;

        const isFromOwner = isOwnerName(msg.fromName);
        if (!isFromOwner) {
          console.log(`[slack-mentions] Skipping non-owner mention from: ${msg.fromName || 'unknown'}`);
          continue;
        }

        await _processMention(msg, seenTs, stats, { source: 'search' });
      }
    } catch (err) {
      if (err.message.includes('401') || err.message.includes('expired') ||
          err.message.includes('Not authenticated') || err.message.includes('invalid_auth')) {
        const errMsg = 'Slack token expired. Reconnect Slack from Settings or run a Slack MCP tool in Claude Code.';
        console.error(`[slack-mentions] ${errMsg}`);
        throw new Error(errMsg);
      }
      console.error(`[slack-mentions] Search error for "${query}": ${err.message}`);
    }

    await sleep(PAUSE_MS);
  }

  // Phase 2b: DM channel polling — catches thread replies that search misses
  try {
    await pollDmChannels(lastTs, seenTs, async (msg, seen) => {
      await _processMention(msg, seen, stats, { source: 'dm_poll' });
    });
  } catch (err) {
    console.warn(`[slack-mentions] DM polling error: ${err.message}`);
  }

  if (stats.maxTs && stats.maxTs !== lastTs) {
    saveWatermark(stats.maxTs);
    console.log(`[slack-mentions] Watermark updated: ${stats.maxTs}`);
  }

  if (stats.newMentions === 0) {
    console.log('[slack-mentions] No new mentions.');
  } else {
    console.log(`[slack-mentions] Processed ${stats.newMentions} mention(s): ${stats.tasksCreated} task(s), ${stats.questionsFound} question(s).`);
  }
}

// ── DM channel polling (catches thread replies that search misses) ──

function parseDmMessages(rawText) {
  const messages = [];
  // Slack MCP read_channel returns messages in a structured format
  const blocks = rawText.split(/(?=--- (?:Message|Reply) \d)/);

  for (const block of blocks) {
    if (!block.trim()) continue;

    const fromMatch = block.match(/From:\s*(.+?)(?:\s*\(ID:\s*(\w+)\)|\n)/);
    const fromName = fromMatch ? fromMatch[1].trim() : null;
    const fromUserId = fromMatch ? fromMatch[2] : null;

    const tsMatch = block.match(/(?:Message[_ ]ts|TS|Timestamp):\s*(\d+\.\d+)/i);
    const msgTs = tsMatch ? tsMatch[1] : null;

    const threadMatch = block.match(/Thread[_ ]ts:\s*(\d+\.\d+)/i);
    const threadTs = threadMatch ? threadMatch[1] : null;

    const linkMatch = block.match(/Permalink:\s*\[.*?\]\((https?:\/\/[^\s)]+)\)/);
    let permalink = linkMatch ? linkMatch[1] : null;
    if (permalink) permalink = permalink.replace(/^(https:\/\/\w+)\.slack\.com/, '$1.enterprise.slack.com');

    // Extract text content — everything after metadata lines
    const textMatch = block.match(/(?:Text|Content|Message):\s*\n?([\s\S]*?)(?:\n---|$)/i);
    let content = textMatch ? textMatch[1].trim() : '';
    if (!content) {
      // Fallback: grab everything after the last metadata line
      const lines = block.split('\n');
      const metaEnd = lines.reduce((last, l, i) => /^(?:From|Time|TS|Thread|Permalink|Channel):/.test(l) ? i : last, -1);
      content = lines.slice(metaEnd + 1).join('\n').trim();
    }

    if (msgTs && hasWalleActivation(content)) {
      messages.push({ ts: msgTs, threadTs, fromName, fromUserId, permalink, content });
    }
  }

  return messages;
}

async function pollDmChannels(lastTs, seenTs, processMsg) {
  // Gather known channel IDs from existing Slack tasks and watched threads
  const channelIds = new Set();

  const allTasks = brain.listTasks({ source: 'slack' });
  for (const t of allTasks) {
    if (t.source_ref) {
      const m = t.source_ref.match(/\/archives\/([A-Z0-9]+)\//);
      if (m) channelIds.add(m[1]);
    }
  }

  const activeThreads = brain.listActiveSlackThreads(WATCH_DURATION_MS);
  for (const th of activeThreads) {
    if (th.channel_id) channelIds.add(th.channel_id);
  }

  if (channelIds.size === 0) return;

  // Exclude home channels (already polled in Phase 1) to avoid duplicates
  const homeSet = new Set(HOME_CHANNELS);
  const pollChannels = [...channelIds].filter(id => !homeSet.has(id));
  if (pollChannels.length === 0) return;

  console.log(`[slack-mentions] Polling ${pollChannels.length} known channel(s) for missed mentions`);

  for (const channelId of pollChannels) {
    try {
      const result = await slackMcp.callSlackMcp('slack_read_channel', {
        channel_id: channelId,
        limit: 20,
      });
      const rawText = extractText(result);
      if (!rawText || rawText.length < 10) continue;

      const messages = parseDmMessages(rawText);
      for (const msg of messages) {
        if (lastTs && msg.ts <= lastTs) continue;
        if (seenTs.has(msg.ts)) continue;

        // Check sender is owner
        const isFromOwner = isOwnerName(msg.fromName);
        if (!isFromOwner) continue;

        msg.channelId = channelId;
        await processMsg(msg, seenTs);
      }

      await sleep(PAUSE_MS);
    } catch (err) {
      console.warn(`[slack-mentions] DM poll error for ${channelId}: ${err.message}`);
    }
  }
}

// ── Adaptive schedule ──

function setNextRunInterval() {
  const activeThreads = brain.listActiveSlackThreads(WATCH_DURATION_MS);
  const hasActiveThreads = activeThreads.length > 0;

  const allTasks = brain.listTasks({});
  const selfTask = allTasks.find(t => t.skill === 'slack-mentions' && t.type === 'recurring');
  if (selfTask) {
    const intervalMs = hasActiveThreads ? 10_000 : 30_000;
    const nextRun = new Date(Date.now() + intervalMs).toISOString();
    brain.updateTask(selfTask.id, { next_run_at: nextRun });
    if (hasActiveThreads) {
      console.log(`[slack-mentions] Active threads detected — next poll in 10s`);
    }
  }
}

// ── Main ──

async function main() {
  brain.initDb();
  if (typeof brain.pruneSlackInboundEvents === 'function') {
    try {
      const pruned = brain.pruneSlackInboundEvents(7 * 24 * 60 * 60 * 1000);
      if (pruned > 0) console.log(`[slack-mentions] Pruned ${pruned} old inbound event(s)`);
    } catch (err) {
      console.warn(`[slack-mentions] Could not prune inbound event ledger: ${err.message}`);
    }
  }
  HOME_CHANNELS = discoverHomeChannels();

  if (!(await slackMcp.isAuthenticated())) {
    throw new Error('[slack-mentions] Slack token expired — run: node tools/slack-mcp.js auth');
  }

  // Phase 1: Check watched threads for follow-up replies
  await pollWatchedThreads();

  // Phase 2: Search for new Wall-E activations
  await processNewMentions();

  // Phase 3: Set next poll interval (10s if active threads, 30s otherwise)
  setNextRunInterval();

  brain.closeDb();
}

// Export for skill-planner; also run directly when invoked as CLI script
module.exports = {
  run: main,
  processIncomingSlackEvent,
  shouldAcceptSocketEvent,
  cleanWalleAddress,
  hasWalleActivation,
  sourceRefMatchesMessage,
  isOwnerName,
  isMessageDraftRequest,
  slackResponseInstruction,
  buildSlackThreadSessionId,
  buildSlackDeliveryId,
  stripSlackAppAttribution,
  isAssistantSlackAppAttribution,
  extractSlackMessageTs,
  markSlackOutboundDelivery,
  filterSlackOutboundReplies,
  looksLikeToolControlMarkup,
  validateSlackAnswer,
  buildSlackChatOptions,
  buildSlackCtmContextBlock,
  generateAndSendReply,
  parseChannelMessages,
  parseDmMessages,
  parseSearchResults,
  parseThreadReplies,
  selectThreadMessagesToProcess,
};

if (require.main === module) {
  main().catch(err => {
    console.error('[slack-mentions] Error:', err.message);
    try { brain.closeDb(); } catch {};
    process.exit(1);
  });
}
