/**
 * Block-scoped CSS rewriter for the Custom HTML block's `<style>` support.
 *
 * `scopeCss(css, scopeSelector)` rewrites every rule's selector so it only
 * matches inside the block's own wrapper element (`scopeSelector`, e.g.
 * `.whp-html-1a2b3c4d`) -- a customer's `<style>h1{color:red}</style>`
 * restyles their own block, never the rest of the page.
 *
 * Hand-rolled, dependency-free tokenise-then-transform parser -- same shape
 * as `formatHtml` in `./format-html.ts` (read that file's header first; it
 * documents why a naive regex approach breaks on this class of problem and
 * went through three fix rounds to get right). The key primitive shared by
 * both scanners is "walk forward, but treat `{`/`}`/`,`/`@` inside a CSS
 * comment or a quoted string as inert text, not syntax" -- see
 * `skipCommentOrString` below.
 *
 * What this does NOT do (by design, not oversight -- see each branch):
 * - `@keyframes` bodies are copied through untouched. Their selectors
 *   (`from`, `to`, `50%`) are keyframe offsets, not element selectors --
 *   scoping them would break the animation. The animation `name` stays
 *   global (a documented wart: two blocks using the same `@keyframes name`
 *   collide) -- fixing that would mean rewriting every `animation-name`
 *   declaration too, which is out of scope here.
 * - `@font-face` has no selector at all; copied through untouched.
 * - `@import` is stripped entirely (network fetch + exfiltration channel,
 *   and its remote rules would never go through this scoper anyway).
 * - `@media`/`@supports`/`@container` keep their own prelude (the
 *   `(min-width: ...)` condition etc.) untouched; only the selectors
 *   *inside* the block are recursively scoped.
 * - `:root`, `html`, `body` map to `scopeSelector` itself (not
 *   `${scopeSelector} html`, which would match nothing -- `html` is an
 *   ancestor of the wrapper, not a descendant). This is how a customer's
 *   `:root { --brand: ... }` custom-property block keeps working instead of
 *   silently doing nothing.
 * - Any other at-rule with a `{ }` body (`@page`, `@layer`, ...) is copied
 *   through untouched -- deliberately conservative rather than guessing at
 *   a selector-scoping rule for at-rules this task doesn't enumerate.
 *
 * Robustness (review finding, Task 25 follow-up): `transformBlock` recurses
 * once per nesting level of `@media`/`@supports`/`@container`. A
 * pathological block -- ~7000 nested `@media` rules reproduced through the
 * real `purifyHtml()` call, ~190KB of CSS -- blew the call stack
 * (`RangeError: Maximum call stack size exceeded`), and there is NO error
 * handling anywhere between a Custom HTML block's `toHtml()` and the
 * publish pipeline (`html-export.ts`'s `renderNode()` calls
 * `component.toHtml()` with no try/catch in its own recursion), nor an
 * `ErrorBoundary` around the editor canvas -- so one malformed or malicious
 * block would have taken down an entire page's publish, and crashed the
 * live editor on every keystroke. Two independent defenses, matching this
 * codebase's existing contract for best-effort transforms (see
 * `repairOrphanNodes` in `orphan-repair.ts`, which never throws either):
 * 1. `MAX_NESTING_DEPTH` caps the recursion; beyond it, a `@media`/
 *    `@supports`/`@container` body is passed through UNSCOPED rather than
 *    recursed into further -- unscoped CSS inside an absurdly-deep at-rule
 *    is a far better failure than a dead publish.
 * 2. `scopeCss` itself is wrapped so it can never throw on ANY input --
 *    if anything unexpected still slips past (1), the original CSS is
 *    returned unscoped rather than propagating the exception. A page must
 *    never fail to render because a best-effort transform couldn't parse
 *    something.
 */

/**
 * Real-world CSS nesting (the cases this file explicitly supports --
 * `@media`/`@supports`/`@container`) is almost always 1 level deep, rarely
 * 2-3. 20 is generous headroom for any legitimate customer stylesheet while
 * still stopping a multi-thousand-rule pathological/malicious input well
 * short of the JS engine's real call-stack limit (which varies by engine
 * and is not something this code should ever get close to testing).
 */
const MAX_NESTING_DEPTH = 20;

/**
 * If a CSS comment (`/* ... *\/`) or a single/double-quoted string starts at
 * `css[i]`, returns the index immediately after it. Otherwise returns `i`
 * unchanged (caller treats `css[i]` as ordinary syntax). An unterminated
 * comment/string consumes to the end of the string rather than looping
 * forever or mis-splitting the remainder.
 */
function skipCommentOrString(css: string, i: number): number {
  if (css.charCodeAt(i) === 47 /* '/' */ && css[i + 1] === '*') {
    const end = css.indexOf('*/', i + 2);
    return end === -1 ? css.length : end + 2;
  }
  const ch = css[i];
  if (ch === '"' || ch === "'") {
    let j = i + 1;
    while (j < css.length) {
      if (css[j] === '\\') {
        j += 2;
        continue;
      }
      if (css[j] === ch) return j + 1;
      j += 1;
    }
    return css.length;
  }
  return i;
}

/** Split `text` on top-level commas -- respecting comments, strings, and
 *  parens (so `:not(h1, h2)` doesn't get split into two selectors). */
function splitTopLevelCommas(text: string): string[] {
  const parts: string[] = [];
  let start = 0;
  let depth = 0;
  let i = 0;
  while (i < text.length) {
    const skip = skipCommentOrString(text, i);
    if (skip !== i) {
      i = skip;
      continue;
    }
    const ch = text[i];
    if (ch === '(') depth += 1;
    else if (ch === ')') depth = Math.max(0, depth - 1);
    else if (ch === ',' && depth === 0) {
      parts.push(text.slice(start, i));
      i += 1;
      start = i;
      continue;
    }
    i += 1;
  }
  parts.push(text.slice(start));
  return parts;
}

const ROOT_LIKE = new Set([':root', 'html', 'body']);

/** Scope a single selector (no top-level commas left in it). */
function scopeSingleSelector(selector: string, scopeSelector: string): string {
  const trimmed = selector.trim();
  if (trimmed === '') return trimmed;

  const lower = trimmed.toLowerCase();
  // `:root`/`html`/`body` target an ancestor OUTSIDE the block -- map to the
  // scope root itself rather than prefixing (`${scopeSelector} html` would
  // match nothing, since html is never a descendant of the wrapper div).
  if (ROOT_LIKE.has(lower)) return scopeSelector;

  // Idempotency guard (Constraint: running scopeCss twice must not
  // double-prefix). Our own output only ever takes two shapes: the bare
  // scope selector (from the ROOT_LIKE branch above) or
  // `${scopeSelector} ${original}` (the branch below) -- recognising both
  // is enough to make a second pass a no-op.
  if (trimmed === scopeSelector || trimmed.startsWith(`${scopeSelector} `)) {
    return trimmed;
  }

  return `${scopeSelector} ${trimmed}`;
}

/** Scope a comma-separated selector list, e.g. `h1, h2 > p`. */
function scopeSelectorList(selectorList: string, scopeSelector: string): string {
  return splitTopLevelCommas(selectorList)
    .map((s) => scopeSingleSelector(s, scopeSelector))
    .join(', ');
}

/**
 * Scope a rule's raw prelude text (as sliced straight out of the source,
 * still carrying whatever whitespace sat between the previous rule and the
 * selector, and between the selector and the `{`). Only the trimmed core is
 * run through `scopeSelectorList`; the original leading/trailing whitespace
 * envelope is preserved around it so scoping doesn't visibly reformat the
 * customer's CSS (e.g. `h1 {` stays `${scope} h1 {`, not `${scope} h1{`).
 */
function scopeRulePrelude(prelude: string, scopeSelector: string): string {
  const leadLen = prelude.length - prelude.replace(/^\s+/, '').length;
  const trailLen = prelude.length - prelude.replace(/\s+$/, '').length;
  const lead = prelude.slice(0, leadLen);
  const core = prelude.slice(leadLen, prelude.length - trailLen);
  const trail = trailLen ? prelude.slice(prelude.length - trailLen) : '';
  return lead + scopeSelectorList(core, scopeSelector) + trail;
}

const KEYFRAMES_RE = /^@(-\w+-)?keyframes\b/i;
const FONT_FACE_RE = /^@font-face\b/i;
const RECURSE_RE = /^@(media|supports|container)\b/i;
const IMPORT_RE = /^@import\b/i;

/**
 * Recursively transform one nesting level of CSS text: a sequence of
 * `selector { declarations }` rules and/or at-rules. Used both for the
 * top-level stylesheet and, recursively, for the body of a `@media`/
 * `@supports`/`@container` block (whose contents are themselves a nested
 * sequence of rules). `depth` counts how many `@media`/`@supports`/
 * `@container` levels deep this call already is -- see `MAX_NESTING_DEPTH`.
 */
function transformBlock(css: string, scopeSelector: string, depth: number): string {
  let result = '';
  let i = 0;
  const n = css.length;

  while (i < n) {
    const start = i;

    // Scan the prelude (selector list, or an at-rule's condition/name) up to
    // the next top-level `{`, `;`, or stray `}` -- comment/string aware so a
    // brace or semicolon inside a comment or quoted string is inert.
    let j = i;
    while (j < n) {
      const skip = skipCommentOrString(css, j);
      if (skip !== j) {
        j = skip;
        continue;
      }
      const ch = css[j];
      if (ch === '{' || ch === ';' || ch === '}') break;
      j += 1;
    }

    if (j >= n) {
      // Trailing content with no terminator (whitespace, or a syntax error
      // in the customer's CSS) -- copy through verbatim rather than drop it.
      result += css.slice(start);
      break;
    }

    if (css[j] === '}') {
      // Stray close-brace with no matching open at this level -- shouldn't
      // happen for well-formed input, but copy it through rather than
      // fabricate a parse error or lose customer content.
      result += css.slice(start, j + 1);
      i = j + 1;
      continue;
    }

    const prelude = css.slice(start, j);
    const trimmedPrelude = prelude.trim();

    if (css[j] === ';') {
      // Statement at-rule, e.g. `@import url(...);` or `@charset "UTF-8";`.
      if (IMPORT_RE.test(trimmedPrelude)) {
        // Strip @import entirely: it fetches remote CSS outside this
        // function's scoping guarantee, and is a plain exfiltration
        // channel (the fetch itself leaks referrer/cookies/IP to whatever
        // host the customer -- or an attacker who edited their block --
        // points it at).
      } else {
        result += css.slice(start, j + 1);
      }
      i = j + 1;
      continue;
    }

    // css[j] === '{' : find the matching close brace for THIS block,
    // comment/string aware, counting nested braces (a @media block's body
    // contains further `{ }` rule blocks). Named `braceDepth` -- deliberately
    // NOT `depth` -- to avoid shadowing the outer `depth` parameter (the
    // @media/@supports/@container NESTING depth used by MAX_NESTING_DEPTH
    // below): a `let depth` here would be block-scoped to this same `while`
    // body and silently shadow the parameter for the rest of this iteration,
    // making the recursion-depth check below always compare against this
    // brace-matching counter (which is always 0 by the time control reaches
    // it) instead -- defeating the cap entirely without any type error.
    const blockContentStart = j + 1;
    let braceDepth = 1;
    let k = blockContentStart;
    while (k < n && braceDepth > 0) {
      const skip = skipCommentOrString(css, k);
      if (skip !== k) {
        k = skip;
        continue;
      }
      const ch = css[k];
      if (ch === '{') braceDepth += 1;
      else if (ch === '}') braceDepth -= 1;
      k += 1;
    }
    // k is now just past the matching '}' (or end of string if unterminated).
    const blockContentEnd = braceDepth === 0 ? k - 1 : k;
    const body = css.slice(blockContentStart, blockContentEnd);

    if (KEYFRAMES_RE.test(trimmedPrelude) || FONT_FACE_RE.test(trimmedPrelude)) {
      // @keyframes: body selectors are keyframe offsets (from/to/50%), not
      // element selectors -- scoping them would break the animation.
      // @font-face: has no selector to scope at all.
      // Either way: copy the whole block through untouched.
      result += prelude + '{' + body + '}';
    } else if (RECURSE_RE.test(trimmedPrelude)) {
      // @media/@supports/@container: keep the condition prelude as-is,
      // recursively scope the selectors nested inside -- UNLESS we've hit
      // MAX_NESTING_DEPTH, in which case stop recursing and pass the body
      // through unscoped rather than risk a stack overflow. Unscoped CSS
      // nested this deep is an extreme edge case (or a hostile input) --
      // a far better failure than crashing the publish pipeline / editor.
      const inner = depth >= MAX_NESTING_DEPTH ? body : transformBlock(body, scopeSelector, depth + 1);
      result += prelude + '{' + inner + '}';
    } else if (trimmedPrelude.startsWith('@')) {
      // Any other braced at-rule (@page, @layer, ...): conservatively leave
      // untouched rather than guess at a scoping rule this task doesn't
      // enumerate.
      result += prelude + '{' + body + '}';
    } else {
      // Ordinary rule: prelude is a selector list.
      result += scopeRulePrelude(prelude, scopeSelector) + '{' + body + '}';
    }

    i = k;
  }

  return result;
}

/**
 * Rewrite `css` so every rule only matches inside `scopeSelector` (expected
 * to be a single class selector like `.whp-html-1a2b3c4d`, matching the
 * Custom HTML block's own wrapper element). Pure function: same
 * `(css, scopeSelector)` in, same string out, every time -- see the module
 * doc comment above for the exact at-rule/selector rules applied.
 *
 * NEVER THROWS, on any input -- same defensive contract as
 * `repairOrphanNodes` elsewhere in this codebase. `MAX_NESTING_DEPTH` above
 * should make a stack overflow unreachable in practice, but this is the
 * last line of defense: if `transformBlock` throws for any reason this
 * function did not anticipate, the original CSS is returned completely
 * unscoped rather than letting the exception reach `purifyHtml()` and, from
 * there, either the live editor's render or the publish pipeline (which has
 * no error handling of its own around a component's `toHtml()`). Unscoped
 * CSS leaking page-wide is a real but bounded failure (CSS can misstyle,
 * never execute); a thrown exception here is unbounded (dead publish, dead
 * editor canvas) -- so this function must always prefer the former.
 */
export function scopeCss(css: string, scopeSelector: string): string {
  if (!css) return '';
  try {
    return transformBlock(css, scopeSelector, 0);
  } catch {
    return css;
  }
}
