import { BookNote } from '@/types/book';
import { FoliateView } from '@/types/view';

/**
 * Synthetic-cfi marker. We embed it in the value/cfi of additional overlays
 * generated by `expandGlobalAnnotation` so that:
 *   - foliate-js's overlayer.add() sees a unique key (one overlay per match);
 *   - readest's click handler can recognise the synthetic note and map it
 *     back to its source booknote via the substring before `#g`.
 *
 * Format: `${originalCfi}#g${index}`. The leading `#g` is intentionally not
 * a valid CFI fragment so it can never collide with a real anchor.
 */
const SYNTH_MARKER = '#g';

export const isSyntheticGlobalValue = (value: string): boolean => value.includes(SYNTH_MARKER);

export const sourceCfiFromSyntheticValue = (value: string): string => {
  const idx = value.indexOf(SYNTH_MARKER);
  return idx === -1 ? value : value.slice(0, idx);
};

/**
 * Build a synthetic foliate-annotation `value` (also serves as overlay key)
 * for an occurrence of a global note's text. We include the section index in
 * the key so the same note can fan out across multiple sections without
 * collisions on the overlayer.
 */
const makeSyntheticValue = (note: BookNote, sectionIndex: number, occurrence: number): string =>
  `${note.cfi}${SYNTH_MARKER}${sectionIndex}-${occurrence}`;

/**
 * Strip ruby annotation chunks (`<rt>…</rt>` and `<rp>…</rp>`) from a string.
 * Used as a fallback matcher when the stored `note.text` contains the ruby
 * readings (because the selection's `toString()` concatenated base + reading)
 * but the section's text nodes — after we skip `<rt>/<rp>` in the walker —
 * obviously do not.
 */
const stripRuby = (s: string): string => s.replace(/<r[tp]>[\s\S]*?<\/r[tp]>/g, '');

/**
 * Some EPUBs encode ruby readings as bare adjacent characters interleaved
 * with the base text in the selection string, e.g. selecting 「漢字」 with
 * furigana 「かんじ」 may end up as "漢かん字じ". We strip CJK kana that
 * immediately follows kanji as a last-ditch fallback so cross-section
 * global highlights still match the base-text DOM.
 *
 * This is intentionally heuristic and only kicks in when the exact text
 * fails to match anywhere — false positives just mean we drop the global
 * fan-out, never that we highlight the wrong text.
 */
const collapseInlineFurigana = (s: string): string => {
  // Remove sequences of hiragana/katakana that sit between two CJK
  // ideographs (a very common pattern for inlined readings).
  return s.replace(
    /([\u3400-\u9fff\uF900-\uFAFF])[\u3041-\u309F\u30A0-\u30FF]+(?=[\u3400-\u9fff\uF900-\uFAFF])/g,
    '$1',
  );
};

/**
 * Walk every Text node in `doc.body` (skipping `<rt>` ruby annotations and
 * inline `<script>/<style>`) and for each occurrence of `text`, yield a
 * Range covering exactly that occurrence.
 *
 * If the exact text doesn't match anywhere we retry with two progressively
 * looser variants — ruby-stripped and inline-furigana-collapsed — so a
 * selection that captured furigana as part of its string still matches the
 * base text in other places. False negatives are silently dropped; we
 * never inject a highlight on a string we didn't actually match.
 */
function* findTextRanges(doc: Document, text: string): Generator<Range> {
  if (!text) return;
  const root = doc.body ?? doc.documentElement;
  if (!root) return;

  const buildWalker = () =>
    doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
      acceptNode: (node) => {
        let p: Node | null = node.parentNode;
        while (p) {
          if (p.nodeType === Node.ELEMENT_NODE) {
            const tag = (p as Element).tagName.toLowerCase();
            if (tag === 'rt' || tag === 'rp' || tag === 'script' || tag === 'style') {
              return NodeFilter.FILTER_REJECT;
            }
          }
          p = p.parentNode;
        }
        return NodeFilter.FILTER_ACCEPT;
      },
    });

  const tryWith = function* (needle: string): Generator<Range> {
    if (!needle) return;
    const walker = buildWalker();
    let node: Node | null;
    while ((node = walker.nextNode())) {
      const data = (node as Text).data;
      if (!data) continue;
      let from = 0;
      while (from <= data.length - needle.length) {
        const i = data.indexOf(needle, from);
        if (i === -1) break;
        const range = doc.createRange();
        try {
          range.setStart(node, i);
          range.setEnd(node, i + needle.length);
        } catch {
          from = i + Math.max(needle.length, 1);
          continue;
        }
        yield range;
        from = i + Math.max(needle.length, 1);
      }
    }
  };

  // First pass: exact text. Most highlights round-trip cleanly here.
  let matched = 0;
  for (const r of tryWith(text)) {
    matched += 1;
    yield r;
  }
  if (matched > 0) return;

  // Fallback 1: strip explicit ruby markup tokens from the stored text.
  const stripped = stripRuby(text);
  if (stripped && stripped !== text) {
    for (const r of tryWith(stripped)) {
      matched += 1;
      yield r;
    }
    if (matched > 0) return;
  }

  // Fallback 2: collapse inlined furigana (kana between two kanji).
  const collapsed = collapseInlineFurigana(stripped || text);
  if (collapsed && collapsed !== text && collapsed !== stripped) {
    for (const r of tryWith(collapsed)) {
      yield r;
    }
  }
}

/**
 * Locate the live overlayer for a given section index on a foliate view.
 * Returns undefined when the section isn't currently rendered OR when
 * `create-overlayer` hasn't yet attached an overlayer (e.g. very first
 * paint frame). Callers should treat undefined as "skip, try again on
 * the next render pass".
 */
function getLiveSection(
  view: FoliateView,
  index: number,
): { overlayer: OverlayerLike; doc: Document } | undefined {
  const contents = (view.renderer?.getContents?.() ?? []) as Array<{
    index?: number;
    doc?: Document;
    overlayer?: OverlayerLike;
  }>;
  const hit = contents.find((c) => c.index === index && c.overlayer && c.doc);
  if (!hit) return undefined;
  return { overlayer: hit.overlayer!, doc: hit.doc! };
}

/**
 * Minimal interface we rely on from foliate's `Overlayer`. We intentionally
 * stay loose-typed because the foliate-js package ships plain JS and we
 * don't want a hard dependency on its internals beyond these two methods.
 */
interface OverlayerLike {
  add: (value: string, range: Range, draw: unknown, opts?: unknown) => void;
  remove: (value: string) => void;
}

/**
 * Per-section memo of which global notes have already been fanned out.
 *
 * Issue #4575: the `progress` effect re-expands every global annotation across
 * every rendered section on EVERY page turn. The overlays already exist after
 * the first pass, so re-walking the DOM and re-computing `view.getCFI()` for
 * each occurrence is pure wasted work — and on books where highlighted words
 * (e.g. recurring character names) appear hundreds of times per chapter it was
 * the dominant per-page-turn cost (tens of ms of synchronous main-thread work
 * each turn, multiplied on slower mobile hardware).
 *
 * Keying on the live section `Document` makes invalidation automatic: a section
 * that is unloaded and later re-rendered gets a fresh `Document`, so its
 * overlays are rebuilt; the stale entry is collected with the old document. The
 * signature embeds `updatedAt` (bumped on every edit/recolor/global toggle) so
 * an edited note re-expands once the caller has torn down its old overlays.
 */
const expandedByDoc = new WeakMap<Document, Map<string, string>>();

const annotationSignature = (note: BookNote): string =>
  `${note.updatedAt ?? 0}:${note.style ?? ''}:${note.color ?? ''}:${note.text ?? ''}`;

/**
 * For a single `note` with `global=true`, fan out one overlay per
 * occurrence of `note.text` in the given section.
 *
 * Implementation notes — DO NOT replace with `view.addAnnotation` again:
 *   foliate's `view.addAnnotation` runs `resolveNavigation(value)` on
 *   whatever `value` we pass. Our synthetic values include a `#g…`
 *   marker so the overlayer key is unique per occurrence, but that
 *   string is NOT a valid CFI — `resolveNavigation` returns undefined
 *   and the subsequent destructure throws. Instead we draw directly on
 *   the section's overlayer and re-emit `draw-annotation` so readest's
 *   own `onDrawAnnotation` styling pipeline still runs.
 *
 * The original anchor (note.cfi) is *not* drawn here — the caller is
 * responsible for having drawn it via the regular
 * `view.addAnnotation(note)` path.
 *
 * Returns the synthetic values that were added so callers can later
 * remove them via `removeGlobalAnnotationOverlays`.
 */
export function expandGlobalAnnotation(
  view: FoliateView | null,
  note: BookNote,
  doc: Document | null | undefined,
  index: number,
): string[] {
  if (!view || !doc) return [];
  if (!note.global) return [];
  if (!note.text || note.text.trim().length === 0) return [];
  if (note.deletedAt) return [];

  // We need the *live* overlayer for this section — the `doc` arg may
  // be the same one the overlayer is attached to, but the overlayer
  // itself lives on the renderer's content record.
  const live = getLiveSection(view, index);
  if (!live) return [];
  const { overlayer } = live;

  // Skip the (expensive) re-fan-out when this exact note has already been
  // expanded into this section's current document — see `expandedByDoc`.
  const signature = annotationSignature(note);
  let docMemo = expandedByDoc.get(doc);
  if (docMemo?.get(note.id) === signature) return [];

  const added: string[] = [];
  const seen = new Set<string>();
  let occ = 0;
  for (const range of findTextRanges(doc, note.text)) {
    const sig = `${(range.startContainer as Text).data?.length ?? 0}:${range.startOffset}:${range.endOffset}:${rangeNodePathHash(range.startContainer)}`;
    if (seen.has(sig)) continue;
    seen.add(sig);

    let cfi: string;
    try {
      cfi = view.getCFI(index, range);
    } catch {
      continue;
    }
    // Skip the original anchor: we don't want to double-draw on the
    // first match. The test is conservative — exact CFI equality.
    if (cfi === note.cfi) {
      occ += 1;
      continue;
    }

    const value = makeSyntheticValue(note, index, occ);
    occ += 1;

    try {
      // Drive the same draw pipeline foliate uses internally, but
      // without going through `resolveNavigation(value)` (which would
      // fail on our synthetic, non-CFI value).
      const draw = (func: unknown, opts?: unknown) => overlayer.add(value, range, func, opts);
      const annotationForDraw = { ...note, cfi, value };
      const target = view as unknown as EventTarget;
      target.dispatchEvent(
        new CustomEvent('draw-annotation', {
          detail: { draw, annotation: annotationForDraw, doc, range },
        }),
      );
      added.push(value);
    } catch (err) {
      console.warn('Failed to add global annotation overlay', { note: note.id, err });
    }
  }

  // Record that this note (at its current content signature) is now expanded
  // into this section's document, so later page turns short-circuit above.
  // Recorded even when there were zero matches — a note that doesn't appear in
  // this section must not be re-walked every turn either.
  if (!docMemo) {
    docMemo = new Map<string, string>();
    expandedByDoc.set(doc, docMemo);
  }
  docMemo.set(note.id, signature);

  return added;
}

/**
 * Fan out a global annotation across every section the renderer currently
 * has live. This is the right entry point for "book-wide" semantics: we
 * don't try to scan unloaded sections (foliate hasn't materialised their
 * DOM), and `onCreateOverlay` will pick them up as the reader navigates.
 */
export function expandAllRenderedSections(view: FoliateView | null, note: BookNote): void {
  if (!view || !note.global || note.deletedAt) return;
  const sections = view.renderer?.getContents?.() ?? [];
  for (const section of sections) {
    const sec = section as { doc?: Document; index?: number };
    if (sec.doc && typeof sec.index === 'number') {
      expandGlobalAnnotation(view, note, sec.doc, sec.index);
    }
  }
}

/**
 * Cheap stable identifier for a node so we can dedupe ranges that fall on
 * the same text node + offsets without keeping the actual node references
 * around (the DOM may re-render between iterations).
 */
function rangeNodePathHash(node: Node): string {
  let path = '';
  let n: Node | null = node;
  let depth = 0;
  while (n && depth < 16) {
    const parent: Node | null = n.parentNode;
    if (!parent) break;
    const idx = Array.prototype.indexOf.call(parent.childNodes, n);
    path = `${idx}/${path}`;
    n = parent;
    depth += 1;
  }
  return path;
}

/**
 * Remove every synthetic overlay this note may have laid down across
 * currently-rendered sections. We hit the overlayer directly (same
 * reason as `expandGlobalAnnotation` — a synthetic `value` cannot
 * survive `resolveNavigation`). `overlayer.remove()` is a no-op for
 * unknown keys, so over-iterating up to `maxOccurrencesPerSection` is
 * cheap and avoids us having to track the exact occurrence count.
 */
export function removeGlobalAnnotationOverlays(
  view: FoliateView | null,
  note: BookNote,
  maxOccurrencesPerSection = 256,
): void {
  if (!view) return;
  const sections = (view.renderer?.getContents?.() ?? []) as Array<{
    index?: number;
    doc?: Document;
    overlayer?: OverlayerLike;
  }>;
  for (const section of sections) {
    const { index, doc, overlayer } = section;
    if (typeof index !== 'number' || !overlayer) continue;
    // Drop the expansion memo so a later re-add re-fans-out even if the note's
    // content signature is unchanged (e.g. toggle global off then on again).
    if (doc) expandedByDoc.get(doc)?.delete(note.id);
    for (let i = 0; i < maxOccurrencesPerSection; i += 1) {
      const value = makeSyntheticValue(note, index, i);
      try {
        overlayer.remove(value);
      } catch {
        // ignore — best-effort cleanup
      }
    }
  }
}
