// Copyright 2011 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable @devtools/no-imperative-dom-api */

import * as i18n from '../../../../core/i18n/i18n.js';
import * as Platform from '../../../../core/platform/platform.js';
import * as SDK from '../../../../core/sdk/sdk.js';
import * as Highlighting from '../../../components/highlighting/highlighting.js';
import * as VisualLogging from '../../../visual_logging/visual_logging.js';
import * as UI from '../../legacy.js';
import * as ObjectUI from '../object_ui/object_ui.js';

import jsonViewStyles from './jsonView.css.js';

const UIStrings = {
  /**
   * @description Text to find an item
   */
  find: 'Find',
} as const;
const str_ = i18n.i18n.registerUIStrings('ui/legacy/components/source_frame/JSONView.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

export class JSONView extends UI.Widget.VBox implements UI.SearchableView.Searchable {
  private initialized: boolean;
  private readonly parsedJSON: ParsedJSON;
  private startCollapsed: boolean;
  private searchableView!: UI.SearchableView.SearchableView|null;
  private treeOutline!: ObjectUI.ObjectPropertiesSection.ObjectPropertiesSection;
  private currentSearchFocusIndex: number;
  private currentSearchTreeElements: ObjectUI.ObjectPropertiesSection.ObjectPropertyTreeElement[];
  private searchRegex: RegExp|null;
  constructor(parsedJSON: ParsedJSON, startCollapsed?: boolean) {
    super();
    this.initialized = false;
    this.registerRequiredCSS(jsonViewStyles);
    this.parsedJSON = parsedJSON;
    this.startCollapsed = Boolean(startCollapsed);
    this.element.classList.add('json-view');
    this.element.setAttribute('jslog', `${VisualLogging.section('json-view')}`);
    this.currentSearchFocusIndex = 0;
    this.currentSearchTreeElements = [];
    this.searchRegex = null;
  }

  static async createView(content: string): Promise<UI.SearchableView.SearchableView|null> {
    // We support non-strict JSON parsing by parsing an AST tree which is why we offload it to a worker.
    const parsedJSON = await JSONView.parseJSON(content);
    if (!parsedJSON || typeof parsedJSON.data !== 'object') {
      return null;
    }

    const jsonView = new JSONView(parsedJSON);
    const searchableView = new UI.SearchableView.SearchableView(jsonView, null);
    searchableView.setPlaceholder(i18nString(UIStrings.find));
    jsonView.searchableView = searchableView;
    jsonView.show(searchableView.element);
    return searchableView;
  }

  static createViewSync(obj: Object|null, element?: HTMLElement): UI.SearchableView.SearchableView {
    const jsonView = new JSONView(new ParsedJSON(obj, '', ''));
    const searchableView = new UI.SearchableView.SearchableView(jsonView, null, undefined, element);
    searchableView.setPlaceholder(i18nString(UIStrings.find));
    jsonView.searchableView = searchableView;
    jsonView.show(searchableView.element);
    jsonView.element.tabIndex = 0;
    return searchableView;
  }

  setSearchableView(searchableView: UI.SearchableView.SearchableView): void {
    this.searchableView = searchableView;
  }

  private static parseJSON(text: string|null): Promise<ParsedJSON|null> {
    let returnObj: ParsedJSON<string>|null = null;
    if (text) {
      returnObj = JSONView.extractJSON(text);
    }
    if (!returnObj) {
      return Promise.resolve(null);
    }
    try {
      const json = JSON.parse(returnObj.data);
      if (!json) {
        return Promise.resolve(null);
      }
      returnObj.data = json;
    } catch {
      returnObj = null;
    }

    return Promise.resolve(returnObj);
  }

  private static extractJSON(text: string): ParsedJSON<string>|null {
    // Do not treat HTML as JSON.
    if (text.startsWith('<')) {
      return null;
    }
    let inner = JSONView.findBrackets(text, '{', '}');
    const inner2 = JSONView.findBrackets(text, '[', ']');
    inner = inner2.length > inner.length ? inner2 : inner;

    // Return on blank payloads or on payloads significantly smaller than original text.
    if (inner.length === -1 || text.length - inner.length > 80) {
      return null;
    }

    const prefix = text.substring(0, inner.start);
    const suffix = text.substring(inner.end + 1);
    text = text.substring(inner.start, inner.end + 1);

    // Only process valid JSONP.
    if (suffix.trim().length && !(suffix.trim().startsWith(')') && prefix.trim().endsWith('('))) {
      return null;
    }

    return new ParsedJSON(text, prefix, suffix);
  }

  private static findBrackets(text: string, open: string, close: string): {
    start: number,
    end: number,
    length: number,
  } {
    const start = text.indexOf(open);
    const end = text.lastIndexOf(close);
    let length: -1|number = end - start - 1;
    if (start === -1 || end === -1 || end < start) {
      length = -1;
    }
    return {start, end, length};
  }

  override wasShown(): void {
    super.wasShown();
    this.initialize();
  }

  private initialize(): void {
    if (this.initialized) {
      return;
    }
    this.initialized = true;

    const obj = SDK.RemoteObject.RemoteObject.fromLocalObject(this.parsedJSON.data);
    const title = this.parsedJSON.prefix + obj.description + this.parsedJSON.suffix;
    this.treeOutline = new ObjectUI.ObjectPropertiesSection.ObjectPropertiesSection(
        obj, title, undefined, true /* showOverflow */, false /* editable */);
    this.treeOutline.enableContextMenu();
    if (!this.startCollapsed) {
      this.treeOutline.expand();
    }
    this.element.appendChild(this.treeOutline.element);
    const firstChild = this.treeOutline.firstChild();
    if (firstChild) {
      firstChild.select(true /* omitFocus */, false /* selectedByUser */);
    }
  }

  private jumpToMatch(index: number): void {
    if (!this.searchRegex) {
      return;
    }
    const previousFocusElement = this.currentSearchTreeElements[this.currentSearchFocusIndex];
    if (previousFocusElement) {
      previousFocusElement.setSearchRegex(this.searchRegex);
    }

    const newFocusElement = this.currentSearchTreeElements[index];
    if (newFocusElement) {
      this.updateSearchIndex(index);
      newFocusElement.setSearchRegex(this.searchRegex, Highlighting.highlightedCurrentSearchResultClassName);
      newFocusElement.reveal();
    } else {
      this.updateSearchIndex(0);
    }
  }

  private updateSearchCount(count: number): void {
    if (!this.searchableView) {
      return;
    }
    this.searchableView.updateSearchMatchesCount(count);
  }

  private updateSearchIndex(index: number): void {
    this.currentSearchFocusIndex = index;
    if (!this.searchableView) {
      return;
    }
    this.searchableView.updateCurrentMatchIndex(index);
  }

  onSearchCanceled(): void {
    this.searchRegex = null;
    this.currentSearchTreeElements = [];

    let element: UI.TreeOutline.TreeElement|null;
    for (element = this.treeOutline.rootElement(); element; element = element.traverseNextTreeElement(false)) {
      if (!(element instanceof ObjectUI.ObjectPropertiesSection.ObjectPropertyTreeElement)) {
        continue;
      }
      element.revertHighlightChanges();
    }
    this.updateSearchCount(0);
    this.updateSearchIndex(0);
  }

  performSearch(searchConfig: UI.SearchableView.SearchConfig, _shouldJump: boolean, jumpBackwards?: boolean): void {
    let newIndex: number = this.currentSearchFocusIndex;
    const previousSearchFocusElement = this.currentSearchTreeElements[newIndex];
    this.onSearchCanceled();
    this.searchRegex = searchConfig.toSearchRegex(true).regex;

    let element: UI.TreeOutline.TreeElement|null;
    for (element = this.treeOutline.rootElement(); element; element = element.traverseNextTreeElement(false)) {
      if (!(element instanceof ObjectUI.ObjectPropertiesSection.ObjectPropertyTreeElement)) {
        continue;
      }
      const hasMatch = element.setSearchRegex(this.searchRegex);
      if (hasMatch) {
        this.currentSearchTreeElements.push(element);
      }
      if (previousSearchFocusElement === element) {
        const currentIndex = this.currentSearchTreeElements.length - 1;
        if (hasMatch || jumpBackwards) {
          newIndex = currentIndex;
        } else {
          newIndex = currentIndex + 1;
        }
      }
    }
    this.updateSearchCount(this.currentSearchTreeElements.length);

    if (!this.currentSearchTreeElements.length) {
      this.updateSearchIndex(-1);
      return;
    }
    newIndex = Platform.NumberUtilities.mod(newIndex, this.currentSearchTreeElements.length);

    this.jumpToMatch(newIndex);
  }

  jumpToNextSearchResult(): void {
    if (!this.currentSearchTreeElements.length) {
      return;
    }
    const newIndex =
        Platform.NumberUtilities.mod(this.currentSearchFocusIndex + 1, this.currentSearchTreeElements.length);
    this.jumpToMatch(newIndex);
  }

  jumpToPreviousSearchResult(): void {
    if (!this.currentSearchTreeElements.length) {
      return;
    }
    const newIndex =
        Platform.NumberUtilities.mod(this.currentSearchFocusIndex - 1, this.currentSearchTreeElements.length);
    this.jumpToMatch(newIndex);
  }

  supportsCaseSensitiveSearch(): boolean {
    return true;
  }

  supportsWholeWordSearch(): boolean {
    return true;
  }

  supportsRegexSearch(): boolean {
    return true;
  }
}

export class ParsedJSON<T extends unknown = unknown> {
  data: T;
  prefix: string;
  suffix: string;

  constructor(data: T, prefix: string, suffix: string) {
    this.data = data;
    this.prefix = prefix;
    this.suffix = suffix;
  }
}

export class SearchableJsonView extends UI.SearchableView.SearchableView {
  #jsonView: JSONView;

  constructor(element: HTMLElement) {
    const jsonView = new JSONView(new ParsedJSON('', '', ''));
    super(jsonView, null, undefined, element);
    this.#jsonView = jsonView;
    this.setPlaceholder(i18nString(UIStrings.find));
    jsonView.setSearchableView(this);
    jsonView.show(this.element);
    jsonView.element.tabIndex = 0;
  }

  set jsonObject(obj: Object|null|undefined) {
    const jsonView = new JSONView(new ParsedJSON(obj, '', ''));
    this.#jsonView.detach();
    this.#jsonView = jsonView;
    this.searchProvider = jsonView;
    jsonView.show(this.element);
    this.requestUpdate();
  }
}
