import Convert from "../convert"
import * as Utils from "../utils"
import { CancellationTokenSource } from "vscode-jsonrpc"
import { ActiveServer } from "../server-manager"
import { ObjectArrayFilterer } from "zadeh"
import {
  CompletionContext,
  CompletionItem,
  CompletionItemKind,
  CompletionList,
  CompletionParams,
  CompletionTriggerKind,
  InsertTextFormat,
  InsertReplaceEdit,
  LanguageClientConnection,
  Range,
  ServerCapabilities,
  TextEdit,
} from "../languageclient"
import ApplyEditAdapter from "./apply-edit-adapter"
import { Point, TextEditor } from "atom"
import * as ac from "atom/autocomplete-plus"
import { Suggestion, TextSuggestion, SnippetSuggestion, SuggestionBase } from "../types/autocomplete-extended"

/**
 * Defines the behavior of suggestion acceptance. Assume you have "cons|ole" in the editor ( `|` is the cursor position)
 * and the autocomplete suggestion is `const`.
 *
 * - If `false` -> the edits are inserted : const|ole
 * - If `true`` -> the edits are replaced: const|
 */
type ShouldReplace = boolean

/**
 * Holds a list of suggestions generated from the CompletionItem[] list sent by the server, as well as metadata about
 * the context it was collected in
 */
interface SuggestionCacheEntry {
  /** If `true`, the server will send a list of suggestions to replace this one */
  isIncomplete: boolean
  /** The point left of the first character in the original prefix sent to the server */
  triggerPoint: Point
  /** The point right of the last character in the original prefix sent to the server */
  originalBufferPoint: Point
  /** The trigger string that caused the autocomplete (if any) */
  triggerChar: string
  suggestionMap: Map<Suggestion, PossiblyResolvedCompletionItem>
}

type CompletionItemAdjuster = (
  item: CompletionItem,
  suggestion: ac.AnySuggestion,
  request: ac.SuggestionsRequestedEvent
) => void

class PossiblyResolvedCompletionItem {
  constructor(public completionItem: CompletionItem, public isResolved: boolean) {}
}

/** Public: Adapts the language server protocol "textDocument/completion" to the Atom AutoComplete+ package. */
export default class AutocompleteAdapter {
  public static canAdapt(serverCapabilities: ServerCapabilities): boolean {
    return serverCapabilities.completionProvider != null
  }

  public static canResolve(serverCapabilities: ServerCapabilities): boolean {
    return (
      serverCapabilities.completionProvider != null && serverCapabilities.completionProvider.resolveProvider === true
    )
  }

  private _suggestionCache: WeakMap<ActiveServer, SuggestionCacheEntry> = new WeakMap()
  private _cancellationTokens: WeakMap<LanguageClientConnection, CancellationTokenSource> = new WeakMap()

  /**
   * Public: Obtain suggestion list for AutoComplete+ by querying the language server using the `textDocument/completion` request.
   *
   * @param server An {ActiveServer} pointing to the language server to query.
   * @param request The {atom$AutocompleteRequest} to satisfy.
   * @param onDidConvertCompletionItem An optional function that takes a {CompletionItem}, an
   *   {atom$AutocompleteSuggestion} and a {atom$AutocompleteRequest} allowing you to adjust converted items.
   * @param shouldReplace The behavior of suggestion acceptance (see {ShouldReplace}).
   * @returns A {Promise} of an {Array} of {atom$AutocompleteSuggestion}s containing the AutoComplete+ suggestions to display.
   */
  public async getSuggestions(
    server: ActiveServer,
    request: ac.SuggestionsRequestedEvent,
    onDidConvertCompletionItem?: CompletionItemAdjuster,
    minimumWordLength?: number,
    shouldReplace: ShouldReplace = false
  ): Promise<ac.AnySuggestion[]> {
    const triggerChars =
      server.capabilities.completionProvider != null
        ? server.capabilities.completionProvider.triggerCharacters || []
        : []

    // triggerOnly is true if we have just typed in a trigger character, and is false if we
    // have typed additional characters following a trigger character.
    const [triggerChar, triggerOnly] = AutocompleteAdapter.getTriggerCharacter(request, triggerChars)

    if (!this.shouldTrigger(request, triggerChar, minimumWordLength || 0)) {
      return []
    }

    // Get the suggestions either from the cache or by calling the language server
    const suggestions = await this.getOrBuildSuggestions(
      server,
      request,
      triggerChar,
      triggerOnly,
      shouldReplace,
      onDidConvertCompletionItem
    )

    // We must update the replacement prefix as characters are added and removed
    const cache = this._suggestionCache.get(server)!
    const replacementPrefix = request.editor.getTextInBufferRange([
      [cache.triggerPoint.row, cache.triggerPoint.column + cache.triggerChar.length],
      request.bufferPosition,
    ])
    for (const suggestion of suggestions) {
      if (suggestion.customReplacmentPrefix) {
        // having this property means a custom range was provided
        const len = replacementPrefix.length
        const preReplacementPrefix =
          suggestion.customReplacmentPrefix +
          replacementPrefix.substring(len + cache.originalBufferPoint.column - request.bufferPosition.column, len)
        // we cannot replace text after the cursor with the current autocomplete-plus API
        // so we will simply ignore it for now
        suggestion.replacementPrefix = preReplacementPrefix
      } else {
        suggestion.replacementPrefix = replacementPrefix
      }
    }

    const filtered = !(request.prefix === "" || (triggerChar !== "" && triggerOnly))
    if (filtered) {
      // filter the suggestions who have `filterText` property
      const validSuggestions = suggestions.filter((sgs) => typeof sgs.filterText === "string") as Suggestion[] &
        { filterText: string }[]
      // TODO use `ObjectArrayFilterer.setCandidate` in `_suggestionCache` to avoid creating `ObjectArrayFilterer` every time from scratch
      const objFilterer = new ObjectArrayFilterer(validSuggestions, "filterText")
      // zadeh returns an array of the selected `Suggestions`
      return objFilterer.filter(request.prefix) as any as Suggestion[]
    } else {
      return suggestions
    }
  }

  private shouldTrigger(request: ac.SuggestionsRequestedEvent, triggerChar: string, minWordLength: number): boolean {
    return (
      request.activatedManually || triggerChar !== "" || minWordLength <= 0 || request.prefix.length >= minWordLength
    )
  }

  private async getOrBuildSuggestions(
    server: ActiveServer,
    request: ac.SuggestionsRequestedEvent,
    triggerChar: string,
    triggerOnly: boolean,
    shouldReplace: ShouldReplace,
    onDidConvertCompletionItem?: CompletionItemAdjuster
  ): Promise<Suggestion[]> {
    const cache = this._suggestionCache.get(server)

    const triggerColumn =
      triggerChar !== "" && triggerOnly
        ? request.bufferPosition.column - triggerChar.length
        : request.bufferPosition.column - request.prefix.length - triggerChar.length
    const triggerPoint = new Point(request.bufferPosition.row, triggerColumn)

    // Do we have complete cached suggestions that are still valid for this request?
    if (
      cache &&
      !cache.isIncomplete &&
      cache.triggerChar === triggerChar &&
      cache.triggerPoint.isEqual(triggerPoint) &&
      cache.originalBufferPoint.isLessThanOrEqual(request.bufferPosition)
    ) {
      return Array.from(cache.suggestionMap.keys())
    }

    // Our cached suggestions can't be used so obtain new ones from the language server
    const completions = await Utils.doWithCancellationToken(
      server.connection,
      this._cancellationTokens,
      (cancellationToken) =>
        server.connection.completion(
          AutocompleteAdapter.createCompletionParams(request, triggerChar, triggerOnly),
          cancellationToken
        )
    )

    // spec guarantees all edits are on the same line, so we only need to check the columns
    const triggerColumns: [number, number] = [triggerPoint.column, request.bufferPosition.column]

    // Setup the cache for subsequent filtered results
    const isComplete = completions === null || Array.isArray(completions) || !completions.isIncomplete
    const suggestionMap = this.completionItemsToSuggestions(
      completions,
      request,
      triggerColumns,
      shouldReplace,
      onDidConvertCompletionItem
    )
    this._suggestionCache.set(server, {
      isIncomplete: !isComplete,
      triggerChar,
      triggerPoint,
      originalBufferPoint: request.bufferPosition,
      suggestionMap,
    })

    return Array.from(suggestionMap.keys())
  }

  /**
   * Public: Obtain a complete version of a suggestion with additional information the language server can provide by
   * way of the `completionItem/resolve` request.
   *
   * @param server An {ActiveServer} pointing to the language server to query.
   * @param suggestion An {atom$AutocompleteSuggestion} suggestion that should be resolved.
   * @param request An {Object} with the AutoComplete+ request to satisfy.
   * @param onDidConvertCompletionItem An optional function that takes a {CompletionItem}, an
   *   {atom$AutocompleteSuggestion} and a {atom$AutocompleteRequest} allowing you to adjust converted items.
   * @returns A {Promise} of an {atom$AutocompleteSuggestion} with the resolved AutoComplete+ suggestion.
   */
  public async completeSuggestion(
    server: ActiveServer,
    suggestion: ac.AnySuggestion,
    request: ac.SuggestionsRequestedEvent,
    onDidConvertCompletionItem?: CompletionItemAdjuster
  ): Promise<ac.AnySuggestion> {
    const cache = this._suggestionCache.get(server)
    if (cache) {
      const possiblyResolvedCompletionItem = cache.suggestionMap.get(suggestion)
      if (possiblyResolvedCompletionItem != null && !possiblyResolvedCompletionItem.isResolved) {
        const resolvedCompletionItem = await server.connection.completionItemResolve(
          possiblyResolvedCompletionItem.completionItem
        )
        if (resolvedCompletionItem != null) {
          AutocompleteAdapter.resolveSuggestion(resolvedCompletionItem, suggestion, request, onDidConvertCompletionItem)
          possiblyResolvedCompletionItem.isResolved = true
        }
      }
    }
    return suggestion
  }

  public static resolveSuggestion(
    resolvedCompletionItem: CompletionItem,
    suggestion: ac.AnySuggestion,
    request: ac.SuggestionsRequestedEvent,
    onDidConvertCompletionItem?: CompletionItemAdjuster
  ): void {
    // only the `documentation` and `detail` properties may change when resolving
    AutocompleteAdapter.applyDetailsToSuggestion(resolvedCompletionItem, suggestion)
    if (onDidConvertCompletionItem != null) {
      onDidConvertCompletionItem(resolvedCompletionItem, suggestion as ac.AnySuggestion, request)
    }
  }

  /**
   * Public: Get the trigger character that caused the autocomplete (if any). This is required because AutoComplete-plus
   * does not have trigger characters. Although the terminology is 'character' we treat them as variable length strings
   * as this will almost certainly change in the future to support '->' etc.
   *
   * @param request An {Array} of {atom$AutocompleteSuggestion}s to locate the prefix, editor, bufferPosition etc.
   * @param triggerChars The {Array} of {string}s that can be trigger characters.
   * @returns A [{string}, boolean] where the string is the matching trigger character or an empty string if one was not
   *   matched, and the boolean is true if the trigger character is in request.prefix, and false if it is in the word
   *   before request.prefix. The boolean return value has no meaning if the string return value is an empty string.
   */
  public static getTriggerCharacter(request: ac.SuggestionsRequestedEvent, triggerChars: string[]): [string, boolean] {
    // AutoComplete-Plus considers text after a symbol to be a new trigger. So we should look backward
    // from the current cursor position to see if one is there and thus simulate it.
    const buffer = request.editor.getBuffer()
    const cursor = request.bufferPosition
    const prefixStartColumn = cursor.column - request.prefix.length
    for (const triggerChar of triggerChars) {
      if (request.prefix.endsWith(triggerChar)) {
        return [triggerChar, true]
      }
      if (prefixStartColumn >= triggerChar.length) {
        // Far enough along a line to fit the trigger char
        const start = new Point(cursor.row, prefixStartColumn - triggerChar.length)
        const possibleTrigger = buffer.getTextInRange([start, [cursor.row, prefixStartColumn]])
        if (possibleTrigger === triggerChar) {
          // The text before our trigger is a trigger char!
          return [triggerChar, false]
        }
      }
    }

    // There was no explicit trigger char
    return ["", false]
  }

  /**
   * Public: Create TextDocumentPositionParams to be sent to the language server based on the editor and position from
   * the AutoCompleteRequest.
   *
   * @param request The {atom$AutocompleteRequest} to obtain the editor from.
   * @param triggerPoint The {atom$Point} where the trigger started.
   * @returns A {string} containing the prefix including the trigger character.
   */
  public static getPrefixWithTrigger(request: ac.SuggestionsRequestedEvent, triggerPoint: Point): string {
    return request.editor.getBuffer().getTextInRange([[triggerPoint.row, triggerPoint.column], request.bufferPosition])
  }

  /**
   * Public: Create {CompletionParams} to be sent to the language server based on the editor and position from the
   * Autocomplete request etc.
   *
   * @param request The {atom$AutocompleteRequest} containing the request details.
   * @param triggerCharacter The {string} containing the trigger character (empty if none).
   * @param triggerOnly A {boolean} representing whether this completion is triggered right after a trigger character.
   * @returns A {CompletionParams} with the keys:
   *
   *   - `textDocument` the language server protocol textDocument identification.
   *   - `position` the position within the text document to display completion request for.
   *   - `context` containing the trigger character and kind.
   */
  public static createCompletionParams(
    request: ac.SuggestionsRequestedEvent,
    triggerCharacter: string,
    triggerOnly: boolean
  ): CompletionParams {
    return {
      textDocument: Convert.editorToTextDocumentIdentifier(request.editor),
      position: Convert.pointToPosition(request.bufferPosition),
      context: AutocompleteAdapter.createCompletionContext(triggerCharacter, triggerOnly),
    }
  }

  /**
   * Public: Create {CompletionContext} to be sent to the language server based on the trigger character.
   *
   * @param triggerCharacter The {string} containing the trigger character or '' if none.
   * @param triggerOnly A {boolean} representing whether this completion is triggered right after a trigger character.
   * @returns An {CompletionContext} that specifies the triggerKind and the triggerCharacter if there is one.
   */
  public static createCompletionContext(triggerCharacter: string, triggerOnly: boolean): CompletionContext {
    if (triggerCharacter === "") {
      return { triggerKind: CompletionTriggerKind.Invoked }
    } else {
      return triggerOnly
        ? { triggerKind: CompletionTriggerKind.TriggerCharacter, triggerCharacter }
        : { triggerKind: CompletionTriggerKind.TriggerForIncompleteCompletions, triggerCharacter }
    }
  }

  /**
   * Public: Convert a language server protocol CompletionItem array or CompletionList to an array of ordered
   * AutoComplete+ suggestions.
   *
   * @param completionItems An {Array} of {CompletionItem} objects or a {CompletionList} containing completion items to
   *   be converted.
   * @param request The {atom$AutocompleteRequest} to satisfy.
   * @param shouldReplace The behavior of suggestion acceptance (see {ShouldReplace}).
   * @param onDidConvertCompletionItem A function that takes a {CompletionItem}, an {atom$AutocompleteSuggestion} and a
   *   {atom$AutocompleteRequest} allowing you to adjust converted items.
   * @returns A {Map} of AutoComplete+ suggestions ordered by the CompletionItems sortText.
   */
  public completionItemsToSuggestions(
    completionItems: CompletionItem[] | CompletionList | null,
    request: ac.SuggestionsRequestedEvent,
    triggerColumns: [number, number],
    shouldReplace: ShouldReplace,
    onDidConvertCompletionItem?: CompletionItemAdjuster
  ): Map<Suggestion, PossiblyResolvedCompletionItem> {
    const completionsArray = Array.isArray(completionItems)
      ? completionItems
      : (completionItems && completionItems.items) || []
    return new Map(
      completionsArray
        .sort((a, b) => (a.sortText || a.label).localeCompare(b.sortText || b.label))
        .map<[Suggestion, PossiblyResolvedCompletionItem]>((s) => [
          AutocompleteAdapter.completionItemToSuggestion(
            s,
            {} as Suggestion,
            request,
            triggerColumns,
            shouldReplace,
            onDidConvertCompletionItem
          ),
          new PossiblyResolvedCompletionItem(s, false),
        ])
    )
  }

  /**
   * Public: Convert a language server protocol CompletionItem to an AutoComplete+ suggestion.
   *
   * @param item An {CompletionItem} containing a completion item to be converted.
   * @param suggestion A {atom$AutocompleteSuggestion} to have the conversion applied to.
   * @param request The {atom$AutocompleteRequest} to satisfy.
   * @param shouldReplace The behavior of suggestion acceptance (see {ShouldReplace}).
   * @param onDidConvertCompletionItem A function that takes a {CompletionItem}, an {atom$AutocompleteSuggestion} and a
   *   {atom$AutocompleteRequest} allowing you to adjust converted items.
   * @returns The {atom$AutocompleteSuggestion} passed in as suggestion with the conversion applied.
   */
  public static completionItemToSuggestion(
    item: CompletionItem,
    suggestion: Suggestion,
    request: ac.SuggestionsRequestedEvent,
    triggerColumns: [number, number],
    shouldReplace: ShouldReplace,
    onDidConvertCompletionItem?: CompletionItemAdjuster
  ): Suggestion {
    AutocompleteAdapter.applyCompletionItemToSuggestion(item, suggestion as TextSuggestion)
    AutocompleteAdapter.applyTextEditToSuggestion(
      item.textEdit,
      request.editor,
      triggerColumns,
      request.bufferPosition,
      suggestion as TextSuggestion,
      shouldReplace
    )
    AutocompleteAdapter.applySnippetToSuggestion(item, suggestion as SnippetSuggestion)
    if (onDidConvertCompletionItem != null) {
      onDidConvertCompletionItem(item, suggestion as ac.AnySuggestion, request)
    }

    return suggestion
  }

  /**
   * Public: Convert the primary parts of a language server protocol CompletionItem to an AutoComplete+ suggestion.
   *
   * @param item An {CompletionItem} containing the completion items to be merged into.
   * @param suggestion The {Suggestion} to merge the conversion into.
   * @returns The {Suggestion} with details added from the {CompletionItem}.
   */
  public static applyCompletionItemToSuggestion(item: CompletionItem, suggestion: TextSuggestion): void {
    suggestion.text = item.insertText || item.label
    suggestion.filterText = item.filterText || item.label
    suggestion.displayText = item.label
    suggestion.type = AutocompleteAdapter.completionKindToSuggestionType(item.kind)
    AutocompleteAdapter.applyDetailsToSuggestion(item, suggestion)
    suggestion.completionItem = item
  }

  public static applyDetailsToSuggestion(item: CompletionItem, suggestion: Suggestion): void {
    suggestion.rightLabel = item.detail

    // Older format, can't know what it is so assign to both and hope for best
    if (typeof item.documentation === "string") {
      suggestion.descriptionMarkdown = item.documentation
      suggestion.description = item.documentation
    }

    if (item.documentation != null && typeof item.documentation === "object") {
      // Newer format specifies the kind of documentation, assign appropriately
      if (item.documentation.kind === "markdown") {
        suggestion.descriptionMarkdown = item.documentation.value
      } else {
        suggestion.description = item.documentation.value
      }
    }
  }

  /**
   * Public: Applies the textEdit part of a language server protocol CompletionItem to an AutoComplete+ Suggestion via
   * the replacementPrefix and text properties.
   *
   * @param textEdit A {TextEdit} from a CompletionItem to apply.
   * @param editor An Atom {TextEditor} used to obtain the necessary text replacement.
   * @param suggestion An {atom$AutocompleteSuggestion} to set the replacementPrefix and text properties of.
   * @param shouldReplace The behavior of suggestion acceptance (see {ShouldReplace}).
   */
  public static applyTextEditToSuggestion(
    textEdit: TextEdit | InsertReplaceEdit | undefined,
    editor: TextEditor,
    triggerColumns: [number, number],
    originalBufferPosition: Point,
    suggestion: TextSuggestion,
    shouldReplace: ShouldReplace
  ): void {
    if (!textEdit) {
      return
    }
    let range: Range
    if ("range" in textEdit) {
      range = textEdit.range
    } else if (shouldReplace) {
      range = textEdit.replace
    } else {
      range = textEdit.insert
    }

    if (range.start.character !== triggerColumns[0]) {
      const atomRange = Convert.lsRangeToAtomRange(range)
      suggestion.customReplacmentPrefix = editor.getTextInBufferRange([atomRange.start, originalBufferPosition])
    }
    suggestion.text = textEdit.newText
  }

  /**
   * Handle additional text edits after a suggestion insert, e.g. `additionalTextEdits`.
   *
   * `additionalTextEdits` are An optional array of additional text edits that are applied when selecting this
   * completion. Edits must not overlap (including the same insert position) with the main edit nor with themselves.
   *
   * Additional text edits should be used to change text unrelated to the current cursor position (for example adding an
   * import statement at the top of the file if the completion item will insert an unqualified type).
   */
  public static applyAdditionalTextEdits(event: ac.SuggestionInsertedEvent): void {
    const suggestion = event.suggestion as SuggestionBase
    const additionalEdits = suggestion.completionItem?.additionalTextEdits
    const buffer = event.editor.getBuffer()

    ApplyEditAdapter.applyEdits(buffer, Convert.convertLsTextEdits(additionalEdits))
    buffer.groupLastChanges()
  }

  /**
   * Public: Adds a snippet to the suggestion if the CompletionItem contains snippet-formatted text
   *
   * @param item An {CompletionItem} containing the completion items to be merged into.
   * @param suggestion The {atom$AutocompleteSuggestion} to merge the conversion into.
   */
  public static applySnippetToSuggestion(item: CompletionItem, suggestion: SnippetSuggestion): void {
    if (item.insertTextFormat === InsertTextFormat.Snippet) {
      suggestion.snippet = item.textEdit != null ? item.textEdit.newText : item.insertText || item.label
    }
  }

  /**
   * Public: Obtain the textual suggestion type required by AutoComplete+ that most closely maps to the numeric
   * completion kind supplies by the language server.
   *
   * @param kind A {Number} that represents the suggestion kind to be converted.
   * @returns A {String} containing the AutoComplete+ suggestion type equivalent to the given completion kind.
   */
  public static completionKindToSuggestionType(kind: number | undefined): string {
    switch (kind) {
      case CompletionItemKind.Constant:
        return "constant"
      case CompletionItemKind.Method:
        return "method"
      case CompletionItemKind.Function:
      case CompletionItemKind.Constructor:
        return "function"
      case CompletionItemKind.Field:
      case CompletionItemKind.Property:
        return "property"
      case CompletionItemKind.Variable:
        return "variable"
      case CompletionItemKind.Class:
        return "class"
      case CompletionItemKind.Struct:
      case CompletionItemKind.TypeParameter:
        return "type"
      case CompletionItemKind.Operator:
        return "selector"
      case CompletionItemKind.Interface:
        return "mixin"
      case CompletionItemKind.Module:
        return "module"
      case CompletionItemKind.Unit:
        return "builtin"
      case CompletionItemKind.Enum:
      case CompletionItemKind.EnumMember:
        return "enum"
      case CompletionItemKind.Keyword:
        return "keyword"
      case CompletionItemKind.Snippet:
        return "snippet"
      case CompletionItemKind.File:
      case CompletionItemKind.Folder:
        return "import"
      case CompletionItemKind.Reference:
        return "require"
      default:
        return "value"
    }
  }
}

/**
 * Normalizes the given grammar scope for autoComplete package so it always starts with `.` Based on
 * https://github.com/atom/autocomplete-plus/wiki/Autocomplete-Providers
 *
 * @param grammarScope Such as 'source.python' or '.source.python'
 * @returns The normalized grammarScope such as `.source.python`
 */
export function grammarScopeToAutoCompleteSelector(grammarScope: string): string {
  return grammarScope.includes(".") && grammarScope[0] !== "." ? `.${grammarScope}` : grammarScope
}
