/**
 * AdiaUI Template — Tagged template literal rendering.
 *
 * Lightweight lit-html-style rendering with reactive bindings.
 * Depends on DOM but not on UIElement — usable standalone.
 *
 *   import { html, repeat } from './template.js';
 *
 *   const result = html`<div class=${cls}>${text}</div>`;
 *   stamp(result, container);
 */

import { effect, isSignal } from './signals.js';

const [PART, KEY_MAP] = [Symbol(), Symbol()];

export function html(strings, ...values) {
  return { strings, values };
}

export function css(strings, ...values) {
  let text = '';
  for (let i = 0; i < strings.length; i++) {
    text += strings[i];
    if (i < values.length) text += values[i];
  }
  const sheet = new CSSStyleSheet();
  sheet.replaceSync(text);
  return sheet;
}

const templateCache = new WeakMap();

// §155 (v0.5.3): walks the accumulated static-string chunk to decide
// whether each ${…} placeholder is in tag-position (substitute {{p:N}})
// or content-position (substitute <!--p:N-->). The walker tracks `<`,
// `>`, `'`, `"` for that decision.
//
// FEEDBACK-06 §1 P0: HTML comments must be treated as opaque. An
// apostrophe inside `<!-- foo's bar -->` previously put `q = 1`
// (single-quoted-string state); the `-->` closer was then treated as
// string content; all subsequent `<` / `>` chars were masked. Result:
// every placeholder after the comment got mis-classified, producing
// `<segmented-ui .value=<!--p:10--> …>` (which the DOM parser eats),
// `scan()` finds no part registered, and `update()` falls through
// `isFn` branch — invoking the consumer's arrow handler with no
// arguments → "Cannot read properties of undefined (reading 'detail')"
// crash at mount with a stack trace pointing at user code.
//
// Fix: skip HTML comments wholesale before the quote/tag walker runs.
// Comments cannot open a tag and cannot contain real string delimiters
// as far as the live HTML parser is concerned. Unterminated comments
// fall through to `last = 0` (content-position) which is the safe
// default.
function inTag(m) {
  let q = 0, last = 0, i = 0;
  while (i < m.length) {
    if (q === 0 &&
        m.charCodeAt(i)     === 60 /* < */ &&
        m.charCodeAt(i + 1) === 33 /* ! */ &&
        m.charCodeAt(i + 2) === 45 /* - */ &&
        m.charCodeAt(i + 3) === 45 /* - */) {
      const end = m.indexOf('-->', i + 4);
      if (end === -1) { last = 0; break; }
      i = end + 3;
      last = 0;
      continue;
    }
    const c = m.charCodeAt(i);
    if (q) { if (c === (q === 1 ? 39 : 34)) q = 0; }
    else if (c === 39) q = 1;
    else if (c === 34) q = 2;
    else if (c === 60) last = 1;
    else if (c === 62) last = 0;
    i++;
  }
  return last === 1;
}

function getTemplate(strings) {
  let tpl = templateCache.get(strings);
  if (tpl) return tpl;
  let m = '';
  for (let i = 0; i < strings.length; i++) {
    m += strings[i];
    if (i < strings.length - 1) m += inTag(m) ? `{{p:${i}}}` : `<!--p:${i}-->`;
  }
  tpl = document.createElement('template');
  tpl.innerHTML = m;
  templateCache.set(strings, tpl);
  return tpl;
}

// §-TBD (FB-55, P1): the HTML parser lowercases attribute names inside
// <template>.innerHTML per HTML5 §13.2.5.32, so `.className=${expr}`
// arrives at scan() as `.classname`. The `name.slice(1)` strips the dot
// and the binding writes to a lowercase enumerable expando instead of
// invoking the camelCase property setter. Net: classes never apply, no
// warning, no error.
//
// The trap affects every camelCase DOM property surface — `.className`,
// `.innerText`, `.tabIndex`, `.ariaLabel`, `.contentEditable`,
// `.readOnly`, `.maxLength`, `.minLength`, `.colSpan`, `.rowSpan` — AND
// every camelCase property declared via `UIElement.static properties`
// (e.g. `.minL`, `.maxChroma`, `.hueDriftMax`, `.colorScheme`,
// `.strokeWidth`, `.collapseKeepLeading`). Even the canonical example
// `<my-panel .minL=${minL}>` from packages/web-components/README.md:212
// silently failed pre-fix.
//
// Two-layer fix:
//
//   1. Static PROP_CASE_FIX map covers built-in DOM camelCase property
//      names (zero-cost lookup, no prototype walk per binding for the
//      common case).
//
//   2. Prototype-walk fallback finds the canonical camelCase property
//      name on the element's prototype chain via case-insensitive
//      match. Cost is O(prototype-chain-depth × prototype-key-count)
//      per UNIQUE template binding (cached in parts[i].name, not
//      re-walked per update() tick). Custom-element prototype chains
//      are short (subclass → UIElement → HTMLElement → Element → Node
//      → EventTarget); cost is negligible.
//
// Backward-compatible: when the prototype walk finds no case-insensitive
// match (genuine lowercase-expando case — rare but possible), the
// original lowercase name is preserved. No consumer that was
// deliberately writing to a lowercase expando regresses.
//
// Verification: 8-test matrix in core/template.test.js's FB-55 describe
// block. RESPONSE-55 documents the trap end-to-end.
const PROP_CASE_FIX = {
  classname: 'className',
  innertext: 'innerText',
  innerhtml: 'innerHTML',
  outerhtml: 'outerHTML',
  textcontent: 'textContent',
  tabindex: 'tabIndex',
  arialabel: 'ariaLabel',
  ariadescribedby: 'ariaDescribedBy',
  arialabelledby: 'ariaLabelledBy',
  ariarole: 'role',
  contenteditable: 'contentEditable',
  readonly: 'readOnly',
  maxlength: 'maxLength',
  minlength: 'minLength',
  colspan: 'colSpan',
  rowspan: 'rowSpan',
  cellpadding: 'cellPadding',
  cellspacing: 'cellSpacing',
  usemap: 'useMap',
  ismap: 'isMap',
  accesskey: 'accessKey',
  defaultchecked: 'defaultChecked',
  defaultvalue: 'defaultValue',
  defaultselected: 'defaultSelected',
};

// Resolve a lowercase property name to its canonical camelCase form on
// the given element. Returns the original `lower` name when no
// case-insensitive match exists (preserves expando semantics). Walks
// instance own-properties first (UIElement installs camelCase props via
// `Object.defineProperty(this, ...)` in the constructor, so they live
// on the instance, not the prototype), then prototype chain. Skips
// Object.prototype to avoid noisy matches on `toString` etc.
//
// IMPORTANT: at scan() time the custom element has typically NOT yet
// been constructed (template fragment is cloned + scanned before
// insertion → upgrade), so instance-defined props aren't visible yet.
// PROP_CASE_FIX covers built-in DOM camelCase props at scan() time;
// the per-element walk runs ALSO at applyValue() time (lazy resolve,
// cached on the part after first hit) to catch UIElement instance props
// once the element has been upgraded.
function resolvePropName(el, lower) {
  if (PROP_CASE_FIX[lower]) return PROP_CASE_FIX[lower];
  // Instance own-properties (UIElement installProps lives here).
  const ownNames = Object.getOwnPropertyNames(el);
  for (let i = 0; i < ownNames.length; i++) {
    const n = ownNames[i];
    if (n !== lower && n.toLowerCase() === lower) return n;
  }
  // Prototype chain (built-in DOM camelCase + class-defined accessors).
  let proto = Object.getPrototypeOf(el);
  while (proto && proto !== Object.prototype) {
    const names = Object.getOwnPropertyNames(proto);
    for (let i = 0; i < names.length; i++) {
      const n = names[i];
      if (n !== lower && n.toLowerCase() === lower) return n;
    }
    proto = Object.getPrototypeOf(proto);
  }
  return lower;
}

export function stamp(result, container) {
  let inst = container._i;
  if (!inst || inst.s !== result.strings) {
    if (inst) disposeParts(inst.p);
    inst = mount(result, container);
    container._i = inst;
  }
  update(inst.p, result.values);
}

function mount(result, container) {
  const { strings } = result;
  const tpl = getTemplate(strings);
  let f = tpl.content.cloneNode(true);

  // §-TBD (FB-57, P1): when stamping into an SVG or MathML context, the
  // fragment's Elements arrive HTML-namespaced because `tpl.innerHTML =
  // m` is parsed at document level without foreign-content context. SVG
  // and MathML layout are namespace-strict — HTMLUnknownElement nodes
  // inside an SVG render to zero-bound-rect invisible. Re-namespace the
  // fragment before insertion, respecting `<foreignObject>` and
  // `<annotation-xml>` HTML boundaries per the HTML5 foreign-content
  // insertion-mode spec.
  //
  // Discriminator runs once per mount() call (no per-update cost).
  // Re-namespacing walks the fragment recursively (O(node-count)),
  // cheap for typical interpolation arrays (16 lines + 11 stops in
  // the canonical curve-preview case = 27 createElementNS calls).
  //
  // Implementation site choice: mount() rather than getTemplate(),
  // because:
  //   - container reference is in hand only at mount()
  //   - the same cached template may be stamped into either context;
  //     namespacing per-cache-entry would double the WeakMap size
  //     and require a cache-key extension
  //   - cloning is already happening here; re-namespacing piggybacks
  //     on the existing fragment walk
  //
  // RESPONSE-57 documents the trap end-to-end. Regression tests pin
  // the inline-SVG path (must keep working), nested-SVG path (would
  // fail pre-fix), foreignObject boundary (HTML inside SVG stays
  // HTML), and MathML parallel.
  const ns = foreignContentNS(container);
  if (ns) f = renamespaceFragment(f, ns);

  const parts = scan(f, result.values.length);
  container.replaceChildren(f);
  return { s: strings, p: parts };
}

const SVG_NS = 'http://www.w3.org/2000/svg';
const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
const XHTML_NS = 'http://www.w3.org/1999/xhtml';

// Determine whether `container` is inside an SVG or MathML subtree, and
// return the namespace to re-namespace stamped fragments into. Returns
// null when the container is in HTML context (the no-op case — vast
// majority of templates). Respects `<foreignObject>` (SVG) and
// `<annotation-xml encoding=text/html|application/xhtml+xml>` (MathML)
// HTML boundaries per HTML5 §12.2.5 foreign-content insertion mode.
function foreignContentNS(container) {
  if (!container || container.nodeType !== 1) return null;
  // Direct check — container's own namespace is a fast-path.
  if (container.namespaceURI === SVG_NS) return SVG_NS;
  if (container.namespaceURI === MATHML_NS) return MATHML_NS;
  // Walk ancestors to find a foreign-content root. `closest()` matches
  // the element itself + ancestors, so this catches the wrap()-span
  // case (HTML span inside an SVG subtree). The first match wins —
  // foreignObject/annotation-xml inside SVG short-circuits before we
  // see the outer <svg>, correctly returning null (HTML context).
  if (typeof container.closest !== 'function') return null;
  const anchor = container.closest('svg, math, foreignObject, annotation-xml');
  if (!anchor) return null;
  const tag = anchor.localName;
  if (tag === 'foreignobject' || tag === 'foreignObject') return null;
  if (tag === 'annotation-xml') {
    const enc = (anchor.getAttribute('encoding') || '').toLowerCase();
    if (enc === 'text/html' || enc === 'application/xhtml+xml') return null;
    return MATHML_NS;
  }
  if (tag === 'svg') return SVG_NS;
  if (tag === 'math') return MATHML_NS;
  return null;
}

// Recursively transfer fragment children into a new fragment with each
// Element recreated in the target namespace. Text/comment nodes clone
// unchanged. `<foreignObject>` and `<annotation-xml encoding=text/html>`
// stay in the target namespace themselves, but their descendants revert
// to HTML (matches the browser parser's foreign-content insertion mode).
function renamespaceFragment(fragment, ns) {
  const out = document.createDocumentFragment();
  transferChildren(fragment, out, ns);
  return out;
}

function transferChildren(src, dstParent, ns) {
  for (const child of [...src.childNodes]) {
    const t = child.nodeType;
    if (t === 1 /* Element */) {
      const dst = document.createElementNS(ns, child.localName);
      for (const a of child.attributes) {
        if (a.namespaceURI) dst.setAttributeNS(a.namespaceURI, a.name, a.value);
        else dst.setAttribute(a.name, a.value);
      }
      dstParent.appendChild(dst);
      // Boundary handling: <foreignObject> (SVG) and
      // <annotation-xml encoding=text/html> (MathML) revert to HTML.
      const local = child.localName;
      let childNs = ns;
      if (ns === SVG_NS && (local === 'foreignObject' || local === 'foreignobject')) {
        childNs = XHTML_NS;
      } else if (ns === MATHML_NS && local === 'annotation-xml') {
        const enc = (child.getAttribute('encoding') || '').toLowerCase();
        if (enc === 'text/html' || enc === 'application/xhtml+xml') childNs = XHTML_NS;
      }
      transferChildren(child, dst, childNs);
    } else {
      // Text, comment, etc. — clone unchanged. Comments are load-bearing
      // (the `<!--p:N-->` placeholders scan() looks for).
      dstParent.appendChild(child.cloneNode(true));
    }
  }
}

function scan(fragment, count) {
  const parts = new Array(count);
  const w = document.createTreeWalker(fragment, 129);
  let n;
  while ((n = w.nextNode())) {
    if (n.nodeType === 8) {
      const m = n.data.match(/^p:(\d+)$/);
      if (m) {
        const a = document.createTextNode('');
        n.replaceWith(a);
        w.currentNode = a;
        parts[+m[1]] = { t: 'n', n: a, c: undefined, _fx: null };
      }
    } else if (n.nodeType === 1) {
      for (const attr of [...n.attributes]) {
        const m = attr.value.match(/^\{\{p:(\d+)\}\}$/);
        if (!m) {
          // §152 (v0.5.3): partial-attribute interpolation runtime guard.
          // The attribute-part contract is whole-value-only (one placeholder
          // per attribute, full replacement). Partial like `style="bg:${a};
          // color:${b}"` produces post-concat `bg:{{p:0}}; color:{{p:1}}`
          // which fails the ^…$ regex above. Without this guard the literal
          // {{p:N}} text reaches the DOM as the attribute's static value —
          // silent-garbage class. Warn loudly so consumers don't ship the
          // bug. Hot template paths fire many times, so warn (not throw):
          // a thrown error would crash render; warn surfaces the bug class.
          if (attr.value.includes('{{p:')) {
            // §184 (v0.5.5, FEEDBACK-08 §5): `class` is special-cased
            // in the hint because `.class=${expr}` sets a JS expando
            // property (`element.class = "foo"`) — NOT the `class`
            // attribute / className. Consumers following the original
            // §152 generic hint silently lost CSS classes. For class,
            // suggest `.className=${expr}`; for style suggest the
            // canonical style-object path; for everything else keep
            // the generic property-assignment recipe.
            const a = attr.name;
            // §-TBD (FB-55 #2, P1): updated post-fix. The v0.5.5 §184
            // recommendation had `.className=${expression}` first;
            // that path now works (§-TBD PROP_CASE_FIX above) but
            // `class="${expression}"` is the more discoverable form
            // that doesn't require knowing the camelCase property
            // name. Promoting it first. The `.classList=` line is
            // removed entirely — classList is a read-only getter, no
            // PROP_CASE_FIX entry can make it assignable.
            const specific =
              a === 'class'
                ? `    class="\${expression}"      ← full replacement (whole class string is the expression)\n` +
                  `    .className=\${expression}  ← writes to the className property (resolved camelCase since FB-55 fix)\n`
                : a === 'style'
                  ? `    style="\${expression}"      ← full replacement of the style string\n` +
                    `    .style.cssText=\${expression}  ← write CSS text via the style.cssText accessor\n`
                  : `    ${a}="\${expression}"      ← full replacement (whole attr is the placeholder)\n` +
                    `    .${a}=\${expression}        ← property assignment (preferred for objects/functions)\n`;
            // eslint-disable-next-line no-console
            console.warn(
              `[template] Partial attribute interpolation is not supported.\n` +
              `  Element: <${n.tagName.toLowerCase()}>\n` +
              `  Attribute: ${a}="${attr.value.slice(0, 80)}${attr.value.length > 80 ? '…' : ''}"\n` +
              `  Use one of:\n` +
              specific +
              `  Compute the full attr value as a single expression and interpolate the whole.`
            );
          }
          continue;
        }
        const i = +m[1], name = attr.name;
        if (name[0] === '@') {
          n.removeAttribute(name);
          parts[i] = { t: 'e', n, name: name.slice(1), c: undefined, _fx: null };
        } else if (name[0] === '.') {
          // §-TBD (FB-55, P1): resolve the parser-lowercased property
          // name back to its canonical camelCase via PROP_CASE_FIX +
          // prototype walk. See PROP_CASE_FIX comment above for context.
          // Lazy-resolve in applyValue() too — see _resolved guard there.
          n.removeAttribute(name);
          parts[i] = { t: 'p', n, name: resolvePropName(n, name.slice(1)), c: undefined, _fx: null, _resolved: false };
        } else if (name[0] === '?') {
          // §250 (v0.5.11, FEEDBACK-27): Lit-style boolean attribute syntax
          // (`?attr=${bool}`) is NOT supported. Without this branch, the
          // attribute name registers verbatim including the `?` — silent
          // inert binding. Strip the bogus attribute, warn loudly + degrade
          // to a no-op text-node part so nothing reaches the DOM.
          // Consumers should use `.attr=${bool}` (property binding); AdiaUI
          // primitives reflect properties to DOM attributes automatically.
          // Parallel structural fix to v0.5.3 §152 partial-interpolation warn.
          n.removeAttribute(name);
          // eslint-disable-next-line no-console
          console.warn(
            `[template] Lit-style boolean attribute "${name}=" is not supported.\n` +
            `  Element: <${n.tagName.toLowerCase()}>\n` +
            `  Use .${name.slice(1)}=\${value} (property binding) instead — ` +
            `the primitive reflects the property to the DOM attribute for you.\n` +
            `  See USAGE.md § Template parser — invariants + unsupported syntaxes.`
          );
          parts[i] = { t: 'n', n: document.createTextNode(''), c: undefined, _fx: null };
        } else {
          parts[i] = { t: 'a', n, name, c: undefined, _fx: null };
        }
      }
    }
  }
  return parts;
}

export function disposeParts(parts) {
  for (const p of parts) {
    if (p && p._fx) { p._fx(); p._fx = null; }
  }
}

function wrap(part) {
  if (part.n.nodeType === 1 && part.n[PART]) return part.n;
  const c = document.createElement('span');
  c.style.display = 'contents';
  // ARIA-transparent: display:contents removes the box from layout, but the
  // span still generates a generic accessibility node — which breaks
  // required-owned / required-parent relationships when it sits between a
  // role-bearing parent and role-bearing children rendered via interpolation
  // (tree→treeitem, menu→menuitem via `.map()`/`repeat()`). role="presentation"
  // promotes the children to the real parent in the a11y tree. (FB-94 / FB-95)
  c.setAttribute('role', 'presentation');
  c[PART] = true;
  part.n.replaceWith(c);
  part.n = c;
  return c;
}

const isResult = (v) => v != null && typeof v === 'object' && v.strings;
const isHandler = (v) => typeof v === 'function' || (v != null && typeof v.handleEvent === 'function');

function applyValue(p, v) {
  if (v === p.c) return;
  if (p.t === 'n') {
    if (v != null && v._directive) {
      v._commit(wrap(p));
    } else if (isResult(v)) {
      stamp(v, wrap(p));
    } else if (Array.isArray(v)) {
      const c = wrap(p);
      c.replaceChildren();
      for (const item of v) {
        if (isResult(item)) {
          const el = document.createElement('span');
          el.style.display = 'contents';
          el.setAttribute('role', 'presentation'); // ARIA-transparent — see wrap()
          c.appendChild(el);
          stamp(item, el);
        } else {
          c.appendChild(document.createTextNode(item ?? ''));
        }
      }
    } else if (v instanceof Node) {
      p.n.replaceWith(v);
      p.n = v;
    } else if (p.n[PART]) {
      const t = document.createTextNode(v ?? '');
      p.n.replaceWith(t);
      p.n = t;
    } else {
      p.n.textContent = v ?? '';
    }
  } else if (p.t === 'a') {
    if (v == null || v === false) p.n.removeAttribute(p.name);
    else p.n.setAttribute(p.name, v === true ? '' : v);
  } else if (p.t === 'p') {
    // §-TBD (FB-55, P1): two-phase property-name resolution. PROP_CASE_FIX
    // resolved at scan() time for zero-cost built-in DOM camelCase (the
    // common case). For UIElement custom-element props installed on the
    // INSTANCE via the constructor's installProps(), the property doesn't
    // exist at scan() time (element not yet upgraded), so a lazy lookup
    // here catches them. Resolution is cached back into p.name on first
    // hit — subsequent updates skip the walk.
    if (!p._resolved) {
      const resolved = resolvePropName(p.n, p.name);
      if (resolved !== p.name) p.name = resolved;
      p._resolved = true;
    }
    p.n[p.name] = v;
  } else if (p.t === 'e') {
    if (isHandler(p.c)) p.n.removeEventListener(p.name, p.c);
    if (isHandler(v)) p.n.addEventListener(p.name, v);
  }
  p.c = v;
}

function update(parts, values) {
  for (let i = 0; i < parts.length; i++) {
    const p = parts[i];
    if (!p) continue;
    const v = values[i];
    const isFn = p.t !== 'e' && typeof v === 'function';
    const isSig = !isFn && p.t !== 'e' && isSignal(v);
    if (p._fx && !isFn && !isSig) { p._fx(); p._fx = null; }
    if (isFn) { if (!p._fx) { p._fx = effect(() => applyValue(p, v())); } continue; }
    if (isSig) { if (!p._fx) { p._fx = effect(() => applyValue(p, v.value)); } continue; }
    applyValue(p, v);
  }
}

export function repeat(items, keyFn, tplFn) {
  return {
    _directive: true,
    _commit(container) {
      const oldMap = container[KEY_MAP] || new Map();
      const newMap = new Map();
      const keys = [];
      for (let i = 0; i < items.length; i++) {
        const key = keyFn(items[i], i);
        if (newMap.has(key)) continue;
        keys.push(key);
        const result = tplFn(items[i], i);
        if (oldMap.has(key)) {
          const entry = oldMap.get(key);
          stamp(result, entry);
          newMap.set(key, entry);
        } else {
          const el = document.createElement('span');
          el.style.display = 'contents';
          el.setAttribute('role', 'presentation'); // ARIA-transparent — see wrap()
          stamp(result, el);
          newMap.set(key, el);
        }
      }
      for (const [key, el] of oldMap) {
        if (!newMap.has(key)) { if (el._i) disposeParts(el._i.p); el.remove(); }
      }
      let ref = null;
      for (let i = keys.length - 1; i >= 0; i--) {
        const el = newMap.get(keys[i]);
        if (el.nextSibling !== ref || el.parentNode !== container)
          container.insertBefore(el, ref);
        ref = el;
      }
      container[KEY_MAP] = newMap;
    },
  };
}

export { KEY_MAP };
