//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2020 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
//
//===----------------------------------------------------------------------===//

package import Foundation
@_spi(SourceKitLSP) package import LanguageServerProtocol
@_spi(SourceKitLSP) import LanguageServerProtocolExtensions
@_spi(SourceKitLSP) import SKLogging
import SwiftExtensions
import TSCExtensions
@_spi(SourceKitLSP) import ToolsProtocolsSwiftExtensions

#if os(Windows)
import WinSDK
#endif

/// A single compilation database command.
///
/// See https://clang.llvm.org/docs/JSONCompilationDatabase.html
package struct CompilationDatabaseCompileCommand: Equatable, Codable {
  /// The working directory for the compilation.
  package var directory: String

  /// The path of the main file for the compilation, which may be relative to `directory`.
  package var filename: String

  /// The compile command as a list of strings, with the program name first.
  package var commandLine: [String]

  /// The name of the build output, or nil.
  package var output: String? = nil

  package init(directory: String, filename: String, commandLine: [String], output: String? = nil) {
    self.directory = directory
    self.filename = filename
    self.commandLine = commandLine
    self.output = output
  }

  private enum CodingKeys: String, CodingKey {
    case directory
    case file
    case command
    case arguments
    case output
  }

  package init(from decoder: any Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    self.directory = try container.decode(String.self, forKey: .directory)
    self.filename = try container.decode(String.self, forKey: .file)
    self.output = try container.decodeIfPresent(String.self, forKey: .output)
    if let arguments = try container.decodeIfPresent([String].self, forKey: .arguments) {
      self.commandLine = arguments
    } else if let command = try container.decodeIfPresent(String.self, forKey: .command) {
      #if os(Windows)
      self.commandLine = splitWindowsCommandLine(command, initialCommandName: true)
      #else
      self.commandLine = splitShellEscapedCommand(command)
      #endif
    } else {
      throw CompilationDatabaseDecodingError.missingCommandOrArguments
    }
  }

  package func encode(to encoder: any Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)
    try container.encode(directory, forKey: .directory)
    try container.encode(filename, forKey: .file)
    try container.encode(commandLine, forKey: .arguments)
    try container.encodeIfPresent(output, forKey: .output)
  }

  /// The `DocumentURI` for this file. If this a relative path, it will be interpreted relative to the compile command's
  /// working directory, which in turn is relative to `compileCommandsDirectory`, the directory that contains the
  /// `compile_commands.json` file.
  package func uri(compileCommandsDirectory: URL) -> DocumentURI {
    if filename.isAbsolutePath {
      return DocumentURI(URL(fileURLWithPath: self.filename))
    }
    return DocumentURI(
      URL(
        fileURLWithPath: self.filename,
        relativeTo: self.directoryURL(compileCommandsDirectory: compileCommandsDirectory)
      )
    )
  }

  /// A file URL representing `directory`. If `directory` is relative, it's interpreted relative to
  /// `compileCommandsDirectory`, the directory that contains the `compile_commands.json` file.
  func directoryURL(compileCommandsDirectory: URL) -> URL {
    return URL(fileURLWithPath: directory, isDirectory: true, relativeTo: compileCommandsDirectory)
  }
}

extension CodingUserInfoKey {
  /// When decoding `JSONCompilationDatabase` a `URL` representing the directory that contains the
  /// `compile_commands.json`.
  package static let compileCommandsDirectoryKey: CodingUserInfoKey =
    CodingUserInfoKey(rawValue: "lsp.compile-commands-dir")!
}

/// The JSON clang-compatible compilation database.
///
/// Example:
///
/// ```
/// [
///   {
///     "directory": "/src",
///     "file": "/src/file.cpp",
///     "command": "clang++ file.cpp"
///   }
/// ]
/// ```
///
/// See https://clang.llvm.org/docs/JSONCompilationDatabase.html
package struct JSONCompilationDatabase: Equatable, Codable {
  private var pathToCommands: [DocumentURI: [Int]] = [:]
  var commands: [CompilationDatabaseCompileCommand] = []

  /// The directory that contains the `compile_commands.json` file.
  private let compileCommandsDirectory: URL

  package init(_ commands: [CompilationDatabaseCompileCommand] = [], compileCommandsDirectory: URL) {
    self.compileCommandsDirectory = compileCommandsDirectory
    for command in commands {
      add(command)
    }
  }

  /// Decode the `JSONCompilationDatabase` from a decoder.
  ///
  /// A `URL` representing the directory that contains the `compile_commands.json` must be passed in the decoder's
  /// `userInfo` via the `compileCommandsDirectoryKey`.
  package init(from decoder: any Decoder) throws {
    guard let compileCommandsDirectory = decoder.userInfo[.compileCommandsDirectoryKey] as? URL else {
      struct MissingCompileCommandsDirectoryKeyError: Error {}
      throw MissingCompileCommandsDirectoryKeyError()
    }
    self.compileCommandsDirectory = compileCommandsDirectory
    var container = try decoder.unkeyedContainer()
    while !container.isAtEnd {
      self.add(try container.decode(CompilationDatabaseCompileCommand.self))
    }
  }

  /// Loads the compilation database located in `directory`, if any.
  ///
  /// - Returns: `nil` if `compile_commands.json` was not found
  package init(directory: URL) throws {
    let path = directory.appending(component: JSONCompilationDatabaseBuildServer.dbName)
    try self.init(file: path)
  }

  /// Loads the compilation database from `file`
  /// - Returns: `nil` if the file does not exist
  package init(file: URL) throws {
    let data = try Data(contentsOf: file)
    let decoder = JSONDecoder()
    decoder.userInfo[.compileCommandsDirectoryKey] = file.deletingLastPathComponent()
    self = try decoder.decode(JSONCompilationDatabase.self, from: data)
  }

  package func encode(to encoder: any Encoder) throws {
    var container = encoder.unkeyedContainer()
    for command in commands {
      try container.encode(command)
    }
  }

  package subscript(_ uri: DocumentURI) -> [CompilationDatabaseCompileCommand] {
    if let indices = pathToCommands[uri] {
      return indices.map { commands[$0] }
    }
    if let fileURL = try? uri.fileURL?.realpath, let indices = pathToCommands[DocumentURI(fileURL)] {
      return indices.map { commands[$0] }
    }
    return []
  }

  private mutating func add(_ command: CompilationDatabaseCompileCommand) {
    let uri = command.uri(compileCommandsDirectory: compileCommandsDirectory)
    pathToCommands[uri, default: []].append(commands.count)

    if let symlinkTarget = uri.symlinkTarget {
      pathToCommands[symlinkTarget, default: []].append(commands.count)
    }

    commands.append(command)
  }
}

enum CompilationDatabaseDecodingError: Error {
  case missingCommandOrArguments
  case fixedDatabaseDecodingError
}

fileprivate extension String {
  var isAbsolutePath: Bool {
    #if os(Windows)
    // PathIsRelativeW requires a null-terminated UTF16 encoded string
    return withCString(encodedAs: UTF16.self) { ptr in
      return !PathIsRelativeW(ptr)
    }
    #else
    return self.hasPrefix("/")
    #endif
  }
}
