// src/lib/keywords/markdown-analysis.ts — pure, deterministic helpers to derive
// a competitor page's Hn outline, word count, and a byte-capped markdown blob
// from the crawler's markdown rendering. No I/O, no deps beyond the repo.
import type { HeadingNode } from "./serp-types";

const ATX_HEADING = /^(#{1,6})\s+(.*?)\s*$/;
const FENCE = /^\s*(```+|~~~+)/;
const TRAILING_HASHES = /\s+#+\s*$/;

/** True while iterating lines that sit inside a ``` or ~~~ fenced code block. */
function* nonCodeLines(md: string): Generator<string> {
  let fence: string | null = null;
  for (const line of md.split("\n")) {
    const m = line.match(FENCE);
    if (m) {
      const marker = m[1][0]; // ` or ~
      if (fence === null) {
        fence = marker; // opening fence
      } else if (marker === fence) {
        fence = null; // matching closing fence
      }
      continue; // fence delimiters are never prose/headings
    }
    if (fence !== null) continue; // inside a code block
    yield line;
  }
}

/**
 * Parse ATX headings (`#`…`######` at the start of a line, space after the
 * hashes) in document order. `level` = number of leading hashes; `text` is
 * trimmed with any trailing `#` run stripped. Lines inside fenced code blocks
 * are ignored, and 7+ hashes are not headings.
 */
export function outlineFromMarkdown(md: string): HeadingNode[] {
  const out: HeadingNode[] = [];
  for (const line of nonCodeLines(md)) {
    const m = line.match(ATX_HEADING);
    if (!m) continue;
    const level = m[1].length as HeadingNode["level"];
    const text = m[2].replace(TRAILING_HASHES, "").trim();
    out.push({ level, text });
  }
  return out;
}

/**
 * Approximate word count of the prose: drop fenced code blocks, strip heading /
 * list / blockquote markers, unwrap links to their text, drop images, drop
 * inline emphasis markers, then count whitespace-separated tokens.
 */
export function wordCountFromMarkdown(md: string): number {
  let words = 0;
  for (const rawLine of nonCodeLines(md)) {
    let line = rawLine;
    // images first (so their alt text isn't counted as link text)
    line = line.replace(/!\[[^\]]*\]\([^)]*\)/g, " ");
    // links [text](url) -> text
    line = line.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
    // heading / blockquote markers at line start
    line = line.replace(/^\s*#{1,6}\s+/, "");
    line = line.replace(/^\s*>+\s?/, "");
    // list markers (-, *, +, or "1.") at line start
    line = line.replace(/^\s*([-*+]|\d+[.)])\s+/, "");
    // inline emphasis / inline-code / strikethrough markers
    line = line.replace(/[*_`~]/g, "");
    const tokens = line.split(/\s+/).filter(Boolean);
    words += tokens.length;
  }
  return words;
}

const ELISION = "\n\n[…tronqué]";

/**
 * If the UTF-8 byte length is ≤ maxBytes, return as-is. Otherwise truncate to
 * ≤ maxBytes on a char boundary and append an elision marker. Never produces
 * invalid UTF-8; the result's byte length never exceeds maxBytes + the marker.
 */
export function capMarkdown(md: string, maxBytes = 8192): string {
  if (Buffer.byteLength(md, "utf8") <= maxBytes) return md;

  // Walk code points, accumulating bytes until the next one would exceed the
  // budget. Iterating the string yields whole code points, so we never split a
  // multibyte character.
  let bytes = 0;
  let cut = "";
  for (const ch of md) {
    const chBytes = Buffer.byteLength(ch, "utf8");
    if (bytes + chBytes > maxBytes) break;
    bytes += chBytes;
    cut += ch;
  }
  return cut + ELISION;
}
