/**
 * reject_changes — reject all tracked changes in a OOXML document body.
 *
 * Produces the document as it was before tracked changes were made by:
 * - Removing w:ins elements AND their content (insertions undone)
 * - Unwrapping w:del elements and converting w:delText → w:t (deletions restored)
 * - Unwrapping w:moveFrom (keep at original position), removing w:moveTo and content
 * - Restoring original properties from *PrChange records
 * - Preserving cross-paragraph bookmark boundaries when removing inserted paragraphs
 * - Stripping paragraph-level revision markers and rsidDel attributes
 *
 * Operates on the W3C DOM (`@xmldom/xmldom`).
 */

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

const W_NS = OOXML.W_NS;

export type RejectChangesResult = {
  insertionsRemoved: number;
  deletionsRestored: number;
  movesReverted: number;
  propertyChangesReverted: number;
};

// ── DOM helpers (internal) ──────────────────────────────────────────

function isW(node: Node, localName: string): node is Element {
  return (
    node.nodeType === 1 &&
    (node as Element).namespaceURI === W_NS &&
    (node as Element).localName === localName
  );
}

function getDepth(node: Node): number {
  let depth = 0;
  let cur: Node | null = node.parentNode;
  while (cur) {
    depth++;
    cur = cur.parentNode;
  }
  return depth;
}

function collectByLocalName(container: Document | Element, localName: string): Element[] {
  return Array.from(container.getElementsByTagNameNS(W_NS, localName));
}

function removeAllByLocalName(container: Document | Element, localName: string): number {
  const elements = collectByLocalName(container, localName);
  let count = 0;
  for (const el of elements) {
    if (el.parentNode) {
      el.parentNode.removeChild(el);
      count++;
    }
  }
  return count;
}

function unwrapAllByLocalName(container: Document | Element, localName: string): number {
  const elements = collectByLocalName(container, localName);
  // Sort deepest-first to handle nested wrappers correctly
  elements.sort((a, b) => getDepth(b) - getDepth(a));
  let count = 0;
  for (const el of elements) {
    const parent = el.parentNode;
    if (!parent) continue;
    while (el.firstChild) {
      parent.insertBefore(el.firstChild, el);
    }
    parent.removeChild(el);
    count++;
  }
  return count;
}

/**
 * Check if a paragraph has a paragraph-level revision marker.
 * Pattern: w:p > w:pPr > w:rPr > w:ins (or w:del)
 */
function paragraphHasParaMarker(p: Element, markerLocalName: string): boolean {
  for (let i = 0; i < p.childNodes.length; i++) {
    const child = p.childNodes[i]!;
    if (!isW(child, 'pPr')) continue;
    for (let j = 0; j < child.childNodes.length; j++) {
      const pPrChild = child.childNodes[j]!;
      if (!isW(pPrChild, 'rPr')) continue;
      for (let k = 0; k < pPrChild.childNodes.length; k++) {
        const rPrChild = pPrChild.childNodes[k]!;
        if (isW(rPrChild, markerLocalName)) return true;
      }
    }
  }
  return false;
}

// Property change element local names (all 6 types)
const PR_CHANGE_LOCALS = [
  'rPrChange', 'pPrChange', 'sectPrChange',
  'tblPrChange', 'trPrChange', 'tcPrChange',
];

/**
 * Relocate orphaned bookmarks from a paragraph being removed to an
 * adjacent kept paragraph. This preserves cross-paragraph bookmark boundaries.
 */
function relocateBookmarks(p: Element, paragraphsToRemove: Set<Element>): void {
  const parent = p.parentNode;
  if (!parent) return;

  // Find an adjacent kept paragraph (prefer next, fallback to previous)
  let target: Element | null = null;
  let sibling: Node | null = p.nextSibling;
  while (sibling) {
    if (sibling.nodeType === 1 && isW(sibling as Element, 'p') && !paragraphsToRemove.has(sibling as Element)) {
      target = sibling as Element;
      break;
    }
    sibling = sibling.nextSibling;
  }
  if (!target) {
    sibling = p.previousSibling;
    while (sibling) {
      if (sibling.nodeType === 1 && isW(sibling as Element, 'p') && !paragraphsToRemove.has(sibling as Element)) {
        target = sibling as Element;
        break;
      }
      sibling = sibling.previousSibling;
    }
  }
  if (!target) return;

  // Move bookmarkStart/bookmarkEnd elements that are direct children of the paragraph
  const toMove: Element[] = [];
  for (let i = 0; i < p.childNodes.length; i++) {
    const child = p.childNodes[i]!;
    if (child.nodeType !== 1) continue;
    const el = child as Element;
    if (isW(el, 'bookmarkStart') || isW(el, 'bookmarkEnd')) {
      toMove.push(el);
    }
  }

  // Also check sibling bookmarks (sibling-style: <bookmarkStart/><p/><bookmarkEnd/>)
  let prev: Node | null = p.previousSibling;
  while (prev) {
    if (prev.nodeType === 1) {
      const el = prev as Element;
      if (isW(el, 'bookmarkStart') || isW(el, 'bookmarkEnd')) {
        toMove.push(el);
        prev = prev.previousSibling;
        continue;
      }
      break;
    }
    prev = prev.previousSibling;
  }
  let next: Node | null = p.nextSibling;
  while (next) {
    if (next.nodeType === 1) {
      const el = next as Element;
      if (isW(el, 'bookmarkStart') || isW(el, 'bookmarkEnd')) {
        toMove.push(el);
        next = next.nextSibling;
        continue;
      }
      break;
    }
    next = next.nextSibling;
  }

  for (const bm of toMove) {
    // Insert at the beginning of the target paragraph (after pPr if present)
    const firstNonPPr = Array.from(target.childNodes).find(
      (n) => !(n.nodeType === 1 && isW(n as Element, 'pPr')),
    );
    if (bm.parentNode) bm.parentNode.removeChild(bm);
    target.insertBefore(bm, firstNonPPr ?? null);
  }
}

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

/**
 * Reject all tracked changes in the document body or story root, restoring
 * the document to its pre-edit state.
 *
 * Mutates the Document in place (same convention as acceptChanges).
 */
export function rejectChanges(doc: Document): RejectChangesResult {
  const root = doc.getElementsByTagNameNS(W_NS, 'body').item(0) ?? doc.documentElement;
  if (!root) {
    return { insertionsRemoved: 0, deletionsRestored: 0, movesReverted: 0, propertyChangesReverted: 0 };
  }

  // Phase A — Identify paragraphs to remove (entirely inserted paragraphs)
  const paragraphsToRemove = new Set<Element>();
  const allParagraphs = collectByLocalName(root, 'p');

  for (const p of allParagraphs) {
    // Remove a paragraph iff its paragraph MARK is a tracked insertion
    // (w:p > w:pPr > w:rPr > w:ins) — the paragraph break itself was inserted.
    // We deliberately do NOT drop a paragraph based on content ("all runs inside
    // w:ins/w:moveTo"): a run-level insertion under an untracked mark means text was
    // inserted into a pre-existing paragraph, which Word/LibreOffice keep (empty) on
    // reject. safe-docx's inserted paragraphs always carry the mark now, so the
    // mark-based rule suffices and is Word-faithful. (Mirrors rejectAllChanges.)
    if (paragraphHasParaMarker(p, 'ins')) {
      paragraphsToRemove.add(p);
    }
  }

  // Phase B — Preserve cross-paragraph bookmark boundaries
  for (const p of paragraphsToRemove) {
    relocateBookmarks(p, paragraphsToRemove);
  }

  // Phase C — Remove insertions and move destinations
  const insertionsRemoved = removeAllByLocalName(root, 'ins');
  const moveToRemoved = removeAllByLocalName(root, 'moveTo');
  removeAllByLocalName(root, 'moveToRangeStart');
  removeAllByLocalName(root, 'moveToRangeEnd');
  removeAllByLocalName(root, 'moveFromRangeStart');
  removeAllByLocalName(root, 'moveFromRangeEnd');

  // Phase D — Unwrap deletions and convert w:delText → w:t
  const deletionsRestored = unwrapAllByLocalName(root, 'del');

  // Rename all w:delText elements to w:t so getParagraphText() sees them
  const delTexts = collectByLocalName(root, 'delText');
  for (const dt of delTexts) {
    const parent = dt.parentNode;
    if (!parent) continue;
    const t = doc.createElementNS(W_NS, 'w:t');
    // Copy text content
    if (dt.textContent) {
      t.appendChild(doc.createTextNode(dt.textContent));
    }
    // Copy xml:space attribute if present
    const xmlSpace = dt.getAttributeNS('http://www.w3.org/XML/1998/namespace', 'space')
      ?? dt.getAttribute('xml:space');
    if (xmlSpace) {
      t.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', xmlSpace);
    }
    parent.replaceChild(t, dt);
  }

  // Phase E — Unwrap move sources (keep content at original position)
  const moveFromUnwrapped = unwrapAllByLocalName(root, 'moveFrom');

  // Phase F — Restore original properties from *PrChange records
  let propertyChangesReverted = 0;
  for (const localName of PR_CHANGE_LOCALS) {
    const changes = collectByLocalName(root, localName);
    // Sort deepest-first
    changes.sort((a, b) => getDepth(b) - getDepth(a));
    for (const change of changes) {
      const parentProp = change.parentNode as Element | null;
      if (!parentProp) continue;
      const grandParent = parentProp.parentNode;
      if (!grandParent) continue;

      // The *PrChange element contains the original properties.
      // Extract the original property element (e.g. rPr inside rPrChange).
      // The expected mapping:
      //   rPrChange → child rPr = original run properties
      //   pPrChange → child pPr = original paragraph properties
      //   etc.
      const expectedChildLocal = localName.replace('Change', '');
      let originalProps: Element | null = null;
      for (let i = 0; i < change.childNodes.length; i++) {
        const child = change.childNodes[i]!;
        if (child.nodeType === 1 && isW(child as Element, expectedChildLocal)) {
          originalProps = child as Element;
          break;
        }
      }

      if (originalProps) {
        // Replace the current property element with the original
        const restored = originalProps.cloneNode(true) as Element;
        grandParent.replaceChild(restored, parentProp);
      } else {
        // Original props were empty — remove the parent property element entirely
        grandParent.removeChild(parentProp);
      }
      propertyChangesReverted++;
    }
  }

  // Phase G — Cleanup
  // Strip paragraph-level revision markers from w:pPr/w:rPr
  for (const p of collectByLocalName(root, 'p')) {
    for (let i = 0; i < p.childNodes.length; i++) {
      const child = p.childNodes[i]!;
      if (!isW(child, 'pPr')) continue;
      for (let j = 0; j < child.childNodes.length; j++) {
        const pPrChild = child.childNodes[j]!;
        if (!isW(pPrChild, 'rPr')) continue;
        const toRemove: Element[] = [];
        for (let k = 0; k < pPrChild.childNodes.length; k++) {
          const rPrChild = pPrChild.childNodes[k]!;
          if (isW(rPrChild, 'ins') || isW(rPrChild, 'del')) {
            toRemove.push(rPrChild as Element);
          }
        }
        for (const el of toRemove) {
          pPrChild.removeChild(el);
        }
      }
    }
  }

  // Remove paragraphs collected in Phase A
  for (const p of paragraphsToRemove) {
    if (p.parentNode) {
      p.parentNode.removeChild(p);
    }
  }

  // Strip w:rsidDel attributes on remaining elements
  const allElements = root.getElementsByTagNameNS(W_NS, '*');
  for (let i = 0; i < allElements.length; i++) {
    const el = allElements[i]!;
    if (el.hasAttributeNS(W_NS, 'rsidDel')) {
      el.removeAttributeNS(W_NS, 'rsidDel');
    }
    if (el.hasAttribute('w:rsidDel')) {
      el.removeAttribute('w:rsidDel');
    }
  }

  return {
    insertionsRemoved,
    deletionsRestored,
    movesReverted: moveFromUnwrapped + moveToRemoved,
    propertyChangesReverted,
  };
}
