// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import type * as TextUtils from '../../../models/text_utils/text_utils.js';

export class RangeWalker {
  #offset = 0;
  readonly #treeWalker: TreeWalker;
  #eof: boolean;

  constructor(readonly root: Node) {
    const nodeFilter = {
      acceptNode(node: Node): number {
        if (['STYLE', 'SCRIPT'].includes(node.parentNode?.nodeName ?? '')) {
          return NodeFilter.FILTER_REJECT;
        }
        return NodeFilter.FILTER_ACCEPT;
      },
    };
    this.#treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, nodeFilter);
    this.#eof = !this.#treeWalker.firstChild();
  }

  #next(): boolean {
    this.#offset += this.#treeWalker.currentNode.textContent?.length ?? 0;
    this.#eof = !this.#treeWalker.nextNode();
    return !this.#eof;
  }

  #goToPosition(offset: number): Node|null {
    if (offset < this.#offset || this.#eof) {
      return null;
    }
    // `>` here implies that, except for the first range, start offsets are left-leaning, i.e., when the offset falls
    // between two text nodes, the preceding one is returned. This doesn't matter for Range semantics, but isn't
    // intuitive wrt. the usual understanding of intervals. Making start offsets right-leaning but end offsets
    // left-leaning would incur an unwarranted amount of complexity.
    while (offset > this.#offset + (this.#treeWalker.currentNode.textContent?.length ?? 0)) {
      if (!this.#next()) {
        return null;
      }
    }
    return this.#treeWalker.currentNode;
  }

  nextRange(start: number, length: number): Range|null {
    if (length <= 0 || this.#eof) {
      return null;
    }

    const startNode = this.#goToPosition(start);
    if (!startNode) {
      return null;
    }
    const offsetInStartNode = start - this.#offset;
    const endNode = this.#goToPosition(start + length);
    if (!endNode) {
      return null;
    }
    const offsetInEndNode = start + length - this.#offset;

    const range = new Range();
    range.setStart(startNode, offsetInStartNode);
    range.setEnd(endNode, offsetInEndNode);
    return range;
  }

  goToTextNode(node: Text): void {
    while (this.#treeWalker.currentNode !== node) {
      if (!this.#next()) {
        return;
      }
    }
  }

  get offset(): number {
    return this.#offset;
  }
}

export const HIGHLIGHT_REGISTRY = 'highlighted-search-result';
export const CURRENT_HIGHLIGHT_REGISTRY = 'current-search-result';

interface HighlightState {
  activeRanges: Range[];
  ranges: TextUtils.TextRange.SourceRange[];
  currentRange?: TextUtils.TextRange.SourceRange;
}

let highlightManagerInstance: HighlightManager;
export class HighlightManager {
  #highlights = new Highlight();
  #currentHighlights = new Highlight();
  #stateByNode = new WeakMap<Node, HighlightState>();

  constructor() {
    CSS.highlights.set(HIGHLIGHT_REGISTRY, this.#highlights);
    CSS.highlights.set(CURRENT_HIGHLIGHT_REGISTRY, this.#currentHighlights);
  }

  static instance(opts: {
    forceNew: boolean|null,
  }|undefined = {forceNew: null}): HighlightManager {
    const {forceNew} = opts;
    if (!highlightManagerInstance || forceNew) {
      highlightManagerInstance = new HighlightManager();
    }

    return highlightManagerInstance;
  }

  addHighlights(ranges: Range[]): void {
    ranges.forEach(this.addHighlight.bind(this));
  }

  removeHighlights(ranges: Range[]): void {
    ranges.forEach(this.removeHighlight.bind(this));
  }

  addCurrentHighlight(range: Range): void {
    this.#currentHighlights.add(range);
  }

  addCurrentHighlights(ranges: Range[]): void {
    ranges.forEach(this.addCurrentHighlight.bind(this));
  }

  addHighlight(range: Range): void {
    this.#highlights.add(range);
  }

  removeHighlight(range: Range): void {
    this.#highlights.delete(range);
    this.#currentHighlights.delete(range);
  }

  highlightOrderedTextRanges(root: Node, sourceRanges: TextUtils.TextRange.SourceRange[], isCurrent = false): Range[] {
    const rangeWalker = new RangeWalker(root);
    const ranges = sourceRanges.map(range => rangeWalker.nextRange(range.offset, range.length))
                       .filter((r): r is Range => r !== null && !r.collapsed);
    if (isCurrent) {
      this.addCurrentHighlights(ranges);
    } else {
      this.addHighlights(ranges);
    }
    return ranges;
  }

  #getOrCreateState(node: Node): HighlightState {
    let state = this.#stateByNode.get(node);
    if (!state) {
      state = {
        activeRanges: [],
        ranges: [],
      };
      this.#stateByNode.set(node, state);
    }
    return state;
  }

  apply(node: Node): void {
    const state = this.#getOrCreateState(node);
    this.removeHighlights(state.activeRanges);
    state.activeRanges = this.highlightOrderedTextRanges(node, state.ranges);
    if (state.currentRange) {
      state.activeRanges.push(...this.highlightOrderedTextRanges(node, [state.currentRange], /* isCurrent=*/ true));
    }
  }

  set(element: Node, ranges: TextUtils.TextRange.SourceRange[],
      currentRange: TextUtils.TextRange.SourceRange|undefined): void {
    const state = this.#getOrCreateState(element);
    state.ranges = ranges;
    state.currentRange = currentRange;
    this.apply(element);
  }
}
