// Shiki-based highlighting plus the v1 comment classifier. Uses Shiki's shorthand // API (full language bundle, lazy-loaded); an unknown grammar renders via Shiki's // built-in `plaintext`, so highlighted and plain code share one DOM // (`
` with `.line` spans) and one CSS class. Highlighting runs
// at build time only — nothing ships to the client but HTML + CSS. Dual light/dark
// themes are emitted as CSS variables and swapped by prefers-color-scheme (see
// Shell.astro).

import { codeToHast, codeToTokens, hastToHtml } from 'shiki';
import type { ShikiTransformer, ThemedToken } from 'shiki';
import type { Element, ElementContent, Root } from 'hast';

const THEMES = { light: 'github-light', dark: 'github-dark' } as const;

// Shiki's no-op grammar: line-wrapped, untokenized. Used for unknown extensions
// and as the fallback when a named grammar fails to load.
const PLAIN = 'plaintext';

// Stamps each line with its file-absolute number as `id="L{n}"` (for #L
// permalinks) and `data-line-no` (rendered as the gutter in both views). `line`
// is 1-based within the highlighted chunk; `startLine` is that chunk's first line
// in the file, so a rendered-view code block after a comment run numbers correctly.
function lineNumbers(startLine: number): ShikiTransformer {
  return {
    name: 'owl-line-numbers',
    line(node, line) {
      const n = startLine + line - 1;
      node.properties.id = `L${n}`;
      node.properties['data-line-no'] = String(n);
    },
  };
}

// Tokenize a chunk once to a hast tree (dual themes as CSS vars + gutter). An
// unknown or unavailable grammar degrades to `plaintext` — identical markup, no
// token colors — so every code block renders the same way.
async function toHast(
  code: string,
  lang: string | undefined,
  startLine: number,
): Promise {
  const opts = {
    themes: THEMES,
    defaultColor: false,
    transformers: [lineNumbers(startLine)],
  } as const;
  try {
    return await codeToHast(code, { lang: lang ?? PLAIN, ...opts });
  } catch {
    return await codeToHast(code, { lang: PLAIN, ...opts });
  }
}

/**
 * Highlight a whole chunk to dual-theme HTML with a line-number gutter, numbered
 * from `startLine`. Used by the raw and plain views (the entire file in one pass).
 */
export async function highlightToHtml(
  code: string,
  lang: string | undefined,
  startLine = 1,
): Promise {
  return hastToHtml(await toHast(code, lang, startLine));
}

/** A file tokenized once, exposing contiguous line ranges as separate blocks. */
export interface HighlightedLines {
  /** Number of source lines (1:1 with `code.split('\n')`). */
  count: number;
  /** Render source lines `[start, end)` (0-based) as one `
`. */
  block(start: number, end: number): string;
}

/**
 * Tokenize a file ONCE, then hand back its highlighted lines to be sliced into
 * blocks. The rendered view of a `code` file interleaves prose boxes with source;
 * re-tokenizing each source run in isolation would lose stateful grammar context
 * (Astro's `---` frontmatter fence, template literals, multi-line comments), so
 * every block is cut from this single whole-file pass and stays correctly colored.
 */
export async function highlightLines(
  code: string,
  lang: string | undefined,
): Promise {
  const root = await toHast(code, lang, 1);
  const pre = root.children.find((c): c is Element => c.type === 'element') as Element;
  const codeEl = pre.children.find((c): c is Element => c.type === 'element') as Element;
  const lineEls = codeEl.children.filter((c): c is Element => c.type === 'element');
  return {
    count: lineEls.length,
    block(start, end) {
      const kids: ElementContent[] = [];
      for (let k = start; k < end; k++) {
        if (k > start) kids.push({ type: 'text', value: '\n' });
        kids.push(lineEls[k]);
      }
      const codeNode: Element = { type: 'element', tagName: 'code', properties: {}, children: kids };
      const preNode: Element = { ...pre, children: [codeNode] };
      return hastToHtml({ type: 'root', children: [preNode] });
    },
  };
}

// Per-line comment classification — the ONLY place the comment-detection engine
// lives. A line is a "comment line" when its non-whitespace tokens are all
// comment-scoped (a whole-line comment or a line inside a block comment); a line
// with any code token (including a trailing inline comment) is not. Swapping in
// web-tree-sitter later means replacing just this function. Unknown lang or a
// tokenization failure → every line false (all code, no boxes).
export async function classifyCommentLines(
  code: string,
  lang: string | undefined,
): Promise {
  const lineCount = code.split('\n').length;
  if (!lang) return new Array(lineCount).fill(false);
  try {
    const { tokens } = await codeToTokens(code, {
      lang,
      theme: 'github-light',
      includeExplanation: true,
    });
    const result = tokens.map((line) => {
      let sawCode = false;
      let sawComment = false;
      for (const t of line) {
        if (t.content.trim() === '') continue;
        if (isCommentToken(t)) sawComment = true;
        else sawCode = true;
      }
      return sawComment && !sawCode;
    });
    while (result.length < lineCount) result.push(false);
    return result.slice(0, lineCount);
  } catch {
    return new Array(lineCount).fill(false);
  }
}

// A real comment scope follows the TextMate convention `comment.line.*` /
// `comment.block.*`. Astro tags its `---` frontmatter fence with a *bare*
// `comment` scope — a structural JS/HTML divider, not prose — so match the
// sub-scoped form to keep the fence as code instead of lifting it into a box.
function isCommentToken(token: ThemedToken): boolean {
  const ex = token.explanation;
  if (!ex || ex.length === 0) return false;
  return ex.every((e) => e.scopes.some((s) => s.scopeName.startsWith('comment.')));
}