/**
 * textChunker tests.
 *
 * Pins:
 *   - Empty / short text → 1 chunk (or 0 for empty)
 *   - Paragraph boundaries preferred → splits on \n\n when possible
 *   - Sentence-level fallback when a paragraph is bigger than maxChars
 *   - Hard slice when even a sentence is too big
 *   - Overlap between successive chunks (continuity for the model)
 *   - Offsets are correct so caller can reconstruct
 *   - No regex (the codebase prefers native string ops)
 */

import { describe, test, expect } from '@jest/globals';
import {
  chunkText,
  DEFAULT_MAX_CHARS,
  DEFAULT_OVERLAP_CHARS,
  estimateTokensChunked,
} from '../textChunker.js';

describe('chunkText — edge cases', () => {
  test('empty string returns []', () => {
    expect(chunkText('')).toEqual([]);
  });

  test('non-string returns []', () => {
    expect(chunkText(null)).toEqual([]);
    expect(chunkText(undefined)).toEqual([]);
    expect(chunkText(42)).toEqual([]);
  });

  test('text shorter than maxChars → single chunk, no overlap', () => {
    const text = 'A short message about telegram dedup.';
    const chunks = chunkText(text);
    expect(chunks).toHaveLength(1);
    expect(chunks[0]).toEqual({
      text,
      start: 0,
      end: text.length,
      index: 0,
    });
  });
});

describe('chunkText — paragraph packing', () => {
  test('paragraphs ≤ maxChars are kept together', () => {
    const text = ['para 1 — short.', 'para 2 — short too.', 'para 3 — also short.'].join('\n\n');
    const chunks = chunkText(text, { maxChars: 200 });
    expect(chunks).toHaveLength(1);
    expect(chunks[0].text).toBe(text);
  });

  test('multiple chunks split at paragraph boundaries when possible', () => {
    // Three paragraphs, each ~100 chars; maxChars=120 → each paragraph its own chunk.
    const p1 = 'A'.repeat(100);
    const p2 = 'B'.repeat(100);
    const p3 = 'C'.repeat(100);
    const text = [p1, p2, p3].join('\n\n');
    const chunks = chunkText(text, { maxChars: 120, overlapChars: 0 });
    expect(chunks).toHaveLength(3);
    // Each chunk should be its paragraph (with separator if not last).
    expect(chunks[0].text.startsWith(p1)).toBe(true);
    expect(chunks[1].text.startsWith(p2)).toBe(true);
    expect(chunks[2].text.startsWith(p3)).toBe(true);
  });

  test('reconstruction: concatenating chunk text[start:end] equals input', () => {
    const text = [
      'First paragraph about telegram dedup floods.',
      'Second paragraph about the static analysis tool refactor.',
      'Third paragraph about web tool authentication flow.',
      'Fourth paragraph about scheduler retry semantics.',
    ].join('\n\n');
    const chunks = chunkText(text, { maxChars: 80, overlapChars: 20 });
    let reconstructed = '';
    for (const c of chunks) {
      reconstructed += text.slice(c.start, c.end);
    }
    expect(reconstructed).toBe(text);
  });
});

describe('chunkText — sentence-level fallback', () => {
  test('paragraph larger than maxChars splits on sentence boundaries', () => {
    const sentence = 'This is a sentence about something. ';   // 36 chars
    const long = sentence.repeat(10);                            // 360 chars
    const chunks = chunkText(long, { maxChars: 100, overlapChars: 0 });
    // ~360 / ~100 = 4 chunks, but sentence boundaries make ranges uneven.
    expect(chunks.length).toBeGreaterThanOrEqual(3);
    // No chunk should exceed maxChars + overlap.
    for (const c of chunks) {
      expect(c.text.length).toBeLessThanOrEqual(100);
    }
  });

  test('every sentence boundary type is recognised (.! ? \\n)', () => {
    // Five "sentences", each ~40 chars, mix of terminators
    const text = [
      'Sentence one with a period. ',
      'Sentence two has an exclamation! ',
      'Sentence three asks a question? ',
      'Sentence four uses a newline.\n',
      'Sentence five is at the end.',
    ].join('');
    const chunks = chunkText(text, { maxChars: 80, overlapChars: 0 });
    // Each chunk ends on a sentence boundary (loose check: ends with one of . ! ? \n or is the final piece)
    for (let i = 0; i < chunks.length - 1; i++) {
      const last = chunks[i].text.slice(-2);
      const endsWell = ['. ', '! ', '? ', '.\n', '!\n', '?\n'].includes(last) || chunks[i].text.endsWith('\n');
      expect(endsWell).toBe(true);
    }
  });
});

describe('chunkText — hard slicing', () => {
  test('a single sentence longer than maxChars gets hard-sliced', () => {
    // Single 1000-char "sentence" with no terminator
    const blob = 'X'.repeat(1000);
    const chunks = chunkText(blob, { maxChars: 200, overlapChars: 0 });
    expect(chunks.length).toBeGreaterThanOrEqual(5);
    for (const c of chunks) expect(c.text.length).toBeLessThanOrEqual(200);
  });
});

describe('chunkText — overlap', () => {
  test('each non-first chunk begins with content from the prior chunk', () => {
    const text = [
      'A'.repeat(80),
      'B'.repeat(80),
      'C'.repeat(80),
      'D'.repeat(80),
    ].join('\n\n');
    const chunks = chunkText(text, { maxChars: 100, overlapChars: 30 });
    expect(chunks.length).toBeGreaterThanOrEqual(2);
    for (let i = 1; i < chunks.length; i++) {
      // The text rendered to the model starts with overlap from previous chunk
      // (the overlap doesn't change the offset bounds — those reflect NEW content).
      const prevTail = chunks[i - 1].text.slice(-30);
      expect(chunks[i].text.startsWith(prevTail)).toBe(true);
    }
  });

  test('overlap=0 disables overlap', () => {
    const text = ['A'.repeat(100), 'B'.repeat(100)].join('\n\n');
    const chunks = chunkText(text, { maxChars: 120, overlapChars: 0 });
    expect(chunks.length).toBeGreaterThanOrEqual(2);
    // No prefix from previous chunk
    expect(chunks[1].text.startsWith('A')).toBe(false);
  });

  test('overlap is clamped to maxChars - 50 so it never starves the chunk', () => {
    const text = 'X'.repeat(500);
    const chunks = chunkText(text, { maxChars: 100, overlapChars: 999 });
    // Should not infinite-loop or produce zero-content chunks.
    expect(chunks.length).toBeGreaterThan(0);
    for (const c of chunks) expect(c.text.length).toBeGreaterThan(0);
  });
});

describe('chunkText — defaults', () => {
  test('default maxChars = 1200, default overlap = 200', () => {
    expect(DEFAULT_MAX_CHARS).toBe(1200);
    expect(DEFAULT_OVERLAP_CHARS).toBe(200);
  });

  test('default sizing handles a typical 4K-char message in 3-4 chunks', () => {
    const text = 'A typical sentence here. '.repeat(200);   // ~5000 chars
    const chunks = chunkText(text);                         // defaults
    expect(chunks.length).toBeGreaterThanOrEqual(3);
    expect(chunks.length).toBeLessThanOrEqual(6);
  });
});

describe('estimateTokensChunked', () => {
  test('matches the 4-chars-per-token rule', () => {
    expect(estimateTokensChunked('a'.repeat(4))).toBe(1);
    expect(estimateTokensChunked('a'.repeat(400))).toBe(100);
  });

  test('returns 0 for non-string / empty', () => {
    expect(estimateTokensChunked(null)).toBe(0);
    expect(estimateTokensChunked('')).toBe(0);
    expect(estimateTokensChunked(123)).toBe(0);
  });
});

describe('chunkText — no regex usage', () => {
  // Mirror the codebase's "fragile regex" rule from the scheduler refactor:
  // textChunker.js must only use native string operations.
  test('source file does not introduce any RegExp literals', async () => {
    const fs = await import('fs/promises');
    const url = new URL('../textChunker.js', import.meta.url);
    const src = await fs.readFile(url, 'utf8');
    // Strip JS strings + comments to avoid false positives on the word "regex"
    // appearing inside docs. Cheap heuristic: just count `/` chars used as
    // regex delimiters. The chunker uses zero — assert that.
    const lines = src.split('\n').filter(l => !l.trim().startsWith('*') && !l.trim().startsWith('//'));
    const joined = lines.join('\n');
    // Look for the typical `/.../g`, `/.../i`, etc. literal pattern.
    // We allow division operators by requiring at least one regex-flag char
    // immediately after the closing slash.
    let foundRegex = false;
    for (let i = 0; i < joined.length - 2; i++) {
      if (joined[i] === '/' && joined[i + 1] !== '/' && joined[i + 1] !== '*') {
        // Try to find matching close slash on same line
        const newline = joined.indexOf('\n', i);
        const close = joined.indexOf('/', i + 1);
        if (close !== -1 && (newline === -1 || close < newline)) {
          const after = joined[close + 1] || '';
          if ('gimsuy'.includes(after)) { foundRegex = true; break; }
        }
      }
    }
    expect(foundRegex).toBe(false);
  });
});
