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

import CompletionScoring
import Csourcekitd
import Foundation
@_spi(SourceKitLSP) import SKLogging
import SKUtilities
import SourceKitD
import SwiftExtensions

extension PopularityIndex.Scope {
  init(string name: String) {
    if let dotIndex = name.firstIndex(of: ".") {
      self.init(
        container: String(name[name.index(after: dotIndex)...]),
        module: String(name[..<dotIndex])
      )
    } else {
      self.init(container: nil, module: name)
    }
  }
}

/// Execute the given block on a thread with the given stack size and wait for that thread to finish.
private func withStackSize<T>(_ stackSize: Int, execute block: @Sendable @escaping () -> T) -> T {
  var result: T! = nil
  nonisolated(unsafe) let workItem = DispatchWorkItem(block: {
    result = block()
  })
  let thread = Thread {
    workItem.perform()
  }
  thread.stackSize = stackSize
  thread.start()
  workItem.wait()
  return result!
}

final class Connection {
  enum Error: SourceKitPluginError, CustomStringConvertible {
    case openingFileFailed(path: String)
    /// An error that occurred inside swiftIDE while performing completion.
    case swiftIDEError(String)
    case cancelled

    var description: String {
      switch self {
      case .openingFileFailed(let path):
        return "Could not open file '\(path)'"
      case .swiftIDEError(let message):
        return message
      case .cancelled:
        return "Request cancelled"
      }
    }

    func response(sourcekitd: SourceKitD) -> SKDResponse {
      switch self {
      case .openingFileFailed, .swiftIDEError:
        return SKDResponse(error: .failed, description: description, sourcekitd: sourcekitd)
      case .cancelled:
        return SKDResponse(error: .cancelled, description: "Request cancelled", sourcekitd: sourcekitd)
      }
    }
  }

  let logger = Logger(subsystem: "org.swift.sourcekit.service-plugin", category: "Connection")

  private let impl: swiftide_api_connection_t
  let sourcekitd: SourceKitD

  /// The list of documents that are open in SourceKitD. The key is the file's path on disk or a pseudo-path that
  /// uniquely identifies the document if it doesn't exist on disk.
  private var documents: [String: Document] = [:]

  /// Information to construct `PopularityIndex`.
  private var scopedPopularityDataPath: String?
  private var popularModules: [String]?
  private var notoriousModules: [String]?

  /// Cached data read from `scopedPopularityDataPath`.
  private var _scopedPopularityData: LazyValue<[PopularityIndex.Scope: [String: Double]]?> = .uninitialized

  /// Cached index.
  private var _popularityIndex: LazyValue<PopularityIndex?> = .uninitialized

  /// Deprecated.
  /// NOTE: `PopularityTable` was replaced with `PopularityIndex`. We keep this
  /// until all clients migrates to `PopularityIndex`.
  private var onlyPopularCompletions: PopularityTable = .init()

  /// Recent completions that were accepted by the client.
  private var recentCompletions: [String] = []

  /// The stack size that should be used for all operations that end up invoking the type checker.
  private let semanticStackSize = 8 << 20  // 8 MB.

  init(opaqueIDEInspectionInstance: UnsafeMutableRawPointer?, sourcekitd: SourceKitD) {
    self.sourcekitd = sourcekitd
    impl = sourcekitd.ideApi.connection_create_with_inspection_instance(opaqueIDEInspectionInstance)
  }

  deinit {
    sourcekitd.ideApi.connection_dispose(impl)
  }

  //// A function that can be called to cancel a request with a request.
  ///
  /// This is not a member function on `Connection` so that `CompletionProvider` can store
  /// this closure in a member and call it even while the `CompletionProvider` actor is busy
  /// fulfilling a completion request and thus can't access `connection`.
  var cancellationFunc: @Sendable (RequestHandle) -> Void {
    nonisolated(unsafe) let impl = self.impl
    return { [sourcekitd] handle in
      sourcekitd.ideApi.cancel_request(impl, handle.handle)
    }
  }

  func openDocument(path: String, contents: String, compilerArguments: [String]? = nil) {
    if documents[path] != nil {
      logger.error("Document at '\(path)' is already open")
    }
    documents[path] = Document(contents: contents, compilerArguments: compilerArguments)
    sourcekitd.ideApi.set_file_contents(impl, path, contents)
  }

  func editDocument(path: String, atUTF8Offset offset: Int, length: Int, newText: String) {
    guard let document = documents[path] else {
      logger.error("Document at '\(path)' is not open")
      return
    }

    // Try replace the range, ignoring an invalid input. This matches SourceKit's
    // behavior.
    orLog("Replacing text") {
      try document.lineTable.tryReplace(utf8Offset: offset, length: length, with: newText)
    }
    sourcekitd.ideApi.set_file_contents(impl, path, document.lineTable.content)
  }

  func closeDocument(path: String) {
    if documents[path] == nil {
      logger.error("Document at '\(path)' was not open")
    }
    documents[path] = nil
    sourcekitd.ideApi.set_file_contents(impl, path, nil)
  }

  func complete(
    at loc: Location,
    arguments reqArgs: [String]? = nil,
    options: CompletionOptions,
    handle: swiftide_api_request_handle_t?
  ) throws -> CompletionSession {
    let offset: Int = try {
      if let lineTable = documents[loc.path]?.lineTable {
        return lineTable.utf8OffsetOf(line: loc.line - 1, utf8Column: loc.utf8Column - 1)
      } else {
        // FIXME: move line:column translation into C++ impl. so that we can avoid reading the file an extra time here.
        do {
          logger.log("Received code completion request for file that wasn't open. Reading file contents from disk.")
          let contents = try String(contentsOfFile: loc.path, encoding: .utf8)
          let lineTable = LineTable(contents)
          return lineTable.utf8OffsetOf(line: loc.line - 1, utf8Column: loc.utf8Column - 1)
        } catch {
          throw Error.openingFileFailed(path: loc.path)
        }
      }
    }()

    let arguments = reqArgs ?? documents[loc.path]?.compilerArguments ?? []

    let result: swiftide_api_completion_response_t = withArrayOfCStrings(arguments) { cargs in
      let req = sourcekitd.ideApi.completion_request_create(loc.path, UInt32(offset), cargs, UInt32(cargs.count))
      defer { sourcekitd.ideApi.completion_request_dispose(req) }
      sourcekitd.ideApi.completion_request_set_annotate_result(req, options.annotateResults)
      sourcekitd.ideApi.completion_request_set_include_objectliterals(req, options.includeObjectLiterals)
      sourcekitd.ideApi.completion_request_set_add_inits_to_top_level(req, options.addInitsToTopLevel)
      sourcekitd.ideApi.completion_request_set_add_call_with_no_default_args(req, options.addCallWithNoDefaultArgs)

      do {
        let sourcekitd = self.sourcekitd
        nonisolated(unsafe) let impl = impl
        nonisolated(unsafe) let req = req
        nonisolated(unsafe) let handle = handle
        return withStackSize(semanticStackSize) {
          sourcekitd.ideApi.complete_cancellable(impl, req, handle)!
        }
      }
    }

    if sourcekitd.ideApi.completion_result_is_error(result) {
      let errorDescription = String(cString: sourcekitd.ideApi.completion_result_get_error_description(result)!)
      // Usually `CompletionSession` is responsible for disposing the result.
      // Since we don't form a `CompletionSession`, dispose of the result manually.
      sourcekitd.ideApi.completion_result_dispose(result)
      throw Error.swiftIDEError(errorDescription)
    } else if sourcekitd.ideApi.completion_result_is_cancelled(result) {
      sourcekitd.ideApi.completion_result_dispose(result)
      throw Error.cancelled
    }

    return CompletionSession(
      connection: self,
      location: loc,
      response: result,
      options: options
    )
  }

  func markCachedCompilerInstanceShouldBeInvalidated() {
    sourcekitd.ideApi.connection_mark_cached_compiler_instance_should_be_invalidated(impl, nil)
  }

  // MARK: 'PopularityIndex' APIs.

  func updatePopularityIndex(
    scopedPopularityDataPath: String,
    popularModules: [String],
    notoriousModules: [String]
  ) {

    // Clear the cache if necessary.
    // We don't check the content of the path assuming it's not changed.
    // For 'popular/notoriousModules', we expect around 200 elements.
    if scopedPopularityDataPath != self.scopedPopularityDataPath {
      self._popularityIndex = .uninitialized
      self._scopedPopularityData = .uninitialized
    } else if popularModules != self.popularModules || notoriousModules != self.notoriousModules {
      self._popularityIndex = .uninitialized
    }

    self.scopedPopularityDataPath = scopedPopularityDataPath
    self.popularModules = popularModules
    self.notoriousModules = notoriousModules
  }

  private var scopedPopularityData: [PopularityIndex.Scope: [String: Double]]? {
    _scopedPopularityData.cachedValueOrCompute {
      guard let jsonPath = self.scopedPopularityDataPath else {
        return nil
      }

      // A codable representation of `PopularityIndex.symbolPopularity`.
      struct ScopedSymbolPopularity: Codable {
        let values: [String]
        let scores: [Double]

        var table: [String: Double] {
          var map = [String: Double]()
          for (value, score) in zip(values, scores) {
            map[value] = score
          }
          return map
        }
      }

      do {
        let jsonURL = URL(fileURLWithPath: jsonPath)
        let decoder = JSONDecoder()
        let data = try Data(contentsOf: jsonURL)
        let decoded = try decoder.decode([String: ScopedSymbolPopularity].self, from: data)
        var result = [PopularityIndex.Scope: [String: Double]]()
        for (rawScope, popularity) in decoded {
          let scope = PopularityIndex.Scope(string: rawScope)
          result[scope] = popularity.table
        }
        return result
      } catch {
        logger.error("Failed to read popularity data at '\(jsonPath)'")
        return nil
      }
    }
  }

  var popularityIndex: PopularityIndex? {
    _popularityIndex.cachedValueOrCompute {
      guard let scopedPopularityData, let popularModules, let notoriousModules else {
        return nil
      }
      return PopularityIndex(
        symbolReferencePercentages: scopedPopularityData,
        notoriousSymbols: /*unused*/ [],
        popularModules: popularModules,
        notoriousModules: notoriousModules
      )
    }
  }

  // MARK: 'PopularityTable' APIs (DEPRECATED).

  func updatePopularAPI(popularityTable: PopularityTable) {
    self.onlyPopularCompletions = popularityTable
  }

  func updateRecentCompletions(_ recent: [String]) {
    self.recentCompletions = recent
  }

  var popularityTable: PopularityTable {
    var result = onlyPopularCompletions
    result.add(popularSymbols: recentCompletions)
    return result
  }
}

private final class Document {
  var lineTable: LineTable
  var compilerArguments: [String]? = nil

  init(contents: String, compilerArguments: [String]? = nil) {
    self.lineTable = LineTable(contents)
    self.compilerArguments = compilerArguments
  }
}
