//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2024 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) import BuildServerProtocol
package import Foundation
@_spi(SourceKitLSP) import LanguageServerProtocol
@_spi(SourceKitLSP) import LanguageServerProtocolExtensions
@_spi(SourceKitLSP) import LanguageServerProtocolTransport
@_spi(SourceKitLSP) import SKLogging
package import SKOptions
import SwiftExtensions
import TSCExtensions
package import ToolchainRegistry
@_spi(SourceKitLSP) import ToolsProtocolsSwiftExtensions

import func TSCBasic.getEnvSearchPaths
import var TSCBasic.localFileSystem
import func TSCBasic.lookupExecutablePath

private func executable(_ name: String) -> String {
  #if os(Windows)
  guard !name.hasSuffix(".exe") else { return name }
  return "\(name).exe"
  #else
  return name
  #endif
}

private let python3ExecutablePath: URL? = {
  let pathVariable: String
  #if os(Windows)
  pathVariable = "Path"
  #else
  pathVariable = "PATH"
  #endif
  let searchPaths =
    getEnvSearchPaths(
      pathString: ProcessInfo.processInfo.environment[pathVariable],
      currentWorkingDirectory: localFileSystem.currentWorkingDirectory
    )

  return lookupExecutablePath(filename: executable("python3"), searchPaths: searchPaths)?.asURL
    ?? lookupExecutablePath(filename: executable("python"), searchPaths: searchPaths)?.asURL
}()

struct ExecutableNotFoundError: Error {
  let executableName: String
}

enum BuildServerNotFoundError: Error {
  case fileNotFound
}

/// BSP configuration
///
/// See https://build-server-protocol.github.io/docs/overview/server-discovery#the-bsp-connection-details
package struct BuildServerConfig: Codable {
  /// The name of the build tool.
  let name: String

  /// The version of the build tool.
  let version: String

  /// The bsp version of the build tool.
  let bspVersion: String

  /// A collection of languages supported by this BSP server.
  let languages: [String]

  /// Command arguments runnable via server processes to start a BSP server.
  package let argv: [String]

  static func load(from path: URL) throws -> BuildServerConfig {
    let decoder = JSONDecoder()
    let fileData = try Data(contentsOf: path)
    return try decoder.decode(BuildServerConfig.self, from: fileData)
  }

  enum SwiftPMBuildServerConfigError: Error {
    case unsupportedToolchainForSwiftPMBuildServerWithoutBackgroundIndexing
  }

  package static func forSwiftPMBuildServer(
    projectRoot: URL,
    options: SourceKitLSPOptions,
    toolchainRegistry: ToolchainRegistry
  ) async throws -> BuildServerConfig {
    let toolchain = await toolchainRegistry.preferredToolchain(containing: [\.swift])
    guard let swiftPath = try toolchain?.swift?.filePath else {
      throw ExecutableNotFoundError(executableName: "swift")
    }
    var args: [String] = [swiftPath, "package", "experimental-build-server"]
    // The build server requires use of the Swift Build backend.
    args.append(contentsOf: ["--build-system", "swiftbuild"])

    let swiftVersion = try? await toolchain?.swiftVersion
    if let swiftVersion, swiftVersion >= SwiftVersion(6, 4) {
      // Skip acquiring the workspace lock.
      args.append("--experimental-skip-acquiring-lock")
    } else if !options.backgroundIndexingOrDefault {
      // Without background indexing we always need to be able to coexist with user-initiated builds,
      // which older toolchains can't do because they can't skip acquiring the workspace lock.
      throw SwiftPMBuildServerConfigError.unsupportedToolchainForSwiftPMBuildServerWithoutBackgroundIndexing
    }
    if let swiftVersion, swiftVersion >= SwiftVersion(6, 5) {
      // Preserve symlinks in the package and source file paths so that the paths reported by the
      // build server match the path SourceKit-LSP opened the package with.
      args.append("--experimental-skip-resolving-package-paths")
    }
    if !options.backgroundIndexingOrDefault {
      // When not background indexing we don't want to modify the user's `Package.resolved`, so force
      // the use of the already-resolved versions. We can't do this while background indexing because
      // the package may not have been resolved yet, in which case the native build server can still
      // enumerate targets from the manifest alone.
      args.append("--force-resolved-versions")
    }
    // Explicitly specify the package path.
    try args.append(contentsOf: ["--package-path", projectRoot.filePath])
    // Map LSP SwiftPM options to build server flags

    func resolvedRelativeToProjectRoot(_ path: String) throws -> String {
      try URL(fileURLWithPath: path, relativeTo: projectRoot.ensuringCorrectTrailingSlash).filePath
    }

    if let configuration = options.swiftPMOrDefault.configuration {
      args.append(contentsOf: ["--configuration", configuration.rawValue])
    }
    if let scratchPath = options.swiftPMOrDefault.scratchPath {
      args.append(contentsOf: ["--scratch-path", try resolvedRelativeToProjectRoot(scratchPath)])
    } else if options.backgroundIndexingOrDefault {
      // When background indexing without an explicit scratch path, build into `.build/index-build`
      // so that the index build is independent of the user's regular `.build` directory and doesn't
      // contend with user-initiated builds.
      try args.append(contentsOf: [
        "--scratch-path", projectRoot.appending(components: ".build", "index-build").filePath,
      ])
    }
    if let swiftSDKsDirectory = options.swiftPMOrDefault.swiftSDKsDirectory {
      args.append(contentsOf: ["--swift-sdks-path", try resolvedRelativeToProjectRoot(swiftSDKsDirectory)])
    }
    if let swiftSDK = options.swiftPMOrDefault.swiftSDK {
      args.append(contentsOf: ["--swift-sdk", swiftSDK])
    }
    if let triple = options.swiftPMOrDefault.triple {
      args.append(contentsOf: ["--triple", triple])
    }
    if let toolsets = options.swiftPMOrDefault.toolsets {
      for toolset in toolsets {
        args.append(contentsOf: ["--toolset", try resolvedRelativeToProjectRoot(toolset)])
      }
    }
    if let sdk = options.swiftPMOrDefault.sdk {
      args.append(contentsOf: ["--sdk", try resolvedRelativeToProjectRoot(sdk)])
    }
    if let pkgConfigPaths = options.swiftPMOrDefault.pkgConfigPaths {
      for pkgConfigPath in pkgConfigPaths {
        args.append(contentsOf: ["--pkg-config-path", try resolvedRelativeToProjectRoot(pkgConfigPath)])
      }
    }
    if let traits = options.swiftPMOrDefault.traits {
      args.append(contentsOf: ["--traits", traits.joined(separator: ",")])
    }
    if let cCompilerFlags = options.swiftPMOrDefault.cCompilerFlags {
      for flag in cCompilerFlags {
        args.append(contentsOf: ["-Xcc", flag])
      }
    }
    if let cxxCompilerFlags = options.swiftPMOrDefault.cxxCompilerFlags {
      for flag in cxxCompilerFlags {
        args.append(contentsOf: ["-Xcxx", flag])
      }
    }
    if let swiftCompilerFlags = options.swiftPMOrDefault.swiftCompilerFlags {
      for flag in swiftCompilerFlags {
        args.append(contentsOf: ["-Xswiftc", flag])
      }
    }
    if let linkerFlags = options.swiftPMOrDefault.linkerFlags {
      for flag in linkerFlags {
        args.append(contentsOf: ["-Xlinker", flag])
      }
    }
    if let buildToolsSwiftCompilerFlags = options.swiftPMOrDefault.buildToolsSwiftCompilerFlags {
      for flag in buildToolsSwiftCompilerFlags {
        args.append(contentsOf: ["-Xbuild-tools-swiftc", flag])
      }
    }
    if options.swiftPMOrDefault.disableSandbox == true {
      args.append("--disable-sandbox")
    }
    // Keep this last so extra arguments can override the options above.
    if let extraArguments = options.swiftPMOrDefault.extraArguments {
      args.append(contentsOf: extraArguments)
    }
    // The skipPlugins option isn't currently respected because the underlying build server does not support it.
    // We may want to reconsider this in the future, or remove the option entirely.
    return BuildServerConfig(
      name: "SwiftPM Build Server",
      version: "",
      bspVersion: "2.2.0",
      languages: [Language.c, .cpp, .objective_c, .objective_cpp, .swift].map(\.rawValue),
      argv: args
    )
  }
}

/// Launches a subprocess that is a BSP server and manages the process's lifetime.
actor ExternalBuildServerAdapter {
  /// The root folder of the project. Used to resolve relative server paths.
  private let projectRoot: URL

  /// The configuration for this build server.
  private let serverConfig: BuildServerConfig

  /// Hooks that allow tests to intercept the messages sent to the build server.
  private let buildServerHooks: BuildServerHooks

  /// The `BuildServerManager` that handles messages from the BSP server to SourceKit-LSP.
  var messagesToSourceKitLSPHandler: any MessageHandler

  /// The JSON-RPC connection between SourceKit-LSP and the BSP server.
  private(set) var connectionToBuildServer: LegacyNameFallbackConnection?

  /// After a `build/initialize` request has been sent to the BSP server, that request, so we can replay it in case the
  /// server crashes.
  private var initializeRequest: InitializeBuildRequest?

  /// The process that runs the external BSP server.
  private var process: Process?

  /// The date at which `clangd` was last restarted.
  /// Used to delay restarting in case of a crash loop.
  private var lastRestart: Date?

  static package func searchForConfig(
    in workspaceFolder: URL,
    onlyConsiderRoot: Bool,
    options: SourceKitLSPOptions
  ) -> BuildServerSpec? {
    guard let configPath = getConfigPath(for: workspaceFolder, onlyConsiderRoot: onlyConsiderRoot) else {
      return nil
    }

    return BuildServerSpec(kind: .externalBuildServer, projectRoot: workspaceFolder, configPath: configPath)
  }

  init(
    projectRoot: URL,
    config: BuildServerConfig,
    messagesToSourceKitLSPHandler: any MessageHandler,
    buildServerHooks: BuildServerHooks
  ) async throws {
    self.projectRoot = projectRoot
    self.serverConfig = config
    self.buildServerHooks = buildServerHooks
    self.messagesToSourceKitLSPHandler = messagesToSourceKitLSPHandler
    self.connectionToBuildServer = LegacyNameFallbackConnection(
      try await self.createConnectionToBspServer(),
      legacyNames: MessageRegistry.bspLegacyNames
    )
  }

  init(
    projectRoot: URL,
    configPath: URL,
    messagesToSourceKitLSPHandler: any MessageHandler,
    buildServerHooks: BuildServerHooks
  ) async throws {
    let serverConfig = try BuildServerConfig.load(from: configPath)
    try await self.init(
      projectRoot: projectRoot,
      config: serverConfig,
      messagesToSourceKitLSPHandler: messagesToSourceKitLSPHandler,
      buildServerHooks: buildServerHooks
    )
  }

  /// Change the handler that handles messages from the build server.
  ///
  /// The intended use of this is to intercept messages from the build server by `LegacyBuildServer`.
  func changeMessageToSourceKitLSPHandler(to newHandler: any MessageHandler) {
    messagesToSourceKitLSPHandler = newHandler
    connectionToBuildServer?.changeReceiveHandler(messagesToSourceKitLSPHandler)
  }

  /// Send a notification to the build server.
  func send(_ notification: some NotificationType) {
    guard let connectionToBuildServer else {
      logger.error("Dropping notification because BSP server has crashed: \(notification.forLogging)")
      return
    }
    connectionToBuildServer.send(notification)
  }

  /// Send a request to the build server.
  func send<Request: RequestType>(_ request: Request) async throws -> Request.Response {
    await buildServerHooks.preHandleRequest?(request)
    guard let connectionToBuildServer else {
      throw ResponseError.internalError("BSP server has crashed")
    }
    if let request = request as? InitializeBuildRequest {
      if initializeRequest != nil {
        logger.error("BSP server was initialized multiple times")
      }
      self.initializeRequest = request
    }
    return try await connectionToBuildServer.send(request)
  }

  /// Create a new JSONRPCConnection to the build server.
  private func createConnectionToBspServer() async throws -> JSONRPCConnection {
    var serverPath = URL(fileURLWithPath: serverConfig.argv[0], relativeTo: projectRoot.ensuringCorrectTrailingSlash)
    var serverArgs = Array(serverConfig.argv[1...])

    if serverPath.pathExtension == "py" {
      serverArgs = [try serverPath.filePath] + serverArgs
      guard let interpreterPath = python3ExecutablePath else {
        throw ExecutableNotFoundError(executableName: "python3")
      }

      serverPath = interpreterPath
    }

    let (connection, process) = try JSONRPCConnection.start(
      executable: serverPath,
      arguments: serverArgs,
      name: "BSP-Server",
      protocol: MessageRegistry.bspProtocol,
      stderrLoggingCategory: "bsp-server-stderr",
      client: messagesToSourceKitLSPHandler,
      terminationHandler: { [weak self] terminationReason in
        guard let self else {
          return
        }
        if terminationReason != .exited(exitCode: 0) {
          Task {
            await orLog("Restarting BSP server") {
              try await self.handleBspServerCrash()
            }
          }
        }
      }
    )
    self.process = process
    return connection
  }

  private static func getConfigPath(for workspaceFolder: URL? = nil, onlyConsiderRoot: Bool = false) -> URL? {
    var buildServerConfigLocations: [URL?] = []
    if let workspaceFolder = workspaceFolder {
      buildServerConfigLocations.append(workspaceFolder.appending(component: ".bsp"))
    }

    if !onlyConsiderRoot {
      #if os(Windows)
      if let localAppData = ProcessInfo.processInfo.environment["LOCALAPPDATA"] {
        buildServerConfigLocations.append(URL(fileURLWithPath: localAppData).appending(component: "bsp"))
      }
      if let programData = ProcessInfo.processInfo.environment["PROGRAMDATA"] {
        buildServerConfigLocations.append(URL(fileURLWithPath: programData).appending(component: "bsp"))
      }
      #else
      if let xdgDataHome = ProcessInfo.processInfo.environment["XDG_DATA_HOME"] {
        buildServerConfigLocations.append(URL(fileURLWithPath: xdgDataHome).appending(component: "bsp"))
      }

      if let libraryUrl = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
        buildServerConfigLocations.append(libraryUrl.appending(component: "bsp"))
      }

      if let xdgDataDirs = ProcessInfo.processInfo.environment["XDG_DATA_DIRS"] {
        buildServerConfigLocations += xdgDataDirs.split(separator: ":").map { xdgDataDir in
          URL(fileURLWithPath: String(xdgDataDir)).appending(component: "bsp")
        }
      }

      if let libraryUrl = FileManager.default.urls(for: .applicationSupportDirectory, in: .systemDomainMask).first {
        buildServerConfigLocations.append(libraryUrl.appending(component: "bsp"))
      }
      #endif
    }

    for case let buildServerConfigLocation? in buildServerConfigLocations {
      let jsonFiles =
        try? FileManager.default.contentsOfDirectory(at: buildServerConfigLocation, includingPropertiesForKeys: nil)
        .filter {
          guard let config = try? BuildServerConfig.load(from: $0) else {
            return false
          }
          return !Set([Language.c, .cpp, .objective_c, .objective_cpp, .swift].map(\.rawValue))
            .intersection(config.languages).isEmpty
        }

      if let configFileURL = jsonFiles?.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }).first {
        return configFileURL
      }
    }

    // Pre Swift 6.1 SourceKit-LSP looked for `buildServer.json` in the project root. Maintain this search location for
    // compatibility even though it's not a standard BSP search location.
    if let buildServerPath = workspaceFolder?.appending(component: "buildServer.json"),
      FileManager.default.isFile(at: buildServerPath)
    {
      return buildServerPath
    }

    return nil
  }

  /// Restart the BSP server after it has crashed.
  private func handleBspServerCrash() async throws {
    // Set `connectionToBuildServer` to `nil` to indicate that there is currently no BSP server running.
    connectionToBuildServer = nil

    guard let initializeRequest else {
      logger.error("BSP server crashed before it was sent an initialize request. Not restarting.")
      return
    }

    logger.error("The BSP server has crashed. Restarting.")
    let restartDelay: Duration
    if let lastClangdRestart = self.lastRestart, Date().timeIntervalSince(lastClangdRestart) < 30 {
      logger.log("BSP server has been restarted in the last 30 seconds. Delaying another restart by 10 seconds.")
      restartDelay = .seconds(10)
    } else {
      restartDelay = .zero
    }
    self.lastRestart = Date()

    try await Task.sleep(for: restartDelay)

    let restartedConnection = try await self.createConnectionToBspServer()

    // We assume that the server returns the same initialize response after being restarted.
    // BSP does not set any state from the client to the server, so there are no other requests we need to replay
    // (other than `textDocument/registerForChanges`, which is only used by the legacy BSP protocol, which didn't have
    // crash recovery and doesn't need to gain it because it is deprecated).
    _ = try await restartedConnection.send(initializeRequest)
    restartedConnection.send(OnBuildInitializedNotification())
    self.connectionToBuildServer = LegacyNameFallbackConnection(
      restartedConnection,
      legacyNames: MessageRegistry.bspLegacyNames
    )

    // The build targets might have changed after the restart. Send a `buildTarget/didChange` notification to
    // SourceKit-LSP to discard cached information.
    self.messagesToSourceKitLSPHandler.handle(OnBuildTargetDidChangeNotification(changes: nil))
  }

  /// If the build server is still running after `duration`, terminate it. Otherwise a no-op.
  package func terminateIfRunning(after duration: Duration) async throws {
    try await process?.terminateIfRunning(after: duration)
  }
}

fileprivate extension URL {
  /// If the path of this URL represents a directory, ensure that it has a trailing slash.
  ///
  /// This is important because if we form a file URL relative to eg. file:///tmp/a would assumes that `a` is a file
  /// and use `/tmp` as the base, not `/tmp/a`.
  var ensuringCorrectTrailingSlash: URL {
    guard self.isFileURL else {
      return self
    }
    // `URL(fileURLWithPath:)` checks the file system to decide whether a directory exists at the path.
    return URL(fileURLWithPath: self.path)
  }
}
