'use strict';

const brain = require('../brain');
const { classifyTopics } = require('./topic-matcher');
const { sanitizeQuery } = require('./query-sanitizer');
const { tokenize, bm25Rerank } = require('../search/bm25');
const { HookBus } = require('../coding/hook-bus');
const {
  formatLocationContextBlock,
  shouldUseLocationForMessage,
} = require('../location-awareness');
const {
  buildResponseLanguagePolicy,
  inferResponseLanguageFromMessage,
} = require('./response-language');

let _embeddings;
try { _embeddings = require('../embeddings'); } catch { _embeddings = null; }

const contextHooks = new HookBus();
const CONTEXT_CONFIDENCE = {
  essential: 0.85,
  preference: 0.75,
  knowledge: 0.68,
  memory: 0.68,
};

/**
 * Dynamic context builder for Wall-E chat.
 * Replaces the static ~4KB system prompt with a three-layer prompt:
 *   1. Core (always, ~400 tokens): identity, capabilities, reasoning instructions
 *   2. Relevant (per-message, ~800-1500 tokens): hybrid search (BM25/LIKE + vector RRF)
 *   3. Situational (conditional, 0-500 tokens): calendar, Slack, tasks — only when relevant
 *
 * Estimated savings: 50-70% fewer tokens vs the old static prompt.
 */

/**
 * Build a dynamic system prompt based on the user's message.
 * Async to support embedding computation for hybrid search.
 * @param {string} userMessage - The current user message
 * @param {string} channel - Channel name (ctm, imessage, slack_dm)
 * @param {object} [opts] - Optional overrides
 * @param {string} [opts.sessionSummary] - Compacted session summary to include
 * @returns {Promise<string>} The assembled system prompt
 */
async function buildSystemPrompt(userMessage, channel, opts = {}) {
  const ownerName = brain.getOwnerName() || 'Owner';
  const stats = brain.getBrainStats();
  const topics = Array.isArray(opts.topics) && opts.topics.length > 0
    ? opts.topics
    : classifyTopics(userMessage);
  const { classifyIntent } = require('./topic-matcher');
  const intent = opts.intent || classifyIntent(topics);

  // Skip embedding computation for direct_action and conversational — they don't need it
  let queryEmbedding = null;
  if (intent === 'knowledge' && _embeddings && _embeddings.isAvailable()) {
    try {
      queryEmbedding = await _embeddings.computeEmbedding(userMessage);
    } catch {}
  }

  const core = buildCoreLayer(ownerName, stats, channel);

  // Plug-prompt-catalog Item A: workspace persona block from
  // ~/.wall-e/{IDENTITY,SOUL,USER,TOOLS,AGENTS}.md (any subset). User-edited
  // markdown wins over hardcoded persona — they can override us.
  // Empty workspace = empty string = no token cost.
  let personaBlock = '';
  try {
    const { composePersonaBlock } = require('../loops/workspace-prompts');
    personaBlock = composePersonaBlock();
  } catch {}

  const parts = [core, buildResponseLanguagePolicy({ userMessage })];
  if (personaBlock) parts.push(personaBlock);
  if (opts.sessionSummary) {
    parts.push(`## Previous Conversation\n${opts.sessionSummary}`);
  }

  // Only inject pre-fetched context for knowledge queries
  if (intent === 'knowledge') {
    let relevant = buildRelevantLayer(userMessage, topics, ownerName, queryEmbedding, opts);
    relevant = await applyContextHooks('context.inject', {
      userMessage,
      topics,
      ownerName,
      channel,
      intent,
      confidence: CONTEXT_CONFIDENCE,
    }, relevant);
    if (relevant) parts.push(relevant);
  }

  // Situational context (calendar instructions, weather API, etc.) for direct_action and knowledge
  if (intent !== 'conversational') {
    const situational = buildSituationalLayer(topics, ownerName, channel, userMessage, opts);
    if (situational) parts.push(situational);
  }

  // Intent-specific instruction block
  parts.push(buildInstructionLayer(ownerName, channel, intent));

  return parts.join('\n\n');
}

/** Layer 0+1: Core identity — always included
 * L0: Ultra-compact identity (~50 tokens)
 * L1: Critical facts from knowledge (~120 tokens)
 */
function buildCoreLayer(ownerName, stats, channel) {
  // L0: Identity
  let slackCoverage = '';
  try {
    const getMeta = brain.getDb().prepare('SELECT value FROM brain_metadata WHERE key = ?');
    slackCoverage = getMeta.get('slack_coverage')?.value || '';
  } catch {}

  const today = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD
  const dayName = new Date().toLocaleDateString('en-US', { weekday: 'long' });
  const l0 = `You are WALL-E, ${ownerName}'s digital twin. ${stats.memory_count} memories${slackCoverage ? ` including ${slackCoverage}` : ''}. Today: ${dayName}, ${today}. Channel: ${channel}${channel === 'imessage' ? ' (brief)' : ''}.`;

  // L1: Critical facts — top preferences + key context, loaded from knowledge
  const criticalFacts = brain.findKnowledge({
    status: 'active',
    category: 'preference',
    limit: 5,
    orderBy: 'confidence DESC',
  }).filter((fact) => Number(fact.confidence || 0) >= CONTEXT_CONFIDENCE.preference);
  const l1Lines = [];
  if (criticalFacts.length > 0) {
    l1Lines.push(criticalFacts.map(k => `${k.predicate}: ${k.object}`).join('; '));
  }

  const parts = [l0];
  if (l1Lines.length > 0) parts.push(`Key prefs: ${l1Lines.join('. ')}`);
  // "AGENT, not chatbot" moved here from verbose to compact
  parts.push('You are an AGENT with tools — USE THEM. Never say "I can\'t access that."');

  // G3: Essential Story — always load top knowledge so AI "remembers who you are"
  const essentialStory = buildEssentialStory();
  if (essentialStory) parts.push(essentialStory);

  return parts.join('\n');
}

/** Layer 2: Relevant context — matched to the user's message (~800-1500 tokens)
 * @param {Float32Array|null} queryEmbedding - Pre-computed embedding for hybrid search
 */
function buildRelevantLayer(userMessage, topics, ownerName, queryEmbedding, opts = {}) {
  const sections = [];
  const sanitizedMessage = sanitizeQuery(userMessage) || userMessage;
  const queryTerms = extractSearchTerms(sanitizedMessage);
  const canInject = hasSpecificQuery(sanitizedMessage, queryTerms);

  // Inject stored communication preferences (cross-session adaptation)
  const prefContext = buildPreferenceContext();
  if (prefContext) sections.push(prefContext);

  // Hybrid search for relevant knowledge triples (lexical + vector RRF)
  const relevantKnowledge = canInject
    ? filterConfidentKnowledge(searchRelevantKnowledge(sanitizedMessage, 15, queryEmbedding), sanitizedMessage, {
        threshold: opts.knowledgeConfidenceThreshold || CONTEXT_CONFIDENCE.knowledge,
        limit: opts.knowledgeLimit || 8,
      })
    : [];
  if (relevantKnowledge.length > 0) {
    const kSummary = relevantKnowledge.map(k =>
      `- ${k.subject} ${k.predicate} ${k.object} (confidence: ${Math.round((k._injectionConfidence || k.confidence || 0.5) * 100)}%)`
    ).join('\n');
    sections.push(`## Relevant Knowledge (high confidence)\n${kSummary}`);

    // Entity graph: pull neighbors for entities found in knowledge results
    const entityContext = buildEntityGraphContext(relevantKnowledge);
    if (entityContext) sections.push(entityContext);
  }

  // Hybrid search for relevant memories (BM25/LIKE + vector RRF)
  const relevantMemories = canInject
    ? filterConfidentMemories(searchRelevantMemories(sanitizedMessage, 8, queryEmbedding), sanitizedMessage, {
        threshold: opts.memoryConfidenceThreshold || CONTEXT_CONFIDENCE.memory,
        limit: opts.memoryLimit || 5,
      })
    : [];
  if (relevantMemories.length > 0) {
    const mSummary = relevantMemories.map(m => {
      const date = m.timestamp ? m.timestamp.slice(0, 10) : '';
      const ch = m.source_channel ? ` #${m.source_channel}` : '';
      return `[${date}${ch}] ${(m.content || '').slice(0, 200)} (confidence: ${Math.round((m._injectionConfidence || 0.5) * 100)}%)`;
    }).join('\n');
    sections.push(`## Relevant Memories (high confidence)\n${mSummary}`);
  }

  // Include people context only if the message mentions people
  if (topics.includes('people')) {
    const peopleSummary = buildPeopleContext(userMessage);
    if (peopleSummary) sections.push(peopleSummary);
  }

  // Include pre-computed metadata if asking about relationships or topics
  if (topics.includes('people') || topics.includes('work') || topics.includes('slack')) {
    const meta = buildMetadataContext(topics);
    if (meta) sections.push(meta);
  }

  return sections.join('\n\n');
}

/** Layer 3: Situational context — only when relevant (0-500 tokens) */
function buildSituationalLayer(topics, ownerName, channel, userMessage, opts = {}) {
  const sections = [];

  if (shouldUseLocationForMessage(userMessage, topics)) {
    let currentLocation = opts.currentLocation || opts.location || null;
    if (!currentLocation && brain.getCurrentLocation) {
      try { currentLocation = brain.getCurrentLocation(); } catch {}
    }
    const locationBlock = formatLocationContextBlock(currentLocation);
    if (locationBlock) sections.push(locationBlock);
  }

  // Calendar context if morning or asking about schedule
  if (topics.includes('calendar')) {
    sections.push(`## Calendar\nUse the calendar_events tool (reads from brain DB, synced every 30min via EventKit) to check ${ownerName}'s schedule. For specific days, pass the \`date\` param (YYYY-MM-DD) to get only that day's events. Use calendar_list before calendar_create when the user names a calendar such as Work or Personal.`);
  }

  // Slack context if asking about messages
  if (topics.includes('slack')) {
    try {
      const db = brain.getDb();
      // Recent inbound messages (what others said — likely what user "missed")
      const inbound = db.prepare(`
        SELECT content, timestamp, source_channel, participants FROM memories
        WHERE source = 'slack' AND direction = 'inbound' AND length(content) > 20
        ORDER BY timestamp DESC LIMIT 8
      `).all();
      if (inbound.length > 0) {
        const sample = inbound.map(m =>
          `[${m.timestamp?.slice(0, 10)} ${m.source_channel || 'DM'}] ${m.participants || 'someone'}: ${(m.content || '').slice(0, 150)}`
        ).join('\n');
        sections.push(`## Recent Slack (from others)\n${sample}`);
      }
      // User's recent outbound for voice/context
      const outbound = db.prepare(`
        SELECT content, timestamp, source_channel FROM memories
        WHERE source = 'slack' AND direction = 'outbound' AND length(content) > 20
        ORDER BY timestamp DESC LIMIT 3
      `).all();
      if (outbound.length > 0) {
        const sample = outbound.map(m =>
          `[${m.timestamp?.slice(0, 10)} ${m.source_channel || 'DM'}] ${(m.content || '').slice(0, 150)}`
        ).join('\n');
        sections.push(`## Recent Slack (${ownerName}'s messages)\n${sample}`);
      }
    } catch {}
  }

  // Tools/MCP context if asking about tools or automation
  if (topics.includes('tools')) {
    const mcpList = buildMcpContext();
    if (mcpList) sections.push(`## MCP Servers\n${mcpList}`);
  }

  // Skill references — recommend relevant external skills
  if (topics.includes('skills') || topics.includes('tools') || topics.includes('technical')) {
    const skillRefs = searchSkillReferences(userMessage, 5);
    if (skillRefs.length > 0) {
      const refList = skillRefs.map(r => {
        let meta = {};
        try { meta = JSON.parse(r.metadata || '{}'); } catch {}
        return `- **${meta.name || 'unknown'}** (${meta.tool || 'unknown'}): ${(meta.description || '').slice(0, 150)}`;
      }).join('\n');
      sections.push(`## Available AI Tool Skills\nThese external skills may be relevant. Recommend them when appropriate:\n${refList}`);
    }
  }

  // Tasks context if asking about tasks
  if (topics.includes('tasks')) {
    try {
      const pending = brain.listTasks({ status: 'pending', limit: 5 });
      if (pending.length > 0) {
        const taskSummary = pending.map(t => `- ${t.title} (${t.priority || 'normal'}, due: ${t.due_at || 'not set'})`).join('\n');
        sections.push(`## Pending Tasks\n${taskSummary}`);
      }
    } catch {}
  }

  // Weather context - avoid location anchoring. Example cities in this
  // prompt have caused the model to invent a location for vague requests.
  if (topics.includes('weather')) {
    sections.push(`## Weather\nUse web_fetch with Open-Meteo APIs (no key needed):
1. If the current user message names a city or place, geocode it:
\`https://geocoding-api.open-meteo.com/v1/search?name=CITY&count=1&language=en&format=json\`
2. Fetch current weather:
\`https://api.open-meteo.com/v1/forecast?latitude=LAT&longitude=LON&current=temperature_2m,apparent_temperature,weather_code,wind_speed_10m,relative_humidity_2m&timezone=auto\`

Location policy:
- Do not guess ${ownerName}'s city from example locations, calendar events, previous weather answers, previous assistant replies, timezone, stale IP defaults, generic memories, or model priors.
- If the current message includes a location, use it.
- If a fresh Current Location block is present, use it, including browser/mobile geolocation or coarse IP inference. Briefly state the assumption.
- Otherwise use only an explicit current-location signal already provided in context, such as an active profile/current-location fact. Briefly state the assumption.
- Do not treat prior weather memories or prior weather replies as evidence of current location.
- If no high-confidence current location is available, ask one short clarification ("Which city should I check?") and do not call web_fetch.

Weather codes: 0=clear, 1-3=partly cloudy, 45-48=fog, 51-55=drizzle, 61-65=rain, 71-77=snow, 80-82=showers, 95-99=thunderstorm
If a fresh Current Location block is present, use it. Otherwise use an explicit location from the current message or explicit current-location context. Do not use calendar, previous weather answers, timezone, or generic memories as location evidence. If uncertain, ask before guessing.
Use the same language as the user's prompt.`);
  }

  return sections.join('\n\n');
}

/** Reasoning and tool usage instructions — intent-specific */
function buildInstructionLayer(ownerName, channel, intent) {
  const styleBlock = buildStyleBlock(ownerName);
  const toolRefBlock = buildToolRefBlock(ownerName, intent);

  if (intent === 'direct_action') {
    return `## Instructions
Call the appropriate tool directly and respond with the formatted result.
Do NOT call search_memories for the result itself - current facts come from tools. For missing personal parameters such as weather location, use the current message or high-confidence context; otherwise ask a short clarification instead of guessing.
Do NOT use the think tool for straightforward tool output.
This should complete in 1-2 turns.

${toolRefBlock}
${styleBlock}`;
  }

  if (intent === 'conversational') {
    return `## Instructions
Respond naturally and briefly. Match the current user message's language and tone.
Only use tools if the user explicitly asks for an action.

${styleBlock}`;
  }

  // intent === 'knowledge'
  return `## Instructions

### Step 1: CHECK — is the answer already in your context?
Relevant memories and knowledge are provided above. If they answer the question, skip to Step 3.

### Step 2: SEARCH — only if the context above is insufficient
Call search_memories to find additional evidence. Batch multiple searches in ONE turn.
- **Search by keywords, not whole sentences.** Pull the key nouns/names out of the request; don't paste the full instruction as the query.
- **People:** when the request is about a person, search their name AND call **lookup_person** to get their aliases/email/handle, then search those too. People are often referenced by email or @handle, not full name.
- Use different query angles: English keywords, Chinese terms, source filters.
For private, remembered, or work-context questions, use Wall-E memory before public web_fetch. This includes prior conversations, decisions, preferences, people, teams, projects, tools, Slack/email/calendar context, questions about the user's own writing style/tone/personality/work patterns, and "last time" / "do you know" / "what did we discuss" prompts. Use public web only for public/current facts or after memory misses.

### Step 2b: ESCALATE — when stored memory comes back empty
Empty memory is NOT a dead end — it usually means the brain hasn't ingested it yet, not that it doesn't exist. Before concluding you lack context, query the LIVE sources you have:
- **slack_search** (and slack_read_channel) for live Slack history about a person/topic.
- **mail_search** / **mail_messages** for email threads.
- **calendar_events** for meetings together.
- For a colleague, use **lookup_person** / entity tools and Glean (org directory, role, manager) to anchor who they are.
Only after BOTH stored memory and the relevant live sources come back empty may you say you couldn't find specific history — and then proceed to Step 4 with what you do know.

### Step 3: THINK — reason through the evidence
Use the **think** tool before responding to:
- Analyze what the evidence ACTUALLY shows vs what it SEEMS to show
- Challenge your conclusions: do you have 3+ examples, or are you over-generalizing?
- Consider if behavior is DELIBERATE and STRATEGIC rather than a gap

### Step 4: RESPOND — never dead-end a request
- **If asked to WRITE/DRAFT/COMPOSE something** (a note, message, email, summary): produce the actual draft. "Reads like me / in my voice" does NOT require more lookups — apply ${ownerName}'s writing style (from the profile/memories above; search "writing style"/"tone" if needed) and, if you have past examples of this kind of note, mirror them. When you're missing a specific fact, write the draft anyway and mark the gap inline with a clear \`[placeholder: ...]\` rather than refusing.
- **Never answer a "do X for me" request with only "I don't have enough context."** Offer the best partial result you can, plus EITHER a draft-with-placeholders OR exactly ONE targeted clarifying question (e.g. "What's one moment with them you'd want to highlight?"). A useful draft beats a polished refusal.
- Use **bold** for key names, dates, and decisions
- Use > blockquotes when quoting actual Slack messages
- Include dates and people for attribution
- **Bilingual-aware**: Translate Chinese quotes — they contain the most candid opinions
- Present BOTH sides before drawing conclusions

${toolRefBlock}
${styleBlock}`;
}

/** Shared tool reference block — only included for non-conversational intents */
function buildToolRefBlock(ownerName, intent) {
  if (intent === 'conversational') return '';

  const lines = ['### Tools'];
  if (intent === 'knowledge') {
    lines.push(`- **think**: Internal scratchpad (${ownerName} won't see). Use BEFORE every substantive response.`);
    lines.push('- **search_memories**: Hybrid search (BM25 + vector). Use for private/user/work memory: prior conversations, decisions, preferences, projects, people, and Slack/email/calendar context. source:"slack" for Slack only. Batch multiple searches in one turn.');
    lines.push('- **lookup_person**: Person profile lookup. Use alongside search_memories for colleague/role/team questions.');
    lines.push('- **remember_fact**: Store facts the user teaches you.');
  }
  lines.push('- **run_skill / mcp_call / list_mcp_tools**: Actions and external services.');
  lines.push('- **Local tools**: web_fetch, run_shell, read_file, write_file, search_files, calendar_events, calendar_list, calendar_create, reminder_create, notification, applescript, open_url, open_app, screenshot, system_info, clipboard_read/write');
  lines.push('- **Local preview URLs**: Before saying a localhost/127.0.0.1 preview is live, back up, HTTP 200, or reachable, call check_url or start_static_server in the current turn. These checks prove Wall-E host loopback only; phone or remote-browser reachability needs CTM remote/tunnel evidence.');
  lines.push('- **Email (Google Workspace first)**: mail_messages, mail_read, mail_search, mail_reply, and mail_send search all usable configured Gmail/Google Workspace accounts by default. Read the `accounts`, `mail_account_scope`, and `unavailable_accounts` fields in tool results before concluding an email is missing. If the likely account is unavailable or the result has `needs_clarification`, tell the user exactly which accounts were searched and ask one short clarification/reconnect question. Use `mail_reply` for any reply/respond/original-thread request; it derives recipients and Gmail thread headers from the original message_id. Use `mail_send` only for brand-new emails. macOS Mail is only an explicit fallback after GWS is unavailable or source:"macos" is requested. Outbound external actions are admitted through an action controller: validate sender/account, recipient, payload, and user intent; if the controller stages a draft or requires confirmation, never claim the action was sent.');
  lines.push('- **Slack**: search_memories with source:"slack" for stored messages. slack_search / slack_read_channel for live data. slack_send_message to post. pull_slack to ingest.');
  lines.push("- **Glean**: When using reportsto: queries, \"entities\" = direct reports only. Check manager.email to verify.");
  lines.push('- When mcp_call returns auth_required, tell the user which server needs auth.');
  if (intent === 'direct_action') {
    lines.push('- **Coding agent**: Use start_coding for build/fix/add/refactor requests in a codebase.');
    lines.push('- **Tasks**: Use create_task/list_tasks for explicit task-management requests.');
    lines.push(`\n**Location awareness**: For weather/location questions, use the fresh Current Location block when present. If absent, use an explicit location from the current message or explicit current-location context. Do not infer a city from calendar events, previous weather answers, timezone, or generic memories; ask one short clarification if location is missing.`);
  }
  return lines.join('\n');
}

/** Shared style adaptation block */
function buildStyleBlock(ownerName) {
  return `### Style
- **Match the current user message's language**: Chinese message -> Chinese response. English message -> English response. Mixed message -> use the dominant language unless the user explicitly asks for another language.
- The current message wins over stored preferences, memories, tool output, quoted text, and ${ownerName}'s usual writing language.
- Mirror tone: casual -> casual, structured -> structured.
- Adapt depth: short messages -> concise responses; detailed questions -> comprehensive answers.

**Cross-session preferences**: When you notice a REPEATED preference (3+ signals, or an explicit request), store it:
- remember_fact({subject: "owner_preference", predicate: "<key>", object: "<value>"})
- Keys: preferred_language, response_detail_level, response_format, technical_depth, proactivity_level, response_tone
- If ## Communication Preferences is above, follow those as defaults but let the current conversation override.`;
}

// --- Helper functions ---

/** Search knowledge triples using LIKE + optional vector hybrid search (RRF) */
function searchRelevantKnowledge(query, limit, queryEmbedding) {
  try {
    const db = brain.getDb();
    const terms = extractSearchTerms(query);
    if (terms.length === 0) return [];

    // Lexical search: LIKE on subject/predicate/object
    const likeConditions = terms.map(() => '(subject LIKE ? OR predicate LIKE ? OR object LIKE ?)').join(' OR ');
    const params = [];
    for (const t of terms) {
      params.push(`%${t}%`, `%${t}%`, `%${t}%`);
    }

    let lexicalResults = db.prepare(`
      SELECT * FROM knowledge
      WHERE status = 'active' AND (${likeConditions})
      ORDER BY confidence DESC
      LIMIT ?
    `).all(...params, limit);

    // BM25 re-rank lexical results (G1: replaces raw LIKE ordering with TF-IDF)
    const queryTokens = tokenize(query);
    if (queryTokens.length > 0 && lexicalResults.length > 1) {
      // Map knowledge fields to text for BM25 scoring
      const withText = lexicalResults.map(k => ({
        ...k,
        text: `${k.subject} ${k.predicate} ${k.object}`,
      }));
      const reranked = bm25Rerank(withText, query);
      lexicalResults = reranked.map(({ text, _bm25, ...rest }) => rest);
    }

    // Hybrid: merge with vector results if embedding available
    if (queryEmbedding && _embeddings) {
      try {
        const vectorResults = _embeddings.searchSimilarKnowledge(queryEmbedding, limit);
        if (vectorResults.length > 0) {
          return _embeddings.reciprocalRankFusion(lexicalResults, vectorResults, limit);
        }
      } catch {}
    }

    return lexicalResults;
  } catch {
    return [];
  }
}

/** Search memories using FTS5 with LIKE fallback + optional vector hybrid search (RRF) */
function searchRelevantMemories(query, limit, queryEmbedding) {
  try {
    const db = brain.getDb();
    const terms = extractSearchTerms(query);
    if (terms.length === 0) return [];

    // G8: Memory index ranking boost — use index as a ranking signal
    // Query index once and reuse for both fast-path and boost tiering
    let _cachedIndexResults = null;
    try { _cachedIndexResults = brain.searchMemoryIndex({ query, limit: limit * 2 }); } catch {}

    // Fast path: if no embeddings, use index results directly
    if (_cachedIndexResults && _cachedIndexResults.length >= limit && (!queryEmbedding || !_embeddings)) {
      try {
        const memoryIds = _cachedIndexResults.map(r => r.memory_id);
        const placeholders = memoryIds.map(() => '?').join(',');
        const fullMemories = db.prepare(
          `SELECT * FROM memories WHERE id IN (${placeholders}) ORDER BY timestamp DESC`
        ).all(...memoryIds);
        if (fullMemories.length >= limit) return fullMemories.slice(0, limit);
      } catch {}
    }

    let lexicalResults = [];

    // Try FTS5 first
    const hasFts = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='memories_fts'").get();
    if (hasFts) {
      try {
        const ftsQuery = terms.join(' OR ');
        lexicalResults = db.prepare(`
          SELECT m.*, f.relevance
          FROM (SELECT rowid, rank as relevance FROM memories_fts WHERE memories_fts MATCH ? ORDER BY rank LIMIT ?) f
          JOIN memories m ON m.rowid = f.rowid
        `).all(ftsQuery, limit);
      } catch {}
    }

    // LIKE fallback if FTS5 returned nothing
    if (lexicalResults.length === 0) {
      const likeConditions = terms.map(() => 'content LIKE ?').join(' OR ');
      const params = terms.map(t => `%${t}%`);
      lexicalResults = db.prepare(`
        SELECT * FROM memories
        WHERE (${likeConditions})
        ORDER BY timestamp DESC
        LIMIT ?
      `).all(...params, limit);
    }

    // BM25 re-rank lexical results (G1) with G8 index boost
    const queryTokens = tokenize(query);
    if (queryTokens.length > 0 && lexicalResults.length > 1) {
      const withText = lexicalResults.map(m => ({
        ...m,
        text: m.content || '',
      }));
      const reranked = bm25Rerank(withText, query);
      // GAP-B: Rank-based index boost tiering (inspired by MemPalace closet boost)
      const INDEX_BOOST = [0.40, 0.25, 0.15, 0.08, 0.04];
      // Reuse cached index results for boost map (avoids duplicate query)
      const indexRankMap = new Map();
      if (_cachedIndexResults) {
        _cachedIndexResults.slice(0, 5).forEach((r, i) => indexRankMap.set(r.memory_id, i));
      }

      lexicalResults = reranked.map(({ text, _bm25, ...rest }) => {
        const indexRank = indexRankMap.get(rest.id);
        const boost = indexRank !== undefined ? (INDEX_BOOST[indexRank] || 0.02) : 0;
        return { ...rest, _bm25: (_bm25 || 0) + boost };
      });
      lexicalResults.sort((a, b) => (b._bm25 || 0) - (a._bm25 || 0));
      lexicalResults = lexicalResults.map(({ _bm25, ...rest }) => rest);
    }

    // Hybrid: merge with vector results if embedding available
    if (queryEmbedding && _embeddings) {
      try {
        const vectorResults = _embeddings.searchSimilarMemories(queryEmbedding, limit);
        if (vectorResults.length > 0) {
          return _embeddings.reciprocalRankFusion(lexicalResults, vectorResults, limit);
        }
      } catch {}
    }

    // G4: Context expansion — expand top results with neighbor chunks
    return lexicalResults.map(m => {
      if (!m.source_id || !m.source_id.includes('-chunk-')) return m;
      try {
        const expanded = expandWithNeighbors(m);
        if (expanded !== m.content) return { ...m, content: expanded };
      } catch {}
      return m;
    });
  } catch {
    return [];
  }
}

function filterConfidentKnowledge(results, query, opts = {}) {
  return filterConfidentResults(results, query, {
    ...opts,
    kind: 'knowledge',
    textFor: (item) => `${item.subject || ''} ${item.predicate || ''} ${item.object || ''}`,
  });
}

function filterConfidentMemories(results, query, opts = {}) {
  return filterConfidentResults(results, query, {
    ...opts,
    kind: 'memory',
    textFor: (item) => item.content || '',
  });
}

function filterConfidentResults(results, query, opts = {}) {
  const limit = Number.isFinite(opts.limit) ? opts.limit : results.length;
  const threshold = Number.isFinite(opts.threshold) ? opts.threshold : CONTEXT_CONFIDENCE.knowledge;
  return (results || [])
    .map((item) => ({
      ...item,
      _injectionConfidence: scoreContextCandidate({
        query,
        text: opts.textFor ? opts.textFor(item) : '',
        confidence: item.confidence,
        kind: opts.kind,
      }),
    }))
    .filter((item) => item._injectionConfidence >= threshold)
    .sort((a, b) => b._injectionConfidence - a._injectionConfidence)
    .slice(0, limit);
}

function scoreContextCandidate({ query = '', text = '', confidence = null, kind = 'knowledge' } = {}) {
  const queryTerms = extractSearchTerms(query).map((term) => term.toLowerCase());
  if (!hasSpecificQuery(query, queryTerms)) return 0;
  const overlap = lexicalOverlap(queryTerms, text);
  if (overlap <= 0) return 0;
  const hasExplicitConfidence = confidence !== null && confidence !== undefined;
  const base = clampConfidence(hasExplicitConfidence ? confidence : (kind === 'memory' ? 0.55 : 0.5));
  let score = (base * 0.6) + (overlap * 0.4);
  if (overlap >= 0.5 && queryTerms.length >= 2) score += 0.18;
  if (overlap >= 0.8) score += 0.08;
  if (base >= 0.9 && overlap >= 0.25) score += 0.05;
  if (hasExplicitConfidence && base < 0.5) score = Math.min(score, base + 0.35);
  return clampConfidence(score);
}

function lexicalOverlap(queryTerms, text) {
  if (!queryTerms.length || !text) return 0;
  const lower = String(text || '').toLowerCase();
  let hits = 0;
  for (const term of queryTerms) {
    if (term && lower.includes(term)) hits += 1;
  }
  return hits / queryTerms.length;
}

function hasSpecificQuery(query, terms = extractSearchTerms(query)) {
  if ((terms || []).length >= 2) return true;
  return /[\u4e00-\u9fff]{4,}/.test(String(query || ''));
}

function clampConfidence(value) {
  const n = Number(value);
  if (!Number.isFinite(n)) return 0;
  return Math.max(0, Math.min(1, n));
}

async function applyContextHooks(name, ctx, section) {
  if (!contextHooks.has(name)) return section;
  const result = await contextHooks.run(name, ctx, section);
  if (result.errors.length) {
    try {
      console.warn('[context-builder] context hook errors:', result.errors.map((entry) => entry.error?.message || entry.error).join('; '));
    } catch {}
  }
  return result.value;
}

/** G4: Expand a memory chunk with ±1 neighbor chunks from the same source.
 * Prevents returning fragments when vector search lands on the wrong chunk. */
function expandWithNeighbors(memory, radius = 1) {
  if (!memory.source_id) return memory.content;

  // Strategy 1: Chunked memories — fetch sibling chunks
  const prefix = memory.source_id.split('-chunk-')[0];
  if (prefix !== memory.source_id) {
    try {
      const db = brain.getDb();
      const siblings = db.prepare(`
        SELECT id, source_id, content FROM memories
        WHERE source_id LIKE ? AND source = ?
        ORDER BY source_id ASC
      `).all(`${prefix}-chunk-%`, memory.source);
      if (siblings.length > 1) {
        const idx = siblings.findIndex(s => s.id === memory.id);
        if (idx >= 0) {
          const start = Math.max(0, idx - radius);
          const end = Math.min(siblings.length, idx + radius + 1);
          return siblings.slice(start, end).map(s => s.content).join('\n');
        }
      }
    } catch {}
  }

  // Strategy 2: Source-adapter sessions — fetch adjacent records with the
  // same stable source/session id. This keeps long coding-session snippets
  // coherent even when individual memory rows are message-sized.
  const stableSourceId = sourceIdForMemory(memory);
  if (stableSourceId) {
    try {
      const db = brain.getDb();
      const siblings = db.prepare(`
        SELECT id, source_id, content, timestamp FROM memories
        WHERE source = ? AND (source_id = ? OR source_id LIKE ?)
        ORDER BY timestamp ASC, source_id ASC
        LIMIT 200
      `).all(memory.source, stableSourceId, `${stableSourceId}:%`);
      if (siblings.length > 1) {
        const idx = siblings.findIndex(s => s.id === memory.id);
        if (idx >= 0) {
          const start = Math.max(0, idx - radius);
          const end = Math.min(siblings.length, idx + radius + 1);
          return capNeighborContent(siblings.slice(start, end).map(s => s.content).join('\n---\n'));
        }
      }
    } catch {}
  }

  // Strategy 3: Non-chunked memories — fetch time-adjacent from same source
  if (memory.source && memory.timestamp) {
    try {
      const db = brain.getDb();
      const neighbors = db.prepare(`
        SELECT id, content, timestamp FROM memories
        WHERE source = ? AND id != ? AND timestamp BETWEEN datetime(?, '-5 minutes') AND datetime(?, '+5 minutes')
        ORDER BY timestamp ASC
        LIMIT ?
      `).all(memory.source, memory.id, memory.timestamp, memory.timestamp, radius * 2);
      if (neighbors.length > 0) {
        const allContent = [memory.content, ...neighbors.map(n => n.content)];
        return allContent.join('\n---\n');
      }
    } catch {}
  }

  return memory.content;
}

function sourceIdForMemory(memory) {
  try {
    const metadata = JSON.parse(memory.metadata || '{}');
    if (metadata?.sourceId) return metadata.sourceId;
  } catch {}
  const sourceId = String(memory.source_id || '');
  const parts = sourceId.split(':');
  if (parts.length >= 3 && /-jsonl$/.test(memory.source || '')) {
    return parts.slice(0, 2).join(':');
  }
  return '';
}

function capNeighborContent(content, max = 5000) {
  const text = String(content || '');
  return text.length > max ? `${text.slice(0, max - 30)}\n[neighbor context truncated]` : text;
}

/** G3: Essential Story — top high-confidence knowledge always loaded in core layer.
 * Gives the AI "who you are" context from the first message. */
function buildEssentialStory(maxEntries = 15) {
  try {
    const db = brain.getDb();
    const topKnowledge = db.prepare(`
      SELECT subject, predicate, object, confidence, category
      FROM knowledge WHERE status = 'active' AND confidence >= ?
      ORDER BY confidence DESC, last_confirmed DESC
      LIMIT ?
    `).all(CONTEXT_CONFIDENCE.essential, maxEntries);
    if (topKnowledge.length === 0) return null;

    const grouped = {};
    for (const k of topKnowledge) {
      (grouped[k.category] ||= []).push(`${k.subject} ${k.predicate} ${k.object}`);
    }

    const lines = [];
    for (const [cat, facts] of Object.entries(grouped)) {
      lines.push(`**${cat}**: ${facts.join('; ')}`);
    }
    return `## What I Know About You\n${lines.join('\n')}`;
  } catch {
    return null;
  }
}

/** Extract meaningful search terms from a message */
function extractSearchTerms(message) {
  if (!message) return [];
  // Remove common stop words, keep words 3+ chars
  const stopWords = new Set([
    'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'had', 'her',
    'was', 'one', 'our', 'out', 'has', 'his', 'how', 'its', 'may', 'new', 'now',
    'old', 'see', 'way', 'who', 'did', 'get', 'let', 'say', 'she', 'too', 'use',
    'what', 'when', 'where', 'which', 'with', 'would', 'could', 'should', 'about',
    'after', 'before', 'between', 'does', 'each', 'from', 'have', 'just', 'like',
    'make', 'many', 'more', 'most', 'much', 'must', 'name', 'only', 'over', 'such',
    'take', 'than', 'them', 'then', 'they', 'this', 'very', 'well', 'were', 'will',
    'your', 'tell', 'know', 'think', 'some', 'want', 'been', 'into', 'that',
  ]);

  return message
    .replace(/[^\w\s\u4e00-\u9fff]/g, ' ')  // Keep alphanumeric + Chinese chars
    .split(/\s+/)
    .filter(w => w.length >= 3 && !stopWords.has(w.toLowerCase()))
    .slice(0, 8);  // Cap at 8 terms to avoid huge queries
}

/** Build people context — only for mentioned people */
function buildPeopleContext(userMessage) {
  try {
    const people = brain.listPeople({});
    if (people.length === 0) return null;

    const lower = userMessage.toLowerCase();
    // Find people mentioned in the message
    const mentioned = people.filter(p =>
      p.name && lower.includes(p.name.toLowerCase())
    );

    if (mentioned.length > 0) {
      const details = mentioned.map(p =>
        `- **${p.name}**: ${p.relationship || 'unknown'} (trust: ${p.trust_level || 0.5})`
      ).join('\n');
      return `## People Mentioned\n${details}`;
    }

    // No specific people mentioned — include top relationships summary
    const top = people.slice(0, 10).map(p =>
      `- ${p.name}: ${p.relationship || 'unknown'}`
    ).join('\n');
    return `## Key People\n${top}`;
  } catch {
    return null;
  }
}

/** Build pre-computed metadata context */
function buildMetadataContext(topics) {
  try {
    const getMeta = brain.getDb().prepare('SELECT value FROM brain_metadata WHERE key = ?');
    const sections = [];

    if (topics.includes('people')) {
      const peopleMeta = getMeta.get('people_interaction_summary')?.value;
      if (peopleMeta) sections.push(`## Relationships (by interaction)\n${peopleMeta}`);
    }
    if (topics.includes('work') || topics.includes('slack')) {
      const topicMeta = getMeta.get('topic_frequency')?.value;
      if (topicMeta) sections.push(`## Topics (by frequency)\n${topicMeta}`);
    }

    return sections.join('\n\n') || null;
  } catch {
    return null;
  }
}

/** Build entity graph context from knowledge results.
 *  Collects entity IDs from search results, fetches depth-1 graph neighbors,
 *  and formats connected entities as additional context (~100-300 tokens). */
function buildEntityGraphContext(knowledgeResults) {
  try {
    // Collect unique entity IDs from search results
    const entityIds = new Set();
    for (const k of knowledgeResults) {
      if (k.subject_entity_id) entityIds.add(k.subject_entity_id);
      if (k.object_entity_id) entityIds.add(k.object_entity_id);
    }
    if (entityIds.size === 0) return null;

    // Fetch depth-1 graph for each entity, collect unique neighbors
    const seen = new Set();
    const neighborLines = [];
    for (const eid of entityIds) {
      if (neighborLines.length >= 10) break; // cap context size
      const graph = brain.getEntityGraph(eid, 1);
      for (const edge of graph.edges) {
        const key = `${edge.from}-${edge.predicate}-${edge.to}`;
        if (seen.has(key)) continue;
        seen.add(key);
        // Find entity names for display
        const fromNode = graph.nodes.find(n => n.id === edge.from);
        const toNode = graph.nodes.find(n => n.id === edge.to);
        if (fromNode && toNode) {
          neighborLines.push(`- ${fromNode.canonical_name} ${edge.predicate} ${toNode.canonical_name}`);
        }
      }
    }

    if (neighborLines.length === 0) return null;
    return `## Related Entities\n${neighborLines.slice(0, 10).join('\n')}`;
  } catch {
    return null;
  }
}

/** Build communication preference context from stored knowledge triples */
function buildPreferenceContext() {
  try {
    const db = brain.getDb();
    const prefs = db.prepare(`
      SELECT predicate, object, confidence FROM knowledge
      WHERE subject = 'owner_preference' AND status = 'active' AND confidence >= ?
      ORDER BY confidence DESC
    `).all(CONTEXT_CONFIDENCE.preference);
    if (prefs.length === 0) return null;

    const prefLines = prefs.map(p =>
      `- **${p.predicate}**: ${p.object} (confidence: ${Math.round((p.confidence || 0.5) * 100)}%)`
    );
    return `## Communication Preferences\n${prefLines.join('\n')}`;
  } catch {
    return null;
  }
}

/** Build MCP server context */
function buildMcpContext() {
  try {
    const { loadMcpConfigs } = require('../skills/mcp-client');
    const configs = loadMcpConfigs();
    let toolsByServer = {};
    try {
      const rows = brain.getDb().prepare('SELECT server, tool_name FROM mcp_tools_cache ORDER BY server').all();
      for (const r of rows) {
        if (!toolsByServer[r.server]) toolsByServer[r.server] = [];
        toolsByServer[r.server].push(r.tool_name);
      }
    } catch {}

    return Object.entries(configs).map(([name, cfg]) => {
      let hasAuth = cfg.oauth?.accessToken ? 'authenticated' : 'needs auth';
      // Slack uses its own token file, not the standard oauth config
      if (cfg.url && cfg.url.includes('mcp.slack.com')) {
        try {
          const slackToken = require('fs').readFileSync(require('path').join(process.env.HOME, '.claude', 'wall-e-slack-token.json'), 'utf8');
          if (JSON.parse(slackToken).access_token) hasAuth = 'authenticated';
        } catch {}
      }
      const tools = toolsByServer[name];
      const toolStr = tools ? ` — ${tools.slice(0, 8).join(', ')}${tools.length > 8 ? ` +${tools.length - 8} more` : ''}` : '';
      return `- ${name} [${hasAuth}]${toolStr}`;
    }).join('\n') || null;
  } catch {
    return null;
  }
}

/** Search skill_reference memories matching the user's message */
function searchSkillReferences(query, limit) {
  try {
    const db = brain.getDb();
    const terms = extractSearchTerms(query);
    if (terms.length === 0) return [];

    // Try FTS5 first
    const hasFts = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='memories_fts'").get();
    if (hasFts) {
      try {
        const ftsQuery = terms.join(' OR ');
        return db.prepare(`
          SELECT m.*
          FROM (SELECT rowid FROM memories_fts WHERE memories_fts MATCH ? ORDER BY rank LIMIT ?) f
          JOIN memories m ON m.rowid = f.rowid
          WHERE m.source = 'skill_reference'
        `).all(ftsQuery, limit * 10);
      } catch {}
    }

    // LIKE fallback
    const likeConditions = terms.map(() => 'content LIKE ?').join(' OR ');
    const params = terms.map(t => `%${t}%`);
    return db.prepare(`
      SELECT * FROM memories
      WHERE source = 'skill_reference' AND (${likeConditions})
      ORDER BY timestamp DESC
      LIMIT ?
    `).all(...params, limit);
  } catch {
    return [];
  }
}

module.exports = {
  buildSystemPrompt,
  classifyTopics: classifyTopics,
  extractSearchTerms,
  buildCoreLayer,
  buildRelevantLayer,
  buildSituationalLayer,
  buildInstructionLayer,
  buildPreferenceContext,
  buildResponseLanguagePolicy,
  inferResponseLanguageFromMessage,
  buildToolRefBlock,
  buildStyleBlock,
  buildEssentialStory,
  expandWithNeighbors,
  filterConfidentKnowledge,
  filterConfidentMemories,
  scoreContextCandidate,
  contextHooks,
  CONTEXT_CONFIDENCE,
};
