/**
 * changelog-lib.mjs — parse the repository CHANGELOG.md into the compact,
 * deterministic shape the website renders (`lib/changelog.generated.ts`).
 *
 * Keep a Changelog format: `## [version] - date` (or `## [Unreleased]`),
 * `### Section` headings, and `- ` bullets that may continue on indented
 * lines. Compare links live at the bottom as `[version]: url`.
 *
 * Pure and dependency-free so the derive script, the drift test, and any
 * future check can share one parser.
 */

const DEFAULT_LIMIT = 6;
// Most Keep-a-Changelog bullets in this repository run one to three
// sentences; 480 chars keeps the great majority readable in place, and the
// per-release "Full notes" link on /changelog carries the rest.
const DEFAULT_ITEMS_PER_SECTION = 12;
const DEFAULT_ITEM_CHARS = 480;

/** Collapse Markdown emphasis and links to plain text for a one-line summary. */
export function plainText(markdown) {
  return markdown
    .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
    .replace(/`([^`]*)`/g, "$1")
    .replace(/\*\*([^*]+)\*\*/g, "$1")
    .replace(/\s+/g, " ")
    .trim();
}

/** Truncate at a word boundary; never mid-word, never past `max` chars. */
export function clip(text, max = DEFAULT_ITEM_CHARS) {
  if (text.length <= max) return text;
  const cut = text.lastIndexOf(" ", max - 1);
  return `${text.slice(0, cut > max / 2 ? cut : max - 1).trimEnd()}…`;
}

/**
 * Parse a CHANGELOG.md string.
 *
 * @returns {{ releases: Array<{version: string, date: string | null, unreleased: boolean, compareUrl: string | null, sections: Array<{heading: string, items: string[], itemCount: number}>}> }}
 */
export function parseChangelog(markdown, options = {}) {
  const limit = options.limit ?? DEFAULT_LIMIT;
  const itemsPerSection = options.itemsPerSection ?? DEFAULT_ITEMS_PER_SECTION;
  const itemChars = options.itemChars ?? DEFAULT_ITEM_CHARS;

  const lines = markdown.split(/\r?\n/);
  const links = new Map();
  for (const line of lines) {
    const m = line.match(/^\[([^\]]+)\]:\s*(\S+)\s*$/);
    if (m) links.set(m[1], m[2]);
  }

  const releases = [];
  let release = null;
  let section = null;
  let item = null;

  const flushItem = () => {
    if (section && item !== null) {
      section.raw.push(plainText(item));
    }
    item = null;
  };

  for (const line of lines) {
    const heading = line.match(/^## \[([^\]]+)\](?:\s*-\s*(.+))?\s*$/);
    if (heading) {
      flushItem();
      section = null;
      const label = heading[1].trim();
      const unreleased = /^unreleased$/i.test(label);
      const dateText = heading[2]?.trim() ?? null;
      release = {
        version: label,
        date: dateText && /^\d{4}-\d{2}-\d{2}$/.test(dateText) ? dateText : null,
        unreleased,
        compareUrl: links.get(label) ?? null,
        sections: [],
      };
      releases.push(release);
      continue;
    }
    if (!release) continue;

    const sub = line.match(/^### (.+?)\s*$/);
    if (sub) {
      flushItem();
      section = { heading: sub[1], raw: [] };
      release.sections.push(section);
      continue;
    }
    if (!section) continue;

    const bullet = line.match(/^- (.*)$/);
    if (bullet) {
      flushItem();
      item = bullet[1];
      continue;
    }
    if (item !== null && /^\s{2,}\S/.test(line)) {
      item += ` ${line.trim()}`;
      continue;
    }
    if (item !== null && line.trim() === "") {
      flushItem();
    }
  }
  flushItem();

  return {
    releases: releases.slice(0, limit).map((r) => ({
      version: r.version,
      date: r.date,
      unreleased: r.unreleased,
      compareUrl: r.compareUrl,
      sections: r.sections
        .filter((s) => s.raw.length > 0)
        .map((s) => ({
          heading: s.heading,
          items: s.raw.slice(0, itemsPerSection).map((t) => clip(t, itemChars)),
          itemCount: s.raw.length,
        })),
    })),
  };
}

/**
 * The fragment GitHub's Markdown renderer assigns to a release heading such
 * as `## [0.9.11] - 2026-08-22`: lower-cased, punctuation other than hyphens
 * dropped, spaces turned to hyphens — so `0911---2026-08-22`. Lets the site
 * deep-link a version's full notes instead of the top of a 470 KB file.
 */
export function changelogAnchor(release) {
  const heading = release.unreleased
    ? "Unreleased"
    : release.date
      ? `[${release.version}] - ${release.date}`
      : `[${release.version}]`;
  return heading
    .toLowerCase()
    .replace(/[^\p{L}\p{N} _-]/gu, "")
    .replace(/ /g, "-");
}

/** Render the generated TypeScript module from a parse result. */
export function renderChangelogModule(parsed, sourcePath = "CHANGELOG.md") {
  return `// AUTO-GENERATED by web/scripts/derive-changelog.mjs at prebuild from ${sourcePath}.
// DO NOT EDIT — re-run \`npm run prebuild\` (or just \`npm run build\`) after changing the changelog.
// Deterministic: no timestamps, so a clean rebuild leaves the tracked file unchanged.

export interface ChangelogSection {
  heading: string;
  /** Plain-text entries, clipped for the web; \`itemCount\` is the full count. */
  items: string[];
  itemCount: number;
}

export interface ChangelogRelease {
  /** "Unreleased" or a semantic version such as "0.9.11". */
  version: string;
  /** ISO date from the heading, or null for the unreleased lane. */
  date: string | null;
  unreleased: boolean;
  /** The changelog's own compare link for this version, when it has one. */
  compareUrl: string | null;
  sections: ChangelogSection[];
}

export const CHANGELOG: ChangelogRelease[] = ${JSON.stringify(parsed.releases, null, 2)};
`;
}