/**
 * # doc fences — the documentation-integrity extractor + classifier (kestrel-gsh.1)
 *
 * Every Kestrel code fence that ships in Markdown is a *product assertion*: prose that
 * contains syntax is an executable claim, and a claim that no longer parses is a lie the
 * repo tells its readers. This module makes that claim testable. It extracts fences from
 * the SOURCE docs themselves (never a copied fixture), reads a small metadata contract off
 * each fence's info string, and classifies every fence into exactly one role so the CI
 * checker (see `tests/docs.fences.test.ts`) can hold each to the right invariant:
 *
 * - **example** — an accepted fence (` ```kestrel id=… [check=parse|roundtrip] `). Must
 *   parse with the REAL parser and print stably; `check=roundtrip` additionally demands the
 *   source text already be byte-canonical (`print(parse(text)) === text`, ADR-0004).
 * - **proposed** — aspirational / reference notation not yet executable (grammar skeletons
 *   with `<placeholders>`). Visibly non-`kestrel` fence + `role=proposed id=…`. Asserted to
 *   FAIL closed, so the doc can never quietly imply the parser accepts sugar it does not.
 * - **reject** — a diagnostic "this should fail" fence (`role=reject id=… expect="frag"`).
 *   Asserted to raise a {@link KestrelParseError}; the required `expect` pins the doctrine in
 *   the message (a reject with no `expect` is a structural error — assert *which* rule fails).
 *
 * The classifier fails closed itself: an unlabeled ` ```kestrel ` fence, a duplicate id, or
 * an unknown role/check is a structural error the checker surfaces — no accepted fence may
 * escape metadata, and no metadata may be silently ignored.
 *
 * Only the language surface is consumed here (`src/lang`); nothing in this module is on the
 * runtime path and it never mutates the parser/printer.
 */

import { readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";

/**
 * Repo root, derived from this file's location. `fileURLToPath(import.meta.url)`, NOT
 * `import.meta.dir`: the latter is a Bun-ism that `bun build --target=node` emits verbatim and node
 * evaluates to `undefined`, turning any `join()` over it into a raw TypeError (kestrel-mkn review B1).
 */
export const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");

/** A fenced code block lifted verbatim from a source doc, with its parsed info string. */
export interface RawFence {
  /** Repo-relative path of the source file (e.g. `README.md`). */
  readonly file: string;
  /** 1-based line of the opening fence, for legible diagnostics. */
  readonly startLine: number;
  /** The info-string language token (`kestrel`, `text`, `mermaid`, …); `""` if absent. */
  readonly lang: string;
  /** `key=value` attributes parsed off the info string after the language token. */
  readonly attrs: Readonly<Record<string, string>>;
  /** The block's body, exactly as written (no trailing newline of the closing fence). */
  readonly body: string;
}

/** The three roles every managed fence resolves to. */
export type FenceRole = "example" | "proposed" | "reject";

/** The canonical check an accepted fence must pass. */
export type CheckMode = "parse" | "roundtrip";

/** A classified, metadata-bearing fence ready for the checker. */
export interface ManagedFence extends RawFence {
  readonly id: string;
  readonly role: FenceRole;
  /** Only meaningful for `role === "example"`; defaults to `"parse"`. */
  readonly check: CheckMode;
  /** Only for `role === "reject"`: a required substring of the failure message. */
  readonly expect?: string;
}

// ── Extraction ────────────────────────────────────────────────────────────────

const FENCE_OPEN = /^(\s*)(`{3,})(.*)$/;
const ATTR = /([A-Za-z][\w-]*)=(?:"([^"]*)"|(\S+))/g;

/** Parse an info string (`kestrel id=x check=parse`) into its language + attributes. */
function parseInfo(info: string): { lang: string; attrs: Record<string, string> } {
  const trimmed = info.trim();
  const langMatch = /^([^\s]*)/.exec(trimmed);
  const lang = langMatch ? langMatch[1]! : "";
  const rest = trimmed.slice(lang.length);
  const attrs: Record<string, string> = {};
  for (const m of rest.matchAll(ATTR)) attrs[m[1]!] = m[2] ?? m[3]!;
  return { lang, attrs };
}

/**
 * Extract every fenced code block from a Markdown source. A block opens on a line of three
 * or more backticks and closes on a line of at least as many backticks with no info string;
 * the info string on the opening line is parsed for the language and `key=value` attributes.
 */
export function extractFences(markdown: string, file: string): RawFence[] {
  const lines = markdown.split("\n");
  const out: RawFence[] = [];
  let i = 0;
  while (i < lines.length) {
    const open = FENCE_OPEN.exec(lines[i]!);
    if (open === undefined || open === null) {
      i += 1;
      continue;
    }
    // A backtick fence's info string may not contain backticks (CommonMark §4.5). A line
    // like `   ```` ```kestrel ```` blocks …` is an *inline* code span, not a fence open;
    // treating it as one would silently swallow the rest of the file as this "fence"'s body,
    // dropping any real fence after it from coverage. Fail closed: skip such non-openers.
    if (open[3]!.includes("`")) {
      i += 1;
      continue;
    }
    const ticks = open[2]!;
    const { lang, attrs } = parseInfo(open[3]!);
    const startLine = i + 1;
    const bodyLines: string[] = [];
    let j = i + 1;
    let closed = false;
    for (; j < lines.length; j += 1) {
      const line = lines[j]!;
      const close = /^(\s*)(`{3,})\s*$/.exec(line);
      if (close !== null && close[2]!.length >= ticks.length) {
        closed = true;
        break;
      }
      bodyLines.push(line);
    }
    out.push({ file, startLine, lang, attrs, body: bodyLines.join("\n") });
    i = closed ? j + 1 : j;
  }
  return out;
}

// ── Source discovery ────────────────────────────────────────────────────────────

const DOC_EXT = /\.mdx?$/;

/**
 * Recursively list `.md`/`.mdx` files under `dir` (repo-relative paths). `required` roots
 * fail closed: a governed root that has been deleted/renamed is a coverage hole, not an
 * empty set, so a missing root throws rather than silently dropping its fences (a discovery
 * fail-open would let the whole reject/proposed corpus vanish from CI unnoticed). Only
 * recursive descents are lenient, and only against a mid-walk race (a subdir seen via
 * `statSync` then gone), which surfaces as its own entries simply being absent.
 */
function listDocs(root: string, dir: string, required = false): string[] {
  let names: string[];
  try {
    names = readdirSync(join(root, dir));
  } catch (e) {
    if (required) {
      throw new Error(
        `governed docs root "${dir}" is missing — it must exist so its fences stay under coverage ` +
          `(fail closed, not open). Original error: ${(e as Error).message}`,
      );
    }
    return [];
  }
  const out: string[] = [];
  for (const name of names) {
    const rel = join(dir, name);
    let isDir = false;
    try {
      isDir = statSync(join(root, rel)).isDirectory();
    } catch {
      continue;
    }
    if (isDir) out.push(...listDocs(root, rel));
    else if (DOC_EXT.test(name)) out.push(rel);
  }
  return out.sort();
}

/**
 * The canonical set of source documents the checker governs: `README.md` plus every
 * Markdown file under `docs/public/**`. Returns `{ file, text }` for each, in a stable order
 * so the extracted fence stream is deterministic.
 */
export function docSources(root: string = REPO_ROOT): { file: string; text: string }[] {
  const files = ["README.md", ...listDocs(root, "docs/public", true)];
  return files.map((file) => ({ file, text: readFileSync(join(root, file), "utf8") }));
}

// ── Classification (fails closed) ──────────────────────────────────────────────

/** A structural problem with a fence's metadata — surfaced by the checker as a hard error. */
export interface FenceStructError {
  readonly file: string;
  readonly startLine: number;
  readonly message: string;
}

export interface Classified {
  readonly managed: ManagedFence[];
  readonly errors: FenceStructError[];
}

const ROLES: ReadonlySet<string> = new Set(["example", "proposed", "reject"]);
const CHECKS: ReadonlySet<string> = new Set(["parse", "roundtrip"]);

/**
 * Classify raw fences into the managed set, accumulating structural errors instead of
 * throwing so the checker can report every offender at once. The rules encode the
 * acceptance contract:
 *
 * - Any ` ```kestrel ` fence is an accepted example and MUST carry a stable `id` — an
 *   unlabeled one is an error (no accepted fence escapes metadata).
 * - A managed non-`kestrel` fence declares its `role` explicitly (`proposed`/`reject`) so
 *   rejected/proposed syntax is *visibly* classified and excluded from acceptance parsing.
 * - `id`s are unique across all sources; unknown `role`/`check` values are errors.
 * - A `role=reject` fence must carry an `expect` fragment so every diagnostic pins the
 *   specific failure it demonstrates (not merely "something threw").
 */
export function classify(raws: RawFence[]): Classified {
  const managed: ManagedFence[] = [];
  const errors: FenceStructError[] = [];
  const seen = new Map<string, RawFence>();

  for (const f of raws) {
    const at = { file: f.file, startLine: f.startLine };
    const hasId = typeof f.attrs["id"] === "string";
    const declaredRole = f.attrs["role"];

    if (f.lang === "kestrel") {
      if (declaredRole !== undefined) {
        errors.push({
          ...at,
          message:
            `a \`kestrel\` fence is always an accepted example — it may not declare role="${declaredRole}". ` +
            `Rejected/proposed syntax must use a non-\`kestrel\` fence language so it is visibly excluded ` +
            `(and skipped by parseMarkdown).`,
        });
        continue;
      }
      if (!hasId) {
        errors.push({
          ...at,
          message: `unlabeled \`kestrel\` fence: every accepted fence needs a stable \`id=…\` (and optional \`check=parse|roundtrip\`).`,
        });
        continue;
      }
    } else if (!hasId) {
      continue; // unmanaged (mermaid, plain text/frame, prose) — ignored
    }

    // From here: hasId is true (managed).
    const id = f.attrs["id"]!;
    const role = (declaredRole ?? "example") as FenceRole;
    if (!ROLES.has(role)) {
      errors.push({ ...at, message: `fence "${id}" has unknown role="${role}"; expected example, proposed, or reject.` });
      continue;
    }
    const prior = seen.get(id);
    if (prior !== undefined) {
      errors.push({
        ...at,
        message: `duplicate fence id "${id}" (first seen at ${prior.file}:${prior.startLine}); ids must be globally unique.`,
      });
      continue;
    }
    seen.set(id, f);

    const rawCheck = f.attrs["check"] ?? "parse";
    if (role === "example" && !CHECKS.has(rawCheck)) {
      errors.push({ ...at, message: `fence "${id}" has unknown check="${rawCheck}"; expected parse or roundtrip.` });
      continue;
    }
    const expect = f.attrs["expect"];
    if (role === "reject" && expect === undefined) {
      // A diagnostic fence must pin *which* doctrine it demonstrates; asserting only that
      // "something threw" would pass on an incidental typo elsewhere in the block. Fail closed.
      errors.push({
        ...at,
        message: `reject fence "${id}" must carry an \`expect="…"\` fragment pinning the required failure-message substring.`,
      });
      continue;
    }

    managed.push({
      ...f,
      id,
      role,
      check: (rawCheck === "roundtrip" ? "roundtrip" : "parse") satisfies CheckMode,
      ...(expect !== undefined ? { expect } : {}),
    });
  }

  return { managed, errors };
}

/** Extract + classify every governed source in one call (the checker's entry point). */
export function collectManagedFences(root: string = REPO_ROOT): Classified {
  const raws = docSources(root).flatMap((s) => extractFences(s.text, s.file));
  return classify(raws);
}

/** Human-legible location for a fence, used in test names and failure messages. */
export function loc(f: RawFence): string {
  return `${relative(REPO_ROOT, join(REPO_ROOT, f.file))}:${f.startLine}`;
}
