/**
 * Indent-only HTML prettifier for the Edit HTML modal's Format button.
 *
 * Deliberately small and dependency-free: it re-indents BLOCK-level tags onto
 * their own lines and nests them two spaces per level. It does not reflow
 * text, reorder attributes, or normalise quoting -- a formatter that rewrites
 * user markup is a formatter people stop trusting.
 *
 * Inline tags (<strong>, <a>, <span>, ...) are left exactly where they sit,
 * and <pre>/<script>/<style> contents are copied through verbatim -- their
 * content is scanned only for the literal closing tag, never treated as
 * markup, so `<` / `>` inside JS comparisons or CSS combinators can't be
 * mistaken for tag boundaries.
 *
 * The tag-boundary scan itself is quote-aware: a `>` inside a single- or
 * double-quoted attribute value (e.g. `title="a>b"`) does not end the tag.
 *
 * Design note: a block-open tag is not committed to its own output line the
 * moment it is seen. It stays "pending" on a stack frame; if only inline
 * content follows before its matching close (e.g. `<p>hi</p>`), the open
 * tag, the inline content, and the close tag are merged onto a single line.
 * The pending open is only forced onto its own line ("committed") once a
 * nested block-level token (open/void/verbatim) proves the element spans
 * more than one line.
 */

const BLOCK_TAGS = new Set([
  'html', 'head', 'body', 'div', 'section', 'article', 'aside', 'header', 'footer',
  'main', 'nav', 'form', 'fieldset', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th',
  'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
  'blockquote', 'figure', 'figcaption', 'pre', 'hr', 'br', 'img', 'iframe', 'video',
  'audio', 'source', 'canvas', 'script', 'style', 'select', 'option', 'textarea',
]);

const VOID_TAGS = new Set([
  'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
  'link', 'meta', 'param', 'source', 'track', 'wbr',
]);

/** Elements whose content is never markup -- read verbatim to the literal closing tag. */
const RAW_TEXT_TAGS = new Set(['pre', 'script', 'style']);

const INDENT = '  ';

/** True for a real, known block-level tag name (see BLOCK_TAGS); '' (no tag) is not block. */
function isBlockTag(tag: string): boolean {
  return tag !== '' && BLOCK_TAGS.has(tag);
}

interface Token {
  /** Raw text of the token. Text runs have interior whitespace collapsed. */
  text: string;
  /** Lowercased tag name, or '' for a text run. */
  tag: string;
  kind: 'open' | 'close' | 'void' | 'text' | 'verbatim';
}

/**
 * Turn a raw run of text (between two tags) into a text token, collapsing
 * interior whitespace to single spaces. A run that is nothing but
 * whitespace is dropped entirely UNLESS it sits between two inline-level
 * things, in which case it's a real (if content-free) word gap -- e.g.
 * `<span>a</span> <span>b</span>` -- and collapses to one significant space.
 *
 * For a run with real content, its leading/trailing whitespace is boiled
 * down to at most one space each, kept only on sides that border something
 * inline. The discriminator is deliberately NOT "does this whitespace
 * contain a newline" -- that would also strip a hand-wrapped
 * `"hello\n<strong>"`, turning it into "helloworld" and rewriting the
 * user's markup. It's "what does this boundary sit next to": whitespace
 * between two inline-level things (text, `<strong>`, `<a>`, ...) is always
 * significant in HTML and must survive regardless of newlines; whitespace
 * touching a block-tag boundary carries no rendered meaning and is always
 * dropped, regardless of newlines. Because this formatter's own emitted
 * indentation always sits at a block boundary (an element's own line is
 * only ever created next to another block tag), that side of the rule is
 * also what keeps repeated formatting from accumulating phantom spaces.
 *
 * `prevIsBlock`/`nextIsBlock` describe whatever sits immediately before/
 * after this run: true for a block tag or "nothing there" (start/end of
 * document, or an unterminated tag), false for an inline tag.
 */
function pushTextToken(tokens: Token[], raw: string, prevIsBlock: boolean, nextIsBlock: boolean): void {
  const core = raw.trim();

  if (!core) {
    if (!prevIsBlock && !nextIsBlock) {
      tokens.push({ text: ' ', tag: '', kind: 'text' });
    }
    return;
  }

  const leadSpace = /^\s/.test(raw) && !prevIsBlock ? ' ' : '';
  const trailSpace = /\s$/.test(raw) && !nextIsBlock ? ' ' : '';
  const text = leadSpace + core.replace(/\s+/g, ' ') + trailSpace;

  tokens.push({ text, tag: '', kind: 'text' });
}

/**
 * Find the '>' that closes the tag opened at `lt` (the index of its '<'),
 * without being fooled by a '>' inside a single- or double-quoted
 * attribute value. Returns -1 if the tag is never closed.
 */
function findTagEnd(src: string, lt: number): number {
  let i = lt + 1;
  let quote: string | null = null;

  while (i < src.length) {
    const ch = src[i];
    if (quote) {
      if (ch === quote) quote = null;
    } else if (ch === '"' || ch === "'") {
      quote = ch;
    } else if (ch === '>') {
      return i;
    }
    i += 1;
  }

  return -1;
}

/** Split source into tags and text runs, treating raw-text element bodies as one atom. */
function tokenize(src: string): Token[] {
  const tokens: Token[] = [];
  let i = 0;

  // Whatever tag most recently landed in `tokens` (undefined at the very
  // start of the document, which counts as a block-like boundary).
  const prevIsBlock = (): boolean =>
    tokens.length === 0 || isBlockTag(tokens[tokens.length - 1].tag);

  while (i < src.length) {
    const lt = src.indexOf('<', i);

    if (lt === -1) {
      // Nothing more to tokenize after this -- end of document is a
      // block-like boundary too.
      pushTextToken(tokens, src.slice(i), prevIsBlock(), true);
      break;
    }

    // Peek the upcoming tag's name once, up front: it decides both the
    // trailing-space behaviour of the text run before it (if any) and,
    // below, how this tag itself is tokenized -- no need to re-scan it.
    const gt = findTagEnd(src, lt);
    const raw = gt === -1 ? '' : src.slice(lt, gt + 1);
    const nameMatch = raw ? /^<\/?\s*([a-zA-Z][a-zA-Z0-9-]*)/.exec(raw) : null;
    const tag = nameMatch ? nameMatch[1].toLowerCase() : '';
    // An unterminated tag never really opens/closes anything, so treat it
    // like the end of the document for the preceding text run's purposes.
    const nextIsBlock = gt === -1 || isBlockTag(tag);

    if (lt > i) {
      pushTextToken(tokens, src.slice(i, lt), prevIsBlock(), nextIsBlock);
    }

    if (gt === -1) {
      // Unterminated '<' -- emit the remainder as text rather than looping.
      pushTextToken(tokens, src.slice(lt), prevIsBlock(), true);
      break;
    }

    // <pre>/<script>/<style> swallow everything up to their closing tag,
    // untouched -- their content is never scanned as markup. If no closing
    // tag exists, swallow to the end of the document rather than risk
    // misparsing raw JS/CSS as tags.
    if (RAW_TEXT_TAGS.has(tag) && !raw.startsWith('</')) {
      const closeRe = new RegExp(`</${tag}\\s*>`, 'i');
      const rest = src.slice(gt + 1);
      const match = closeRe.exec(rest);
      const end = match ? gt + 1 + match.index + match[0].length : src.length;
      tokens.push({ text: src.slice(lt, end), tag, kind: 'verbatim' });
      i = end;
      continue;
    }

    const isClose = raw.startsWith('</');
    const selfClosing = /\/>\s*$/.test(raw);
    const kind: Token['kind'] = isClose
      ? 'close'
      : selfClosing || VOID_TAGS.has(tag)
        ? 'void'
        : 'open';

    tokens.push({ text: raw, tag, kind });
    i = gt + 1;
  }

  return tokens;
}

/** A pending block-level element: may still merge onto a single line. */
interface Frame {
  /** Raw text of the open tag. */
  text: string;
  /** Lowercased tag name, used to pair this frame with its real close tag. */
  tag: string;
  /** Indent depth at which this element's tags render. */
  depth: number;
  /** Inline content accumulated directly under this element since it opened
   *  (or since it was last committed). */
  inline: string;
  /** Whether the open tag has already been written to its own line. */
  committed: boolean;
}

/**
 * Emit whatever a still-open frame needs, without ever fabricating a
 * closing tag for it: its own open-tag line if it was never committed, and
 * any inline content it was holding. Used both for elements that are
 * genuinely never closed anywhere in the document, and for elements
 * implicitly closed by an ancestor's close tag (e.g. an optional end tag
 * like `<p>` that the author skipped) -- either way, no synthetic close tag
 * is written; only indentation for content that really was opened.
 */
function implicitlyClose(lines: string[], frame: Frame): void {
  if (!frame.committed) {
    lines.push(INDENT.repeat(frame.depth) + frame.text);
  }
  if (frame.inline) {
    lines.push(INDENT.repeat(frame.depth + 1) + frame.inline);
  }
}

export function formatHtml(src: string): string {
  if (!src || !src.trim()) return '';

  const tokens = tokenize(src);
  const lines: string[] = [];
  const stack: Frame[] = [];
  /** Inline/text content seen while no block frame is open. */
  let rootInline = '';

  const flushRootInline = (): void => {
    if (!rootInline) return;
    lines.push(rootInline);
    rootInline = '';
  };

  /** Force the innermost pending frame onto its own line, if not already. */
  const commitTop = (): void => {
    if (stack.length === 0) return;
    const top = stack[stack.length - 1];
    if (top.committed) return;
    lines.push(INDENT.repeat(top.depth) + top.text);
    if (top.inline) {
      lines.push(INDENT.repeat(top.depth + 1) + top.inline);
      top.inline = '';
    }
    top.committed = true;
  };

  for (const token of tokens) {
    const isBlock = isBlockTag(token.tag);

    if (!isBlock) {
      // Inline tag or text -- accumulate against the innermost open element.
      if (stack.length) {
        stack[stack.length - 1].inline += token.text;
      } else {
        rootInline += token.text;
      }
      continue;
    }

    if (token.kind === 'close') {
      // Find the nearest still-open frame with this tag name, anywhere on
      // the stack -- not just the top. HTML permits skipping optional end
      // tags (e.g. `<p>`), so the element a close tag pairs with is not
      // always the innermost open element.
      let matchIndex = -1;
      for (let k = stack.length - 1; k >= 0; k -= 1) {
        if (stack[k].tag === token.tag) {
          matchIndex = k;
          break;
        }
      }

      if (matchIndex === -1) {
        // Genuinely stray: no frame anywhere was opened with this tag
        // name, so there is nothing to pair it with. Don't fabricate a
        // pairing -- emit it in place and leave the stack untouched.
        if (stack.length === 0) flushRootInline();
        lines.push(INDENT.repeat(stack.length) + token.text);
        continue;
      }

      // Everything above the match was opened but never explicitly closed
      // in the source (e.g. a skipped `</p>`). Draining them here -- with
      // no synthetic close tag -- keeps the stack from staying wedged for
      // the rest of the document, restoring the self-draining property
      // without fabricating markup.
      while (stack.length - 1 > matchIndex) {
        implicitlyClose(lines, stack.pop()!);
      }

      const top = stack.pop()!;
      if (!top.committed) {
        // Nothing block-level ever interrupted this element: merge the
        // open tag, its inline content, and the close tag onto one line.
        lines.push(INDENT.repeat(top.depth) + top.text + top.inline + token.text);
      } else {
        if (top.inline) {
          lines.push(INDENT.repeat(top.depth + 1) + top.inline);
        }
        lines.push(INDENT.repeat(top.depth) + token.text);
      }
      continue;
    }

    // token.kind is 'open' | 'void' | 'verbatim': a block-level element is
    // about to render at the current depth, so any still-pending parent
    // frame can no longer merge onto a single line.
    commitTop();
    if (stack.length === 0) flushRootInline();
    const depth = stack.length;

    if (token.kind === 'open') {
      stack.push({ text: token.text, tag: token.tag, depth, inline: '', committed: false });
      continue;
    }

    // void or verbatim: renders on its own line, opens no new frame.
    lines.push(INDENT.repeat(depth) + token.text);
  }

  // Unbalanced opens: nothing ever closed them. Flush what's left rather
  // than silently dropping content.
  while (stack.length) {
    implicitlyClose(lines, stack.pop()!);
  }

  flushRootInline();

  return lines.join('\n');
}
