(function () {
  "use strict";

  const ARABIC_DIACRITICS = /[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06ED\u0640]/u;
  const BLOCK_ELEMENTS = new Set([
    "ADDRESS", "ARTICLE", "ASIDE", "BLOCKQUOTE", "BR", "DD", "DIV", "DL", "DT", "FIGCAPTION",
    "FIGURE", "FOOTER", "H1", "H2", "H3", "H4", "H5", "H6", "HEADER", "HR", "LI", "MAIN",
    "NAV", "OL", "P", "PRE", "SECTION", "TABLE", "TD", "TH", "TR", "UL"
  ]);

  function normaliseCharacter(character) {
    if (ARABIC_DIACRITICS.test(character)) return "";
    if (character === "\u064A" || character === "\u0649") return "\u06CC";
    if (character === "\u0643") return "\u06A9";
    if (character === "\u06C0" || character === "\u0629") return "\u0647";
    return character.normalize("NFKC").toLocaleLowerCase();
  }

  function normalizeWithMap(text) {
    const source = String(text || "");
    const output = [];
    const map = [];
    let sourceIndex = 0;
    let previousWasSpace = true;

    for (const character of source) {
      const normalized = normaliseCharacter(character);
      const isSpace = !normalized || /^\s+$/u.test(normalized);

      if (!normalized) {
        sourceIndex += character.length;
        continue;
      }

      if (isSpace) {
        if (!previousWasSpace) {
          output.push(" ");
          map.push(sourceIndex);
          previousWasSpace = true;
        }
        sourceIndex += character.length;
        continue;
      }

      for (const normalizedCharacter of normalized) {
        output.push(normalizedCharacter);
        map.push(sourceIndex);
      }
      previousWasSpace = false;
      sourceIndex += character.length;
    }

    if (output[output.length - 1] === " ") {
      output.pop();
      map.pop();
    }

    return { text: output.join(""), map };
  }

  function normalizeForSearch(text) {
    return normalizeWithMap(text).text.trim();
  }

  function collectTextModel(document) {
    const body = document.body || document.documentElement;
    const segments = [];
    let original = "";

    function appendText(node) {
      const value = node.nodeValue || "";
      if (!value) return;
      const start = original.length;
      original += value;
      segments.push({ node, start, end: original.length });
    }

    function walk(node) {
      if (node.nodeType === 3) {
        appendText(node);
        return;
      }
      if (node.nodeType !== 1) return;
      const element = node;
      const tagName = element.tagName;
      if (["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"].includes(tagName)) return;
      const block = BLOCK_ELEMENTS.has(tagName);
      if (block && original && !/\s$/u.test(original)) original += "\n";
      for (const child of element.childNodes) walk(child);
      if (block && original && !/\s$/u.test(original)) original += "\n";
    }

    walk(body);
    const normalized = normalizeWithMap(original);
    return { original, segments, normalizedText: normalized.text, normalizedMap: normalized.map };
  }

  function nodePosition(model, originalOffset, preferEnd) {
    if (!model.segments.length) return null;
    const clamped = Math.max(0, Math.min(originalOffset, model.original.length));
    let previous = model.segments[0];

    for (const segment of model.segments) {
      if (clamped >= segment.start && clamped <= segment.end) {
        return {
          node: segment.node,
          offset: Math.max(0, Math.min(clamped - segment.start, segment.node.nodeValue.length))
        };
      }
      if (segment.start > clamped) {
        return preferEnd
          ? { node: segment.node, offset: 0 }
          : { node: previous.node, offset: previous.node.nodeValue.length };
      }
      previous = segment;
    }

    return { node: previous.node, offset: previous.node.nodeValue.length };
  }

  function rangeForMatch(document, model, normalizedStart, normalizedLength) {
    const startOriginal = model.normalizedMap[normalizedStart];
    const lastMapped = model.normalizedMap[Math.max(normalizedStart, normalizedStart + normalizedLength - 1)];
    if (!Number.isInteger(startOriginal) || !Number.isInteger(lastMapped)) return null;

    const start = nodePosition(model, startOriginal, false);
    const end = nodePosition(model, lastMapped + 1, true);
    if (!start || !end) return null;

    const range = document.createRange();
    range.setStart(start.node, start.offset);
    range.setEnd(end.node, end.offset);
    return range;
  }

  function flattenNavigation(items, depth = 0, output = []) {
    for (const item of items || []) {
      output.push({ ...item, depth });
      flattenNavigation(item.subitems || item.children || [], depth + 1, output);
    }
    return output;
  }

  function hrefWithoutFragment(href) {
    return String(href || "").split("#")[0].replace(/^\.?\//, "");
  }

  function labelMap(navigation) {
    const map = new Map();
    for (const item of flattenNavigation(navigation?.toc || [])) {
      const key = hrefWithoutFragment(item.href);
      if (!map.has(key)) map.set(key, String(item.label || "").trim());
    }
    return map;
  }

  function bestLabel(section, labels, index) {
    const href = hrefWithoutFragment(section.href || section.url);
    if (labels.has(href)) return labels.get(href);
    for (const [key, label] of labels) {
      if (href.endsWith(key) || key.endsWith(href)) return label;
    }
    return `Section ${index + 1}`;
  }

  function yieldToInterface() {
    return new Promise((resolve) => setTimeout(resolve, 0));
  }

  function abortIfNeeded(signal) {
    if (signal?.aborted) throw new DOMException("Search indexing was cancelled.", "AbortError");
  }

  async function loadSection(book, section) {
    await section.load(book.load.bind(book));
    if (!section.document) throw new Error("The section did not provide a document.");
    globalThis.SenlizSecurity.sanitizeDocument(section.document);
    return section.document;
  }

  async function buildIndex(book, navigation, options = {}) {
    const spine = book?.spine?.spineItems || [];
    const labels = labelMap(navigation);
    const index = [];

    for (let sectionIndex = 0; sectionIndex < spine.length; sectionIndex += 1) {
      abortIfNeeded(options.signal);
      const section = spine[sectionIndex];
      try {
        const document = await loadSection(book, section);
        const model = collectTextModel(document);
        if (model.normalizedText) {
          index.push({
            sectionIndex,
            href: section.href,
            label: bestLabel(section, labels, sectionIndex),
            normalizedText: model.normalizedText,
            originalText: model.original
          });
        }
      } catch (error) {
        console.warn("A section could not be indexed.", { href: section.href, error });
      } finally {
        try {
          section.unload();
        } catch {
          // Older epub.js sections may not need explicit unloading.
        }
      }
      options.onProgress?.({ completed: sectionIndex + 1, total: spine.length });
      await yieldToInterface();
    }

    return index;
  }

  function allOccurrences(text, query, limit) {
    const occurrences = [];
    let offset = 0;
    while (offset < text.length && occurrences.length < limit) {
      const index = text.indexOf(query, offset);
      if (index < 0) break;
      occurrences.push(index);
      offset = index + Math.max(query.length, 1);
    }
    return occurrences;
  }

  function snippet(text, offset, length) {
    const start = Math.max(0, offset - 70);
    const end = Math.min(text.length, offset + length + 100);
    const value = text.slice(start, end).replace(/\s+/g, " ").trim();
    return `${start > 0 ? "…" : ""}${value}${end < text.length ? "…" : ""}`;
  }

  async function searchBook(book, index, query, options = {}) {
    const normalizedQuery = normalizeForSearch(query);
    if (normalizedQuery.length < 2) return [];

    const limit = Math.max(1, Math.min(Number(options.limit || 200), 500));
    const matchesBySection = [];
    let count = 0;

    for (const item of index) {
      const occurrences = allOccurrences(item.normalizedText, normalizedQuery, limit - count);
      if (occurrences.length) {
        matchesBySection.push({ item, occurrences });
        count += occurrences.length;
      }
      if (count >= limit) break;
    }

    const results = [];
    for (const group of matchesBySection) {
      abortIfNeeded(options.signal);
      const section = book.spine.spineItems[group.item.sectionIndex];
      if (!section) continue;

      try {
        const document = await loadSection(book, section);
        const model = collectTextModel(document);
        for (const normalizedOffset of group.occurrences) {
          const range = rangeForMatch(document, model, normalizedOffset, normalizedQuery.length);
          if (!range) continue;
          const cfi = section.cfiFromRange(range);
          const originalOffset = model.normalizedMap[normalizedOffset] || 0;
          results.push({
            cfi,
            href: section.href,
            sectionIndex: group.item.sectionIndex,
            label: group.item.label,
            snippet: snippet(model.original, originalOffset, normalizedQuery.length)
          });
          if (results.length >= limit) break;
        }
      } catch (error) {
        console.warn("Search results could not be located in a section.", { href: section.href, error });
      } finally {
        try {
          section.unload();
        } catch {
          // No action is needed for engines which retain loaded sections.
        }
      }
      if (results.length >= limit) break;
      await yieldToInterface();
    }

    return results;
  }

  globalThis.SenlizSearch = Object.freeze({
    normalizeForSearch,
    normalizeWithMap,
    flattenNavigation,
    buildIndex,
    searchBook
  });
})();
