/**
 * DOM Helper Layer
 *
 * Thin helpers that bridge xmldom's missing APIs and enforce the text semantics
 * contract for OOXML processing. These helpers address:
 *
 * 1. Leaf text contract — getLeafText() returns direct text child value only,
 *    unlike DOM textContent which is recursive. Critical for hash/compare code.
 * 2. Child element access — wraps xmldom's .children (0.9+) with static array return.
 * 3. Tree manipulation — insertAfter, wrap, unwrap operations.
 * 4. Namespace-safe element creation — always use createElementNS for OOXML.
 */

import { OOXML } from './namespaces.js';

// ── DOM node type constants (avoid magic numbers) ──────────────────

export const NODE_TYPE = {
  ELEMENT: 1,
  TEXT: 3,
  PROCESSING_INSTRUCTION: 7,
  COMMENT: 8,
  DOCUMENT: 9,
} as const;

// ── Leaf text contract ─────────────────────────────────────────────

/**
 * Get direct text child value (leaf-only). Returns undefined if no text child.
 *
 * Use this instead of el.textContent in hash/compare code — DOM textContent
 * is recursive and would silently break hashing on container elements.
 */
export function getLeafText(el: Element): string | undefined {
  for (let i = 0; i < el.childNodes.length; i++) {
    const child = el.childNodes[i]!;
    if (child.nodeType === NODE_TYPE.TEXT) return child.nodeValue ?? '';
  }
  return undefined;
}

/**
 * Set the direct text content of a leaf element. Replaces or creates
 * the first text child node.
 *
 * NOTE: We must update BOTH `data` and `nodeValue` on the text node.
 * In @xmldom/xmldom, `CharacterData` stores text in two separate plain
 * properties: `data` (read by XMLSerializer) and `nodeValue` (read by
 * `textContent` getter). All CharacterData mutation methods keep them
 * in sync, but a direct `child.nodeValue = text` assignment only updates
 * `nodeValue`, leaving the stale original value in `data`. This causes
 * XMLSerializer to output the old text. Using `replaceData` (which sets
 * both atomically) is the correct W3C-compliant approach.
 */
export function setLeafText(el: Element, text: string): void {
  for (let i = 0; i < el.childNodes.length; i++) {
    const child = el.childNodes[i]!;
    if (child.nodeType === NODE_TYPE.TEXT) {
      (child as CharacterData).replaceData(0, (child as CharacterData).length, text);
      return;
    }
  }
  el.appendChild(el.ownerDocument!.createTextNode(text));
}

// ── Child element access (static array from .children) ────────────

/**
 * Get element-only children. Filters out text nodes, comments, PIs.
 */
export function childElements(el: Element | Node): Element[] {
  if ('children' in el) return Array.from(el.children as ArrayLike<Element>);
  const result: Element[] = [];
  for (let i = 0; i < el.childNodes.length; i++) {
    const child = el.childNodes[i]!;
    if (child.nodeType === NODE_TYPE.ELEMENT) result.push(child as Element);
  }
  return result;
}

/**
 * Find the first direct child element with a specific tag name.
 */
export function findChildByTagName(el: Element, tagName: string): Element | null {
  const kids = el.children;
  for (let i = 0; i < kids.length; i++) {
    if ((kids[i] as Element).tagName === tagName) return kids[i] as Element;
  }
  return null;
}

// ── Tree manipulation helpers ──────────────────────────────────────

/**
 * Insert newEl after refEl in parent.
 */
export function insertAfterElement(refEl: Node, newEl: Node): void {
  refEl.parentNode!.insertBefore(newEl, refEl.nextSibling);
}

/**
 * Wrap target in wrapper (replace target with wrapper, wrapper contains target).
 */
export function wrapElement(target: Node, wrapper: Element): void {
  target.parentNode!.insertBefore(wrapper, target);
  wrapper.appendChild(target);
}

/**
 * Unwrap element — replace it with its children in parent.
 */
export function unwrapElement(el: Element): void {
  const parent = el.parentNode!;
  while (el.firstChild) parent.insertBefore(el.firstChild, el);
  parent.removeChild(el);
}

/**
 * Remove all descendant elements matching a tag name.
 * Returns the number of elements removed.
 */
export function removeAllByTagName(root: Element, tagName: string): number {
  const elements = Array.from(root.getElementsByTagName(tagName));
  let count = 0;
  for (const el of elements) {
    if (el.parentNode) {
      el.parentNode.removeChild(el);
      count++;
    }
  }
  return count;
}

/**
 * Unwrap all elements matching a tag name throughout the tree.
 * Returns the number of elements unwrapped.
 */
export function unwrapAllByTagName(root: Element, tagName: string): number {
  // Collect deepest-first to handle nesting correctly
  const elements = Array.from(root.getElementsByTagName(tagName));
  elements.reverse();
  let count = 0;
  for (const el of elements) {
    if (el.parentNode) {
      unwrapElement(el);
      count++;
    }
  }
  return count;
}

/**
 * Find all descendant elements with a specific tag name.
 * Returns a static array (unlike the live NodeList from getElementsByTagName).
 */
export function findAllByTagName(root: Element, tagName: string): Element[] {
  const nodeList = root.getElementsByTagName(tagName);
  const result: Element[] = [];
  for (let i = 0; i < nodeList.length; i++) {
    result.push(nodeList[i] as Element);
  }
  return result;
}

/**
 * Rename an element by creating a new element with the target tag name,
 * copying all attributes and children, and replacing it in the parent.
 *
 * Returns the new element. Callers holding a reference to the old element
 * should update to use the returned value.
 */
export function renameElement(el: Element, newTagName: string): Element {
  const doc = el.ownerDocument!;
  const ns = el.namespaceURI;
  const newEl = ns ? doc.createElementNS(ns, newTagName) : doc.createElement(newTagName);

  // Copy attributes
  for (let i = 0; i < el.attributes.length; i++) {
    const attr = el.attributes[i]!;
    newEl.setAttribute(attr.name, attr.value);
  }

  // Move children
  while (el.firstChild) {
    newEl.appendChild(el.firstChild);
  }

  // Replace in parent
  if (el.parentNode) {
    el.parentNode.replaceChild(newEl, el);
  }

  return newEl;
}

/**
 * Insert a child element at a specific index position.
 * If index >= child count, appends at the end.
 */
export function insertChildAt(parent: Element, child: Node, index: number): void {
  const children = childElements(parent);
  if (index >= children.length) {
    parent.appendChild(child);
  } else {
    parent.insertBefore(child, children[index]!);
  }
}

// ── Namespace-safe OOXML element creation ──────────────────────────

/**
 * Create OOXML element with correct namespace. Never use plain createElement
 * for OOXML tags — it yields namespaceURI=null which breaks namespace-aware code.
 */
export function createWmlElement(
  doc: Document,
  localTag: string,
  attrs?: Record<string, string>,
): Element {
  const el = doc.createElementNS(OOXML.W_NS, `w:${localTag}`);
  if (attrs) {
    for (const [k, v] of Object.entries(attrs)) {
      el.setAttribute(k, v);
    }
  }
  return el;
}

/**
 * Create a w:t text element with the given text content.
 * Automatically sets xml:space="preserve" to maintain whitespace fidelity.
 */
export function createWmlTextElement(doc: Document, text: string): Element {
  const el = doc.createElementNS(OOXML.W_NS, 'w:t');
  el.setAttribute('xml:space', 'preserve');
  el.appendChild(doc.createTextNode(text));
  return el;
}

// ── Namespace-aware OOXML element checks ───────────────────────────

/**
 * Check if an element is in the OOXML WordprocessingML namespace
 * with the given local name.
 */
export function isW(el: Element | null | undefined, localName: string): boolean {
  return !!el && el.namespaceURI === OOXML.W_NS && el.localName === localName;
}

/**
 * Get direct child elements in the OOXML WordprocessingML namespace
 * with a specific local name. Unlike getElementsByTagNameNS, this only
 * returns direct children (not nested descendants).
 */
export function getDirectChildrenByName(parent: Element, localName: string): Element[] {
  const out: Element[] = [];
  const kids = parent.children;
  for (let i = 0; i < kids.length; i++) {
    if (isW(kids[i] as Element, localName)) out.push(kids[i] as Element);
  }
  return out;
}
