import { isTemplate, MARKER } from './html.js';
import { escapeAttr } from './escape.js';
import { isRepeat } from './repeat.js';
import { isUnsafeHTML, isLive, isKeyed, isGuard, isTemplateContent, isRef, isCache, isUntil, isAsyncAppend, isAsyncReplace, isWatch } from './directives.js';
import { Signal } from './signal.js';
import {
  LIGHT_SLOT_ATTR,
  PROJECTION_ATTR,
  PROJECTION_FALLBACK,
  SLOT_FALLBACK_FRAG,
  SLOT_STATE,
  scheduleProjection,
  moveSlotChildrenToPending,
  ensureSlotState,
} from './slot.js';

/**
 * Client-side renderer with **fine-grained** updates.
 *
 * Each TemplateResult is compiled once (keyed by the tagged-template's
 * `strings` array identity, so reuse is free across renders) into:
 *   - a `<template>` element with static HTML + marker comments/attributes
 *     at each dynamic hole
 *   - a list of `Part` descriptors (kind + DOM location + attr/event name)
 *
 * On first render the template is cloned into the container and each Part
 * is bound to the freshly-created node. Subsequent renders compare the new
 * values to the last-applied values and only touch parts that changed.
 * Text-position holes containing nested TemplateResults reuse the existing
 * child instance when the inner `strings` match; they only rebuild when the
 * template shape changes.
 *
 * Consequences worth knowing:
 *   - Input focus, cursor position, selection, and scroll inside components
 *     survive re-renders triggered by property assignments, signal changes,
 *     or `requestUpdate()`.
 *   - Event listeners are attached once and retargeted when the handler
 *     reference changes (swap-in-place via a dispatch closure, so `addEventListener`
 *     isn't churned every render).
 *   - A plain `.map()` array reconciles POSITIONALLY (non-keyed), matching
 *     lit-html: each index updates its item instance in place when the
 *     template shape is unchanged, so DOM node identity (and the focus,
 *     selection, scroll, and in-progress native drag it carries) survives an
 *     item-level update. See `reconcileArray`. Keyed reordering still needs
 *     the `repeat()` directive.
 */

/** @type {WeakMap<TemplateStringsArray | string[], { templateEl: HTMLTemplateElement, parts: PartDescriptor[] }>} */
const templateCache = new WeakMap();
const INSTANCE = Symbol.for('webjs.instance');

/**
 * @typedef {{
 *   kind: 'child' | 'attr' | 'attr-mixed' | 'event' | 'prop' | 'bool' | 'element' | 'slot' | 'noop',
 *   path: number[],
 *   name?: string,
 *   statics?: string[],
 *   group?: number[],
 * }} PartDescriptor
 *
 * @typedef {{
 *   strings: TemplateStringsArray | string[],
 *   bound: BoundPart[],
 *   lastValues: unknown[],
 *   startNode: Comment,
 *   endNode: Comment,
 * }} TemplateInstance
 *
 * @typedef {
 *   | { kind: 'child', marker: Comment, child?: TemplateInstance | ChildNode[] }
 *   | { kind: 'attr', el: Element, name: string }
 *   | { kind: 'attr-mixed', el: Element, name: string, statics: string[], group: number[] }
 *   | { kind: 'event', el: Element, name: string, handler: ((e: Event) => void) | null, dispatcher: (e: Event) => void }
 *   | { kind: 'prop', el: Element, name: string }
 *   | { kind: 'bool', el: Element, name: string }
 *   | { kind: 'element', el: Element, lastTarget?: any }
 *   | { kind: 'slot', slotEl: HTMLSlotElement, applied: boolean }
 *   | { kind: 'noop' }
 * } BoundPart
 */

/**
 * Render a value into a container, reusing DOM where possible.
 *
 * @param {unknown} value
 * @param {Element | DocumentFragment | ShadowRoot} container
 */
export function render(value, container) {
  const host = /** @type any */ (container);
  const prev = host[INSTANCE];

  if (isTemplate(value)) {
    const tr = /** @type {import('./html.js').TemplateResult} */ (value);
    if (prev && prev.strings === tr.strings) {
      updateInstance(prev, tr.values);
      return;
    }
    if (prev) clearInstance(prev, container);

    // Light DOM hydration: if container has SSR content (marked by
    // <!--webjs-hydrate-->), remove the marker and proceed with normal
    // rendering. The content will be replaced with identical output -
    // no visible flash because SSR and client render produce the same HTML.
    const firstChild = container.firstChild;
    if (firstChild && firstChild.nodeType === 8 && /** @type {Comment} */ (firstChild).data === 'webjs-hydrate') {
      firstChild.remove();
    }

    const inst = createInstance(tr, container);
    host[INSTANCE] = inst;
    return;
  }

  // Non-template value: treat as a single text child.
  if (prev) clearInstance(prev, container);
  host[INSTANCE] = null;
  container.replaceChildren();
  if (value == null || value === false || value === true) return;
  if (Array.isArray(value)) {
    for (const v of value) {
      const text = document.createTextNode(String(v ?? ''));
      container.appendChild(text);
    }
    return;
  }
  container.appendChild(document.createTextNode(String(value)));
}

/* ================================================================
 * Template compilation
 * ================================================================ */

/** @param {import('./html.js').TemplateResult} tr */
function compile(tr) {
  const { strings } = tr;
  let cached = templateCache.get(strings);
  if (cached) return cached;

  /** @type {PartDescriptor[]} */
  const parts = [];
  let html = '';
  let state = 'text';
  let attrName = '';
  let attrStart = 0;
  let attrQuote = '';
  let commentDashes = 0;
  /** @type {{ name: string, firstPartIdx: number } | null} */
  let mixedAttr = null;
  let currentTag = '';
  let rawTail = '';

  for (let i = 0; i < strings.length; i++) {
    const s = strings[i];
    for (let j = 0; j < s.length; j++) {
      const c = s[j];
      switch (state) {
        case 'text':
          html += c;
          if (c === '<') state = 'tag-open';
          break;
        case 'tag-open':
          html += c;
          if (c === '!') state = 'bang-1';
          else if (c === '/') { state = 'tag-name'; currentTag = ''; }
          else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); }
          else state = 'text';
          break;
        case 'bang-1':
          html += c;
          state = c === '-' ? 'bang-dash' : 'tag-name';
          break;
        case 'bang-dash':
          html += c;
          if (c === '-') { state = 'comment'; commentDashes = 0; }
          else state = 'tag-name';
          break;
        case 'comment':
          html += c;
          if (c === '-') commentDashes += 1;
          else if (c === '>' && commentDashes >= 2) { state = 'text'; commentDashes = 0; }
          else commentDashes = 0;
          break;
        case 'tag-name':
          html += c;
          if (c === '>') {
            state = (currentTag === 'script' || currentTag === 'style') ? 'rawtext' : 'text';
            if (state === 'rawtext') rawTail = '';
          } else if (/\s/.test(c)) state = 'in-tag';
          else currentTag += c.toLowerCase();
          break;
        case 'in-tag':
          html += c;
          if (c === '>') {
            state = (currentTag === 'script' || currentTag === 'style') ? 'rawtext' : 'text';
            if (state === 'rawtext') rawTail = '';
          } else if (!/\s/.test(c) && c !== '/') {
            state = 'attr-name';
            attrName = c;
            attrStart = html.length - 1;
          }
          break;
        case 'rawtext':
          html += c;
          rawTail = (rawTail + c.toLowerCase()).slice(-9);
          if (rawTail.endsWith('</script>') || rawTail.endsWith('</style>')) {
            state = 'text';
            rawTail = '';
            currentTag = '';
          }
          break;
        case 'attr-name':
          if (c === '=') { state = 'after-eq'; html += c; }
          else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; }
          else if (c === '>') { state = 'text'; attrName = ''; html += c; }
          else { attrName += c; html += c; }
          break;
        case 'after-eq':
          if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; html += c; }
          else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; }
          else if (c === '>') { state = 'text'; attrName = ''; html += c; }
          else { state = 'attr-unquoted'; html += c; }
          break;
        case 'attr-unquoted':
          if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; html += c; }
          else if (c === '>') { state = 'text'; attrName = ''; html += c; }
          else html += c;
          break;
        case 'attr-quoted':
          html += c;
          if (c === attrQuote) { state = 'in-tag'; attrName = ''; }
          break;
        case 'skip-attr':
          // Consume mixed-attribute chars without appending to html.
          // The attribute was replaced with a sentinel on the first hole.
          if (c === attrQuote) {
            // Closing quote: finalize the attr-mixed part.
            if (mixedAttr) {
              const idx0 = mixedAttr.firstPartIdx;
              const group = [];
              for (let k = idx0; k < parts.length; k++) {
                if (parts[k].kind === 'noop' || parts[k].kind === 'attr-mixed') group.push(k);
              }
              // Build statics from the template strings array.
              // For `attr="a ${x} b ${y} c"`, group=[idx0,idx1].
              // statics[0] = tail of strings[idx0] after the `="`
              // statics[1] = strings[idx1] (between holes)
              // statics[n] = prefix of strings[last+1] up to closing quote
              const statics = [];
              const s0 = strings[group[0]];
              const qp = s0.lastIndexOf(attrQuote);
              statics.push(qp >= 0 ? s0.slice(qp + 1) : s0);
              for (let k = 1; k < group.length; k++) {
                statics.push(strings[group[k]]);
              }
              const sLast = strings[group[group.length - 1] + 1];
              const eq = sLast.indexOf(attrQuote);
              statics.push(eq >= 0 ? sLast.slice(0, eq) : sLast);

              parts[idx0] = {
                kind: 'attr-mixed',
                path: [],
                name: mixedAttr.name,
                statics,
                group,
              };
              mixedAttr = null;
            }
            state = 'in-tag';
            attrName = '';
          }
          break;
      }
    }

    if (i < strings.length - 1) {
      const partIdx = parts.length;
      if (state === 'comment') {
        // Holes inside <!-- ... --> are dropped. Comments are inert and
        // the compile cache is keyed on `strings`, so per-render values
        // can't be baked in anyway.
        commentDashes = 0;
        parts.push({ kind: 'noop', path: [] });
        continue;
      }
      if (state === 'rawtext') {
        // Inside <script>/<style>: per-render interpolation isn't supported;
        // the compile cache would lock in whatever was first rendered. The
        // hole is dropped and authors should set body text via a child part
        // outside the raw-text container, or inline style/script directly.
        rawTail = '';
        parts.push({ kind: 'noop', path: [] });
        continue;
      }
      if (state === 'text') {
        // Child hole: insert a comment marker. Use bracketed markers so we can
        // later walk all comments and find ours without ambiguity.
        html += `<!--${MARKER}${partIdx}-->`;
        parts.push({ kind: 'child', path: [] });
      } else if (state === 'in-tag') {
        // Element-position hole: `<tag ${expr}>`. Used by the `ref` directive
        // (and any future element-bound directive). Emit a sentinel attribute
        // on the current open tag; at bind time the attribute is stripped
        // and the element is captured into the part.
        const sentinel = `data-${MARKER}${partIdx}`;
        html += `${sentinel}=""`;
        parts.push({ kind: 'element', path: [] });
      } else if (state === 'after-eq') {
        const prefix = attrName[0];
        const name = attrName.slice(1);
        if (prefix === '@' || prefix === '.' || prefix === '?') {
          // Strip the attribute name+"=" from html and add a sentinel attr.
          html = html.slice(0, attrStart);
          const kind = prefix === '@' ? 'event' : prefix === '.' ? 'prop' : 'bool';
          const sentinel = `data-${MARKER}${partIdx}`;
          html += `${sentinel}=""`;
          parts.push({ kind, path: [], name });
        } else {
          // Regular attribute: rewrite to `attrName="__MARKER__"` and parse as attr.
          html = html.slice(0, attrStart);
          const sentinel = `data-${MARKER}${partIdx}`;
          html += `${sentinel}=""`;
          parts.push({ kind: 'attr', path: [], name: attrName });
        }
        state = 'in-tag';
        attrName = '';
      } else if (state === 'attr-quoted' || state === 'attr-unquoted') {
        // First hole inside a quoted attribute value: start mixed-attr tracking.
        // Replace the entire attribute with a sentinel (same as regular attr).
        html = html.slice(0, attrStart);
        const sentinel = `data-${MARKER}${partIdx}`;
        html += `${sentinel}=""`;
        mixedAttr = { name: attrName, firstPartIdx: partIdx };
        parts.push({ kind: 'noop', path: [] }); // patched to attr-mixed at close-quote
        state = 'skip-attr';
      } else if (state === 'skip-attr') {
        // Subsequent hole in the same mixed attribute.
        parts.push({ kind: 'noop', path: [] });
      }
    }
  }

  const templateEl = document.createElement('template');
  templateEl.innerHTML = html;

  // Mark every <slot> in the template for framework projection and
  // register a SLOT part for each so projectChildren can find them on
  // clones. This runs BEFORE assignPaths so the sentinel attributes the
  // discovery step adds are picked up in the same path-recording walk.
  discoverSlots(templateEl.content, parts);

  // Walk the parsed fragment and record DOM paths for each part.
  assignPaths(templateEl.content, parts);

  cached = { templateEl, parts };
  templateCache.set(strings, cached);
  return cached;
}

/**
 * Walk the compiled template content for <slot> elements (the static ones
 * written into the template, not dynamically-inserted ones). For each:
 *   1. Add the `data-webjs-light` attribute so slot.js's polyfilled APIs
 *      recognise it as a framework-managed light-DOM slot.
 *   2. Add a sentinel attribute (`data-MARKER<idx>`) so the subsequent
 *      assignPaths walk records the slot's path into the new SLOT part.
 *   3. Move the slot's authored children into a `fallbackTemplate`
 *      DocumentFragment stored on the PartDescriptor. The slot in the
 *      cached template becomes empty, so every clone starts empty too.
 *      bindPart clones a fresh fallback fragment per instance from this
 *      template, giving each instance an independent fallback supply
 *      that slot.js swaps in via the SLOT_FALLBACK_FRAG symbol.
 *
 *   Fallback content with template holes (`<slot>fallback ${x}</slot>`)
 *   is captured as a static-HTML snapshot of the template state at
 *   compile time. Dynamic holes inside fallback content are not
 *   re-bound per instance in v1; authors should put dynamic content
 *   outside the slot.
 *
 * @param {DocumentFragment} root
 * @param {PartDescriptor[]} parts
 */
function discoverSlots(root, parts) {
  const slots = root.querySelectorAll('slot');
  for (const slot of slots) {
    slot.setAttribute(LIGHT_SLOT_ATTR, '');
    const partIdx = parts.length;
    slot.setAttribute(`data-${MARKER}${partIdx}`, '');
    parts.push({ kind: 'slot', path: [] });
  }
  // NOTE: fallback content stays IN the slot's children in the cached
  // template. Each clone gets its own copy. For shadow-DOM components,
  // native browser projection uses those children as fallback content
  // when no light child matches. For light-DOM components, the slot's
  // apply step (run after the cloned template is in the live tree)
  // moves the cloned fallback into a per-instance holding fragment
  // owned by slot.js, so the slot is empty and ready to receive
  // projected children. Doing the strip at apply time, not at compile
  // time, lets a single cached template serve both DOM modes.
}

/**
 * Walk the template fragment and record the path (chain of child indices) to
 * each part's anchor node. We use marker comments for child parts and sentinel
 * attributes for everything else.
 *
 * @param {DocumentFragment} root
 * @param {PartDescriptor[]} parts
 */
function assignPaths(root, parts) {
  /** @type {number[]} */
  const path = [];
  /** @param {Node} node */
  function visit(node) {
    for (let i = 0; i < node.childNodes.length; i++) {
      const child = node.childNodes[i];
      path.push(i);
      // Comment marker?
      if (child.nodeType === 8) {
        const txt = /** @type Comment */ (child).data;
        if (txt.startsWith(MARKER)) {
          const idx = Number(txt.slice(MARKER.length));
          if (parts[idx] && parts[idx].kind === 'child') {
            parts[idx].path = path.slice();
          }
        }
      } else if (child.nodeType === 1) {
        const el = /** @type Element */ (child);
        // Sentinel attribute?
        const toRemove = [];
        for (const attr of el.attributes) {
          if (attr.name.startsWith(`data-${MARKER}`)) {
            const idx = Number(attr.name.slice(`data-${MARKER}`.length));
            if (parts[idx] && parts[idx].kind !== 'child') {
              parts[idx].path = path.slice();
            }
            toRemove.push(attr.name);
          }
        }
        for (const a of toRemove) el.removeAttribute(a);
        visit(child);
      }
      path.pop();
    }
  }
  visit(root);
}

/* ================================================================
 * Instance lifecycle
 * ================================================================ */

/**
 * @param {import('./html.js').TemplateResult} tr
 * @param {Element | DocumentFragment | ShadowRoot} container
 */
function createInstance(tr, container) {
  const { templateEl, parts } = compile(tr);
  const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));

  // Bookend markers bound the instance so we can tear it down cleanly.
  const startNode = document.createComment(`${MARKER}s`);
  const endNode = document.createComment(`${MARKER}e`);

  const bound = parts.map((p) => bindPart(p, frag));
  const lastValues = [];
  for (let i = 0; i < tr.values.length; i++) {
    applyPart(bound[i], tr.values[i], undefined, tr.values);
    lastValues.push(tr.values[i]);
  }

  /** @type any */ (container).replaceChildren(startNode, ...frag.childNodes, endNode);

  // Slot parts have no value-hole to drive applyPart from the loop above.
  // Apply them once now that the fragment is inserted into the live
  // container, so each slot can locate its host by walking parents and
  // schedule the first projection through slot.js.
  for (const part of bound) {
    if (part.kind === 'slot') applyPart(part, undefined, undefined, []);
  }

  return { strings: tr.strings, bound, lastValues, startNode, endNode };
}

/**
 * @param {PartDescriptor} p
 * @param {DocumentFragment | Element} root
 * @returns {BoundPart}
 */
function bindPart(p, root) {
  if (p.kind === 'noop') return /** @type any */ ({ kind: 'noop' });
  let node = /** @type Node */ (root);
  for (const i of p.path) node = node.childNodes[i];
  if (p.kind === 'child') {
    return { kind: 'child', marker: /** @type Comment */ (node) };
  }
  const el = /** @type Element */ (node);
  if (p.kind === 'event') {
    /** @type {BoundPart} */
    const part = {
      kind: 'event',
      el,
      name: p.name || '',
      handler: null,
      // The dispatcher is the registered listener; handler swaps behind it.
      dispatcher(ev) { part.handler?.(ev); },
    };
    el.addEventListener(part.name, part.dispatcher);
    return part;
  }
  if (p.kind === 'attr') return { kind: 'attr', el, name: p.name || '' };
  if (p.kind === 'attr-mixed') return { kind: 'attr-mixed', el, name: p.name || '', statics: p.statics || [], group: p.group || [] };
  if (p.kind === 'prop') return { kind: 'prop', el, name: p.name || '' };
  if (p.kind === 'bool') return { kind: 'bool', el, name: p.name || '' };
  if (p.kind === 'element') return { kind: 'element', el };
  if (p.kind === 'slot') {
    const slotEl = /** @type {HTMLSlotElement} */ (el);
    // Defer fallback-strip and SLOT_FALLBACK_FRAG installation to apply
    // time so we know whether the slot is light or shadow at the point
    // where the decision matters. At bind time the cloned slot still
    // holds its fallback content from the template clone; we just
    // record the slot ref.
    return { kind: 'slot', slotEl, applied: false };
  }
  throw new Error(`unknown part kind ${/** @type any */(p).kind}`);
}

/**
 * @param {TemplateInstance} inst
 * @param {unknown[]} values
 */
function updateInstance(inst, values) {
  for (let i = 0; i < values.length; i++) {
    const next = values[i];
    if (Object.is(next, inst.lastValues[i])) continue;
    applyPart(inst.bound[i], next, inst.lastValues[i], values);
    inst.lastValues[i] = next;
  }
}

/**
 * @param {TemplateInstance} inst
 * @param {Element | DocumentFragment | ShadowRoot} container
 */
function clearInstance(inst, container) {
  // Dispose event listeners on event parts, unbind active refs on
  // element parts, and rescue any projected children sitting inside
  // slot parts so they survive teardown of a collapsing conditional
  // fragment.
  for (const p of inst.bound) {
    if (p.kind === 'event') p.el.removeEventListener(p.name, p.dispatcher);
    if (p.kind === 'element') {
      const prev = /** @type any */ (p).lastTarget;
      if (prev) {
        if (typeof prev === 'function') {
          try { prev(undefined); } catch { /* swallow */ }
        } else if (typeof prev === 'object') {
          prev.value = undefined;
        }
        /** @type any */ (p).lastTarget = undefined;
        /** @type any */ (p).__lastEl = undefined;
      }
    }
    if (p.kind === 'slot') {
      const host = findSlotHost(p.slotEl);
      if (host) moveSlotChildrenToPending(host, p.slotEl);
    }
  }
  /** @type any */ (container).replaceChildren();
}

/* ================================================================
 * Part application
 * ================================================================ */

/**
 * @param {BoundPart} part
 * @param {unknown} value
 * @param {unknown} _prev
 */
function applyPart(part, value, _prev, allValues) {
  // Unwrap live() to dirty-check against the live DOM value, not the
  // last rendered value. Essential for <input> two-way binding.
  if (isLive(value)) {
    const liveVal = /** @type any */ (value).value;
    if (part.kind === 'prop' && /** @type any */ (part.el)[part.name] === liveVal) return;
    if (part.kind === 'attr' && part.el.getAttribute(part.name) === String(liveVal)) return;
    if (part.kind === 'bool' && part.el.hasAttribute(part.name) === !!liveVal) return;
    value = liveVal;
  }

  switch (part.kind) {
    case 'child':
      applyChild(part, value);
      break;
    case 'attr': {
      if (value == null || value === false) part.el.removeAttribute(part.name);
      else part.el.setAttribute(part.name, String(value));
      break;
    }
    case 'prop':
      /** @type any */ (part.el)[part.name] = value;
      break;
    case 'bool':
      if (value) part.el.setAttribute(part.name, '');
      else part.el.removeAttribute(part.name);
      break;
    case 'event':
      part.handler = typeof value === 'function' ? /** @type any */ (value) : null;
      break;
    case 'element':
      applyElement(part, value);
      break;
    case 'attr-mixed': {
      // Reconstruct the attribute from static pieces + all dynamic values.
      const mp = /** @type {{ statics: string[], group: number[] }} */ (/** @type any */ (part));
      let val = mp.statics[0];
      for (let j = 0; j < mp.group.length; j++) {
        val += String((allValues ? allValues[mp.group[j]] : value) ?? '');
        val += mp.statics[j + 1] || '';
      }
      part.el.setAttribute(part.name, val);
      break;
    }
    case 'slot': {
      // Slot parts have no template-hole value to apply. The "apply" is
      // a one-shot trigger that runs after the template fragment has
      // been inserted into the host's render root. At this point the
      // slot's parent chain reveals whether it lives inside a shadow
      // root (browser native projection) or in light DOM (framework
      // projection). For shadow-DOM slots we leave the cloned fallback
      // in place. For light-DOM slots we move the fallback into a
      // per-slot holding fragment owned by slot.js (via the
      // SLOT_FALLBACK_FRAG symbol) so the slot can receive projected
      // children, and we kick off projection.
      //
      // For NESTED templates (a slot inside `${cond ? html`<slot/>` : ''}`),
      // the slot's parent chain at first apply may still lead through
      // an unattached fragment. findSlotHost returns null then; we
      // retry on the next microtask, by which point the outer's
      // replaceChildren has placed the entire tree into the host.
      if (part.applied) break;
      const slotEl = part.slotEl;
      const finalize = () => {
        part.applied = true;
        const host = findSlotHost(slotEl);
        if (!host) return; // truly orphan slot
        // Shadow DOM: native projection. Leave fallback in place.
        if (isInShadowRootEl(slotEl)) return;
        // Light DOM: harvest the cloned fallback into a holding
        // fragment, then trigger projection.
        const frag = document.createDocumentFragment();
        while (slotEl.firstChild) frag.appendChild(slotEl.firstChild);
        /** @type {any} */ (slotEl)[SLOT_FALLBACK_FRAG] = frag;
        scheduleProjection(host);
      };
      const directHost = findSlotHost(slotEl);
      if (directHost) {
        finalize();
      } else {
        queueMicrotask(finalize);
      }
      break;
    }
    case 'noop':
      // intentionally empty: used for holes inside HTML comments
      break;
  }
}

/**
 * Walk a slot element's parent chain looking for a WebComponent host
 * (an element that has slot state initialised). Used by the slot-part's
 * apply and teardown steps to coordinate with slot.js.
 *
 * @param {HTMLSlotElement} slotEl
 * @returns {Element | null}
 */
function findSlotHost(slotEl) {
  let p = slotEl.parentElement;
  while (p) {
    if (/** @type any */ (p)[SLOT_STATE]) return p;
    p = p.parentElement;
  }
  return null;
}

/**
 * True when an element is inside a shadow root (so native browser slot
 * projection applies). Mirrors slot.js's helper; duplicated here to
 * avoid the round trip through the slot.js public surface for this
 * hot path.
 * @param {Element} el
 * @returns {boolean}
 */
function isInShadowRootEl(el) {
  let n = /** @type {Node} */ (el);
  for (let depth = 0; depth < 128; depth++) {
    const parent = n.parentNode;
    if (!parent) return false;
    if (parent === n) return false;
    if (/** @type any */ (parent).host) return true;
    n = parent;
  }
  return false;
}

/**
 * Child (text-position) part. Replace the marker's surrounding nodes with the
 * new value's rendered form. Nested TemplateResults get an instance with its
 * own parts; we reuse on `strings` identity.
 *
 * @param {Extract<BoundPart, {kind:'child'}>} part
 * @param {unknown} value
 */
/**
 * Apply a value at an element-position part (`<tag ${expr}>`). The
 * sole supported directive here is `ref(refOrCallback)` and
 * `createRef()`. Other values are ignored so a stray non-ref hole
 * doesn't crash. Tracks the prior target so a change from one ref to
 * another correctly unsets the old target before binding the new one.
 *
 * @param {Extract<BoundPart, {kind:'element'}>} part
 * @param {unknown} value
 */
function applyElement(part, value) {
  // Matches lit-html's RefDirective.update():
  // 1. If the ref target changed since last render, unbind the prior one.
  // 2. If the ref target OR the element identity changed, bind the new
  //    (ref, element) pair. If both are stable, skip entirely.
  // For callback refs, an unset-before-bind cycle runs whenever the
  // same callback is now pointing at a different element.
  const partAny = /** @type any */ (part);
  const nextTarget = isRef(value) ? /** @type any */ (value).target : undefined;
  const prevTarget = partAny.__refTarget;
  const refChanged = nextTarget !== prevTarget;

  if (refChanged && prevTarget) {
    if (typeof prevTarget === 'function') {
      try { prevTarget(undefined); } catch { /* swallow */ }
    } else if (typeof prevTarget === 'object') {
      prevTarget.value = undefined;
    }
  }

  if (refChanged || partAny.__refElement !== part.el) {
    partAny.__refTarget = nextTarget;
    if (nextTarget) {
      if (typeof nextTarget === 'function') {
        // Same callback now pointing at a different element: deliver
        // an `undefined` cleanup for the prior element first.
        if (!refChanged && partAny.__refElement !== undefined) {
          try { nextTarget(undefined); } catch { /* swallow */ }
        }
        try { nextTarget(part.el); } catch { /* swallow */ }
      } else if (typeof nextTarget === 'object') {
        nextTarget.value = part.el;
      }
    }
    partAny.__refElement = part.el;
    // Keep the legacy `lastTarget` field in sync for clearInstance /
    // disposeInstance which read it for template-disposal cleanup.
    part.lastTarget = nextTarget;
  }
}

function applyChild(part, value) {
  // Drop directive state from prior renders when the new value is for a
  // different directive (or no directive at all). Keeps __untilState
  // from leaking across replacements, __guardDeps from causing a stale
  // short-circuit, etc. Done once per outermost applyChild call; the
  // directive handlers recurse via applyChildInner (no re-clear) so
  // their own state survives the recursion.
  clearStaleDirectiveState(part, value);
  return applyChildInner(part, value);
}

/**
 * Internal dispatch. Used both by `applyChild` (which first clears
 * stale per-part directive state) and by directive handlers that
 * recurse with a different value at the same part. Recursing via
 * `applyChild` would clear the directive state that was just set,
 * because the inner value almost always isn't itself a directive.
 *
 * @param {Extract<BoundPart, {kind:'child'}>} part
 * @param {unknown} value
 */
function applyChildInner(part, value) {
  const marker = part.marker;

  // unsafeHTML directive: inject raw HTML string as DOM nodes.
  if (isUnsafeHTML(value)) {
    teardownChild(part);
    const htmlStr = String(/** @type any */ (value).value ?? '');
    const template = document.createElement('template');
    template.innerHTML = htmlStr;
    const nodes = [...template.content.childNodes];
    const frag = document.createDocumentFragment();
    for (const n of nodes) frag.appendChild(n);
    marker.parentNode?.insertBefore(frag, marker);
    part.child = nodes;
    return;
  }

  // keyed directive: when key changes, tear down and remount fresh DOM.
  // When the key matches, recurse so the standard template-reconciliation
  // path can update the existing DOM in place.
  if (isKeyed(value)) {
    const v = /** @type any */ (value);
    const prevKey = /** @type any */ (part).__keyedKey;
    if (prevKey !== undefined && !Object.is(prevKey, v.key)) {
      teardownChild(part);
    }
    /** @type any */ (part).__keyedKey = v.key;
    applyChildInner(part, v.value);
    return;
  }

  // guard directive: skip re-evaluation when the deps array is shallow-
  // equal to the prior call. Stored deps live on the part so they
  // persist across renders that reuse the same template (and thus the
  // same part).
  if (isGuard(value)) {
    const v = /** @type any */ (value);
    const prevDeps = /** @type any */ (part).__guardDeps;
    const nextDeps = v.deps;
    // Accept any value for deps. When deps is an array, compare shallowly.
    // When it's a primitive (number, string, undefined), compare with
    // Object.is. Mirrors lit-html's tolerance for non-array deps so user
    // code like `guard(this.id, () => ...)` works without crashing.
    if (prevDeps !== undefined) {
      const equal = Array.isArray(prevDeps) && Array.isArray(nextDeps)
        ? shallowEqualArray(prevDeps, nextDeps)
        : Object.is(prevDeps, nextDeps);
      if (equal) return;
    }
    /** @type any */ (part).__guardDeps = Array.isArray(nextDeps)
      ? nextDeps.slice()
      : nextDeps;
    applyChildInner(part, v.fn());
    return;
  }

  // templateContent directive: clone the content of a <template> element.
  if (isTemplateContent(value)) {
    teardownChild(part);
    const tpl = /** @type any */ (value).template;
    if (tpl && tpl.content) {
      const frag = tpl.content.cloneNode(true);
      const nodes = [...frag.childNodes];
      marker.parentNode?.insertBefore(frag, marker);
      part.child = nodes;
    }
    return;
  }

  // ref directive in a child position: no DOM produced. Element-position
  // refs are bound via element parts; a stray ref() in a child position
  // is a no-op for compatibility.
  if (isRef(value)) {
    return;
  }

  // cache directive: real DOM retention. When the inner value changes
  // to a different template shape, detach (rather than destroy) the
  // current DOM and stash it keyed by its template strings. When a
  // previously-cached shape returns, re-attach it before the marker
  // and reconcile values. Preserves input state, scroll, focus across
  // toggles between sub-templates (e.g. tab interfaces).
  if (isCache(value)) {
    return applyCache(part, /** @type any */ (value).value);
  }

  // until directive: render the highest-priority resolved value among
  // the candidates. Synchronous values are rendered immediately; Promises
  // are awaited in the background and applied if no higher-priority
  // candidate has resolved yet. When the marker is torn down, in-flight
  // priorities are cleared so late resolves cannot overwrite later DOM.
  if (isUntil(value)) {
    return applyUntil(part, /** @type any */ (value).args);
  }

  // watch directive: bind a part to a signal. Reads the signal at
  // render time and subscribes the part to changes. When the signal
  // fires, only this part updates; the host component's render() does
  // not re-run. The signal read inside the watcher's observe is
  // tracked against the part's private Watcher, NOT the host's render
  // watcher (so the host doesn't subscribe to a full re-render too).
  if (isWatch(value)) {
    return applyWatch(part, /** @type any */ (value).signal);
  }

  // asyncAppend / asyncReplace: subscribe to the AsyncIterable. Each
  // yielded value is mapped (optional) and appended (asyncAppend) or
  // replaces (asyncReplace) the prior content. Teardown aborts the
  // iteration so leaked iterators do not keep references to detached
  // DOM.
  if (isAsyncAppend(value)) {
    return applyAsyncAppend(part, /** @type any */ (value));
  }
  if (isAsyncReplace(value)) {
    return applyAsyncReplace(part, /** @type any */ (value));
  }

  // Repeat directive: keyed reconciliation. Keep previous state when both
  // old and new are repeats; otherwise tear down and rebuild.
  if (isRepeat(value)) {
    if (part.child && /** @type any */ (part.child).kind === 'repeat') {
      reconcileRepeat(part, value);
      return;
    }
    teardownChild(part);
    const state = { kind: 'repeat', map: new Map() };
    part.child = state;
    applyRepeatFresh(marker, state, value);
    return;
  }

  // Plain array (a `.map()` / list interpolation): positional, non-keyed
  // reconciliation. Update each position's instance IN PLACE when its
  // template shape is unchanged, so DOM node identity survives an item
  // update (focus, selection, scroll, and an in-progress native drag all
  // survive). Mirrors lit-html's non-keyed array child part. Without this,
  // flipping one item's binding rebuilt the WHOLE list and detached every
  // node, which cancels a native drag mid-gesture. Use `repeat()` for keyed
  // reordering.
  if (Array.isArray(value)) {
    if (part.child && /** @type any */ (part.child).kind === 'array') {
      reconcileArray(part, value);
      return;
    }
    teardownChild(part);
    const arrState = { kind: 'array', items: [] };
    part.child = arrState;
    applyArrayFresh(marker, /** @type any */ (arrState), value);
    return;
  }

  // Remove previously rendered nodes between marker and its next sibling we own.
  if (part.child) {
    const c = /** @type any */ (part.child);
    if (c.kind === 'repeat') {
      teardownRepeat(c);
      part.child = undefined;
    } else if (c.kind === 'array') {
      teardownArray(/** @type any */ (c));
      part.child = undefined;
    } else if (c.kind === 'async-stream') {
      teardownAsyncStream(c);
      part.child = undefined;
    } else if ('strings' in /** @type any */ (part.child)) {
      // Previous was a TemplateInstance.
      const inst = /** @type TemplateInstance */ (part.child);
      if (isTemplate(value) && inst.strings === /** @type any */ (value).strings) {
        updateInstance(inst, /** @type any */ (value).values);
        return;
      }
      removeBetween(inst.startNode, inst.endNode);
      part.child = undefined;
    } else {
      // Previous was ChildNode[]: remove each node we inserted.
      for (const n of /** @type ChildNode[] */ (part.child)) {
        if (n.parentNode) n.parentNode.removeChild(n);
      }
      part.child = undefined;
    }
  }

  if (value == null || value === false || value === true) return;

  if (isTemplate(value)) {
    const tr = /** @type any */ (value);
    const { templateEl, parts } = compile(tr);
    const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
    const startNode = document.createComment(`${MARKER}s`);
    const endNode = document.createComment(`${MARKER}e`);
    const bound = parts.map((p) => bindPart(p, frag));
    const lastValues = [];
    for (let i = 0; i < tr.values.length; i++) {
      applyPart(bound[i], tr.values[i], undefined, tr.values);
      lastValues.push(tr.values[i]);
    }
    const nodes = [startNode, ...frag.childNodes, endNode];
    marker.parentNode?.insertBefore(nodesToFrag(nodes), marker);
    // Slot parts in this nested template need their one-shot apply just
    // like createInstance does for top-level templates. The slot is now
    // in the live tree (insertBefore above) so its parent walk can
    // reach the host. Without this loop, conditional / nested templates
    // with <slot> inside never trigger projection.
    for (const p of bound) {
      if (p.kind === 'slot') applyPart(p, undefined, undefined, []);
    }
    part.child = { strings: tr.strings, bound, lastValues, startNode, endNode };
    return;
  }

  const node = document.createTextNode(String(value));
  marker.parentNode?.insertBefore(node, marker);
  part.child = [node];
}

/** @param {ChildNode[]} nodes */
function nodesToFrag(nodes) {
  const frag = document.createDocumentFragment();
  for (const n of nodes) frag.appendChild(n);
  return frag;
}

/** @param {Node} start @param {Node} end */
function removeBetween(start, end) {
  if (!start.parentNode) return;
  let n = start;
  while (n && n !== end) {
    const next = n.nextSibling;
    n.parentNode?.removeChild(n);
    n = next;
  }
  if (end.parentNode === start.parentNode) end.parentNode?.removeChild(end);
}

/* ================================================================
 * Keyed list (repeat) support
 * ================================================================ */

/**
 * Build a TemplateInstance whose nodes (including bookends) live in a
 * document fragment that the caller will insert wherever it wants.
 * @param {import('./html.js').TemplateResult} tr
 * @returns {{ inst: TemplateInstance, frag: DocumentFragment }}
 */
function buildDetached(tr) {
  const { templateEl, parts } = compile(tr);
  const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
  const startNode = document.createComment(`${MARKER}s`);
  const endNode = document.createComment(`${MARKER}e`);
  const bound = parts.map((p) => bindPart(p, frag));
  const lastValues = [];
  for (let i = 0; i < tr.values.length; i++) {
    applyPart(bound[i], tr.values[i], undefined, tr.values);
    lastValues.push(tr.values[i]);
  }
  const outFrag = document.createDocumentFragment();
  outFrag.appendChild(startNode);
  while (frag.firstChild) outFrag.appendChild(frag.firstChild);
  outFrag.appendChild(endNode);
  return {
    inst: { strings: tr.strings, bound, lastValues, startNode, endNode },
    frag: outFrag,
  };
}

/** @param {TemplateInstance} inst */
function disposeInstance(inst) {
  for (const p of inst.bound) {
    if (p.kind === 'event') p.el.removeEventListener(p.name, p.dispatcher);
    if (p.kind === 'element') {
      // Unbind any active ref so the user observes the element being
      // removed (callback receives undefined / Ref.value cleared).
      // Mirrors lit-html's cleanup-on-disconnect for element parts.
      const prev = /** @type any */ (p).lastTarget;
      if (prev) {
        if (typeof prev === 'function') {
          try { prev(undefined); } catch { /* swallow */ }
        } else if (typeof prev === 'object') {
          prev.value = undefined;
        }
        /** @type any */ (p).lastTarget = undefined;
        /** @type any */ (p).__lastEl = undefined;
      }
    }
  }
}

/**
 * Initial fresh render of a repeat directive. Inserts all items' nodes
 * immediately before the part's marker comment.
 *
 * @param {Comment} marker
 * @param {{ kind: 'repeat', map: Map<any, TemplateInstance> }} state
 * @param {any} value
 */
function applyRepeatFresh(marker, state, value) {
  const { items, keyFn, templateFn } = value;
  const parent = marker.parentNode;
  if (!parent) return;
  const bulk = document.createDocumentFragment();
  for (let i = 0; i < items.length; i++) {
    const key = keyFn(items[i], i);
    const tr = templateFn(items[i], i);
    if (!isTemplate(tr)) continue;
    const { inst, frag } = buildDetached(/** @type any */ (tr));
    state.map.set(key, inst);
    bulk.appendChild(frag);
  }
  parent.insertBefore(bulk, marker);
}

/**
 * Keyed reconciliation. For each key in the new list:
 *   - hit: update the existing instance in place (if template shape matches),
 *     then move its nodes into position
 *   - miss: build a new instance and insert
 * Finally drop instances whose keys aren't in the new list.
 *
 * @param {Extract<BoundPart, {kind:'child'}>} part
 * @param {any} value
 */
function reconcileRepeat(part, value) {
  const marker = part.marker;
  const parent = marker.parentNode;
  if (!parent) return;
  const state = /** @type {{ kind: 'repeat', map: Map<any, TemplateInstance> }} */ (part.child);
  const { items, keyFn, templateFn } = value;

  const newMap = new Map();

  // Walk the new list and position each item's nodes immediately before the marker.
  for (let i = 0; i < items.length; i++) {
    const key = keyFn(items[i], i);
    const tr = templateFn(items[i], i);
    if (!isTemplate(tr)) continue;
    const existing = state.map.get(key);
    if (existing && existing.strings === /** @type any */ (tr).strings) {
      updateInstance(existing, /** @type any */ (tr).values);
      // Move nodes before marker preserving element identity.
      moveRange(existing.startNode, existing.endNode, parent, marker);
      newMap.set(key, existing);
      state.map.delete(key);
    } else {
      if (existing) {
        disposeInstance(existing);
        removeBetween(existing.startNode, existing.endNode);
        state.map.delete(key);
      }
      const { inst, frag } = buildDetached(/** @type any */ (tr));
      parent.insertBefore(frag, marker);
      newMap.set(key, inst);
    }
  }

  // Remove any keys that remain in the old map.
  for (const inst of state.map.values()) {
    disposeInstance(inst);
    removeBetween(inst.startNode, inst.endNode);
  }
  state.map = newMap;
}

/** @param {{ kind: 'repeat', map: Map<any, TemplateInstance> }} state */
function teardownRepeat(state) {
  for (const inst of state.map.values()) {
    disposeInstance(inst);
    removeBetween(inst.startNode, inst.endNode);
  }
  state.map.clear();
}

/* ================================================================
 * Plain array (.map) support: positional, non-keyed reconciliation
 * ================================================================ */

/**
 * One rendered slot of a plain array. A `tpl` carries a detached template
 * instance (bookended by its own markers), a `text` carries a single text
 * node, and an `empty` slot (a nullish / boolean element) renders nothing
 * but still holds the position so index-based reconciliation stays aligned.
 * @typedef {{ type: 'tpl', inst: TemplateInstance } | { type: 'text', node: Text } | { type: 'empty' }} ArrayItem
 */

/** @param {ArrayItem} item @returns {ChildNode | null} */
function arrayItemFirstNode(item) {
  if (item.type === 'tpl') return item.inst.startNode;
  if (item.type === 'text') return item.node;
  return null;
}

/** @param {ArrayItem} item */
function removeArrayItem(item) {
  if (item.type === 'tpl') {
    disposeInstance(item.inst);
    removeBetween(item.inst.startNode, item.inst.endNode);
  } else if (item.type === 'text') {
    item.node.parentNode?.removeChild(item.node);
  }
}

/**
 * Build the slot for one array element, plus the fragment to insert (null
 * for an empty slot). A TemplateResult becomes a detached instance, a
 * primitive becomes a text node, and nullish / boolean renders nothing.
 * @param {unknown} v
 * @returns {{ item: ArrayItem, frag: Node | null }}
 */
function buildArrayItem(v) {
  if (isTemplate(v)) {
    const { inst, frag } = buildDetached(/** @type any */ (v));
    return { item: { type: 'tpl', inst }, frag };
  }
  if (v != null && v !== false && v !== true) {
    const node = document.createTextNode(String(v));
    return { item: { type: 'text', node }, frag: node };
  }
  return { item: { type: 'empty' }, frag: null };
}

/**
 * Initial render of a plain array: insert every slot's nodes before the marker.
 * @param {Comment} marker
 * @param {{ kind: 'array', items: ArrayItem[] }} state
 * @param {unknown[]} value
 */
function applyArrayFresh(marker, state, value) {
  const parent = marker.parentNode;
  if (!parent) return;
  const bulk = document.createDocumentFragment();
  /** @type {ArrayItem[]} */
  const items = [];
  for (const v of value) {
    const { item, frag } = buildArrayItem(v);
    if (frag) bulk.appendChild(frag);
    items.push(item);
  }
  parent.insertBefore(bulk, marker);
  state.items = items;
}

/**
 * Positional (non-keyed) reconciliation. For each index, update the slot
 * in place when its shape is unchanged (preserving DOM node identity),
 * otherwise replace it; grow or shrink at the tail. There is no key
 * matching, so a reorder reduces to a series of in-place value updates;
 * reach for `repeat()` when element identity must follow a moved key.
 * @param {Extract<BoundPart, {kind:'child'}>} part
 * @param {unknown[]} value
 */
function reconcileArray(part, value) {
  const marker = part.marker;
  const parent = marker.parentNode;
  if (!parent) return;
  const state = /** @type {{ kind: 'array', items: ArrayItem[] }} */ (part.child);
  const old = state.items;
  /** @type {ArrayItem[]} */
  const next = [];

  for (let i = 0; i < value.length; i++) {
    const v = value[i];
    const o = old[i];
    if (isTemplate(v)) {
      const tr = /** @type any */ (v);
      if (o && o.type === 'tpl' && o.inst.strings === tr.strings) {
        updateInstance(o.inst, tr.values);
        next.push(o);
        continue;
      }
    } else if (v != null && v !== false && v !== true) {
      if (o && o.type === 'text') {
        const str = String(v);
        if (o.node.data !== str) o.node.data = str;
        next.push(o);
        continue;
      }
    } else {
      // Empty slot: drop any prior nodes that occupied this position.
      if (o) removeArrayItem(o);
      next.push({ type: 'empty' });
      continue;
    }
    // Shape changed, or the array grew past the old length. Build fresh,
    // insert at this position (before the current / next still-attached
    // old node, else the marker), then drop the old slot it replaced.
    const { item, frag } = buildArrayItem(v);
    if (frag) parent.insertBefore(frag, nextArrayAnchor(old, i, marker));
    if (o) removeArrayItem(o);
    next.push(item);
  }

  // Shrink: remove slots beyond the new length.
  for (let i = value.length; i < old.length; i++) removeArrayItem(old[i]);
  state.items = next;
}

/**
 * The node a freshly-built slot at index `i` inserts before: the first
 * node of the current or next still-attached old slot, else the part
 * marker (a tail append).
 * @param {ArrayItem[]} old @param {number} i @param {Comment} marker
 * @returns {ChildNode}
 */
function nextArrayAnchor(old, i, marker) {
  for (let j = i; j < old.length; j++) {
    const f = arrayItemFirstNode(old[j]);
    if (f && f.parentNode) return f;
  }
  return marker;
}

/** @param {{ kind: 'array', items: ArrayItem[] }} state */
function teardownArray(state) {
  for (const it of state.items) removeArrayItem(it);
  state.items = [];
}

/**
 * Collect [start .. end] (inclusive) and insert immediately before `anchor`.
 * Browsers treat insertBefore of an already-connected node as a move and
 * preserve element identity + focus.
 *
 * @param {Node} start
 * @param {Node} end
 * @param {Node} parent
 * @param {Node} anchor
 */
function moveRange(start, end, parent, anchor) {
  // No-op if the range is already immediately before the anchor.
  if (end.nextSibling === anchor && start.parentNode === parent) return;
  const frag = document.createDocumentFragment();
  let n = start;
  while (n) {
    const next = n.nextSibling;
    frag.appendChild(n);
    if (n === end) break;
    n = next;
  }
  parent.insertBefore(frag, anchor);
}

/**
 * Shallow array equality (Object.is on each element). Used by the
 * `guard` directive to skip re-evaluation when deps are unchanged.
 * @param {readonly unknown[]} a
 * @param {readonly unknown[]} b
 */
function shallowEqualArray(a, b) {
  if (a === b) return true;
  if (!a || !b || a.length !== b.length) return false;
  for (let i = 0; i < a.length; i++) {
    if (!Object.is(a[i], b[i])) return false;
  }
  return true;
}

/** @param {Extract<BoundPart, {kind:'child'}>} part */
function teardownChild(part) {
  // Always abort any in-flight directive state on the part, even if
  // `part.child` itself is something else (e.g. an `until` directive
  // installed __untilState but applyChild's recursion overwrote
  // part.child to the rendered fallback shape).
  const partAny = /** @type any */ (part);
  if (partAny.__untilState) {
    partAny.__untilState.aborted = true;
    partAny.__untilState = undefined;
  }
  if (partAny.__watchSub) {
    teardownWatch(partAny);
  }

  if (!part.child) return;
  const c = /** @type any */ (part.child);
  if (c.kind === 'repeat') {
    teardownRepeat(c);
  } else if (c.kind === 'array') {
    teardownArray(c);
  } else if (c.kind === 'async-stream') {
    teardownAsyncStream(c);
  } else if ('strings' in c) {
    const inst = /** @type TemplateInstance */ (part.child);
    disposeInstance(inst);
    removeBetween(inst.startNode, inst.endNode);
  } else {
    for (const n of /** @type ChildNode[] */ (part.child)) {
      if (n.parentNode) n.parentNode.removeChild(n);
    }
  }
  part.child = undefined;
}

/**
 * Clear per-part directive state slots that don't apply to the value
 * currently being rendered. Prevents stale `__guardDeps` from short-
 * circuiting a render when the directive at this position is no longer
 * a guard, stale `__cacheMap` from accumulating across non-cache
 * renders, and stale `__untilState` from letting a prior Promise
 * resolution overwrite newer DOM.
 *
 * @param {Extract<BoundPart, {kind:'child'}>} part
 * @param {unknown} value
 */
function clearStaleDirectiveState(part, value) {
  const partAny = /** @type any */ (part);
  if (partAny.__untilState && !isUntil(value)) {
    partAny.__untilState.aborted = true;
    partAny.__untilState = undefined;
  }
  if (partAny.__guardDeps !== undefined && !isGuard(value)) {
    partAny.__guardDeps = undefined;
  }
  if (partAny.__cacheMap && !isCache(value)) {
    partAny.__cacheMap = undefined;
  }
  if (partAny.__keyedKey !== undefined && !isKeyed(value)) {
    partAny.__keyedKey = undefined;
  }
  if (partAny.__watchSub && !isWatch(value)) {
    teardownWatch(partAny);
  }
}

/* ================================================================
 * Cache directive: detach + retain prior template instances so that
 * toggling between sub-templates preserves their DOM state.
 * ================================================================ */

/**
 * Apply the `cache` directive at a child position. The cache is stored
 * on the part as `__cacheMap: Map<strings, { inst, holderFrag }>`.
 *
 * When the new inner value is a template whose `strings` already lives
 * in the cache map, re-attach the stashed nodes before the marker and
 * reconcile values against the new template. When the new inner is a
 * template whose strings aren't cached, stash the currently-attached
 * instance (if any) into the cache map before rendering the new one.
 *
 * Non-template inner values fall through to the generic applyChild path
 * (after first stashing any currently-attached cached instance).
 *
 * @param {Extract<BoundPart, {kind:'child'}>} part
 * @param {unknown} inner
 */
function applyCache(part, inner) {
  const marker = part.marker;
  const partAny = /** @type any */ (part);
  /** @type {Map<TemplateStringsArray, { inst: TemplateInstance, holder: DocumentFragment }>} */
  let cacheMap = partAny.__cacheMap;
  if (!cacheMap) {
    cacheMap = new Map();
    partAny.__cacheMap = cacheMap;
  }

  const currentChild = /** @type any */ (part.child);
  const currentIsInstance = currentChild && 'strings' in currentChild;

  // If the currently-attached child IS a template instance, decide
  // whether to update-in-place, stash for later, or destroy.
  if (currentIsInstance) {
    const currentInst = /** @type TemplateInstance */ (currentChild);

    // Same template structure: reconcile values, no detach/re-attach.
    if (isTemplate(inner) && currentInst.strings === /** @type any */ (inner).strings) {
      updateInstance(currentInst, /** @type any */ (inner).values);
      return;
    }

    // Different shape: detach the current instance into a holder fragment
    // and store it in the cache map. We keep the existing instance, slot
    // markers, and rendered nodes; only the parent changes. moveRange's
    // null anchor means "append to parent".
    const holder = document.createDocumentFragment();
    moveRange(currentInst.startNode, currentInst.endNode, holder, null);
    cacheMap.set(currentInst.strings, { inst: currentInst, holder });
    part.child = undefined;
  }

  // Now part.child is either undefined or some non-instance shape (rare;
  // happens when prior render had a string / array / etc.). For non-
  // instance shapes, fall through to the generic teardown via applyChild.

  // If the new inner is a template AND we've cached an instance for its
  // strings, re-attach it.
  if (isTemplate(inner)) {
    const tr = /** @type any */ (inner);
    const cached = cacheMap.get(tr.strings);
    if (cached) {
      cacheMap.delete(tr.strings);
      // Tear down any non-instance child currently attached (a string /
      // array of text nodes from a prior cache(non-template) render).
      // Without this the prior nodes remain in the DOM alongside the
      // re-attached cached template.
      if (part.child) {
        teardownChild(part);
      }
      // Move the cached nodes back before the marker.
      moveRange(cached.inst.startNode, cached.inst.endNode, /** @type Node */ (marker.parentNode), marker);
      // Reconcile values so any state changes since detachment apply.
      updateInstance(cached.inst, tr.values);
      part.child = cached.inst;
      return;
    }
  }

  // No cached instance available. Render the new inner value via the
  // standard applyChild path. The currentIsInstance branch already
  // handled detaching the prior instance; if part.child still holds a
  // non-instance shape, applyChild will tear it down generically.
  applyChildInner(part, inner);
}

/* ================================================================
 * Until directive: render highest-priority resolved candidate.
 * ================================================================ */

/**
 * Apply the `until` directive at a child position.
 *
 * Priority is left-to-right: args[0] has the highest priority. The
 * highest-priority synchronous candidate (if any) renders immediately.
 * Strictly-higher-priority Promises are awaited in the background; when
 * one resolves AND no higher-priority Promise has already resolved, its
 * result becomes the rendered value.
 *
 * The directive's state lives on `part.__untilState` (a stable slot
 * that survives `applyChild`'s overwrites of `part.child`). When a new
 * render replaces the directive, the prior state's `aborted` flag flips
 * to `true` so any in-flight Promise resolutions short-circuit instead
 * of overwriting newer DOM.
 *
 * @param {Extract<BoundPart, {kind:'child'}>} part
 * @param {readonly unknown[]} args
 */
function applyUntil(part, args) {
  // Carry forward the prior render's `highestResolved` ONLY when the
  // args list is unchanged. When any argument identity changes, prior
  // priorities no longer apply (a Promise that won at index 0 may now
  // sit at a different index, or have been replaced entirely); the
  // state must reset to Infinity so the new args' Promises can compete.
  //
  // For TemplateResult args, compare by `strings` array identity rather
  // than the wrapper object identity. `html\`loading...\`` evaluates to
  // a fresh TemplateResult on every call but the strings array is
  // interned per call site, so the conceptual value is unchanged.
  const partAny = /** @type any */ (part);
  const prevState = partAny.__untilState;
  const prevArgs = partAny.__untilArgs;
  const argEq = (a, b) => {
    if (Object.is(a, b)) return true;
    if (isTemplate(a) && isTemplate(b)
        && /** @type any */ (a).strings === /** @type any */ (b).strings) return true;
    return false;
  };
  const argsEqual = prevArgs && prevArgs.length === args.length
    && prevArgs.every((a, i) => argEq(a, args[i]));
  const carriedHighest = argsEqual && prevState ? prevState.highestResolved : Infinity;
  if (prevState) prevState.aborted = true;
  partAny.__untilArgs = args.slice();

  /** @type {{aborted:boolean, highestResolved:number}} */
  const state = { aborted: false, highestResolved: carriedHighest };
  partAny.__untilState = state;

  // Highest-priority synchronous candidate. If found, render it now
  // and cap further Promise subscription to strictly-higher priorities.
  let firstSyncIdx = -1;
  let firstSyncVal = undefined;
  for (let i = 0; i < args.length; i++) {
    const a = args[i];
    if (!a || typeof (/** @type any */ (a).then) !== 'function') {
      firstSyncIdx = i;
      firstSyncVal = a;
      break;
    }
  }

  if (firstSyncIdx !== -1 && firstSyncIdx <= state.highestResolved) {
    // The sync candidate beats any previously-rendered Promise value
    // (when firstSyncIdx < state.highestResolved) OR re-renders the
    // sync fallback at the same priority slot (when ===), in case its
    // value changed between renders.
    applyChildInner(part, firstSyncVal);
    state.highestResolved = firstSyncIdx;
  } else if (firstSyncIdx === -1 && !partAny.__untilEverRendered) {
    // First-ever render of this part with all-Promise args: render
    // empty as the initial fallback while Promises settle.
    applyChildInner(part, '');
  }
  // Else: either there is no sync candidate but the part has rendered
  // before (preserve existing DOM until a Promise resolves), OR the
  // sync candidate is lower-priority than what's already rendered.
  // Either way: leave the existing DOM in place. This prevents the
  // "all-Promises wipes prior content" flash on re-renders.
  partAny.__untilEverRendered = true;

  // Subscribe to Promises with priority strictly less than what's
  // currently rendered. (Lower index = higher priority in lit's model.)
  // Each subscription wraps in Promise.resolve() so synchronous
  // thenables get a microtask boundary, matching lit's contract that
  // all Promise/thenable resolutions are deferred.
  const cap = firstSyncIdx === -1
    ? Math.min(args.length, state.highestResolved)
    : Math.min(firstSyncIdx, state.highestResolved);
  for (let i = 0; i < cap; i++) {
    const a = args[i];
    if (!a || typeof (/** @type any */ (a).then) !== 'function') continue;

    Promise.resolve(/** @type Promise<unknown> */ (a)).then(
      (resolved) => {
        if (state.aborted) return;
        if (i >= state.highestResolved) return;
        state.highestResolved = i;
        applyChildInner(part, resolved);
      },
      () => {
        // Swallow rejection. A rejected Promise is treated as "no value";
        // the existing render stays in place.
      },
    );
  }
}

/**
 * Abort an `until` directive's in-flight Promise tracking. Called from
 * `teardownChild` when the part is being reset.
 * @param {{aborted:boolean}} state
 */
function teardownUntil(state) {
  state.aborted = true;
}

/* ================================================================
 * watch (signal binding): fine-grained reactive part.
 * ================================================================ */

/**
 * Bind a child part to a signal. Reads the signal once and writes its
 * value into the part. Installs a per-part `Signal.subtle.Watcher`
 * that, on signal change, re-reads and re-applies the value WITHOUT
 * re-running the host component's render(). When the part is torn
 * down (teardownChild) the watcher is disposed.
 *
 * The signal read happens inside the watcher's `observe()`, so the
 * dependency edge connects the signal to THIS watcher. The host's
 * own render watcher is outside the active stack here, so the host
 * does not also subscribe to the signal (which would double-fire as
 * both a full re-render and a watch update).
 *
 * @param {Extract<BoundPart, {kind:'child'}>} part
 * @param {{ get: () => unknown, __isSignal: true }} sig
 */
function applyWatch(part, sig) {
  const partAny = /** @type any */ (part);
  // Same signal as last render: refresh dep tracking via observe (the
  // spec-aligned Watcher fires once per arm, so re-observing re-arms).
  if (partAny.__watchSig === sig && partAny.__watchSub) {
    let value;
    partAny.__watchSub.observe(() => { value = sig.get(); });
    applyChildInner(part, value);
    return;
  }
  // Signal changed (or first render). Tear down any prior watcher.
  if (partAny.__watchSub) {
    partAny.__watchSub.dispose();
    partAny.__watchSub = undefined;
  }
  partAny.__watchSig = sig;
  // Notify defers to a microtask because the spec forbids signal reads
  // inside the notify itself. The microtask re-observes (re-arms + re-
  // records the dep) and applies the new value to the part.
  const watcher = new Signal.subtle.Watcher(() => {
    queueMicrotask(() => {
      if (partAny.__watchSub !== watcher) return; // disposed mid-flight
      let v;
      watcher.observe(() => { v = sig.get(); });
      applyChildInner(part, v);
    });
  });
  partAny.__watchSub = watcher;
  let initial;
  watcher.observe(() => { initial = sig.get(); });
  applyChildInner(part, initial);
}

/**
 * Dispose a `watch` directive's per-part watcher. Called from
 * `teardownChild` and from `clearStaleDirectiveState` when the value
 * at the part is no longer a watch.
 * @param {any} partAny
 */
function teardownWatch(partAny) {
  if (partAny.__watchSub) {
    partAny.__watchSub.dispose();
    partAny.__watchSub = undefined;
    partAny.__watchSig = undefined;
  }
}

/* ================================================================
 * asyncAppend / asyncReplace: stream from AsyncIterable.
 * ================================================================ */

/**
 * Apply `asyncAppend(iterable, mapper?)` at a child position.
 *
 * Iterates the AsyncIterable in the background. Each yielded value is
 * mapped (optional) and rendered as a node group, appended before the
 * marker. The state is stored on `part.child` so `teardownChild` can
 * abort the iteration when the part is reset.
 *
 * @param {Extract<BoundPart, {kind:'child'}>} part
 * @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
 */
function applyAsyncAppend(part, dir) {
  // Same-iterable short-circuit: if the prior render's iterable identity
  // matches, the existing iterator is still consuming it. Re-subscribing
  // would start a fresh iterator that misses already-yielded values.
  // Matches lit-html's behavior.
  const currentChild = /** @type any */ (part.child);
  if (currentChild && currentChild.kind === 'async-stream'
      && currentChild.mode === 'append'
      && currentChild.iterable === dir.iterable) {
    return;
  }

  teardownChild(part);

  const iterator = /** @type AsyncIterator<unknown> */ (
    dir.iterable[Symbol.asyncIterator]()
  );
  /** @type {AsyncStreamState} */
  const state = {
    kind: 'async-stream',
    mode: 'append',
    aborted: false,
    iterable: dir.iterable,
    iterator,
    /** @type {ChildNode[]} */ nodes: [],
  };
  part.child = state;

  consumeAsyncStream(state, part, dir);
}

/**
 * Apply `asyncReplace(iterable, mapper?)` at a child position. Same as
 * `applyAsyncAppend` but each new value replaces the previous content.
 *
 * @param {Extract<BoundPart, {kind:'child'}>} part
 * @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
 */
function applyAsyncReplace(part, dir) {
  // Same-iterable short-circuit: see comment in applyAsyncAppend.
  const currentChild = /** @type any */ (part.child);
  if (currentChild && currentChild.kind === 'async-stream'
      && currentChild.mode === 'replace'
      && currentChild.iterable === dir.iterable) {
    return;
  }

  teardownChild(part);

  const iterator = /** @type AsyncIterator<unknown> */ (
    dir.iterable[Symbol.asyncIterator]()
  );
  /** @type {AsyncStreamState} */
  const state = {
    kind: 'async-stream',
    mode: 'replace',
    aborted: false,
    iterable: dir.iterable,
    iterator,
    /** @type {ChildNode[]} */ nodes: [],
  };
  part.child = state;

  consumeAsyncStream(state, part, dir);
}

/**
 * @typedef {{
 *   kind: 'async-stream',
 *   mode: 'append' | 'replace',
 *   aborted: boolean,
 *   iterable: AsyncIterable<unknown>,
 *   iterator: AsyncIterator<unknown>,
 *   nodes: ChildNode[],
 * }} AsyncStreamState
 */

/**
 * Consume an AsyncIterable for `asyncAppend` / `asyncReplace`. Drives
 * the iterator with an explicit `.next()` loop (rather than `for await`)
 * so that `teardownAsyncStream` can call `iterator.return()` to break
 * a generator parked on an `await`. The `aborted` flag is also checked
 * after every `next()` resolve to short-circuit if abortion happened
 * while the iterator was suspended.
 *
 * @param {AsyncStreamState} state
 * @param {Extract<BoundPart, {kind:'child'}>} part
 * @param {{ iterable: AsyncIterable<unknown>, mapper?: (v: unknown, i: number) => unknown }} dir
 */
async function consumeAsyncStream(state, part, dir) {
  const marker = part.marker;
  let i = 0;
  try {
    while (!state.aborted) {
      const result = await state.iterator.next();
      if (state.aborted) break;
      if (result.done) break;
      const mapped = dir.mapper ? dir.mapper(result.value, i) : result.value;
      const newNodes = renderToNodes(mapped);

      if (state.mode === 'replace') {
        for (const n of state.nodes) {
          if (n.parentNode) n.parentNode.removeChild(n);
        }
        state.nodes = [];
      }

      const frag = document.createDocumentFragment();
      for (const n of newNodes) frag.appendChild(n);
      marker.parentNode?.insertBefore(frag, marker);
      state.nodes.push(...newNodes);

      i++;
    }
  } catch (err) {
    // Swallow iteration errors. A leaked iterator throwing should not
    // crash the host's render cycle. Authors who care about errors
    // should handle them in their iterable / generator.
    if (typeof console !== 'undefined') console.error('[webjs] asyncStream error:', err);
  }
}

/**
 * Render a single value into a flat list of DOM nodes for insertion via
 * insertBefore. Handles strings, numbers, TemplateResult, and arrays.
 * @param {unknown} value
 * @returns {ChildNode[]}
 */
function renderToNodes(value) {
  if (value == null || value === false || value === true) return [];
  if (isTemplate(value)) {
    const tr = /** @type any */ (value);
    const { templateEl, parts } = compile(tr);
    const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
    const bound = parts.map((p) => bindPart(p, frag));
    for (let i = 0; i < tr.values.length; i++) {
      applyPart(bound[i], tr.values[i], undefined, tr.values);
    }
    return [...frag.childNodes];
  }
  if (Array.isArray(value)) {
    const nodes = [];
    for (const v of value) nodes.push(...renderToNodes(v));
    return nodes;
  }
  return [document.createTextNode(String(value))];
}

/**
 * Abort an async-stream directive. Sets `aborted = true` (so the next
 * `await iterator.next()` resolution short-circuits), removes all nodes
 * rendered so far, and explicitly calls `iterator.return()` so a
 * generator parked on `await` can unwind via its `finally` blocks
 * instead of leaking.
 * @param {AsyncStreamState} state
 */
function teardownAsyncStream(state) {
  state.aborted = true;
  for (const n of state.nodes) {
    if (n.parentNode) n.parentNode.removeChild(n);
  }
  state.nodes = [];
  // Best-effort iterator cleanup. `.return()` is optional on AsyncIterators;
  // generators built via `async function*` provide it and run their
  // `finally` blocks. Swallow any rejection so teardown can't throw.
  try {
    state.iterator.return?.()?.catch?.(() => {});
  } catch {
    // ignore
  }
}
