// trackedOverlay.js — Phase 3 of the Contribute rewrite. Decorates the
// rendered MarkdownPreview DOM with `<span class="tracked-insert">` and
// `<span class="tracked-delete">` markup for each accepted change on
// the branch, restoring the §8.10 inline tracked-change layer that
// Phase 1 dropped when Tiptap left.
//
// The pipeline is DOM-side (post-marked.parse) rather than markdown-side
// so the overlay sees the exact text the reader sees — header /
// emphasis / inline code shape doesn't matter for matching, and we
// don't try to re-anchor proposed text that crosses inline tags.
// Single-text-node match is the pre-fancy stance; cross-tag spans skip
// cleanly per the Phase 3 instructions ("if proposed doesn't match,
// skip the decoration rather than mis-anchor").
//
// Subtleties handled:
//   • acted_at ordering — later spans win on overlap because we always
//     search for un-wrapped occurrences; an earlier wrap removes that
//     substring from the search space, so a later change that hits the
//     same passage just lands its mark on whatever is left.
//   • mermaid intersections — text nodes inside `.mermaid-block` are
//     skipped before matching, so a tracked span whose `proposed`
//     only matches inside diagram source gets dropped silently.
//   • drift — if the proposed text was further edited since acceptance
//     it won't match verbatim; we skip rather than mis-anchor.
//
// Out of scope for Phase 3 (per the prompt): retro-fitting DiffView
// (which uses dangerouslySetInnerHTML — the per-session injection it
// expects is dying in Phase 7); rendering changes that have been
// declined or are still pending; cursor-aware "what's new since last
// visit" filtering.

const DECORATABLE_PARENT_SKIP = new Set([
  'SCRIPT',
  'STYLE',
  'CODE',
  'PRE',
])

function isInsideMermaid(node) {
  let p = node.parentNode
  while (p && p.nodeType === Node.ELEMENT_NODE) {
    if (p.classList?.contains('mermaid-block')) return true
    p = p.parentNode
  }
  return false
}

function isInsideTrackedSpan(node) {
  let p = node.parentNode
  while (p && p.nodeType === Node.ELEMENT_NODE) {
    if (p.classList?.contains('tracked-insert')) return true
    if (p.classList?.contains('tracked-delete')) return true
    p = p.parentNode
  }
  return false
}

// Yield every text node inside `root` whose nearest non-text parent is
// neither inside a mermaid block nor inside an already-applied tracked
// span. Code/pre nodes are excluded — accepted-change text rarely
// targets verbatim code blocks and matching inside them produces noisy
// false positives.
function* eligibleTextNodes(root) {
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
    acceptNode(node) {
      if (!node.nodeValue) return NodeFilter.FILTER_REJECT
      const parent = node.parentElement
      if (!parent) return NodeFilter.FILTER_REJECT
      if (DECORATABLE_PARENT_SKIP.has(parent.tagName)) return NodeFilter.FILTER_REJECT
      if (isInsideMermaid(node)) return NodeFilter.FILTER_REJECT
      if (isInsideTrackedSpan(node)) return NodeFilter.FILTER_REJECT
      return NodeFilter.FILTER_ACCEPT
    },
  })
  let n = walker.nextNode()
  while (n) {
    yield n
    n = walker.nextNode()
  }
}

// Collapse the kind of whitespace differences marked produces (newlines
// become spaces, indentation folds) so a proposed string authored
// against raw markdown can still match against rendered text.
function normalizeForMatch(s) {
  return String(s || '').replace(/\s+/g, ' ').trim()
}

// Find the first text node containing `needle` (after whitespace
// normalization) and return the (node, offsetInNodeValue,
// matchedLength) for splitting. Returns null when no eligible node
// contains the needle.
function findFirstMatch(root, needle) {
  const target = normalizeForMatch(needle)
  if (!target) return null
  for (const node of eligibleTextNodes(root)) {
    const haystack = node.nodeValue
    const normalized = haystack.replace(/\s+/g, ' ')
    const idx = normalized.indexOf(target)
    if (idx < 0) continue
    // Translate the normalized index back to the raw nodeValue offset.
    // We walk the raw string counting non-whitespace characters until
    // we hit the normalized index, then count out the matched length.
    const start = mapNormalizedOffsetToRaw(haystack, idx)
    const end = mapNormalizedOffsetToRaw(haystack, idx + target.length)
    if (start == null || end == null || end <= start) continue
    return { node, start, end }
  }
  return null
}

function mapNormalizedOffsetToRaw(raw, normalizedOffset) {
  // Re-derive the position in `raw` that corresponds to position
  // `normalizedOffset` in `raw.replace(/\s+/g, ' ')`. We walk raw,
  // tracking how many normalized characters we've emitted so far.
  let emitted = 0
  let i = 0
  let inWhitespaceRun = false
  while (i <= raw.length) {
    if (emitted === normalizedOffset) return i
    if (i === raw.length) break
    const c = raw[i]
    if (/\s/.test(c)) {
      if (!inWhitespaceRun) {
        emitted += 1   // collapsed whitespace counts as one space
        inWhitespaceRun = true
      }
    } else {
      emitted += 1
      inWhitespaceRun = false
    }
    i += 1
  }
  return emitted === normalizedOffset ? raw.length : null
}

function makeSpan(className, text, change) {
  const span = document.createElement('span')
  span.className = className
  span.setAttribute('data-change-id', String(change.id))
  if (change.source_message_id != null) {
    span.setAttribute('data-source-message-id', String(change.source_message_id))
  }
  span.textContent = text
  return span
}

// Replace the matched range inside `node` with: optional tracked-delete
// span (rendering `original` struck-through), followed by tracked-insert
// span wrapping the matched proposed text.
function decorateMatch(match, change) {
  const { node, start, end } = match
  const before = node.nodeValue.slice(0, start)
  const matched = node.nodeValue.slice(start, end)
  const after = node.nodeValue.slice(end)

  const parent = node.parentNode
  if (!parent) return

  const insertSpan = makeSpan('tracked-insert', matched, change)
  let deleteSpan = null
  const originalText = (change.original || '').trim()
  // Skip the strikethrough when original is empty (pure insertion) or
  // when it would duplicate the inserted text exactly — common for
  // light AI edits where the proposal essentially repeats the source.
  if (originalText && normalizeForMatch(originalText) !== normalizeForMatch(matched)) {
    deleteSpan = makeSpan('tracked-delete', change.original, change)
  }

  const beforeNode = before ? document.createTextNode(before) : null
  const afterNode = after ? document.createTextNode(after) : null

  // Build the replacement sequence in order.
  const frag = document.createDocumentFragment()
  if (beforeNode) frag.appendChild(beforeNode)
  if (deleteSpan) frag.appendChild(deleteSpan)
  frag.appendChild(insertSpan)
  if (afterNode) frag.appendChild(afterNode)

  parent.replaceChild(frag, node)
}

// Sort accepted changes by acted_at ascending so later spans win on
// overlap. Falls back to id ordering when acted_at is null/equal.
function orderForDecoration(changes) {
  return changes
    .filter(c => c?.state === 'accepted' && c.proposed)
    .slice()
    .sort((a, b) => {
      const aa = a.acted_at || ''
      const bb = b.acted_at || ''
      if (aa === bb) return (a.id || 0) - (b.id || 0)
      return aa.localeCompare(bb)
    })
}

export function decorateAcceptedChanges(root, changes) {
  if (!root || !changes || changes.length === 0) return
  const ordered = orderForDecoration(changes)
  for (const change of ordered) {
    const match = findFirstMatch(root, change.proposed)
    if (!match) continue
    try {
      decorateMatch(match, change)
    } catch {
      // Tolerate per-change failures so one bad span doesn't kill the
      // overlay for everything that came after it.
    }
  }
}
