/**
 * merge_runs — merge adjacent format-identical runs within paragraphs.
 *
 * Safety barriers: never merge across fldChar/instrText, comment range,
 * bookmark, or tracked-change wrapper boundaries.
 */

import { XMLSerializer } from '@xmldom/xmldom';
import { OOXML, W } from './namespaces.js';

export type MergeRunsResult = {
  runsMerged: number;
  proofErrRemoved: number;
};

export type MergeRunsOptions = {
  /**
   * When true, adjacent runs that differ in any `w:rsid*` attribute are
   * NOT merged, and existing rsid attributes on live runs are not stripped.
   * Use this from edit pipelines (e.g. `replace_text`) so that mutations
   * to one run do not silently rewrite rsid identity on neighbouring runs
   * the caller never touched. The default (`false`) preserves the
   * normalize-on-open behaviour: every run in the body is first stripped
   * of its `w:rsid*` attributes, then format-identical adjacent runs are
   * merged unconditionally.
   */
  preserveRsidIdentity?: boolean;
};

// ── Barrier element local names ────────────────────────────────────────

const BARRIER_LOCALS = new Set([
  W.fldChar,
  W.instrText,
  W.bookmarkStart,
  W.bookmarkEnd,
  'commentRangeStart',
  'commentRangeEnd',
]);

const TC_WRAPPER_LOCALS = new Set([
  'ins',
  'del',
  'moveFrom',
  'moveTo',
]);

// ── Helpers ────────────────────────────────────────────────────────────

const serializer = new XMLSerializer();

function isW(el: Element, localName: string): boolean {
  return el.namespaceURI === OOXML.W_NS && el.localName === localName;
}

function isWRun(node: Node): node is Element {
  return (
    node.nodeType === 1 &&
    (node as Element).namespaceURI === OOXML.W_NS &&
    (node as Element).localName === W.r
  );
}

/** Strip all w:rsid* attributes from an element and its descendants. */
function stripRsidAttributes(el: Element): void {
  const attrs = Array.from(el.attributes);
  for (const attr of attrs) {
    const name = attr.localName ?? attr.name;
    if (/^rsid/i.test(name)) {
      el.removeAttributeNode(attr);
    }
  }
  let child = el.firstChild;
  while (child) {
    if (child.nodeType === 1) stripRsidAttributes(child as Element);
    child = child.nextSibling;
  }
}

/**
 * Canonical key for a run's direct formatting. Two runs with the same
 * key have identical formatting and can be merged (if no barriers).
 */
function runFormattingKey(run: Element): string {
  const rPr = run.getElementsByTagNameNS(OOXML.W_NS, W.rPr).item(0);
  if (!rPr) return '';
  const clone = rPr.cloneNode(true) as Element;
  stripRsidAttributes(clone);
  return serializer.serializeToString(clone);
}

/**
 * Canonical key for a run's rsid attributes. Two runs with the same key
 * carry identical revision-save-id provenance and are mergeable; runs
 * with differing rsids represent distinct edit sessions and must not be
 * merged (Word preserves the boundary as part of its revision-tracking
 * heuristics).
 */
function runRsidKey(run: Element): string {
  return Array.from(run.attributes)
    .filter((attr) => /^rsid/i.test(attr.localName ?? attr.name))
    .map((attr) => `${attr.namespaceURI ?? ''}:${attr.localName ?? attr.name}=${attr.value}`)
    .sort()
    .join('|');
}

/** Check whether a run contains barrier content (fldChar, instrText). */
function runContainsBarrierContent(run: Element): boolean {
  let child = run.firstChild;
  while (child) {
    if (child.nodeType === 1) {
      const el = child as Element;
      if (el.namespaceURI === OOXML.W_NS && BARRIER_LOCALS.has(el.localName)) {
        return true;
      }
    }
    child = child.nextSibling;
  }
  return false;
}

/** Content children of a run (everything except w:rPr). */
function runContentChildren(run: Element): Node[] {
  const out: Node[] = [];
  let child = run.firstChild;
  while (child) {
    if (child.nodeType === 1 && isW(child as Element, W.rPr)) {
      child = child.nextSibling;
      continue;
    }
    out.push(child);
    child = child.nextSibling;
  }
  return out;
}

/**
 * Consolidate adjacent w:t elements within a run into a single w:t.
 * This is needed after run merging, since appending content children from
 * one run into another produces multiple w:t fragments.
 * Skips whitespace-only text nodes between elements (formatting indentation).
 */
function consolidateRunTextNodes(run: Element): void {
  let child = run.firstChild;
  while (child) {
    if (child.nodeType !== 1 || !isW(child as Element, W.t)) {
      child = child.nextSibling;
      continue;
    }
    // Found a w:t — absorb any following w:t siblings, skipping whitespace text nodes.
    const first = child as Element;
    let next: Node | null = first.nextSibling;
    while (next) {
      // Skip whitespace-only text nodes (formatting indentation).
      if (next.nodeType === 3 && !(next.textContent ?? '').trim()) {
        const ws = next;
        next = ws.nextSibling;
        ws.parentNode?.removeChild(ws);
        continue;
      }
      // Absorb adjacent w:t elements.
      if (next.nodeType === 1 && isW(next as Element, W.t)) {
        const following = next.nextSibling;
        first.textContent = (first.textContent ?? '') + (next.textContent ?? '');
        next.parentNode?.removeChild(next);
        next = following;
        continue;
      }
      // Non-text, non-whitespace node — stop consolidation.
      break;
    }
    // Fix xml:space attribute for the consolidated text.
    const text = first.textContent ?? '';
    if (text.startsWith(' ') || text.endsWith(' ')) {
      first.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve');
    } else {
      first.removeAttributeNS('http://www.w3.org/XML/1998/namespace', 'space');
    }
    child = first.nextSibling;
  }
}

/** Remove all `<w:proofErr>` elements from a paragraph. */
function removeProofErrors(paragraph: Element): number {
  const proofErrs = Array.from(
    paragraph.getElementsByTagNameNS(OOXML.W_NS, 'proofErr'),
  );
  for (const el of proofErrs) {
    el.parentNode?.removeChild(el);
  }
  return proofErrs.length;
}

// ── Run group collection ───────────────────────────────────────────────

/**
 * Collect groups of adjacent w:r elements within the same parent, not
 * separated by barrier elements. Each group is a merge candidate.
 */
function collectMergeableRunGroups(paragraph: Element): Element[][] {
  const allGroups: Element[][] = [];
  const containers: Element[] = [paragraph];

  // Also collect runs inside tracked-change wrappers
  for (const local of TC_WRAPPER_LOCALS) {
    const wrappers = Array.from(
      paragraph.getElementsByTagNameNS(OOXML.W_NS, local),
    );
    containers.push(...wrappers);
  }

  for (const container of containers) {
    let currentGroup: Element[] = [];
    let child = container.firstChild;

    while (child) {
      if (child.nodeType === 1) {
        const el = child as Element;
        if (isWRun(el)) {
          currentGroup.push(el);
        } else {
          // Any non-run element breaks the group
          if (currentGroup.length > 0) {
            allGroups.push(currentGroup);
            currentGroup = [];
          }
        }
      }
      child = child.nextSibling;
    }

    if (currentGroup.length > 0) {
      allGroups.push(currentGroup);
    }
  }

  return allGroups;
}

/** Merge adjacent format-identical runs within a paragraph. */
function mergeParagraphRuns(paragraph: Element, preserveRsidIdentity: boolean): number {
  let merged = 0;
  const groups = collectMergeableRunGroups(paragraph);

  for (const group of groups) {
    if (group.length < 2) continue;

    let i = 0;
    while (i < group.length - 1) {
      const current = group[i]!;
      const next = group[i + 1]!;

      if (
        !runContainsBarrierContent(current) &&
        !runContainsBarrierContent(next) &&
        runFormattingKey(current) === runFormattingKey(next) &&
        (!preserveRsidIdentity || runRsidKey(current) === runRsidKey(next))
      ) {
        // Move all content children of `next` into `current`.
        for (const node of runContentChildren(next)) {
          current.appendChild(node);
        }
        // Consolidate adjacent w:t fragments produced by the merge.
        consolidateRunTextNodes(current);
        next.parentNode?.removeChild(next);
        group.splice(i + 1, 1);
        merged++;
      } else {
        i++;
      }
    }
  }

  return merged;
}

// ── Public API ─────────────────────────────────────────────────────────

/**
 * Merge adjacent format-identical runs across all paragraphs in the
 * document body. Removes `<w:proofErr>` elements.
 *
 * Pass `{ preserveRsidIdentity: true }` from edit pipelines that must not
 * disturb rsid attributes on runs the caller did not touch (see #286).
 * The default omits that guard and matches the historical normalize-on-open
 * behaviour, which consolidates rsid-fragmented runs that Word produces
 * across edit sessions.
 *
 * Safety barriers prevent merges across:
 * - Field boundaries (fldChar, instrText)
 * - Comment range boundaries (commentRangeStart, commentRangeEnd)
 * - Bookmark boundaries (bookmarkStart, bookmarkEnd)
 * - Tracked-change wrapper boundaries (ins, del, moveFrom, moveTo)
 */
export function mergeRuns(doc: Document, opts: MergeRunsOptions = {}): MergeRunsResult {
  const body = doc.getElementsByTagNameNS(OOXML.W_NS, W.body).item(0);
  if (!body) return { runsMerged: 0, proofErrRemoved: 0 };

  const preserveRsidIdentity = opts.preserveRsidIdentity === true;
  let totalMerged = 0;
  let totalProofErr = 0;

  const paragraphs = Array.from(
    body.getElementsByTagNameNS(OOXML.W_NS, W.p),
  );

  for (const p of paragraphs) {
    totalProofErr += removeProofErrors(p);

    if (!preserveRsidIdentity) {
      const runs = Array.from(p.getElementsByTagNameNS(OOXML.W_NS, W.r));
      for (const r of runs) {
        stripRsidAttributes(r);
      }
    }

    totalMerged += mergeParagraphRuns(p, preserveRsidIdentity);
  }

  return { runsMerged: totalMerged, proofErrRemoved: totalProofErr };
}
