/**
 * Paragraph-aware text chunking for progressive Discord streaming.
 * Ported from OpenClaw's auto-reply/chunk.ts + markdown/fences.ts.
 *
 * Splits text on paragraph boundaries (\n\n) without breaking inside
 * fenced code blocks. Falls back to word-boundary splitting when a
 * single paragraph exceeds the character limit.
 */

// ── Fence parsing ──────────────────────────────────────────────────

export type FenceSpan = {
  start: number;
  end: number;
  openLine: string;
  marker: string;
  indent: string;
};

export function parseFenceSpans(buffer: string): FenceSpan[] {
  const spans: FenceSpan[] = [];
  let open:
    | {
        start: number;
        markerChar: string;
        markerLen: number;
        openLine: string;
        marker: string;
        indent: string;
      }
    | undefined;

  let offset = 0;
  while (offset <= buffer.length) {
    const nextNewline = buffer.indexOf('\n', offset);
    const lineEnd = nextNewline === -1 ? buffer.length : nextNewline;
    const line = buffer.slice(offset, lineEnd);

    const match = line.match(/^( {0,3})(`{3,}|~{3,})(.*)$/);
    if (match) {
      const indent = match[1];
      const marker = match[2];
      const markerChar = marker[0];
      const markerLen = marker.length;
      if (!open) {
        open = { start: offset, markerChar, markerLen, openLine: line, marker, indent };
      } else if (open.markerChar === markerChar && markerLen >= open.markerLen) {
        spans.push({
          start: open.start,
          end: lineEnd,
          openLine: open.openLine,
          marker: open.marker,
          indent: open.indent,
        });
        open = undefined;
      }
    }

    if (nextNewline === -1) break;
    offset = nextNewline + 1;
  }

  if (open) {
    spans.push({
      start: open.start,
      end: buffer.length,
      openLine: open.openLine,
      marker: open.marker,
      indent: open.indent,
    });
  }

  return spans;
}

export function findFenceSpanAt(spans: FenceSpan[], index: number): FenceSpan | undefined {
  return spans.find((span) => index > span.start && index < span.end);
}

export function isSafeFenceBreak(spans: FenceSpan[], index: number): boolean {
  return !findFenceSpanAt(spans, index);
}

// ── Generic break resolver ─────────────────────────────────────────

function chunkTextByBreakResolver(
  text: string,
  limit: number,
  resolveBreakIndex: (window: string) => number,
): string[] {
  if (!text) return [];
  if (limit <= 0 || text.length <= limit) return [text];

  const chunks: string[] = [];
  let remaining = text;
  while (remaining.length > limit) {
    const window = remaining.slice(0, limit);
    const candidateBreak = resolveBreakIndex(window);
    const breakIdx =
      Number.isFinite(candidateBreak) && candidateBreak > 0 && candidateBreak <= limit
        ? candidateBreak
        : limit;
    const rawChunk = remaining.slice(0, breakIdx);
    const chunk = rawChunk.trimEnd();
    if (chunk.length > 0) chunks.push(chunk);
    const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
    const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0));
    remaining = remaining.slice(nextStart).trimStart();
  }
  if (remaining.length) chunks.push(remaining);
  return chunks;
}

// ── Paren-aware breakpoint scanner ─────────────────────────────────

function scanParenAwareBreakpoints(
  window: string,
  isAllowed: (index: number) => boolean = () => true,
): { lastNewline: number; lastWhitespace: number } {
  let lastNewline = -1;
  let lastWhitespace = -1;
  let depth = 0;

  for (let i = 0; i < window.length; i++) {
    if (!isAllowed(i)) continue;
    const char = window[i];
    if (char === '(') { depth += 1; continue; }
    if (char === ')' && depth > 0) { depth -= 1; continue; }
    if (depth !== 0) continue;
    if (char === '\n') lastNewline = i;
    else if (/\s/.test(char)) lastWhitespace = i;
  }

  return { lastNewline, lastWhitespace };
}

// ── Length-based chunking (word-boundary aware) ────────────────────

export function chunkText(text: string, limit: number): string[] {
  if (!text) return [];
  if (limit <= 0 || text.length <= limit) return [text];

  return chunkTextByBreakResolver(text, limit, (window) => {
    const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window);
    return lastNewline > 0 ? lastNewline : lastWhitespace;
  });
}

// ── Paragraph-boundary chunking ────────────────────────────────────

/**
 * Split text on paragraph boundaries (blank lines), preserving lists and
 * single-newline line wraps inside paragraphs.
 *
 * - Only breaks at paragraph separators ("\n\n" or more)
 * - Falls back to length-based splitting when a single paragraph exceeds `limit`
 * - Never splits inside fenced code blocks
 */
export function chunkByParagraph(
  text: string,
  limit: number,
  opts?: { splitLongParagraphs?: boolean },
): string[] {
  if (!text) return [];
  if (limit <= 0) return [text];

  const splitLongParagraphs = opts?.splitLongParagraphs !== false;
  const normalized = text.replace(/\r\n?/g, '\n');

  const paragraphRe = /\n[\t ]*\n+/;
  if (!paragraphRe.test(normalized)) {
    if (normalized.length <= limit) return [normalized];
    if (!splitLongParagraphs) return [normalized];
    return chunkText(normalized, limit);
  }

  const spans = parseFenceSpans(normalized);
  const parts: string[] = [];
  const re = /\n[\t ]*\n+/g;
  let lastIndex = 0;

  for (const match of normalized.matchAll(re)) {
    const idx = match.index ?? 0;
    if (!isSafeFenceBreak(spans, idx)) continue;
    parts.push(normalized.slice(lastIndex, idx));
    lastIndex = idx + match[0].length;
  }
  parts.push(normalized.slice(lastIndex));

  const chunks: string[] = [];
  for (const part of parts) {
    const paragraph = part.replace(/\s+$/g, '');
    if (!paragraph.trim()) continue;
    if (paragraph.length <= limit) {
      chunks.push(paragraph);
    } else if (!splitLongParagraphs) {
      chunks.push(paragraph);
    } else {
      chunks.push(...chunkText(paragraph, limit));
    }
  }

  return chunks;
}

// ── Markdown-aware chunking (fence-safe) ───────────────────────────

function pickSafeBreakIndex(window: string, spans: FenceSpan[]): number {
  const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window, (index) =>
    isSafeFenceBreak(spans, index),
  );
  if (lastNewline > 0) return lastNewline;
  if (lastWhitespace > 0) return lastWhitespace;
  return -1;
}

function stripLeadingNewlines(value: string): string {
  let i = 0;
  while (i < value.length && value[i] === '\n') i++;
  return i > 0 ? value.slice(i) : value;
}

export function chunkMarkdownText(text: string, limit: number): string[] {
  if (!text) return [];
  if (limit <= 0 || text.length <= limit) return [text];

  const chunks: string[] = [];
  let remaining = text;

  while (remaining.length > limit) {
    const spans = parseFenceSpans(remaining);
    const window = remaining.slice(0, limit);

    const softBreak = pickSafeBreakIndex(window, spans);
    let breakIdx = softBreak > 0 ? softBreak : limit;

    const initialFence = isSafeFenceBreak(spans, breakIdx)
      ? undefined
      : findFenceSpanAt(spans, breakIdx);

    let fenceToSplit = initialFence;
    if (initialFence) {
      const closeLine = `${initialFence.indent}${initialFence.marker}`;
      const maxIdxIfNeedNewline = limit - (closeLine.length + 1);

      if (maxIdxIfNeedNewline <= 0) {
        fenceToSplit = undefined;
        breakIdx = limit;
      } else {
        const minProgressIdx = Math.min(
          remaining.length,
          initialFence.start + initialFence.openLine.length + 2,
        );
        const maxIdxIfAlreadyNewline = limit - closeLine.length;

        let pickedNewline = false;
        let lastNewline = remaining.lastIndexOf('\n', Math.max(0, maxIdxIfAlreadyNewline - 1));
        while (lastNewline !== -1) {
          const candidateBreak = lastNewline + 1;
          if (candidateBreak < minProgressIdx) break;
          const candidateFence = findFenceSpanAt(spans, candidateBreak);
          if (candidateFence && candidateFence.start === initialFence.start) {
            breakIdx = Math.max(1, candidateBreak);
            pickedNewline = true;
            break;
          }
          lastNewline = remaining.lastIndexOf('\n', lastNewline - 1);
        }

        if (!pickedNewline) {
          if (minProgressIdx > maxIdxIfAlreadyNewline) {
            fenceToSplit = undefined;
            breakIdx = limit;
          } else {
            breakIdx = Math.max(minProgressIdx, maxIdxIfNeedNewline);
          }
        }
      }

      const fenceAtBreak = findFenceSpanAt(spans, breakIdx);
      fenceToSplit =
        fenceAtBreak && fenceAtBreak.start === initialFence.start ? fenceAtBreak : undefined;
    }

    let rawChunk = remaining.slice(0, breakIdx);
    if (!rawChunk) break;

    const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
    const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0));
    let next = remaining.slice(nextStart);

    if (fenceToSplit) {
      const closeLine = `${fenceToSplit.indent}${fenceToSplit.marker}`;
      rawChunk = rawChunk.endsWith('\n') ? `${rawChunk}${closeLine}` : `${rawChunk}\n${closeLine}`;
      next = `${fenceToSplit.openLine}\n${next}`;
    } else {
      next = stripLeadingNewlines(next);
    }

    chunks.push(rawChunk);
    remaining = next;
  }

  if (remaining.length) chunks.push(remaining);
  return chunks;
}

// ── Unified paragraph + markdown chunking ──────────────────────────

/**
 * Split text into paragraph-boundary chunks, with fence-safe markdown
 * fallback for paragraphs that exceed the limit.
 *
 * This is the main entry point for Discord streaming.
 */
export function chunkByParagraphMarkdown(text: string, limit: number): string[] {
  // First pass: split on paragraph boundaries (never split long ones here)
  const paragraphChunks = chunkByParagraph(text, limit, { splitLongParagraphs: false });
  const out: string[] = [];

  for (const chunk of paragraphChunks) {
    if (chunk.length <= limit) {
      out.push(chunk);
    } else {
      // Paragraph too long — use markdown-aware splitting
      const nested = chunkMarkdownText(chunk, limit);
      if (!nested.length && chunk) {
        out.push(chunk);
      } else {
        out.push(...nested);
      }
    }
  }

  return out;
}

// ── Incremental paragraph extractor (for streaming) ────────────────

/**
 * Given accumulated text, extract complete paragraphs that are ready
 * to send, and return the remaining (incomplete) tail.
 *
 * A "complete paragraph" ends at a blank-line boundary (\n\n+) that
 * is not inside a fenced code block.
 *
 * Returns { ready: string[], tail: string }
 * - ready: complete paragraph chunks ready to send as new messages
 * - tail: incomplete text still being accumulated
 */
export function extractReadyParagraphs(
  text: string,
  limit: number,
  opts: { forceFlushTail?: boolean } = {},
): { ready: string[]; tail: string } {
  if (!text) return { ready: [], tail: '' };

  const normalized = text.replace(/\r\n?/g, '\n');
  if (opts.forceFlushTail) {
    return {
      ready: normalized.trim() ? chunkByParagraphMarkdown(normalized, limit) : [],
      tail: '',
    };
  }
  const spans = parseFenceSpans(normalized);

  // Find all paragraph break positions
  const re = /\n[\t ]*\n+/g;
  let lastSafeBreakEnd = -1;

  for (const match of normalized.matchAll(re)) {
    const idx = match.index ?? 0;
    if (isSafeFenceBreak(spans, idx)) {
      lastSafeBreakEnd = idx + match[0].length;
    }
  }

  if (lastSafeBreakEnd <= 0) {
    // No complete paragraph boundary found — everything is tail
    return { ready: [], tail: normalized };
  }

  const readyText = normalized.slice(0, lastSafeBreakEnd).replace(/\s+$/g, '');
  const tail = normalized.slice(lastSafeBreakEnd);

  // Chunk the ready text respecting limits and fences
  const ready = chunkByParagraphMarkdown(readyText, limit);

  return { ready, tail };
}

function normalizeChunkForComparison(value: string): string {
  return value.replace(/\r\n?/g, '\n').trim();
}

/**
 * Given a final response and paragraph chunks already streamed to the user,
 * return only the final chunks that still need to be sent.
 */
export function getUnsentFinalChunks(
  finalText: string,
  streamedChunks: string[],
  limit: number,
): string[] {
  const finalChunks = chunkByParagraphMarkdown(finalText, limit);
  let matchedPrefix = 0;

  while (matchedPrefix < streamedChunks.length && matchedPrefix < finalChunks.length) {
    if (
      normalizeChunkForComparison(streamedChunks[matchedPrefix]) !==
      normalizeChunkForComparison(finalChunks[matchedPrefix])
    ) {
      break;
    }
    matchedPrefix += 1;
  }

  return finalChunks.slice(matchedPrefix);
}
