import {extname} from '../../utils.ts';
import {createElementFromHTML, toggleElem} from '../../utils/dom.ts';
import {html, htmlRaw} from '../../utils/html.ts';
import {svg} from '../../svg.ts';
import {commandPalette} from './command-palette.ts';
import type {PaletteCommand} from './command-palette.ts';
import {contextMenu, collectSymbols, selectAllOccurrences} from './context-menu.ts';
import {createJsonLinter, createSyntaxErrorLinter} from './linter.ts';
import {clickableUrls, goToDefinitionAt, trimTrailingWhitespaceFromView} from './utils.ts';
import type {LanguageDescription, LanguageSupport} from '@codemirror/language';
import type {Compartment, Extension} from '@codemirror/state';
import type {EditorView, ViewUpdate} from '@codemirror/view';

// CodeEditorConfig is also used by backend, defined in "editor_util.go"
const codeEditorConfigDefault = {
  filename: '', // the current filename (base name, not full path), used for language detection
  autofocus: false, // whether to autofocus the editor on load
  previewableExtensions: [] as string[], // file extensions that support preview rendering
  lineWrapExtensions: [] as string[], // file extensions that enable line wrapping by default
  lineWrap: false, // whether line wrapping is enabled for the current file

  indentStyle: '', // "space" or "tab", from .editorconfig, or empty for not specified (detect from source code)
  indentSize: 0,  // number of spaces per indent level, from .editorconfig, or 0 for not specified (detect from source code)
  tabWidth: 4, // display width of a tab character, from .editorconfig, defaults to 4
  trimTrailingWhitespace: false, // whether to trim trailing whitespace on save, from .editorconfig
};
type CodeEditorConfig = typeof codeEditorConfigDefault;

export type CodemirrorEditor = {
  view: EditorView;
  trimTrailingWhitespace: boolean;
  togglePalette: (view: EditorView) => boolean;
  updateFilename: (filename: string) => Promise<void>;
  languages: LanguageDescription[];
  compartments: {
    wordWrap: Compartment;
    language: Compartment;
    tabSize: Compartment;
    indentUnit: Compartment;
    lint: Compartment;
  };
};

type LinguistLanguage = {name: string; extensions: string[]; filenames: string[]};

export type CodemirrorModules = Awaited<ReturnType<typeof importCodemirror>>;

export async function importCodemirror() {
  const [autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap, linguist] = await Promise.all([
    import('@codemirror/autocomplete'),
    import('@codemirror/commands'),
    import('@codemirror/language'),
    import('@codemirror/language-data'),
    import('@codemirror/lint'),
    import('@codemirror/search'),
    import('@codemirror/state'),
    import('@codemirror/view'),
    import('@lezer/highlight'),
    import('@replit/codemirror-indentation-markers'),
    import('@replit/codemirror-vscode-keymap'),
    import('../../../../assets/codemirror-languages.json'),
  ]);
  return {autocomplete, commands, language, languageData, lint, search, state, view, highlight, indentMarkers, vscodeKeymap, linguistLanguages: linguist.default as LinguistLanguage[]};
}

const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const filenameUnion = (filenames: string[]) =>
  filenames.length ? new RegExp(`^(${filenames.map(escapeRegex).join('|')})$`) : undefined;

export function buildLanguageDescriptions(cm: CodemirrorModules): LanguageDescription[] {
  const list: LanguageDescription[] = [
    ...buildBaseLanguages(cm),
    cm.language.LanguageDescription.of({
      name: 'Markdown', extensions: ['md', 'markdown', 'mkd', 'mdown', 'mdwn', 'mkdn', 'mkdown'],
      load: async () => (await import('@codemirror/lang-markdown')).markdown({codeLanguages: list}),
    }),
    cm.language.LanguageDescription.of({
      name: 'Dockerfile', extensions: ['dockerfile', 'containerfile'],
      filename: /^(Containerfile|Dockerfile)(\..+)?$/i,
      load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/dockerfile')).dockerFile)),
    }),
    cm.language.LanguageDescription.of({
      name: 'Elixir', extensions: ['ex', 'exs'],
      load: async () => (await import('codemirror-lang-elixir')).elixir(),
    }),
    cm.language.LanguageDescription.of({
      name: 'Nix', extensions: ['nix'],
      load: async () => (await import('@replit/codemirror-lang-nix')).nix(),
    }),
    cm.language.LanguageDescription.of({
      name: 'Svelte', extensions: ['svelte'],
      load: async () => (await import('@replit/codemirror-lang-svelte')).svelte(),
    }),
    cm.language.LanguageDescription.of({
      name: 'Makefile', extensions: ['mk', 'mak', 'make'], filename: /^(GNU|BSD)?[Mm]akefile(\..+)?$/,
      load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)),
    }),
    cm.language.LanguageDescription.of({
      name: 'Dotenv', extensions: ['env'], filename: /^\.env(\..*)?$/,
      load: async () => new cm.language.LanguageSupport(cm.language.StreamLanguage.define((await import('@codemirror/legacy-modes/mode/shell')).shell)),
    }),
    cm.language.LanguageDescription.of({
      name: 'JSON5', extensions: ['json5', 'jsonc'],
      load: async () => (await import('@codemirror/lang-json')).json(),
    }),
  ];
  return list;
}

// Languages that the JSON omits because they're constructed manually above.
const customNames = new Set(['Dockerfile', 'Markdown']);

let baseLanguagesCache: LanguageDescription[] | null = null;
function buildBaseLanguages(cm: CodemirrorModules): LanguageDescription[] {
  if (baseLanguagesCache) return baseLanguagesCache;
  const loadByName = new Map<string, LanguageDescription['load']>(
    cm.languageData.languages.map((l: LanguageDescription) => [l.name, l.load.bind(l)]),
  );
  const overrides = cm.linguistLanguages
    .filter((l) => loadByName.has(l.name))
    .map((l) => cm.language.LanguageDescription.of({
      name: l.name,
      extensions: l.extensions,
      filename: filenameUnion(l.filenames),
      load: loadByName.get(l.name)!,
    }));
  const overrideNames = new Set(overrides.map((o) => o.name));
  const fallback = cm.languageData.languages.filter(
    (l: LanguageDescription) => !overrideNames.has(l.name) && !customNames.has(l.name),
  );
  return baseLanguagesCache = [...overrides, ...fallback];
}

function togglePreviewDisplay(previewable: boolean): void {
  // FIXME: here and below, the selector is too broad, it should only query in the editor related scope
  const previewTab = document.querySelector<HTMLElement>('a[data-tab="preview"]');
  // the "preview tab" exists for "file code editor", but doesn't exist for "git hook editor"
  if (!previewTab) return;

  toggleElem(previewTab, previewable);
  if (previewable) return;

  // If not previewable but the "preview" tab was active (user changes the filename to a non-previewable one),
  // then the "preview" tab becomes inactive (hidden), so the "write" tab should become active
  if (previewTab.classList.contains('active')) {
    const writeTab = document.querySelector<HTMLElement>('a[data-tab="write"]');
    writeTab!.click();
  }
}

export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameInput?: HTMLInputElement): Promise<CodemirrorEditor> {
  const config: CodeEditorConfig = {
    ...codeEditorConfigDefault,
    ...JSON.parse(textarea.getAttribute('data-code-editor-config')!),
  };
  const previewableExts = new Set(config.previewableExtensions || []);
  const lineWrapExts = config.lineWrapExtensions || [];
  const cm = await importCodemirror();
  const languageDescriptions = buildLanguageDescriptions(cm);
  const matchedLang = cm.language.LanguageDescription.matchFilename(languageDescriptions, config.filename);

  const container = document.createElement('div');
  container.className = 'code-editor-container';
  container.setAttribute('data-language', matchedLang?.name.toLowerCase() || '');
  // Replace the loading placeholder with the editor container in one operation
  // to avoid a flash where neither element is in the DOM.
  const loading = textarea.parentNode!.querySelector<HTMLElement>('.editor-loading');
  if (loading) {
    loading.replaceWith(container);
  } else {
    textarea.parentNode!.append(container);
  }

  const loadedLang = matchedLang ? await matchedLang.load() : null;
  const wordWrap = new cm.state.Compartment();
  const language = new cm.state.Compartment();
  const tabSize = new cm.state.Compartment();
  const indentUnitComp = new cm.state.Compartment();
  const lintComp = new cm.state.Compartment();
  const palette = commandPalette(cm);

  const goToSymbol = (view: EditorView) => {
    const symbols = collectSymbols(cm, view);
    const items: PaletteCommand[] = symbols.map((sym) => ({
      label: `${sym.label}  (${sym.kind})`,
      keys: '',
      run: (v: EditorView) => v.dispatch({selection: {anchor: sym.from}, scrollIntoView: true}),
    }));
    palette.showWithItems(view, items, 'Go to symbol…');
    return true;
  };

  const view = new cm.view.EditorView({
    doc: textarea.defaultValue, // use defaultValue to prevent browser from restoring form values on refresh
    parent: container,
    extensions: [
      cm.view.lineNumbers(),
      cm.language.codeFolding({
        placeholderDOM(_view: EditorView, onclick: (event: Event) => void) {
          const el = createElementFromHTML(html`<span class="cm-foldPlaceholder">${htmlRaw(svg('octicon-kebab-horizontal', 13))}</span>`);
          el.addEventListener('click', onclick);
          return el as unknown as HTMLElement;
        },
      }),
      cm.language.foldGutter({
        markerDOM(open: boolean) {
          return createElementFromHTML(svg(open ? 'octicon-chevron-down' : 'octicon-chevron-right', 13));
        },
      }),
      cm.view.highlightActiveLineGutter(),
      cm.view.highlightSpecialChars(),
      cm.view.highlightActiveLine(),
      cm.view.drawSelection(),
      cm.view.dropCursor(),
      cm.view.rectangularSelection(),
      cm.view.crosshairCursor(),
      cm.view.placeholder(textarea.placeholder),
      config.trimTrailingWhitespace ? cm.view.highlightTrailingWhitespace() : [],
      cm.search.search({top: true}),
      cm.search.highlightSelectionMatches(),
      cm.view.keymap.of([
        ...cm.vscodeKeymap.vscodeKeymap,
        ...cm.search.searchKeymap,
        ...cm.lint.lintKeymap,
        cm.commands.indentWithTab,
        {key: 'Mod-k Mod-x', run: (view) => { trimTrailingWhitespaceFromView(view); return true }, preventDefault: true},
        {key: 'Mod-Enter', run: cm.commands.insertBlankLine, preventDefault: true},
        {key: 'Mod-k Mod-k', run: cm.commands.deleteToLineEnd, preventDefault: true},
        {key: 'Mod-k Mod-Backspace', run: cm.commands.deleteToLineStart, preventDefault: true},
      ]),
      cm.state.EditorState.allowMultipleSelections.of(true),
      cm.language.indentOnInput(),
      cm.language.syntaxHighlighting(cm.highlight.classHighlighter),
      cm.language.bracketMatching(),
      indentUnitComp.of(
        cm.language.indentUnit.of(
          config.indentStyle === 'tab' ? '\t' : ' '.repeat(config.indentSize || 4),
        ),
      ),
      cm.autocomplete.closeBrackets(),
      cm.autocomplete.autocompletion(),
      cm.state.EditorState.languageData.of(() => [{autocomplete: cm.autocomplete.completeAnyWord}]),
      cm.indentMarkers.indentationMarkers({
        colors: {
          light: 'transparent',
          dark: 'transparent',
          activeLight: 'var(--color-secondary-dark-3)',
          activeDark: 'var(--color-secondary-dark-3)',
        },
      }),
      cm.commands.history(),
      palette.extensions,
      cm.view.keymap.of([
        {key: 'Mod-Shift-o', run: goToSymbol, preventDefault: true},
        {key: 'Mod-F2', run: (v) => { selectAllOccurrences(cm, v); return true }, preventDefault: true},
        {key: 'F12', run: (v) => goToDefinitionAt(cm, v, v.state.selection.main.from), preventDefault: true},
      ]),
      contextMenu(cm, palette.togglePalette, goToSymbol),
      clickableUrls(cm),
      tabSize.of(cm.state.EditorState.tabSize.of(config.tabWidth || 4)),
      wordWrap.of(config.lineWrap ? cm.view.EditorView.lineWrapping : []),
      language.of(loadedLang ?? []),
      lintComp.of(await getLinterExtension(cm, config.filename, loadedLang)),
      cm.view.EditorView.updateListener.of((update: ViewUpdate) => {
        if (update.docChanged) {
          textarea.value = update.state.doc.toString();
          textarea.dispatchEvent(new Event('change')); // needed for jquery-are-you-sure
        }
      }),
    ],
  });

  const editor: CodemirrorEditor = {
    view,
    trimTrailingWhitespace: config.trimTrailingWhitespace,
    togglePalette: palette.togglePalette,
    updateFilename: async (filename: string) => {
      togglePreviewDisplay(previewableExts.has(extname(filename)));
      await updateEditorLanguage(cm, editor, filename, lineWrapExts);
    },
    languages: languageDescriptions,
    compartments: {wordWrap, language, tabSize, indentUnit: indentUnitComp, lint: lintComp},
  };

  const elEditorOptions = textarea.closest('form')!.querySelector('.code-editor-options');
  if (elEditorOptions) {
    const indentStyleSelect = elEditorOptions.querySelector<HTMLSelectElement>('.js-indent-style-select')!;
    const indentSizeSelect = elEditorOptions.querySelector<HTMLSelectElement>('.js-indent-size-select')!;

    const applyIndentSettings = (style: string, size: number) => {
      view.dispatch({
        effects: [
          indentUnitComp.reconfigure(cm.language.indentUnit.of(style === 'tab' ? '\t' : ' '.repeat(size))),
          tabSize.reconfigure(cm.state.EditorState.tabSize.of(size)),
        ],
      });
    };

    indentStyleSelect.addEventListener('change', () => {
      applyIndentSettings(indentStyleSelect.value, Number(indentSizeSelect.value) || 4);
    });

    indentSizeSelect.addEventListener('change', () => {
      applyIndentSettings(indentStyleSelect.value || 'space', Number(indentSizeSelect.value) || 4);
    });

    elEditorOptions.querySelector('.js-code-find')!.addEventListener('click', () => {
      if (cm.search.searchPanelOpen(view.state)) {
        cm.search.closeSearchPanel(view);
      } else {
        cm.search.openSearchPanel(view);
      }
    });

    elEditorOptions.querySelector('.js-code-command-palette')!.addEventListener('click', () => {
      palette.togglePalette(view);
    });

    elEditorOptions.querySelector<HTMLSelectElement>('.js-line-wrap-select')!.addEventListener('change', (e) => {
      const target = e.target as HTMLSelectElement;
      view.dispatch({
        effects: wordWrap.reconfigure(target.value === 'on' ? cm.view.EditorView.lineWrapping : []),
      });
    });
  }

  togglePreviewDisplay(previewableExts.has(extname(config.filename)));

  if (config.autofocus) {
    editor.view.focus();
  } else if (filenameInput) {
    filenameInput.focus();
  }

  return editor;
}

// files that the JSON parser is too strict for (comments, trailing commas)
const jsoncFilesRegex = /^([jt]sconfig.*|devcontainer)\.json$|\.(jsonc|json5)$/i;

async function getLinterExtension(cm: CodemirrorModules, filename: string, loadedLang: LanguageSupport | null): Promise<Extension> {
  if (!loadedLang) return [];
  const lang = loadedLang.language;
  // StreamLanguage (legacy modes) don't produce Lezer error nodes
  if (lang instanceof cm.language.StreamLanguage) return [];
  if (lang.name === 'json') {
    return jsoncFilesRegex.test(filename) ? [] : [cm.lint.lintGutter(), await createJsonLinter(cm)];
  }
  // markdown's parser emits no error nodes, and nested code-fence overlays aren't traversed
  if (lang.name === 'markdown') return [];
  return [cm.lint.lintGutter(), createSyntaxErrorLinter(cm)];
}

async function updateEditorLanguage(cm: CodemirrorModules, editor: CodemirrorEditor, filename: string, lineWrapExts: string[]): Promise<void> {
  const {compartments, view, languages: editorLanguages} = editor;

  const newLanguage = cm.language.LanguageDescription.matchFilename(editorLanguages, filename);
  const newLoadedLang = newLanguage ? await newLanguage.load() : null;
  view.dom.closest('.code-editor-container')!.setAttribute('data-language', newLanguage?.name.toLowerCase() || '');
  view.dispatch(
    {
      effects: [
        compartments.wordWrap.reconfigure(
          lineWrapExts.includes(extname(filename).toLowerCase()) ? cm.view.EditorView.lineWrapping : [],
        ),
        compartments.language.reconfigure(newLoadedLang ?? []),
        compartments.lint.reconfigure(await getLinterExtension(cm, filename, newLoadedLang)),
      ],
    },
    // clear stale diagnostics from the previous language on filename change
    cm.lint.setDiagnostics(view.state, []),
  );
}
