import {
  Command,
  CommandRegistry,
  Disposable,
  DisposableCollection,
  Emitter,
  MaybeArray,
  MaybePromise,
  nls,
  notEmpty,
} from '@theia/core';
import { ApplicationShell, FrontendApplication } from '@theia/core/lib/browser';
import { ITextModel } from '@theia/monaco-editor-core/esm/vs/editor/common/model';
import URI from '@theia/core/lib/common/uri';
import { inject, injectable } from '@theia/core/shared/inversify';
import {
  Location,
  Range,
} from '@theia/core/shared/vscode-languageserver-protocol';
import {
  EditorWidget,
  TextDocumentChangeEvent,
} from '@theia/editor/lib/browser';
import {
  EditorDecoration,
  TrackedRangeStickiness,
} from '@theia/editor/lib/browser/decorations/editor-decoration';
import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
import * as monaco from '@theia/monaco-editor-core';
import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
import { MonacoToProtocolConverter } from '@theia/monaco/lib/browser/monaco-to-protocol-converter';
import { ProtocolToMonacoConverter } from '@theia/monaco/lib/browser/protocol-to-monaco-converter';
import { OutputUri } from '@theia/output/lib/common/output-uri';
import { CoreError } from '../../common/protocol/core-service';
import { ErrorRevealStrategy } from '../arduino-preferences';
import { ArduinoOutputSelector, InoSelector } from '../selectors';
import { Contribution } from './contribution';
import { CoreErrorHandler } from './core-error-handler';
import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model';

interface ErrorDecorationRef {
  /**
   * This is the unique ID of the decoration given by `monaco`.
   */
  readonly id: string;
  /**
   * The resource this decoration belongs to.
   */
  readonly uri: string;
}
export namespace ErrorDecorationRef {
  export function is(arg: unknown): arg is ErrorDecorationRef {
    if (typeof arg === 'object') {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const object = arg as any;
      return (
        'uri' in object &&
        typeof object['uri'] === 'string' &&
        'id' in object &&
        typeof object['id'] === 'string'
      );
    }
    return false;
  }
  export function sameAs(
    left: ErrorDecorationRef,
    right: ErrorDecorationRef
  ): boolean {
    return left.id === right.id && left.uri === right.uri;
  }
}

interface ErrorDecoration extends ErrorDecorationRef {
  /**
   * The range of the error location the error in the compiler output from the CLI.
   */
  readonly rangesInOutput: monaco.Range[];
}
namespace ErrorDecoration {
  export function rangeOf(
    editorOrModel: MonacoEditor | ITextModel | undefined,
    decorations: ErrorDecoration
  ): monaco.Range | undefined;
  export function rangeOf(
    editorOrModel: MonacoEditor | ITextModel | undefined,
    decorations: ErrorDecoration[]
  ): (monaco.Range | undefined)[];
  export function rangeOf(
    editorOrModel: MonacoEditor | ITextModel | undefined,
    decorations: ErrorDecoration | ErrorDecoration[]
  ): MaybePromise<MaybeArray<monaco.Range | undefined>> {
    if (editorOrModel) {
      const allDecorations = getAllDecorations(editorOrModel);
      if (allDecorations) {
        if (Array.isArray(decorations)) {
          return decorations.map(({ id: decorationId }) =>
            findRangeOf(decorationId, allDecorations)
          );
        } else {
          return findRangeOf(decorations.id, allDecorations);
        }
      }
    }
    return Array.isArray(decorations)
      ? decorations.map(() => undefined)
      : undefined;
  }
  function findRangeOf(
    decorationId: string,
    allDecorations: { id: string; range?: monaco.Range }[]
  ): monaco.Range | undefined {
    return allDecorations.find(
      ({ id: candidateId }) => candidateId === decorationId
    )?.range;
  }
  function getAllDecorations(
    editorOrModel: MonacoEditor | ITextModel
  ): { id: string; range?: monaco.Range }[] {
    if (editorOrModel instanceof MonacoEditor) {
      const model = editorOrModel.getControl().getModel();
      if (!model) {
        return [];
      }
      return model.getAllDecorations();
    }
    return editorOrModel.getAllDecorations();
  }
}

@injectable()
export class CompilerErrors
  extends Contribution
  implements monaco.languages.CodeLensProvider, monaco.languages.LinkProvider
{
  @inject(EditorManager)
  private readonly editorManager: EditorManager;

  @inject(ProtocolToMonacoConverter)
  private readonly p2m: ProtocolToMonacoConverter;

  @inject(MonacoToProtocolConverter)
  private readonly m2p: MonacoToProtocolConverter;

  @inject(CoreErrorHandler)
  private readonly coreErrorHandler: CoreErrorHandler;

  private revealStrategy = ErrorRevealStrategy.Default;
  private experimental = false;

  private readonly errors: ErrorDecoration[] = [];
  private readonly onDidChangeEmitter = new monaco.Emitter<this>();
  private readonly currentErrorDidChangEmitter = new Emitter<ErrorDecoration>();
  private readonly onCurrentErrorDidChange =
    this.currentErrorDidChangEmitter.event;
  private readonly toDisposeOnCompilerErrorDidChange =
    new DisposableCollection();

  private shell: ApplicationShell | undefined;
  private currentError: ErrorDecoration | undefined;
  private get currentErrorIndex(): number {
    const current = this.currentError;
    if (!current) {
      return -1;
    }
    return this.errors.findIndex((error) =>
      ErrorDecorationRef.sameAs(error, current)
    );
  }

  override onStart(app: FrontendApplication): void {
    this.shell = app.shell;
    monaco.languages.registerCodeLensProvider(InoSelector, this);
    monaco.languages.registerLinkProvider(ArduinoOutputSelector, this);
    this.coreErrorHandler.onCompilerErrorsDidChange((errors) =>
      this.handleCompilerErrorsDidChange(errors)
    );
    this.onCurrentErrorDidChange(async (error) => {
      const monacoEditor = await this.monacoEditor(error.uri);
      const monacoRange = ErrorDecoration.rangeOf(monacoEditor, error);
      if (!monacoRange) {
        console.warn(
          'compiler-errors',
          `Could not find range of decoration: ${error.id}`
        );
        return;
      }
      const range = this.m2p.asRange(monacoRange);
      const editor = await this.revealLocationInEditor({
        uri: error.uri,
        range,
      });
      if (!editor) {
        console.warn(
          'compiler-errors',
          `Failed to mark error ${error.id} as the current one.`
        );
      } else {
        const monacoEditor = this.monacoEditor(editor);
        if (monacoEditor) {
          monacoEditor.cursor = range.start;
        }
      }
    });
  }

  override onReady(): MaybePromise<void> {
    this.preferences.ready.then(() => {
      this.experimental = Boolean(
        this.preferences['arduino.compile.experimental']
      );
      const strategy = this.preferences['arduino.compile.revealRange'];
      this.revealStrategy = ErrorRevealStrategy.is(strategy)
        ? strategy
        : ErrorRevealStrategy.Default;
      this.preferences.onPreferenceChanged(
        ({ preferenceName, newValue, oldValue }) => {
          if (newValue === oldValue) {
            return;
          }
          switch (preferenceName) {
            case 'arduino.compile.revealRange': {
              this.revealStrategy = ErrorRevealStrategy.is(newValue)
                ? newValue
                : ErrorRevealStrategy.Default;
              return;
            }
            case 'arduino.compile.experimental': {
              this.experimental = Boolean(newValue);
              this.onDidChangeEmitter.fire(this);
              return;
            }
          }
        }
      );
    });
  }

  override registerCommands(registry: CommandRegistry): void {
    registry.registerCommand(CompilerErrors.Commands.NEXT_ERROR, {
      execute: () => {
        const index = this.currentErrorIndex;
        if (index < 0) {
          console.warn(
            'compiler-errors',
            `Could not advance to next error. Unknown current error.`
          );
          return;
        }
        const nextError =
          this.errors[index === this.errors.length - 1 ? 0 : index + 1];
        return this.markAsCurrentError(nextError, {
          forceReselect: true,
          reveal: true,
        });
      },
      isEnabled: () =>
        this.experimental && !!this.currentError && this.errors.length > 1,
    });
    registry.registerCommand(CompilerErrors.Commands.PREVIOUS_ERROR, {
      execute: () => {
        const index = this.currentErrorIndex;
        if (index < 0) {
          console.warn(
            'compiler-errors',
            `Could not advance to previous error. Unknown current error.`
          );
          return;
        }
        const previousError =
          this.errors[index === 0 ? this.errors.length - 1 : index - 1];
        return this.markAsCurrentError(previousError, {
          forceReselect: true,
          reveal: true,
        });
      },
      isEnabled: () =>
        this.experimental && !!this.currentError && this.errors.length > 1,
    });
    registry.registerCommand(CompilerErrors.Commands.MARK_AS_CURRENT, {
      execute: (arg: unknown) => {
        if (ErrorDecorationRef.is(arg)) {
          return this.markAsCurrentError(
            { id: arg.id, uri: new URI(arg.uri).toString() }, // Make sure the URI fragments are encoded. On Windows, `C:` is encoded as `C%3A`.
            { forceReselect: true, reveal: true }
          );
        }
      },
      isEnabled: () => !!this.errors.length,
    });
  }

  get onDidChange(): monaco.IEvent<this> {
    return this.onDidChangeEmitter.event;
  }

  async provideCodeLenses(
    model: monaco.editor.ITextModel,
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    _token: monaco.CancellationToken
  ): Promise<monaco.languages.CodeLensList> {
    const lenses: monaco.languages.CodeLens[] = [];
    if (
      this.experimental &&
      this.currentError &&
      this.currentError.uri === model.uri.toString() &&
      this.errors.length > 1
    ) {
      const monacoEditor = await this.monacoEditor(model.uri);
      const range = ErrorDecoration.rangeOf(monacoEditor, this.currentError);
      if (range) {
        lenses.push(
          {
            range,
            command: {
              id: CompilerErrors.Commands.PREVIOUS_ERROR.id,
              title: nls.localize(
                'arduino/editor/previousError',
                'Previous Error'
              ),
              arguments: [this.currentError],
            },
          },
          {
            range,
            command: {
              id: CompilerErrors.Commands.NEXT_ERROR.id,
              title: nls.localize('arduino/editor/nextError', 'Next Error'),
              arguments: [this.currentError],
            },
          }
        );
      }
    }
    return {
      lenses,
      dispose: () => {
        /* NOOP */
      },
    };
  }

  async provideLinks(
    model: monaco.editor.ITextModel,
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    _token: monaco.CancellationToken
  ): Promise<monaco.languages.ILinksList> {
    const links: monaco.languages.ILink[] = [];
    if (
      model.uri.scheme === OutputUri.SCHEME &&
      model.uri.path === '/Arduino'
    ) {
      links.push(
        ...this.errors
          .filter((decoration) => !!decoration.rangesInOutput.length)
          .map(({ rangesInOutput, id, uri }) =>
            rangesInOutput.map(
              (range) =>
                <monaco.languages.ILink>{
                  range,
                  url: monaco.Uri.parse(`command://`).with({
                    query: JSON.stringify({ id, uri }),
                    path: CompilerErrors.Commands.MARK_AS_CURRENT.id,
                  }),
                  tooltip: nls.localize(
                    'arduino/editor/revealError',
                    'Reveal Error'
                  ),
                }
            )
          )
          .reduce((acc, curr) => acc.concat(curr), [])
      );
    } else {
      console.warn('unexpected URI: ' + model.uri.toString());
    }
    return { links };
  }

  async resolveLink(
    link: monaco.languages.ILink,
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    _token: monaco.CancellationToken
  ): Promise<monaco.languages.ILink | undefined> {
    if (!this.experimental) {
      return undefined;
    }
    const { url } = link;
    if (url) {
      const candidateUri = new URI(
        typeof url === 'string' ? url : url.toString()
      );
      const candidateId = candidateUri.path.toString();
      const error = this.errors.find((error) => error.id === candidateId);
      if (error) {
        const monacoEditor = await this.monacoEditor(error.uri);
        const range = ErrorDecoration.rangeOf(monacoEditor, error);
        if (range) {
          return {
            range,
            url: monaco.Uri.parse(error.uri),
          };
        }
      }
    }
    return undefined;
  }

  private async handleCompilerErrorsDidChange(
    errors: CoreError.ErrorLocation[]
  ): Promise<void> {
    this.toDisposeOnCompilerErrorDidChange.dispose();
    const groupedErrors = this.groupBy(
      errors,
      (error: CoreError.ErrorLocation) => error.location.uri
    );
    const decorations = await this.decorateEditors(groupedErrors);
    this.errors.push(...decorations.errors);
    this.toDisposeOnCompilerErrorDidChange.pushAll([
      Disposable.create(() => (this.errors.length = 0)),
      Disposable.create(() => this.onDidChangeEmitter.fire(this)),
      ...(await Promise.all([
        decorations.dispose,
        this.trackEditors(
          groupedErrors,
          (editor) =>
            editor.onSelectionChanged((selection) =>
              this.handleSelectionChange(editor, selection)
            ),
          (editor) =>
            editor.onDispose(() =>
              this.handleEditorDidDispose(editor.uri.toString())
            ),
          (editor) =>
            editor.onDocumentContentChanged((event) =>
              this.handleDocumentContentChange(editor, event)
            )
        ),
      ])),
    ]);
    const currentError = this.errors[0];
    if (currentError) {
      await this.markAsCurrentError(currentError, {
        forceReselect: true,
        reveal: true,
      });
    }
  }

  private async decorateEditors(
    errors: Map<string, CoreError.ErrorLocation[]>
  ): Promise<{ dispose: Disposable; errors: ErrorDecoration[] }> {
    const composite = await Promise.all(
      [...errors.entries()].map(([uri, errors]) =>
        this.decorateEditor(uri, errors)
      )
    );
    return {
      dispose: new DisposableCollection(
        ...composite.map(({ dispose }) => dispose)
      ),
      errors: composite.reduce(
        (acc, { errors }) => acc.concat(errors),
        [] as ErrorDecoration[]
      ),
    };
  }

  private async decorateEditor(
    uri: string,
    errors: CoreError.ErrorLocation[]
  ): Promise<{ dispose: Disposable; errors: ErrorDecoration[] }> {
    const editor = await this.monacoEditor(uri);
    if (!editor) {
      return { dispose: Disposable.NULL, errors: [] };
    }
    const oldDecorations = editor.deltaDecorations({
      oldDecorations: [],
      newDecorations: errors.map((error) =>
        this.compilerErrorDecoration(error.location.range)
      ),
    });
    return {
      dispose: Disposable.create(() => {
        if (editor) {
          editor.deltaDecorations({
            oldDecorations,
            newDecorations: [],
          });
        }
      }),
      errors: oldDecorations.map((id, index) => ({
        id,
        uri,
        rangesInOutput: errors[index].rangesInOutput.map((range) =>
          this.p2m.asRange(range)
        ),
      })),
    };
  }

  private compilerErrorDecoration(range: Range): EditorDecoration {
    return {
      range,
      options: {
        isWholeLine: true,
        className: 'compiler-error',
        stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges,
      },
    };
  }

  /**
   * Tracks the selection in all editors that have an error. If the editor selection overlaps one of the compiler error's range, mark as current error.
   */
  private handleSelectionChange(
    monacoEditor: MonacoEditor,
    selection: Range
  ): void {
    const uri = monacoEditor.uri.toString();
    const monacoSelection = this.p2m.asRange(selection);
    console.log(
      'compiler-errors',
      `Handling selection change in editor ${uri}. New (monaco) selection: ${monacoSelection.toJSON()}`
    );
    const calculatePriority = (
      candidateErrorRange: monaco.Range,
      currentSelection: monaco.Range
    ) => {
      console.trace(
        'compiler-errors',
        `Candidate error range: ${candidateErrorRange.toJSON()}`
      );
      console.trace(
        'compiler-errors',
        `Current selection range: ${currentSelection.toJSON()}`
      );
      if (candidateErrorRange.intersectRanges(currentSelection)) {
        console.trace('Intersects.');
        return { score: 2 };
      }
      if (
        candidateErrorRange.startLineNumber <=
          currentSelection.startLineNumber &&
        candidateErrorRange.endLineNumber >= currentSelection.endLineNumber
      ) {
        console.trace('Same line.');
        return { score: 1 };
      }

      console.trace('No match');
      return undefined;
    };
    const errorsPerResource = this.errors.filter((error) => error.uri === uri);
    const rangesPerResource = ErrorDecoration.rangeOf(
      monacoEditor,
      errorsPerResource
    );
    const error = rangesPerResource
      .map((range, index) => ({ error: errorsPerResource[index], range }))
      .map(({ error, range }) => {
        if (range) {
          const priority = calculatePriority(range, monacoSelection);
          if (priority) {
            return { ...priority, error };
          }
        }
        return undefined;
      })
      .filter(notEmpty)
      .sort((left, right) => right.score - left.score) // highest first
      .map(({ error }) => error)
      .shift();
    if (error) {
      this.markAsCurrentError(error);
    } else {
      console.info(
        'compiler-errors',
        `New (monaco) selection ${monacoSelection.toJSON()} does not intersect any error locations. Skipping.`
      );
    }
  }

  /**
   * This code does not deal with resource deletion, but tracks editor dispose events. It does not matter what was the cause of the editor disposal.
   * If editor closes, delete the decorators.
   */
  private handleEditorDidDispose(uri: string): void {
    let i = this.errors.length;
    // `splice` re-indexes the array. It's better to "iterate and modify" from the last element.
    while (i--) {
      const error = this.errors[i];
      if (error.uri === uri) {
        this.errors.splice(i, 1);
      }
    }
    this.onDidChangeEmitter.fire(this);
  }

  /**
   * If the text document changes in the line where compiler errors are, the compiler errors will be removed.
   */
  private handleDocumentContentChange(
    monacoEditor: MonacoEditor,
    event: TextDocumentChangeEvent
  ): void {
    const errorsPerResource = this.errors.filter(
      (error) => error.uri === event.document.uri
    );
    let editorOrModel: MonacoEditor | ITextModel = monacoEditor;
    const doc = event.document;
    if (doc instanceof MonacoEditorModel) {
      editorOrModel = doc.textEditorModel;
    }
    const rangesPerResource = ErrorDecoration.rangeOf(
      editorOrModel,
      errorsPerResource
    );
    const resolvedDecorations = rangesPerResource.map((range, index) => ({
      error: errorsPerResource[index],
      range,
    }));
    const decoratorsToRemove = event.contentChanges
      .map(({ range }) => this.p2m.asRange(range))
      .map((changedRange) =>
        resolvedDecorations
          .filter(({ range: decorationRange }) => {
            if (!decorationRange) {
              return false;
            }
            const affects =
              changedRange.startLineNumber <= decorationRange.startLineNumber &&
              changedRange.endLineNumber >= decorationRange.endLineNumber;
            console.log(
              'compiler-errors',
              `decoration range: ${decorationRange.toString()}, change range: ${changedRange.toString()}, affects: ${affects}`
            );
            return affects;
          })
          .map(({ error }) => {
            const index = this.errors.findIndex((candidate) =>
              ErrorDecorationRef.sameAs(candidate, error)
            );
            return index !== -1 ? { error, index } : undefined;
          })
          .filter(notEmpty)
      )
      .reduce((acc, curr) => acc.concat(curr), [])
      .sort((left, right) => left.index - right.index); // highest index last

    if (decoratorsToRemove.length) {
      let i = decoratorsToRemove.length;
      while (i--) {
        this.errors.splice(decoratorsToRemove[i].index, 1);
      }
      monacoEditor.getControl().deltaDecorations(
        decoratorsToRemove.map(({ error }) => error.id),
        []
      );
      this.onDidChangeEmitter.fire(this);
    }
  }

  private async trackEditors(
    errors: Map<string, CoreError.ErrorLocation[]>,
    ...track: ((editor: MonacoEditor) => Disposable)[]
  ): Promise<Disposable> {
    return new DisposableCollection(
      ...(await Promise.all(
        Array.from(errors.keys()).map(async (uri) => {
          const editor = await this.monacoEditor(uri);
          if (!editor) {
            return Disposable.NULL;
          }
          return new DisposableCollection(...track.map((t) => t(editor)));
        })
      ))
    );
  }

  private async markAsCurrentError(
    ref: ErrorDecorationRef,
    options?: { forceReselect?: boolean; reveal?: boolean }
  ): Promise<void> {
    const index = this.errors.findIndex((candidate) =>
      ErrorDecorationRef.sameAs(candidate, ref)
    );
    if (index < 0) {
      console.warn(
        'compiler-errors',
        `Failed to mark error ${
          ref.id
        } as the current one. Error is unknown. Known errors are: ${this.errors.map(
          ({ id }) => id
        )}`
      );
      return;
    }
    const newError = this.errors[index];
    if (
      options?.forceReselect ||
      !this.currentError ||
      !ErrorDecorationRef.sameAs(this.currentError, newError)
    ) {
      this.currentError = this.errors[index];
      console.log(
        'compiler-errors',
        `Current error changed to ${this.currentError.id}`
      );
      if (options?.reveal) {
        this.currentErrorDidChangEmitter.fire(this.currentError);
      }
      this.onDidChangeEmitter.fire(this);
    }
  }

  // The double editor activation logic is required: https://github.com/eclipse-theia/theia/issues/11284
  private async revealLocationInEditor(
    location: Location
  ): Promise<EditorWidget | undefined> {
    const { uri, range } = location;
    const editor = await this.editorManager.getByUri(new URI(uri), {
      mode: 'activate',
    });
    if (editor && this.shell) {
      // to avoid flickering, reveal the range here and not with `getByUri`, because it uses `at: 'center'` for the reveal option.
      // TODO: check the community reaction whether it is better to set the focus at the error marker. it might cause flickering even if errors are close to each other
      editor.editor.revealRange(range, { at: this.revealStrategy });
      const activeWidget = await this.shell.activateWidget(editor.id);
      if (!activeWidget) {
        console.warn(
          'compiler-errors',
          `editor widget activation has failed. editor widget ${editor.id} expected to be the active one.`
        );
        return editor;
      }
      if (editor !== activeWidget) {
        console.warn(
          'compiler-errors',
          `active widget was not the same as previously activated editor. editor widget ID ${editor.id}, active widget ID: ${activeWidget.id}`
        );
      }
      return editor;
    }
    console.warn(
      'compiler-errors',
      `could not find editor widget for URI: ${uri}`
    );
    return undefined;
  }

  private groupBy<K, V>(
    elements: V[],
    extractKey: (element: V) => K
  ): Map<K, V[]> {
    return elements.reduce((acc, curr) => {
      const key = extractKey(curr);
      let values = acc.get(key);
      if (!values) {
        values = [];
        acc.set(key, values);
      }
      values.push(curr);
      return acc;
    }, new Map<K, V[]>());
  }

  private monacoEditor(widget: EditorWidget): MonacoEditor | undefined;
  private monacoEditor(
    uri: string | monaco.Uri
  ): Promise<MonacoEditor | undefined>;
  private monacoEditor(
    uriOrWidget: string | monaco.Uri | EditorWidget
  ): MaybePromise<MonacoEditor | undefined> {
    if (uriOrWidget instanceof EditorWidget) {
      const editor = uriOrWidget.editor;
      if (editor instanceof MonacoEditor) {
        return editor;
      }
      return undefined;
    } else {
      return this.editorManager
        .getByUri(new URI(uriOrWidget))
        .then((editor) => {
          if (editor) {
            return this.monacoEditor(editor);
          }
          return undefined;
        });
    }
  }
}
export namespace CompilerErrors {
  export namespace Commands {
    export const NEXT_ERROR: Command = {
      id: 'arduino-editor-next-error',
    };
    export const PREVIOUS_ERROR: Command = {
      id: 'arduino-editor-previous-error',
    };
    export const MARK_AS_CURRENT: Command = {
      id: 'arduino-editor-mark-as-current-error',
    };
  }
}
