/* --------------------------------------------------------------------------------------------
 * Copyright (c) Microsoft Corporation. All rights reserved.
 * Licensed under the MIT License. See License.txt in the project root for license information.
 * ------------------------------------------------------------------------------------------ */
import * as path from 'path'
import {
  workspace as Workspace,
  window as Window,
  languages as Languages,
  ExtensionContext,
  TextDocument,
  OutputChannel,
  WorkspaceFolder,
  Uri,
  commands,
  SymbolInformation,
  Position,
  Range,
  TextEditorDecorationType,
  RelativePattern,
  ConfigurationScope,
  WorkspaceConfiguration,
  CompletionItem,
  CompletionItemKind,
  CompletionList,
  ProviderResult,
  SnippetString,
  TextEdit,
  Selection,
} from 'vscode'
import {
  LanguageClient,
  LanguageClientOptions,
  ServerOptions,
  TransportKind,
  State as LanguageClientState,
  RevealOutputChannelOn,
  Disposable,
} from 'vscode-languageclient/node'
import { languages as defaultLanguages } from 'tailwindcss-language-service/src/util/languages'
import * as semver from 'tailwindcss-language-service/src/util/semver'
import isObject from 'tailwindcss-language-service/src/util/isObject'
import { dedupe, equal } from 'tailwindcss-language-service/src/util/array'
import namedColors from 'color-name'
import minimatch from 'minimatch'
import { CONFIG_GLOB, CSS_GLOB } from 'tailwindcss-language-server/src/lib/constants'
import braces from 'braces'
import normalizePath from 'normalize-path'

const colorNames = Object.keys(namedColors)

const CLIENT_ID = 'tailwindcss-intellisense'
const CLIENT_NAME = 'Tailwind CSS IntelliSense'

let clients: Map<string, LanguageClient> = new Map()
let languages: Map<string, string[]> = new Map()
let searchedFolders: Set<string> = new Set()

function getUserLanguages(folder?: WorkspaceFolder): Record<string, string> {
  const langs = Workspace.getConfiguration('tailwindCSS', folder).includeLanguages
  return isObject(langs) ? langs : {}
}

function getGlobalExcludePatterns(scope: ConfigurationScope): string[] {
  return Object.entries(Workspace.getConfiguration('files', scope).get('exclude'))
    .filter(([, value]) => value === true)
    .map(([key]) => key)
    .filter(Boolean)
}

function getExcludePatterns(scope: ConfigurationScope): string[] {
  return [
    ...getGlobalExcludePatterns(scope),
    ...(<string[]>Workspace.getConfiguration('tailwindCSS', scope).get('files.exclude')).filter(
      Boolean
    ),
  ]
}

function isExcluded(file: string, folder: WorkspaceFolder): boolean {
  let exclude = getExcludePatterns(folder)

  for (let pattern of exclude) {
    if (minimatch(file, path.join(folder.uri.fsPath, pattern))) {
      return true
    }
  }

  return false
}

function mergeExcludes(settings: WorkspaceConfiguration, scope: ConfigurationScope): any {
  return {
    ...settings,
    files: {
      ...settings.files,
      exclude: getExcludePatterns(scope),
    },
  }
}

async function fileContainsAtConfig(uri: Uri) {
  let contents = (await Workspace.fs.readFile(uri)).toString()
  return /@config\s*['"]/.test(contents)
}

function selectionsAreEqual(
  aSelections: readonly Selection[],
  bSelections: readonly Selection[]
): boolean {
  if (aSelections.length !== bSelections.length) {
    return false
  }
  for (let i = 0; i < aSelections.length; i++) {
    if (!aSelections[i].isEqual(bSelections[i])) {
      return false
    }
  }
  return true
}

async function getActiveTextEditorProject(): Promise<{ version: string } | null> {
  if (clients.size === 0) {
    return null
  }
  let editor = Window.activeTextEditor
  if (!editor) {
    return null
  }
  let uri = editor.document.uri
  let folder = Workspace.getWorkspaceFolder(uri)
  if (!folder) {
    return null
  }
  let client = clients.get(folder.uri.toString())
  if (!client) {
    return null
  }
  if (isExcluded(uri.fsPath, folder)) {
    return null
  }
  try {
    let project = await client.sendRequest<{ version: string } | null>('@/tailwindCSS/getProject', {
      uri: uri.toString(),
    })
    return project
  } catch {
    return null
  }
}

async function activeTextEditorSupportsClassSorting(): Promise<boolean> {
  let project = await getActiveTextEditorProject()
  if (!project) {
    return false
  }
  return semver.gte(project.version, '3.0.0')
}

async function updateActiveTextEditorContext(): Promise<void> {
  commands.executeCommand(
    'setContext',
    'tailwindCSS.activeTextEditorSupportsClassSorting',
    await activeTextEditorSupportsClassSorting()
  )
}

function resetActiveTextEditorContext(): void {
  commands.executeCommand('setContext', 'tailwindCSS.activeTextEditorSupportsClassSorting', false)
}

export async function activate(context: ExtensionContext) {
  let module = context.asAbsolutePath(path.join('dist', 'server.js'))
  let prod = path.join('dist', 'tailwindServer.js')

  try {
    await Workspace.fs.stat(Uri.joinPath(context.extensionUri, prod))
    module = context.asAbsolutePath(prod)
  } catch (_) {}

  let outputChannel: OutputChannel

  context.subscriptions.push(
    commands.registerCommand('tailwindCSS.showOutput', () => {
      if (outputChannel) {
        outputChannel.show()
      }
    })
  )

  async function sortSelection(): Promise<void> {
    let { document, selections } = Window.activeTextEditor

    if (selections.length === 0) {
      return
    }

    let initialSelections = selections
    let folder = Workspace.getWorkspaceFolder(document.uri)

    if (clients.size === 0 || !folder || isExcluded(document.uri.fsPath, folder)) {
      throw Error(`No active Tailwind project found for file ${document.uri.fsPath}`)
    }

    let client = clients.get(folder.uri.toString())
    if (!client) {
      throw Error(`No active Tailwind project found for file ${document.uri.fsPath}`)
    }

    let result = await client.sendRequest<{ error: string } | { classLists: string[] }>(
      '@/tailwindCSS/sortSelection',
      {
        uri: document.uri.toString(),
        classLists: selections.map((selection) => document.getText(selection)),
      }
    )

    if (
      Window.activeTextEditor.document !== document ||
      !selectionsAreEqual(initialSelections, Window.activeTextEditor.selections)
    ) {
      return
    }

    if ('error' in result) {
      throw Error(
        {
          'no-project': `No active Tailwind project found for file ${document.uri.fsPath}`,
        }[result.error] ?? 'An unknown error occurred.'
      )
    }

    let sortedClassLists = result.classLists
    Window.activeTextEditor.edit((builder) => {
      for (let i = 0; i < selections.length; i++) {
        builder.replace(selections[i], sortedClassLists[i])
      }
    })
  }

  context.subscriptions.push(
    commands.registerCommand('tailwindCSS.sortSelection', async () => {
      try {
        await sortSelection()
      } catch (error) {
        Window.showWarningMessage(`Couldn’t sort Tailwind classes: ${error.message}`)
      }
    })
  )

  context.subscriptions.push(
    Window.onDidChangeActiveTextEditor(async () => {
      await updateActiveTextEditorContext()
    })
  )

  let configWatcher = Workspace.createFileSystemWatcher(`**/${CONFIG_GLOB}`, false, true, true)

  configWatcher.onDidCreate((uri) => {
    let folder = Workspace.getWorkspaceFolder(uri)
    if (!folder || isExcluded(uri.fsPath, folder)) {
      return
    }
    bootWorkspaceClient(folder)
  })

  context.subscriptions.push(configWatcher)

  let cssWatcher = Workspace.createFileSystemWatcher(`**/${CSS_GLOB}`, false, false, true)

  async function bootClientIfCssFileContainsAtConfig(uri: Uri) {
    let folder = Workspace.getWorkspaceFolder(uri)
    if (!folder || isExcluded(uri.fsPath, folder)) {
      return
    }
    if (await fileContainsAtConfig(uri)) {
      bootWorkspaceClient(folder)
    }
  }

  cssWatcher.onDidCreate(bootClientIfCssFileContainsAtConfig)
  cssWatcher.onDidChange(bootClientIfCssFileContainsAtConfig)

  context.subscriptions.push(cssWatcher)

  // TODO: check if the actual language MAPPING changed
  // not just the language IDs
  // e.g. "plaintext" already exists but you change it from "html" to "css"
  context.subscriptions.push(
    Workspace.onDidChangeConfiguration((event) => {
      let toReboot = new Set<WorkspaceFolder>()

      Workspace.textDocuments.forEach((document) => {
        let folder = Workspace.getWorkspaceFolder(document.uri)
        if (!folder) return
        if (event.affectsConfiguration('tailwindCSS.experimental.configFile', folder)) {
          toReboot.add(folder)
        }
      })
      ;[...clients].forEach(([key, client]) => {
        const folder = Workspace.getWorkspaceFolder(Uri.parse(key))
        let reboot = false

        if (event.affectsConfiguration('tailwindCSS.includeLanguages', folder)) {
          const userLanguages = getUserLanguages(folder)
          if (userLanguages) {
            const userLanguageIds = Object.keys(userLanguages)
            const newLanguages = dedupe([...defaultLanguages, ...userLanguageIds])
            if (!equal(newLanguages, languages.get(folder.uri.toString()))) {
              languages.set(folder.uri.toString(), newLanguages)
              reboot = true
            }
          }
        }

        if (event.affectsConfiguration('tailwindCSS.experimental.configFile', folder)) {
          reboot = true
        }

        if (reboot && client) {
          toReboot.add(folder)
        }
      })

      for (let folder of toReboot) {
        clients.get(folder.uri.toString())?.stop()
        clients.delete(folder.uri.toString())
        bootClientForFolderIfNeeded(folder)
      }
    })
  )

  let cssServerBooted = false
  async function bootCssServer() {
    if (cssServerBooted) return
    cssServerBooted = true

    let module = context.asAbsolutePath(path.join('dist', 'cssServer.js'))
    let prod = path.join('dist', 'tailwindModeServer.js')

    try {
      await Workspace.fs.stat(Uri.joinPath(context.extensionUri, prod))
      module = context.asAbsolutePath(prod)
    } catch (_) {}

    let client = new LanguageClient(
      'tailwindcss-intellisense-css',
      'Tailwind CSS',
      {
        run: {
          module,
          transport: TransportKind.ipc,
        },
        debug: {
          module,
          transport: TransportKind.ipc,
          options: {
            execArgv: ['--nolazy', '--inspect=6051'],
          },
        },
      },
      {
        documentSelector: [{ language: 'tailwindcss' }],
        outputChannelName: 'Tailwind CSS Language Mode',
        synchronize: { configurationSection: ['css'] },
        middleware: {
          provideCompletionItem(document, position, context, token, next) {
            function updateRanges(item: CompletionItem) {
              const range = item.range
              if (
                range instanceof Range &&
                range.end.isAfter(position) &&
                range.start.isBeforeOrEqual(position)
              ) {
                item.range = { inserting: new Range(range.start, position), replacing: range }
              }
            }
            function updateLabel(item: CompletionItem) {
              if (item.kind === CompletionItemKind.Color) {
                item.label = {
                  label: item.label as string,
                  description: item.documentation as string,
                }
              }
            }
            function updateProposals(
              r: CompletionItem[] | CompletionList | null | undefined
            ): CompletionItem[] | CompletionList | null | undefined {
              if (r) {
                ;(Array.isArray(r) ? r : r.items).forEach(updateRanges)
                ;(Array.isArray(r) ? r : r.items).forEach(updateLabel)
              }
              return r
            }
            const isThenable = <T>(obj: ProviderResult<T>): obj is Thenable<T> =>
              obj && (<any>obj)['then']

            const r = next(document, position, context, token)
            if (isThenable<CompletionItem[] | CompletionList | null | undefined>(r)) {
              return r.then(updateProposals)
            }
            return updateProposals(r)
          },
        },
      }
    )

    await client.start()
    context.subscriptions.push(initCompletionProvider())

    function initCompletionProvider(): Disposable {
      const regionCompletionRegExpr = /^(\s*)(\/(\*\s*(#\w*)?)?)?$/

      return Languages.registerCompletionItemProvider(['tailwindcss'], {
        provideCompletionItems(doc: TextDocument, pos: Position) {
          let lineUntilPos = doc.getText(new Range(new Position(pos.line, 0), pos))
          let match = lineUntilPos.match(regionCompletionRegExpr)
          if (match) {
            let range = new Range(new Position(pos.line, match[1].length), pos)
            let beginProposal = new CompletionItem('#region', CompletionItemKind.Snippet)
            beginProposal.range = range
            TextEdit.replace(range, '/* #region */')
            beginProposal.insertText = new SnippetString('/* #region $1*/')
            beginProposal.documentation = 'Folding Region Start'
            beginProposal.filterText = match[2]
            beginProposal.sortText = 'za'
            let endProposal = new CompletionItem('#endregion', CompletionItemKind.Snippet)
            endProposal.range = range
            endProposal.insertText = '/* #endregion */'
            endProposal.documentation = 'Folding Region End'
            endProposal.sortText = 'zb'
            endProposal.filterText = match[2]
            return [beginProposal, endProposal]
          }
          return null
        },
      })
    }
  }

  function bootWorkspaceClient(folder: WorkspaceFolder) {
    if (clients.has(folder.uri.toString())) {
      return
    }

    let colorDecorationType: TextEditorDecorationType
    function clearColors(): void {
      if (colorDecorationType) {
        colorDecorationType.dispose()
        colorDecorationType = undefined
      }
    }
    context.subscriptions.push({
      dispose() {
        if (colorDecorationType) {
          colorDecorationType.dispose()
        }
      },
    })

    // placeholder so we don't boot another server before this one is ready
    clients.set(folder.uri.toString(), null)

    if (!languages.has(folder.uri.toString())) {
      languages.set(
        folder.uri.toString(),
        dedupe([...defaultLanguages, ...Object.keys(getUserLanguages(folder))])
      )
    }

    if (!outputChannel) {
      outputChannel = Window.createOutputChannel(CLIENT_NAME)
      context.subscriptions.push(outputChannel)
      commands.executeCommand('setContext', 'tailwindCSS.hasOutputChannel', true)
    }

    let configuration = {
      editor: Workspace.getConfiguration('editor', folder),
      tailwindCSS: mergeExcludes(Workspace.getConfiguration('tailwindCSS', folder), folder),
    }

    let inspectPort = configuration.tailwindCSS.get('inspectPort')

    let serverOptions: ServerOptions = {
      run: {
        module,
        transport: TransportKind.ipc,
        options: { execArgv: inspectPort === null ? [] : [`--inspect=${inspectPort}`] },
      },
      debug: {
        module,
        transport: TransportKind.ipc,
        options: {
          execArgv: ['--nolazy', `--inspect=${6011 + clients.size}`],
        },
      },
    }

    let clientOptions: LanguageClientOptions = {
      documentSelector: languages.get(folder.uri.toString()).map((language) => ({
        scheme: 'file',
        language,
        pattern: normalizePath(`${folder.uri.fsPath.replace(/[\[\]\{\}]/g, '?')}/**/*`),
      })),
      diagnosticCollectionName: CLIENT_ID,
      workspaceFolder: folder,
      outputChannel: outputChannel,
      revealOutputChannelOn: RevealOutputChannelOn.Never,
      middleware: {
        provideCompletionItem(document, position, context, token, next) {
          let workspaceFolder = Workspace.getWorkspaceFolder(document.uri)
          if (workspaceFolder !== folder) {
            return null
          }
          return next(document, position, context, token)
        },
        provideHover(document, position, token, next) {
          let workspaceFolder = Workspace.getWorkspaceFolder(document.uri)
          if (workspaceFolder !== folder) {
            return null
          }
          return next(document, position, token)
        },
        handleDiagnostics(uri, diagnostics, next) {
          let workspaceFolder = Workspace.getWorkspaceFolder(uri)
          if (workspaceFolder !== folder) {
            return
          }
          next(uri, diagnostics)
        },
        provideCodeActions(document, range, context, token, next) {
          let workspaceFolder = Workspace.getWorkspaceFolder(document.uri)
          if (workspaceFolder !== folder) {
            return null
          }
          return next(document, range, context, token)
        },
        async resolveCompletionItem(item, token, next) {
          let result = await next(item, token)
          let selections = Window.activeTextEditor.selections
          if (
            result['data'] === 'variant' &&
            selections.length > 1 &&
            result.additionalTextEdits?.length > 0
          ) {
            let length =
              selections[0].start.character - result.additionalTextEdits[0].range.start.character
            let prefixLength =
              result.additionalTextEdits[0].range.end.character -
              result.additionalTextEdits[0].range.start.character

            let ranges = selections.map((selection) => {
              return new Range(
                new Position(selection.start.line, selection.start.character - length),
                new Position(
                  selection.start.line,
                  selection.start.character - length + prefixLength
                )
              )
            })
            if (
              ranges
                .map((range) => Window.activeTextEditor.document.getText(range))
                .every((text, _index, arr) => arr.indexOf(text) === 0)
            ) {
              // all the same
              result.additionalTextEdits = ranges.map((range) => {
                return { range, newText: result.additionalTextEdits[0].newText }
              })
            } else {
              result.insertText =
                typeof result.label === 'string' ? result.label : result.label.label
              result.additionalTextEdits = []
            }
          }
          return result
        },
        async provideDocumentColors(document, token, next) {
          let workspaceFolder = Workspace.getWorkspaceFolder(document.uri)
          if (workspaceFolder !== folder) {
            return null
          }

          let colors = await next(document, token)
          let editableColors = colors.filter((color) => {
            let text =
              Workspace.textDocuments.find((doc) => doc === document)?.getText(color.range) ?? ''
            return new RegExp(
              `-\\[(${colorNames.join('|')}|((?:#|rgba?\\(|hsla?\\())[^\\]]+)\\]$`
            ).test(text)
          })
          let nonEditableColors = colors.filter((color) => !editableColors.includes(color))

          if (!colorDecorationType) {
            colorDecorationType = Window.createTextEditorDecorationType({
              before: {
                width: '0.8em',
                height: '0.8em',
                contentText: ' ',
                border: '0.1em solid',
                margin: '0.1em 0.2em 0',
              },
              dark: {
                before: {
                  borderColor: '#eeeeee',
                },
              },
              light: {
                before: {
                  borderColor: '#000000',
                },
              },
            })
          }

          Window.visibleTextEditors
            .find((editor) => editor.document === document)
            ?.setDecorations(
              colorDecorationType,
              nonEditableColors.map(({ range, color }) => ({
                range,
                renderOptions: {
                  before: {
                    backgroundColor: `rgba(${color.red * 255}, ${color.green * 255}, ${
                      color.blue * 255
                    }, ${color.alpha})`,
                  },
                },
              }))
            )

          return editableColors
        },
        workspace: {
          configuration: (params) => {
            return params.items.map(({ section, scopeUri }) => {
              let scope: ConfigurationScope = folder
              if (scopeUri) {
                let doc = Workspace.textDocuments.find((doc) => doc.uri.toString() === scopeUri)
                if (doc) {
                  scope = {
                    uri: Uri.parse(scopeUri),
                    languageId: doc.languageId,
                  }
                }
              }
              let settings = Workspace.getConfiguration(section, scope)

              if (section === 'tailwindCSS') {
                return mergeExcludes(settings, scope)
              }

              return settings
            })
          },
        },
      },
      initializationOptions: {
        userLanguages: getUserLanguages(folder),
        workspaceFile:
          Workspace.workspaceFile?.scheme === 'file' ? Workspace.workspaceFile.fsPath : undefined,
      },
      synchronize: {
        configurationSection: ['files', 'editor', 'tailwindCSS'],
      },
    }

    let client = new LanguageClient(CLIENT_ID, CLIENT_NAME, serverOptions, clientOptions)

    client.onNotification('@/tailwindCSS/error', async ({ message }) => {
      let action = await Window.showErrorMessage(message, 'Go to output')
      if (action === 'Go to output') {
        commands.executeCommand('tailwindCSS.showOutput')
      }
    })

    client.onNotification('@/tailwindCSS/clearColors', () => clearColors())

    client.onNotification('@/tailwindCSS/projectInitialized', async () => {
      await updateActiveTextEditorContext()
    })
    client.onNotification('@/tailwindCSS/projectReset', async () => {
      await updateActiveTextEditorContext()
    })
    client.onNotification('@/tailwindCSS/projectsDestroyed', () => {
      resetActiveTextEditorContext()
    })

    client.onRequest('@/tailwindCSS/getDocumentSymbols', async ({ uri }) => {
      return commands.executeCommand<SymbolInformation[]>(
        'vscode.executeDocumentSymbolProvider',
        Uri.parse(uri)
      )
    })

    client.onDidChangeState(({ newState }) => {
      if (newState === LanguageClientState.Stopped) {
        clearColors()
      }
    })

    client.start()
    clients.set(folder.uri.toString(), client)
  }

  async function bootClientForFolderIfNeeded(folder: WorkspaceFolder): Promise<void> {
    let settings = Workspace.getConfiguration('tailwindCSS', folder)
    if (settings.get('experimental.configFile') !== null) {
      bootWorkspaceClient(folder)
      return
    }

    let exclude = `{${getExcludePatterns(folder)
      .flatMap((pattern) => braces.expand(pattern))
      .join(',')
      .replace(/{/g, '%7B')
      .replace(/}/g, '%7D')}}`

    let [configFile] = await Workspace.findFiles(
      new RelativePattern(folder, `**/${CONFIG_GLOB}`),
      exclude,
      1
    )

    if (configFile) {
      bootWorkspaceClient(folder)
      return
    }

    let cssFiles = await Workspace.findFiles(new RelativePattern(folder, `**/${CSS_GLOB}`), exclude)

    for (let cssFile of cssFiles) {
      if (await fileContainsAtConfig(cssFile)) {
        bootWorkspaceClient(folder)
        return
      }
    }
  }

  async function didOpenTextDocument(document: TextDocument): Promise<void> {
    if (document.languageId === 'tailwindcss') {
      bootCssServer()
    }

    // We are only interested in language mode text
    if (document.uri.scheme !== 'file') {
      return
    }

    let uri = document.uri
    let folder = Workspace.getWorkspaceFolder(uri)
    // Files outside a folder can't be handled. This might depend on the language.
    // Single file languages like JSON might handle files outside the workspace folders.
    if (!folder) {
      return
    }

    if (searchedFolders.has(folder.uri.toString())) {
      return
    }

    searchedFolders.add(folder.uri.toString())

    await bootClientForFolderIfNeeded(folder)
  }

  context.subscriptions.push(Workspace.onDidOpenTextDocument(didOpenTextDocument))
  Workspace.textDocuments.forEach(didOpenTextDocument)
  context.subscriptions.push(
    Workspace.onDidChangeWorkspaceFolders((event) => {
      for (let folder of event.removed) {
        let client = clients.get(folder.uri.toString())
        if (client) {
          searchedFolders.delete(folder.uri.toString())
          clients.delete(folder.uri.toString())
          client.stop()
        }
      }
    })
  )
}

export function deactivate(): Thenable<void> {
  let promises: Thenable<void>[] = []
  for (let client of clients.values()) {
    promises.push(client.stop())
  }
  return Promise.all(promises).then(() => undefined)
}
