//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

@_spi(SourceKitLSP) package import LanguageServerProtocol
@_spi(SourceKitLSP) import LanguageServerProtocolExtensions
import LanguageServerProtocolTransport
@_spi(SourceKitLSP) import SKLogging
import SwiftExtensions

/// A class which tracks the client's capabilities as well as our dynamic
/// capability registrations in order to avoid registering conflicting
/// capabilities.
package final actor CapabilityRegistry {
  /// The client's capabilities as they were reported when sourcekit-lsp was launched.
  package nonisolated let clientCapabilities: ClientCapabilities

  // MARK: Tracking capabilities dynamically registered in the client

  /// Dynamically registered completion options.
  private var completion: [CapabilityRegistration: CompletionRegistrationOptions] = [:]

  /// Dynamically registered signature help options.
  private var signatureHelp: [CapabilityRegistration: SignatureHelpRegistrationOptions] = [:]

  /// Dynamically registered folding range options.
  private var foldingRange: [CapabilityRegistration: FoldingRangeRegistrationOptions] = [:]

  /// Dynamically registered semantic tokens options.
  private var semanticTokens: [CapabilityRegistration: SemanticTokensRegistrationOptions] = [:]

  /// Dynamically registered inlay hint options.
  private var inlayHint: [CapabilityRegistration: InlayHintRegistrationOptions] = [:]

  /// Dynamically registered pull diagnostics options.
  private var pullDiagnostics: [CapabilityRegistration: DiagnosticRegistrationOptions] = [:]

  /// Dynamically registered file watchers.
  private var didChangeWatchedFiles: (id: String, options: DidChangeWatchedFilesRegistrationOptions)?

  /// Dynamically registered command IDs.
  private var commandIds: Set<String> = []

  // MARK: Query if client has dynamic registration

  package var clientHasDynamicCompletionRegistration: Bool {
    clientCapabilities.textDocument?.completion?.dynamicRegistration == true
  }

  package var clientHasDynamicSignatureHelpRegistration: Bool {
    clientCapabilities.textDocument?.signatureHelp?.dynamicRegistration == true
  }

  package var clientHasDynamicFoldingRangeRegistration: Bool {
    clientCapabilities.textDocument?.foldingRange?.dynamicRegistration == true
  }

  package var clientHasDynamicSemanticTokensRegistration: Bool {
    clientCapabilities.textDocument?.semanticTokens?.dynamicRegistration == true
  }

  package var clientHasDynamicInlayHintRegistration: Bool {
    clientCapabilities.textDocument?.inlayHint?.dynamicRegistration == true
  }

  package var clientHasDynamicDocumentDiagnosticsRegistration: Bool {
    clientCapabilities.textDocument?.diagnostic?.dynamicRegistration == true
  }

  package var clientHasDynamicExecuteCommandRegistration: Bool {
    clientCapabilities.workspace?.executeCommand?.dynamicRegistration == true
  }

  package var clientHasDynamicDidChangeWatchedFilesRegistration: Bool {
    clientCapabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration == true
  }

  // MARK: Other capability queries

  package var clientHasDiagnosticsCodeDescriptionSupport: Bool {
    clientCapabilities.textDocument?.publishDiagnostics?.codeDescriptionSupport == true
  }

  public var supportedCodeLensCommands: [SupportedCodeLensCommand: String] {
    clientCapabilities.textDocument?.codeLens?.supportedCommands ?? [:]
  }

  /// Since LSP 3.17.0, diagnostics can be reported through pull-based requests in addition to the existing push-based
  /// publish notifications.
  ///
  /// The `DiagnosticOptions` were added at the same time as the pull diagnostics request and allow specification of
  /// options for the pull diagnostics request. If the client doesn't reject this dynamic capability registration,
  /// it supports the pull diagnostics request.
  package func clientSupportsPullDiagnostics(for language: Language) -> Bool {
    registration(for: [language], in: pullDiagnostics) != nil
  }

  package nonisolated var clientSupportsActiveDocumentNotification: Bool {
    return clientHasExperimentalCapability(DidChangeActiveDocumentNotification.method)
      || MessageRegistry.lspLegacyNames[DidChangeActiveDocumentNotification.method].map {
        clientHasExperimentalCapability($0)
      } ?? false
  }

  package nonisolated var clientHasWorkspaceTestsRefreshSupport: Bool {
    return clientHasExperimentalCapability(WorkspaceTestsRefreshRequest.method)
      || MessageRegistry.lspLegacyNames[WorkspaceTestsRefreshRequest.method].map {
        clientHasExperimentalCapability($0)
      } ?? false
  }

  package nonisolated var clientHasWorkspacePlaygroundsRefreshSupport: Bool {
    return clientHasExperimentalCapability(WorkspacePlaygroundsRefreshRequest.method)
      || MessageRegistry.lspLegacyNames[WorkspacePlaygroundsRefreshRequest.method].map {
        clientHasExperimentalCapability($0)
      } ?? false
  }

  package nonisolated var clientHasWorkspaceGetReferenceDocumentSupport: Bool {
    return clientHasExperimentalCapability(GetReferenceDocumentRequest.method)
      || MessageRegistry.lspLegacyNames[GetReferenceDocumentRequest.method].map {
        clientHasExperimentalCapability($0)
      } ?? false
  }

  /// Whether the client supports `workspaceSymbol/resolve` and will resolve `location` or `location.range`.
  package nonisolated var clientSupportsWorkspaceSymbolResolve: Bool {
    guard let properties = clientCapabilities.workspace?.symbol?.resolveSupport?.properties else {
      return false
    }
    return properties.contains("location") || properties.contains("location.range")
  }

  package nonisolated func clientHasExperimentalCapability(_ name: String) -> Bool {
    guard case .dictionary(let experimentalCapabilities) = clientCapabilities.experimental else {
      return false
    }
    // Before Swift 6.3 we expected experimental client capabilities to be passed as `"capabilityName": true`.
    // This proved to be insufficient for experimental capabilities that evolved over time. Since 6.3 we encourage
    // clients to pass experimental capabilities as `"capabilityName": { "supported": true }`, which allows the addition
    // of more configuration parameters to the capability.
    switch experimentalCapabilities[name] {
    case .bool(true):
      return true
    case .dictionary(let dict):
      return dict["supported"] == .bool(true)
    default:
      return false
    }
  }

  // MARK: Initializer

  package init(clientCapabilities: ClientCapabilities) {
    self.clientCapabilities = clientCapabilities
  }

  // MARK: Query registered capabilities

  /// Return a registration in `registrations` for one or more of the given
  /// `languages`.
  private func registration<T: TextDocumentRegistrationOptionsProtocol>(
    for languages: [Language],
    in registrations: [CapabilityRegistration: T]
  ) -> T? {
    var languageIds: Set<String> = []
    for language in languages {
      languageIds.insert(language.rawValue)
    }

    for registration in registrations {
      let options = registration.value.textDocumentRegistrationOptions
      guard let filters = options.documentSelector else { continue }
      for filter in filters {
        guard let filterLanguage = filter.language else { continue }
        if languageIds.contains(filterLanguage) {
          return registration.value
        }
      }
    }
    return nil
  }

  // MARK: Dynamic registration of server capabilities

  /// Register a dynamic server capability with the client.
  ///
  /// If the registration of `options` for the given `method` and `languages` was successful, the capability will be
  /// added to `registrationDict` by calling `setRegistrationDict`.
  /// If registration failed, the capability won't be added to `registrationDict`.
  private func registerLanguageSpecificCapability<
    Options: RegistrationOptions & TextDocumentRegistrationOptionsProtocol & Equatable
  >(
    options: Options,
    forMethod method: String,
    languages: [Language],
    in server: SourceKitLSPServer,
    registrationDict: [CapabilityRegistration: Options],
    setRegistrationDict: (CapabilityRegistration, Options?) -> Void
  ) async {
    if let registration = registration(for: languages, in: registrationDict) {
      if options != registration {
        logger.fault(
          """
          Failed to dynamically register for \(method, privacy: .public) for \(languages, privacy: .public) \
          due to pre-existing options:
          Existing options: \(String(reflecting: registration), privacy: .public)
          New options: \(String(reflecting: options), privacy: .public)
          """
        )
      }
      return
    }

    let registration = CapabilityRegistration(
      method: method,
      registerOptions: options.encodeToLSPAny()
    )

    // Add the capability to the registration dictionary.
    // This ensures that concurrent calls for the same capability don't register it as well.
    // If the capability is rejected by the client, we remove it again.
    setRegistrationDict(registration, options)

    do {
      _ = try await server.client.send(RegisterCapabilityRequest(registrations: [registration]))
    } catch {
      setRegistrationDict(registration, nil)
    }
  }

  /// Dynamically register completion capabilities if the client supports it and
  /// we haven't yet registered any completion capabilities for the given
  /// languages.
  package func registerCompletionIfNeeded(
    options: CompletionOptions,
    for languages: [Language],
    server: SourceKitLSPServer
  ) async {
    guard clientHasDynamicCompletionRegistration else { return }

    await registerLanguageSpecificCapability(
      options: CompletionRegistrationOptions(
        documentSelector: DocumentSelector(for: languages),
        completionOptions: options
      ),
      forMethod: CompletionRequest.method,
      languages: languages,
      in: server,
      registrationDict: completion,
      setRegistrationDict: { completion[$0] = $1 }
    )
  }

  package func registerSignatureHelpIfNeeded(
    options: SignatureHelpOptions,
    for languages: [Language],
    server: SourceKitLSPServer
  ) async {
    guard clientHasDynamicCompletionRegistration else { return }

    await registerLanguageSpecificCapability(
      options: SignatureHelpRegistrationOptions(
        documentSelector: DocumentSelector(for: languages),
        signatureHelpOptions: options
      ),
      forMethod: SignatureHelpRequest.method,
      languages: languages,
      in: server,
      registrationDict: signatureHelp,
      setRegistrationDict: { signatureHelp[$0] = $1 }
    )
  }

  package func registerDidChangeWatchedFiles(
    watchers: [FileSystemWatcher],
    server: SourceKitLSPServer
  ) async {
    guard clientHasDynamicDidChangeWatchedFilesRegistration else { return }
    if let registration = didChangeWatchedFiles {
      do {
        _ = try await server.client.send(
          UnregisterCapabilityRequest(unregistrations: [
            Unregistration(id: registration.id, method: DidChangeWatchedFilesNotification.method)
          ])
        )
      } catch {
        logger.error("Failed to unregister capability \(DidChangeWatchedFilesNotification.method).")
        return
      }
    }
    let registrationOptions = DidChangeWatchedFilesRegistrationOptions(
      watchers: watchers
    )
    let registration = CapabilityRegistration(
      method: DidChangeWatchedFilesNotification.method,
      registerOptions: registrationOptions.encodeToLSPAny()
    )

    self.didChangeWatchedFiles = (registration.id, registrationOptions)

    do {
      _ = try await server.client.send(RegisterCapabilityRequest(registrations: [registration]))
    } catch {
      logger.error("Failed to dynamically register for watched files: \(error.forLogging)")
      self.didChangeWatchedFiles = nil
    }
  }

  /// Dynamically register folding range capabilities if the client supports it and
  /// we haven't yet registered any folding range capabilities for the given
  /// languages.
  package func registerFoldingRangeIfNeeded(
    options: FoldingRangeOptions,
    for languages: [Language],
    server: SourceKitLSPServer
  ) async {
    guard clientHasDynamicFoldingRangeRegistration else { return }

    await registerLanguageSpecificCapability(
      options: FoldingRangeRegistrationOptions(
        documentSelector: DocumentSelector(for: languages),
        foldingRangeOptions: options
      ),
      forMethod: FoldingRangeRequest.method,
      languages: languages,
      in: server,
      registrationDict: foldingRange,
      setRegistrationDict: { foldingRange[$0] = $1 }
    )
  }

  /// Dynamically register semantic tokens capabilities if the client supports
  /// it and we haven't yet registered any semantic tokens capabilities for the
  /// given languages.
  package func registerSemanticTokensIfNeeded(
    options: SemanticTokensOptions,
    for languages: [Language],
    server: SourceKitLSPServer
  ) async {
    guard clientHasDynamicSemanticTokensRegistration else { return }

    await registerLanguageSpecificCapability(
      options: SemanticTokensRegistrationOptions(
        documentSelector: DocumentSelector(for: languages),
        semanticTokenOptions: options
      ),
      forMethod: SemanticTokensRegistrationOptions.method,
      languages: languages,
      in: server,
      registrationDict: semanticTokens,
      setRegistrationDict: { semanticTokens[$0] = $1 }
    )
  }

  /// Dynamically register inlay hint capabilities if the client supports
  /// it and we haven't yet registered any inlay hint capabilities for the
  /// given languages.
  package func registerInlayHintIfNeeded(
    options: InlayHintOptions,
    for languages: [Language],
    server: SourceKitLSPServer
  ) async {
    guard clientHasDynamicInlayHintRegistration else { return }

    await registerLanguageSpecificCapability(
      options: InlayHintRegistrationOptions(
        documentSelector: DocumentSelector(for: languages),
        inlayHintOptions: options
      ),
      forMethod: InlayHintRequest.method,
      languages: languages,
      in: server,
      registrationDict: inlayHint,
      setRegistrationDict: { inlayHint[$0] = $1 }
    )
  }

  /// Dynamically register (pull model) diagnostic capabilities,
  /// if the client supports it.
  package func registerDiagnosticIfNeeded(
    options: DiagnosticOptions,
    for languages: [Language],
    server: SourceKitLSPServer
  ) async {
    guard clientHasDynamicDocumentDiagnosticsRegistration else { return }

    await registerLanguageSpecificCapability(
      options: DiagnosticRegistrationOptions(
        documentSelector: DocumentSelector(for: languages),
        diagnosticOptions: options
      ),
      forMethod: DocumentDiagnosticsRequest.method,
      languages: languages,
      in: server,
      registrationDict: pullDiagnostics,
      setRegistrationDict: { pullDiagnostics[$0] = $1 }
    )
  }

  /// Dynamically register executeCommand with the given IDs if the client supports
  /// it and we haven't yet registered the given command IDs yet.
  package func registerExecuteCommandIfNeeded(
    commands: [String],
    server: SourceKitLSPServer
  ) {
    guard clientHasDynamicExecuteCommandRegistration else { return }

    var newCommands = Set(commands)
    newCommands.subtract(self.commandIds)

    // We only want to send the registration with unregistered command IDs since
    // clients such as VS Code only allow a command to be registered once. We could
    // unregister all our commandIds first but this is simpler.
    guard !newCommands.isEmpty else { return }
    self.commandIds.formUnion(newCommands)

    let registration = CapabilityRegistration(
      method: ExecuteCommandRequest.method,
      registerOptions: ExecuteCommandRegistrationOptions(commands: Array(newCommands)).encodeToLSPAny()
    )

    let _ = server.client.send(RegisterCapabilityRequest(registrations: [registration])) { result in
      if let error = result.failure {
        logger.error("Failed to dynamically register commands: \(error.forLogging)")
      }
    }
  }
}

fileprivate extension DocumentSelector {
  init(for languages: [Language], scheme: String? = nil) {
    self.init(languages.map { DocumentFilter(language: $0.rawValue, scheme: scheme) })
  }
}
