/**
 * textChunker — split text into ~N-token chunks for embedding.
 *
 * Why we chunk:
 *   - Embedding models attend best to inputs in the 200–500 token range.
 *     A 5K-token system prompt embedded as one vector blurs every concept
 *     together; chunked, each topic gets its own retrievable point.
 *   - Provider hard caps: nomic 8K, BGE 512, MiniLM 256. Truncation at
 *     ingest would drop the tail of any long input silently. We chunk
 *     so nothing is lost.
 *
 * Token counting:
 *   We use a char/4 proxy throughout. Real tokenization (tiktoken) would
 *   add a heavy dep just for sizing, and the chunker doesn't need
 *   precision — overshooting by ~10% is fine because providers have
 *   their own internal token-level truncation as a safety net.
 *
 * Algorithm:
 *   1. Treat \n\n separators as paragraph atoms — preserve semantic units.
 *   2. Greedily pack atoms into a chunk up to maxChars.
 *   3. If a single atom exceeds maxChars (rare — only for tool outputs
 *      without paragraph breaks), split it on sentence boundaries (". "
 *      etc.); if still too big, hard-slice at maxChars.
 *   4. Each chunk after the first carries an `overlapChars` tail from the
 *      previous chunk so cross-chunk topics aren't split mid-thought.
 *
 * Output:
 *   Array of `{ text, start, end, index }` where:
 *     - text:  the chunk content (incl. any overlap)
 *     - start: character offset in the original text where THIS CHUNK's
 *              NEW content begins (i.e. excludes the overlap region —
 *              lets the caller hydrate back to the original message)
 *     - end:   exclusive end offset of THIS CHUNK's new content
 *     - index: 0-based chunk index
 *
 *   Concatenating text[start:end] across all chunks reconstructs the
 *   original input exactly (modulo the join character — there isn't one,
 *   chunks are contiguous over the new-content offsets).
 */

/** Default sizing: ~300-token chunks with ~50-token overlap. Approx char-based. */
export const DEFAULT_MAX_CHARS = 1200;
export const DEFAULT_OVERLAP_CHARS = 200;

/**
 * Chunk text into approximately-token-sized pieces.
 *
 * @param {string} text
 * @param {object} [opts]
 * @param {number} [opts.maxChars=1200]      Hard cap per chunk (incl. overlap).
 * @param {number} [opts.overlapChars=200]   Trailing context from prev chunk.
 * @returns {Array<{text: string, start: number, end: number, index: number}>}
 */
export function chunkText(text, opts = {}) {
  if (typeof text !== 'string' || text.length === 0) return [];

  const maxChars = Math.max(50, opts.maxChars ?? DEFAULT_MAX_CHARS);
  const overlap  = Math.min(Math.max(0, opts.overlapChars ?? DEFAULT_OVERLAP_CHARS), maxChars - 50);

  // Fast path: single chunk fits.
  if (text.length <= maxChars) {
    return [{ text, start: 0, end: text.length, index: 0 }];
  }

  // Split into paragraph atoms. We DO NOT collapse whitespace — the
  // chunks must concatenate back to a faithful reconstruction.
  const atoms = splitWithSeparator(text, '\n\n');

  // Convert atoms whose own length exceeds maxChars into sub-atoms.
  // First by sentence, then by hard slicing as a last resort.
  const safeAtoms = [];
  for (const atom of atoms) {
    if (atom.text.length <= maxChars) {
      safeAtoms.push(atom);
      continue;
    }
    const subBySentence = splitLongAtomBySentence(atom, maxChars);
    for (const sub of subBySentence) {
      if (sub.text.length <= maxChars) {
        safeAtoms.push(sub);
        continue;
      }
      // Still too long → hard slice. We pay the semantic cost only here,
      // and only on pathological inputs (a single sentence > maxChars).
      safeAtoms.push(...hardSliceAtom(sub, maxChars));
    }
  }

  // Greedy pack atoms into chunks ≤ maxChars (treating the overlap budget
  // as reserved space at the start of each non-first chunk).
  const chunks = [];
  let bufferStart = safeAtoms[0]?.start ?? 0;
  let bufferEnd   = bufferStart;
  let bufferText  = '';
  let chunkIndex  = 0;
  // Tracks the overlap suffix to prepend to the next chunk.
  let pendingOverlap = '';

  const flush = () => {
    if (bufferText.length === 0) return;
    const combined = pendingOverlap + bufferText;
    chunks.push({
      text: combined,
      start: bufferStart,
      end: bufferEnd,
      index: chunkIndex++,
    });
    // Compute new pending overlap from the tail of the COMBINED text so
    // the overlap window is what the model will actually see at the
    // start of the next chunk.
    pendingOverlap = overlap > 0
      ? combined.slice(Math.max(0, combined.length - overlap))
      : '';
    bufferText = '';
  };

  for (const atom of safeAtoms) {
    // The room left in this chunk = maxChars - overlap (if any) - current.
    const overlapBudget = chunkIndex === 0 ? 0 : overlap;
    const headroom = maxChars - overlapBudget - bufferText.length;

    if (atom.text.length <= headroom || bufferText.length === 0) {
      // Either fits, or this is the first atom in a fresh buffer — accept it.
      if (bufferText.length === 0) bufferStart = atom.start;
      bufferText += atom.text;
      bufferEnd   = atom.end;
    } else {
      // Doesn't fit — flush current buffer, then start new one with this atom.
      flush();
      bufferStart = atom.start;
      bufferText  = atom.text;
      bufferEnd   = atom.end;
    }
  }
  flush();

  return chunks;
}

/**
 * Split `text` on `sep`, preserving offsets and KEEPING the separator
 * at the end of each piece except the last (so concatenation reconstructs
 * the original). Yields `{text, start, end}` per piece.
 * @private
 */
function splitWithSeparator(text, sep) {
  const atoms = [];
  let cursor = 0;
  let i;
  while ((i = text.indexOf(sep, cursor)) !== -1) {
    const end = i + sep.length;
    atoms.push({ text: text.slice(cursor, end), start: cursor, end });
    cursor = end;
  }
  if (cursor < text.length) {
    atoms.push({ text: text.slice(cursor), start: cursor, end: text.length });
  }
  return atoms;
}

/**
 * Split a single oversized atom on sentence boundaries (". ", "! ", "? ",
 * ".\n", or any standalone newline). Same return shape as splitWithSeparator.
 * @private
 */
function splitLongAtomBySentence(atom, maxChars) {
  const text = atom.text;
  const out = [];
  let pieceStart = 0;
  // Track the most recent sentence boundary that's still WITHIN budget,
  // so we can flush there when adding the next sentence would exceed it.
  let lastSafeBoundary = -1;

  let cursor = 0;
  while (cursor < text.length) {
    const candidateEnd = findNextSentenceBoundary(text, cursor);
    if (candidateEnd === -1) break;

    const pieceLen = candidateEnd - pieceStart;

    if (pieceLen > maxChars) {
      // Adding this sentence would overflow.
      if (lastSafeBoundary > pieceStart) {
        // Flush at the last in-budget boundary and re-evaluate THIS
        // sentence against the new piece (don't advance cursor).
        out.push({
          text: text.slice(pieceStart, lastSafeBoundary),
          start: atom.start + pieceStart,
          end:   atom.start + lastSafeBoundary,
        });
        pieceStart = lastSafeBoundary;
        lastSafeBoundary = -1;
        continue;
      }
      // No safe boundary AND this single sentence exceeds maxChars. Emit
      // it anyway — the caller (chunkText) will hard-slice as a last
      // resort. This branch only fires for pathological sentence-less
      // inputs like a base64 blob.
      out.push({
        text: text.slice(pieceStart, candidateEnd),
        start: atom.start + pieceStart,
        end:   atom.start + candidateEnd,
      });
      pieceStart = candidateEnd;
      lastSafeBoundary = -1;
      cursor = candidateEnd;
      continue;
    }

    // Still within budget — remember this boundary as our safe flush point.
    lastSafeBoundary = candidateEnd;
    cursor = candidateEnd;
  }

  // Loop exited because no more sentence boundaries remain. If the
  // remaining tail exceeds maxChars but we DID find an earlier safe
  // boundary inside it, flush there first so the final emitted piece
  // also lands on a sentence break.
  if (pieceStart < text.length) {
    const tailLen = text.length - pieceStart;
    if (tailLen > maxChars && lastSafeBoundary > pieceStart) {
      out.push({
        text: text.slice(pieceStart, lastSafeBoundary),
        start: atom.start + pieceStart,
        end:   atom.start + lastSafeBoundary,
      });
      pieceStart = lastSafeBoundary;
    }
    out.push({
      text: text.slice(pieceStart),
      start: atom.start + pieceStart,
      end:   atom.start + text.length,
    });
  }
  return out.length > 0 ? out : [atom];
}

/**
 * Find the END of the next sentence-ending boundary in `text`, starting
 * from `from`. The boundary characters (e.g. ". ", "\n") are INCLUDED in
 * the returned index, so `text.slice(start, boundaryEnd)` gives a piece
 * that ends naturally. Returns -1 when no boundary remains.
 *
 * Uses native indexOf — no regex.
 * @private
 */
function findNextSentenceBoundary(text, from) {
  // We look for one of: ". ", "! ", "? ", ".\n", "!\n", "?\n", or a lone "\n".
  // Multi-char patterns are tried first; we return whichever STARTS earliest
  // and resolve ties by preferring the longer (more specific) boundary.
  let bestStart = -1;
  let bestEnd = -1;
  for (const candidate of ['. ', '! ', '? ', '.\n', '!\n', '?\n', '\n']) {
    const idx = text.indexOf(candidate, from);
    if (idx === -1) continue;
    const end = idx + candidate.length;
    if (bestStart === -1 || idx < bestStart || (idx === bestStart && end > bestEnd)) {
      bestStart = idx;
      bestEnd = end;
    }
  }
  return bestEnd;
}

/**
 * Hard-slice an atom that's still bigger than maxChars even after
 * sentence splitting (e.g. a single token-less blob like a base64 string).
 * @private
 */
function hardSliceAtom(atom, maxChars) {
  const out = [];
  for (let i = 0; i < atom.text.length; i += maxChars) {
    const end = Math.min(atom.text.length, i + maxChars);
    out.push({
      text: atom.text.slice(i, end),
      start: atom.start + i,
      end:   atom.start + end,
    });
  }
  return out;
}

/**
 * Estimate tokens by chars/4. Same heuristic the orchestrator uses for
 * budget tracking — exported so consumers can pre-flight the sizing.
 */
export function estimateTokensChunked(text) {
  if (typeof text !== 'string' || !text) return 0;
  return Math.ceil(text.length / 4);
}
