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

import * as Common from '../../../core/common/common.js';
import * as i18n from '../../../core/i18n/i18n.js';
import * as WindowBoundsService from '../../../services/window_bounds/window_bounds.js';
import * as CM from '../../../third_party/codemirror.next/codemirror.next.js';
import * as CodeHighlighter from '../code_highlighter/code_highlighter.js';
import * as Icon from '../icon_button/icon_button.js';

import {editorTheme} from './theme.js';

const LINES_TO_SCAN_FOR_INDENTATION_GUESSING = 1000;

const UIStrings = {
  /**
  *@description Label text for the editor
  */
  codeEditor: 'Code editor',
};
const str_ = i18n.i18n.registerUIStrings('ui/components/text_editor/config.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);

const empty: CM.Extension = [];

export const dynamicSetting = CM.Facet.define<DynamicSetting<unknown>>();

// The code below is used to wire up dynamic settings to editors. When
// you include the result of calling `instance()` in an editor
// configuration, the TextEditor class will take care of listening to
// changes in the setting, and updating the configuration as
// appropriate.

export class DynamicSetting<T> {
  compartment = new CM.Compartment();

  constructor(
      readonly settingName: string,
      private readonly getExtension: (value: T) => CM.Extension,
  ) {
  }

  settingValue(): T {
    return Common.Settings.Settings.instance().moduleSetting(this.settingName).get() as T;
  }

  instance(): CM.Extension {
    return [
      this.compartment.of(this.getExtension(this.settingValue())),
      dynamicSetting.of(this as DynamicSetting<unknown>),
    ];
  }

  sync(state: CM.EditorState, value: T): CM.StateEffect<unknown>|null {
    const cur = this.compartment.get(state);
    const needed = this.getExtension(value);
    return cur === needed ? null : this.compartment.reconfigure(needed);
  }

  static bool(name: string, enabled: CM.Extension, disabled: CM.Extension = empty): DynamicSetting<boolean> {
    return new DynamicSetting<boolean>(name, val => val ? enabled : disabled);
  }

  static none: readonly DynamicSetting<unknown>[] = [];
}

export const tabMovesFocus = DynamicSetting.bool('textEditorTabMovesFocus', [], CM.keymap.of([{
  key: 'Tab',
  run: (view: CM.EditorView): boolean => view.state.doc.length ? CM.indentMore(view) : false,
  shift: (view: CM.EditorView): boolean => view.state.doc.length ? CM.indentLess(view) : false,
}]));

export const autocompletion: CM.Extension = [
  CM.autocompletion({
    icons: false,
    optionClass: (option: CM.Completion): string => option.type === 'secondary' ? 'cm-secondaryCompletion' : '',
  }),
  CM.Prec.highest(CM.keymap.of([{key: 'ArrowRight', run: CM.acceptCompletion}])),
];

export const sourcesAutocompletion = DynamicSetting.bool('textEditorAutocompletion', autocompletion);

export const bracketMatching = DynamicSetting.bool('textEditorBracketMatching', CM.bracketMatching());

export const codeFolding = DynamicSetting.bool('textEditorCodeFolding', [
  CM.foldGutter({
    markerDOM(open: boolean): HTMLElement {
      const iconName = open ? 'triangle-expanded' : 'triangle-collapsed';
      const icon = new Icon.Icon.Icon();
      icon.data = {
        iconName,
        color: 'var(--color-text-secondary)',
        width: '12px',
        height: '12px',
      };
      return icon;
    },
  }),
  CM.keymap.of(CM.foldKeymap),
]);

export function guessIndent(doc: CM.Text): string {
  const values: {[indent: string]: number} = Object.create(null);
  let scanned = 0;
  for (let cur = doc.iterLines(1, Math.min(doc.lines + 1, LINES_TO_SCAN_FOR_INDENTATION_GUESSING)); !cur.next().done;) {
    let space = (/^\s*/.exec(cur.value) as string[])[0];
    if (space.length === cur.value.length || !space.length || cur.value[space.length] === '*') {
      continue;
    }
    if (space[0] === '\t') {
      space = '\t';
    } else if (/[^ ]/.test(space)) {
      continue;
    }
    scanned++;
    values[space] = (values[space] || 0) + 1;
  }
  const minOccurrence = scanned * 0.05;
  const shortest = Object.entries(values).reduce((shortest, [string, count]): string|null => {
    return count < minOccurrence || shortest && shortest.length < string.length ? shortest : string;
  }, null as string | null);
  return shortest ?? Common.Settings.Settings.instance().moduleSetting('textEditorIndent').get();
}

const deriveIndentUnit = CM.Prec.highest(CM.indentUnit.compute([], (state: CM.EditorState) => guessIndent(state.doc)));

export const autoDetectIndent = DynamicSetting.bool('textEditorAutoDetectIndent', deriveIndentUnit);

function matcher(decorator: CM.MatchDecorator): CM.Extension {
  return CM.ViewPlugin.define(
      view => ({
        decorations: decorator.createDeco(view),
        update(u): void {
          this.decorations = decorator.updateDeco(u, this.decorations);
        },
      }),
      {
        decorations: v => v.decorations,
      });
}

const WhitespaceDeco = new Map<string, CM.Decoration>();

function getWhitespaceDeco(space: string): CM.Decoration {
  const cached = WhitespaceDeco.get(space);
  if (cached) {
    return cached;
  }
  const result = CM.Decoration.mark({
    attributes: space === '\t' ? {
      class: 'cm-highlightedTab',
    } :
                                 {class: 'cm-highlightedSpaces', 'data-display': '·'.repeat(space.length)},
  });
  WhitespaceDeco.set(space, result);
  return result;
}

const showAllWhitespace = matcher(new CM.MatchDecorator({
  regexp: /\t| +/g,
  decoration: (match: RegExpExecArray): CM.Decoration => getWhitespaceDeco(match[0]),
  boundary: /\S/,
}));

const showTrailingWhitespace = matcher(new CM.MatchDecorator({
  regexp: /\s+$/g,
  decoration: CM.Decoration.mark({class: 'cm-trailingWhitespace'}),
  boundary: /\S/,
}));

export const showWhitespace = new DynamicSetting<string>('showWhitespacesInEditor', value => {
  if (value === 'all') {
    return showAllWhitespace;
  }
  if (value === 'trailing') {
    return showTrailingWhitespace;
  }
  return empty;
});

export const allowScrollPastEof = DynamicSetting.bool('allowScrollPastEof', CM.scrollPastEnd());

const cachedIndentUnit: {[indent: string]: CM.Extension} = Object.create(null);

function getIndentUnit(indent: string): CM.Extension {
  let value = cachedIndentUnit[indent];
  if (!value) {
    value = cachedIndentUnit[indent] = CM.indentUnit.of(indent);
  }
  return value;
}

export const indentUnit = new DynamicSetting<string>('textEditorIndent', getIndentUnit);

export const domWordWrap = DynamicSetting.bool('domWordWrap', CM.EditorView.lineWrapping);

function detectLineSeparator(text: string): CM.Extension {
  if (/\r\n/.test(text) && !/(^|[^\r])\n/.test(text)) {
    return CM.EditorState.lineSeparator.of('\r\n');
  }
  return [];
}

const baseKeymap = CM.keymap.of([
  {key: 'Tab', run: CM.acceptCompletion},
  {key: 'End', run: CM.acceptCompletion},
  {key: 'Ctrl-m', run: CM.cursorMatchingBracket, shift: CM.selectMatchingBracket},
  {key: 'Mod-/', run: CM.toggleComment},
  {key: 'Mod-d', run: CM.selectNextOccurrence},
  {key: 'Alt-ArrowLeft', mac: 'Ctrl-ArrowLeft', run: CM.cursorSubwordBackward, shift: CM.selectSubwordBackward},
  {key: 'Alt-ArrowRight', mac: 'Ctrl-ArrowRight', run: CM.cursorSubwordForward, shift: CM.selectSubwordForward},
  ...CM.standardKeymap,
  ...CM.historyKeymap,
]);

function themeIsDark(): boolean {
  const setting = Common.Settings.Settings.instance().moduleSetting('uiTheme').get();
  return setting === 'systemPreferred' ? window.matchMedia('(prefers-color-scheme: dark)').matches : setting === 'dark';
}

export const dummyDarkTheme = CM.EditorView.theme({}, {dark: true});
export const themeSelection = new CM.Compartment();

export function theme(): CM.Extension {
  return [editorTheme, themeIsDark() ? themeSelection.of(dummyDarkTheme) : themeSelection.of([])];
}

let sideBarElement: HTMLElement|null = null;

function getTooltipSpace(): DOMRect {
  if (!sideBarElement) {
    sideBarElement =
        WindowBoundsService.WindowBoundsService.WindowBoundsServiceImpl.instance().getDevToolsBoundingElement();
  }
  return sideBarElement.getBoundingClientRect();
}

export function baseConfiguration(text: string|CM.Text): CM.Extension {
  return [
    theme(),
    CM.highlightSpecialChars(),
    CM.highlightSelectionMatches(),
    CM.history(),
    CM.drawSelection(),
    CM.EditorState.allowMultipleSelections.of(true),
    CM.indentOnInput(),
    CM.syntaxHighlighting(CodeHighlighter.CodeHighlighter.highlightStyle),
    baseKeymap,
    CM.EditorView.clickAddsSelectionRange.of(mouseEvent => mouseEvent.altKey || mouseEvent.ctrlKey),
    tabMovesFocus.instance(),
    bracketMatching.instance(),
    indentUnit.instance(),
    CM.Prec.lowest(CM.EditorView.contentAttributes.of({'aria-label': i18nString(UIStrings.codeEditor)})),
    text instanceof CM.Text ? [] : detectLineSeparator(text),
    CM.tooltips({
      tooltipSpace: getTooltipSpace,
    }),
  ];
}

export const closeBrackets: CM.Extension = [
  CM.closeBrackets(),
  CM.keymap.of(CM.closeBracketsKeymap),
];

class CompletionHint extends CM.WidgetType {
  constructor(readonly text: string) {
    super();
  }

  eq(other: CompletionHint): boolean {
    return this.text === other.text;
  }

  toDOM(): HTMLElement {
    const span = document.createElement('span');
    span.className = 'cm-completionHint';
    span.textContent = this.text;
    return span;
  }
}

export const showCompletionHint = CM.ViewPlugin.fromClass(class {
  decorations: CM.DecorationSet = CM.Decoration.none;
  currentHint: string|null = null;

  update(update: CM.ViewUpdate): void {
    const top = this.currentHint = this.topCompletion(update.state);
    if (!top) {
      this.decorations = CM.Decoration.none;
    } else {
      this.decorations = CM.Decoration.set(
          [CM.Decoration.widget({widget: new CompletionHint(top), side: 1}).range(update.state.selection.main.head)]);
    }
  }

  topCompletion(state: CM.EditorState): string|null {
    const completion = CM.selectedCompletion(state);
    if (!completion) {
      return null;
    }
    let {label, apply} = completion;
    if (typeof apply === 'string') {
      label = apply;
      apply = undefined;
    }
    if (apply || label.length > 100 || label.indexOf('\n') > -1 || completion.type === 'secondary') {
      return null;
    }
    const pos = state.selection.main.head;
    const lineBefore = state.doc.lineAt(pos);
    if (pos !== lineBefore.to) {
      return null;
    }
    const partBefore = (label[0] === '\'' ? /'(\\.|[^'\\])*$/ : label[0] === '"' ? /"(\\.|[^"\\])*$/ : /#?[\w$]+$/)
                           .exec(lineBefore.text);
    if (partBefore && !label.startsWith(partBefore[0])) {
      return null;
    }
    return label.slice(partBefore ? partBefore[0].length : 0);
  }
}, {decorations: p => p.decorations});

export function contentIncludingHint(view: CM.EditorView): string {
  const plugin = view.plugin(showCompletionHint);
  let content = view.state.doc.toString();
  if (plugin && plugin.currentHint) {
    const {head} = view.state.selection.main;
    content = content.slice(0, head) + plugin.currentHint + content.slice(head);
  }
  return content;
}
