/**
 * ReminisceIndexer — keeps the per-agent `reminisce` vector store in sync
 * with the agent's pre-compaction conversation archive
 * (`agent.conversations.full.messages`), and serves semantic / hybrid
 * search across it.
 *
 * Mirror of MemoryIndexer's shape, with two key differences:
 *
 *   1. CHUNKING. Messages are arbitrary length — a system prompt or a
 *      large tool result can be 5K+ chars. We split via `textChunker`
 *      (~300-token chunks with ~50-token overlap) and index each chunk
 *      separately. IDs follow the convention `${messageId}#${chunkIdx}`
 *      so multiple chunks per message coexist in the store.
 *
 *   2. DEDUP ON SEARCH. When the user asks "where did we discuss X?",
 *      they want one row per message — not three rows for chunks of the
 *      same message. The indexer's search dedups by messageId, keeping
 *      the highest-scoring chunk per message.
 *
 * Why one indexer per surface (not a generic "everything goes here"):
 *   - Different surfaces want different ID schemes (memory: bare id;
 *     reminisce: chunked id), different metadata, different chunking
 *     defaults. Per-surface indexers stay small and let the orchestrator
 *     reason about each scope independently.
 *
 * Failure policy: same as MemoryIndexer — best-effort, structured result,
 * never throws. Archived conversations are immutable on disk; the index
 * is a cache.
 */

import { chunkText } from '../utilities/textChunker.js';
import { makeScoringFn } from '../vectorStore/scoring.js';

/**
 * Default chunking sizes for conversation messages. Match the textChunker
 * defaults — exposed here so consumers can override per-deployment.
 */
export const DEFAULT_CHUNK_MAX_CHARS = 1200;
export const DEFAULT_CHUNK_OVERLAP = 200;

/**
 * Best-effort token estimate for a message's content + tool-arg payload.
 * Same chars/4 heuristic the orchestrator uses for budget reporting.
 */
function estimateMessageTokens(message) {
  const text = messageToText(message);
  return Math.ceil(text.length / 4);
}

/**
 * Pack a single archived message into the text the embedding model sees.
 *
 *   Format:  "<role>: <content>"
 *            optionally followed by:  "\n[tool: <toolId>] <stringified args>"
 *
 * Includes tool-call ARGUMENTS but NOT results (results are noisy and
 * often huge — the search semantics in conversationQuery.js follow the
 * same rule, so we stay aligned). Role prefix gives the model a
 * disambiguating token at the start so "what did the user ask about X"
 * and "what did the assistant respond about X" embed differently.
 *
 * @param {object} message - Archived message (conversations.full.messages[i]).
 * @returns {string}
 */
export function messageToText(message) {
  if (!message) return '';
  const role = String(message.role || 'unknown');
  const parts = [];
  const content = _readMessageContent(message);
  if (content) parts.push(`${role}: ${content}`);
  const toolArgs = _readToolArgs(message);
  if (toolArgs) parts.push(toolArgs);
  return parts.join('\n');
}

/** @private — content can be string or [{type:'text', text:...}] array. */
function _readMessageContent(message) {
  const c = message?.content;
  if (typeof c === 'string') return c;
  if (Array.isArray(c)) {
    return c.map(p => (typeof p === 'string' ? p : p?.text || '')).join('');
  }
  if (c == null) return '';
  return String(c);
}

/** @private — flatten tool calls' inputs to a single searchable string. */
function _readToolArgs(message) {
  const execs = Array.isArray(message?.toolExecutions) ? message.toolExecutions : [];
  if (execs.length === 0) return '';
  return execs.map(e => {
    if (!e) return '';
    const parts = [];
    if (e.toolId) parts.push(`[tool: ${e.toolId}]`);
    if (e.input != null) {
      try { parts.push(typeof e.input === 'string' ? e.input : JSON.stringify(e.input)); }
      catch { parts.push(String(e.input)); }
    }
    return parts.join(' ');
  }).filter(Boolean).join('\n');
}

export class ReminisceIndexer {
  /**
   * @param {object} opts
   * @param {import('../embeddingService.js').EmbeddingService} opts.embeddingService
   * @param {object} [opts.logger]
   * @param {number} [opts.chunkMaxChars]
   * @param {number} [opts.chunkOverlapChars]
   */
  constructor({
    embeddingService,
    logger = null,
    chunkMaxChars = DEFAULT_CHUNK_MAX_CHARS,
    chunkOverlapChars = DEFAULT_CHUNK_OVERLAP,
  }) {
    this._svc = embeddingService;
    this._logger = logger;
    this._chunkMaxChars = chunkMaxChars;
    this._chunkOverlapChars = chunkOverlapChars;
  }

  get isEnabled() {
    return !!(this._svc && this._svc.isEnabled);
  }

  /**
   * Index a single message. Best-effort.
   * @returns {Promise<{indexed: boolean, chunks?: number, reason?: string, error?: string}>}
   */
  async indexOne(agentId, message) {
    if (!this.isEnabled) return { indexed: false, reason: 'disabled' };
    if (!agentId || !message?.id) return { indexed: false, reason: 'invalid-input' };

    const rows = await this._buildRows(agentId, message);
    if (rows.length === 0) return { indexed: false, reason: 'empty-text' };

    try {
      // Always drop existing chunks for this message before re-inserting
      // — if the message got shorter and produced fewer chunks, the old
      // tail chunks would otherwise stay as orphans.
      const store = await this._svc.getStore({ agentId, surface: 'reminisce' });
      await store.deleteWhere(m => m?.messageId === message.id);
      await store.upsertBatch(rows);
      return { indexed: true, chunks: rows.length };
    } catch (err) {
      this._logger?.warn?.('[reminisceIndexer] indexOne upsert failed', {
        agentId, messageId: message.id, error: err?.message,
      });
      return { indexed: false, reason: 'error', error: err?.message };
    }
  }

  /**
   * Delete every chunk belonging to the given messageId. Best-effort.
   */
  async deleteOne(agentId, messageId) {
    if (!this.isEnabled) return { deleted: false, reason: 'disabled' };
    if (!agentId || !messageId) return { deleted: false, reason: 'invalid-input' };
    try {
      const store = await this._svc.getStore({ agentId, surface: 'reminisce' });
      const removed = await store.deleteWhere(m => m?.messageId === messageId);
      return { deleted: removed > 0, removed };
    } catch (err) {
      this._logger?.warn?.('[reminisceIndexer] deleteOne failed', {
        agentId, messageId, error: err?.message,
      });
      return { deleted: false, reason: 'error', error: err?.message };
    }
  }

  /**
   * Bulk index many messages in a single embed call.
   *
   * Returns aggregated counts:
   *   - indexedMessages: how many messages contributed at least one chunk
   *   - indexedChunks:   total vectors written
   *   - skipped:         messages with no id / no embeddable text
   *   - failed:          messages that survived to embed but the call rejected
   */
  async indexMany(agentId, messages) {
    if (!this.isEnabled) {
      return { indexedMessages: 0, indexedChunks: 0, skipped: 0, failed: 0, error: 'disabled' };
    }
    if (!agentId || !Array.isArray(messages) || messages.length === 0) {
      return { indexedMessages: 0, indexedChunks: 0, skipped: 0, failed: 0 };
    }

    // Build chunks + texts up front so we can do ONE batched embed call.
    let skipped = 0;
    const allTexts = [];
    /** @type {Array<{messageId: string, message: object, chunk: object, textIndex: number}>} */
    const chunkRefs = [];
    for (const m of messages) {
      if (!m?.id) { skipped++; continue; }
      const text = messageToText(m);
      if (!text) { skipped++; continue; }
      const chunks = chunkText(text, {
        maxChars: this._chunkMaxChars,
        overlapChars: this._chunkOverlapChars,
      });
      if (chunks.length === 0) { skipped++; continue; }
      for (const chunk of chunks) {
        chunkRefs.push({ messageId: m.id, message: m, chunk, textIndex: allTexts.length });
        allTexts.push(chunk.text);
      }
    }

    if (allTexts.length === 0) {
      return { indexedMessages: 0, indexedChunks: 0, skipped, failed: 0 };
    }

    let vectors;
    try {
      vectors = await this._svc.embedDocuments(allTexts, 'document');
    } catch (err) {
      this._logger?.warn?.('[reminisceIndexer] indexMany embed failed', {
        agentId, chunks: allTexts.length, error: err?.message,
      });
      return {
        indexedMessages: 0, indexedChunks: 0, skipped,
        failed: messages.length, error: err?.message,
      };
    }

    const rows = chunkRefs.map(ref => ({
      id: `${ref.messageId}#${ref.chunk.index}`,
      vector: vectors[ref.textIndex],
      metadata: this._buildMetadata(ref.message, ref.chunk),
    }));

    const messageIdsInBatch = new Set(chunkRefs.map(r => r.messageId));

    try {
      const store = await this._svc.getStore({ agentId, surface: 'reminisce' });
      // Drop ALL chunks for messages we're about to re-index (handles
      // the "message got shorter / fewer chunks now" case).
      await store.deleteWhere(m => messageIdsInBatch.has(m?.messageId));
      await store.upsertBatch(rows);
      return {
        indexedMessages: messageIdsInBatch.size,
        indexedChunks: rows.length,
        skipped,
        failed: 0,
      };
    } catch (err) {
      this._logger?.warn?.('[reminisceIndexer] indexMany upsert failed', {
        agentId, count: rows.length, error: err?.message,
      });
      return {
        indexedMessages: 0, indexedChunks: 0, skipped,
        failed: messageIdsInBatch.size, error: err?.message,
      };
    }
  }

  /**
   * Drop the agent's reminisce index and rebuild from the provided
   * messages. Used for first-enable backfill and provider/model swaps.
   */
  async backfill(agentId, messages) {
    if (!this.isEnabled) return { ok: false, reason: 'disabled' };
    const store = await this._svc.getStore({ agentId, surface: 'reminisce' });
    try { await store.drop(); }
    catch (err) {
      this._logger?.warn?.('[reminisceIndexer] backfill drop failed', { agentId, error: err?.message });
    }
    const result = await this.indexMany(agentId, messages);
    return { ok: true, ...result };
  }

  /**
   * Semantic (or hybrid) search across the agent's archived messages.
   * Returns one ranked entry PER MESSAGE — chunks belonging to the same
   * message are deduped, keeping the highest-scoring chunk's score.
   *
   * @returns {Promise<{enabled: boolean, hits: Array<{messageId, score, chunkText, chunkIndex, metadata}>, error?: string}>}
   */
  async search(agentId, query, { topK = 10, hybrid = true, boost = true } = {}) {
    if (!this.isEnabled) return { enabled: false, hits: [] };
    if (typeof query !== 'string' || !query.trim()) {
      return { enabled: true, hits: [] };
    }
    try {
      const store = await this._svc.getStore({ agentId, surface: 'reminisce' });
      const queryVec = await this._svc.embedQuery(query);
      // Pull a wider candidate set so dedup leaves us enough to fill topK.
      const widen = Math.max(topK * 4, 32);
      // Recency-boost archived messages too — "what did we say last week"
      // is much more likely relevant than "what did we say 6 months ago".
      const scoring = boost ? makeScoringFn({ recency: true }) : null;
      const raw = hybrid
        ? await store.hybridQuery(queryVec, query, { topK: widen, scoring })
        : await store.query(queryVec, { topK: widen, scoring });

      // Dedup by messageId, keeping the highest score per message.
      const byMessage = new Map();
      for (const r of raw) {
        const mid = r?.metadata?.messageId;
        if (!mid) continue;
        const existing = byMessage.get(mid);
        if (!existing || r.score > existing.score) {
          byMessage.set(mid, {
            messageId: mid,
            score: r.score,
            chunkIndex: r.metadata?.chunkIndex ?? 0,
            chunkText: r.metadata?.text || '',
            metadata: r.metadata,
          });
        }
      }
      const hits = Array.from(byMessage.values())
        .sort((a, b) => b.score - a.score)
        .slice(0, topK);
      return { enabled: true, hits };
    } catch (err) {
      this._logger?.warn?.('[reminisceIndexer] search failed', { agentId, error: err?.message });
      return { enabled: true, hits: [], error: err?.message };
    }
  }

  /**
   * Build row objects for a single message: one row per chunk.
   * @private
   */
  async _buildRows(agentId, message) {
    const text = messageToText(message);
    if (!text) return [];
    const chunks = chunkText(text, {
      maxChars: this._chunkMaxChars,
      overlapChars: this._chunkOverlapChars,
    });
    if (chunks.length === 0) return [];
    let vectors;
    try {
      vectors = await this._svc.embedDocuments(chunks.map(c => c.text), 'document');
    } catch (err) {
      this._logger?.warn?.('[reminisceIndexer] _buildRows embed failed', {
        agentId, messageId: message.id, error: err?.message,
      });
      return [];
    }
    return chunks.map((chunk, i) => ({
      id: `${message.id}#${chunk.index}`,
      vector: vectors[i],
      metadata: this._buildMetadata(message, chunk),
    }));
  }

  /** @private */
  _buildMetadata(message, chunk) {
    return {
      messageId: message.id,
      chunkIndex: chunk.index,
      text: chunk.text,
      // Surface message-level fields so hybrid + display work without
      // an extra archive lookup.
      role: String(message.role || 'unknown'),
      createdAt: message.createdAt || message.timestamp || null,
      hasToolCalls: Array.isArray(message.toolExecutions) && message.toolExecutions.length > 0,
      // Chunk offsets for hydration back to the original message.
      chunkStart: chunk.start,
      chunkEnd: chunk.end,
    };
  }
}

// Exported for telemetry consumers / tests that want to estimate tokens
// without invoking the full indexer.
export { estimateMessageTokens };
