# frozen_string_literal: true

require 'diff/lcs'
require 'observer'
require 'securerandom'

module Solargraph
  module LanguageServer
    # The language server protocol's data provider. Hosts are responsible for
    # querying the library and processing messages. They also provide thread
    # safety for multi-threaded transports.
    #
    class Host
      autoload :Diagnoser,     'solargraph/language_server/host/diagnoser'
      autoload :Sources,       'solargraph/language_server/host/sources'
      autoload :Dispatch,      'solargraph/language_server/host/dispatch'
      autoload :MessageWorker, 'solargraph/language_server/host/message_worker'

      include UriHelpers
      include Logging
      include Dispatch
      include Observable

      attr_writer :client_capabilities

      def initialize
        @buffer_semaphore = Mutex.new
        @request_mutex = Mutex.new
        @buffer = String.new
        @stopped = true
        @next_request_id = 1
        @dynamic_capabilities = Set.new
        @registered_capabilities = Set.new
      end

      # Start asynchronous process handling.
      #
      # @return [void]
      def start
        return unless stopped?
        @stopped = false
        diagnoser.start
        message_worker.start
      end

      # Update the configuration options with the provided hash.
      #
      # @param update [Hash]
      # @return [void]
      def configure update
        return if update.nil?
        options.merge! update
        logger.level = LOG_LEVELS[options['logLevel']] || DEFAULT_LOG_LEVEL
      end

      # @return [Hash{String => [Boolean, String]}]
      def options
        @options ||= default_configuration
      end

      # Cancel the method with the specified ID.
      #
      # @param id [Integer]
      # @return [void]
      def cancel id
        cancelled.push id
      end

      # True if the host received a request to cancel the method with the
      # specified ID.
      #
      # @param id [Integer]
      # @return [Boolean]
      def cancel? id
        cancelled.include? id
      end

      # Delete the specified ID from the list of cancelled IDs if it exists.
      #
      # @param id [Integer]
      # @return [void]
      def clear id
        cancelled.delete id
      end

      # Called by adapter, to handle the request
      # @param request [Hash]
      # @return [void]
      def process request
        message_worker.queue(request)
      end

      # Start processing a request from the client. After the message is
      # processed, caller is responsible for sending the response.
      #
      # @param request [Hash{String => unspecified}] The contents of the message.
      #
      # @return [Solargraph::LanguageServer::Message::Base, Solargraph::LanguageServer::Request, nil] The message handler.
      def receive request
        if request['method']
          logger.info "Host received ##{request['id']} #{request['method']}"
          logger.debug request
          message = Message.select(request['method']).new(self, request)
          begin
            message.process unless cancel?(request['id'])
          rescue StandardError => e
            logger.warn "Error processing request: [#{e.class}] #{e.message}"
            logger.warn e.backtrace.join("\n")
            message.set_error Solargraph::LanguageServer::ErrorCodes::INTERNAL_ERROR, "[#{e.class}] #{e.message}"
          end
          message
        elsif request['id']
          if requests[request['id']]
            requests[request['id']].process(request['result'])
            requests.delete request['id']
          else
            logger.warn "Discarding client response to unrecognized message #{request['id']}"
            nil
          end
        else
          logger.warn "Invalid message received."
          logger.debug request
          nil
        end
      end

      # Respond to a notification that files were created in the workspace.
      # The libraries will determine whether the files should be merged; see
      # Solargraph::Library#create_from_disk.
      #
      # @param uris [Array<String>] The URIs of the files.
      # @return [Boolean] True if at least one library accepted at least one file.
      def create *uris
        filenames = uris.map { |uri| uri_to_file(uri) }
        result = false
        libraries.each do |lib|
          result = true if lib.create_from_disk(*filenames)
        end
        uris.each do |uri|
          diagnoser.schedule uri if open?(uri)
        end
        result
      end

      # Delete the specified files from the library.
      #
      # @param uris [Array<String>] The file uris.
      # @return [void]
      def delete *uris
        filenames = uris.map { |uri| uri_to_file(uri) }
        libraries.each do |lib|
          lib.delete_observer self
          lib.delete(*filenames)
        end
        uris.each do |uri|
          send_notification "textDocument/publishDiagnostics", {
            uri: uri,
            diagnostics: []
          }
        end
      end

      # Open the specified file in the library.
      #
      # @param uri [String] The file uri.
      # @param text [String] The contents of the file.
      # @param version [Integer] A version number.
      # @return [void]
      def open uri, text, version
        src = sources.open(uri, text, version)
        libraries.each do |lib|
          lib.merge src
        end
        diagnoser.schedule uri
      end

      # @param uri [String]
      # @return [void]
      def open_from_disk uri
        sources.open_from_disk(uri)
        diagnoser.schedule uri
      end

      # True if the specified file is currently open in the library.
      #
      # @param uri [String]
      # @return [Boolean]
      def open? uri
        sources.include? uri
      end

      # Close the file specified by the URI.
      #
      # @param uri [String]
      # @return [void]
      def close uri
        logger.info "Closing #{uri}"
        sources.close uri
        diagnoser.schedule uri
      end

      # @param uri [String]
      # @return [void]
      def diagnose uri
        if sources.include?(uri)
          library = library_for(uri)
          if library.mapped? && library.synchronized?
            logger.info "Diagnosing #{uri}"
            begin
              results = library.diagnose uri_to_file(uri)
              send_notification "textDocument/publishDiagnostics", {
                uri: uri,
                diagnostics: results
              }
            rescue DiagnosticsError => e
              logger.warn "Error in diagnostics: #{e.message}"
              options['diagnostics'] = false
              send_notification 'window/showMessage', {
                type: LanguageServer::MessageTypes::ERROR,
                message: "Error in diagnostics: #{e.message}"
              }
            rescue FileNotFoundError => e
              # @todo This appears to happen when an external file is open and
              #   scheduled for diagnosis, but the file was closed (i.e., the
              #   editor moved to a different file) before diagnosis started
              logger.warn "Unable to diagnose #{uri} : #{e.message}"
              send_notification 'textDocument/publishDiagnostics', {
                uri: uri,
                diagnostics: []
              }
            end
          else
            logger.info "Deferring diagnosis of #{uri}"
            diagnoser.schedule uri
          end
        else
          send_notification 'textDocument/publishDiagnostics', {
            uri: uri,
            diagnostics: []
          }
        end
      end

      # Update a document from the parameters of a textDocument/didChange
      # method.
      #
      # @param params [Hash]
      # @return [void]
      def change params
        updater = generate_updater(params)
        sources.update params['textDocument']['uri'], updater
        diagnoser.schedule params['textDocument']['uri']
      end

      # Queue a message to be sent to the client.
      #
      # @param message [String] The message to send.
      # @return [void]
      def queue message
        @buffer_semaphore.synchronize { @buffer += message }
        changed
        notify_observers
      end

      # Clear the message buffer and return the most recent data.
      #
      # @return [String] The most recent data or an empty string.
      def flush
        tmp = ''
        @buffer_semaphore.synchronize do
          tmp = @buffer.clone
          @buffer.clear
        end
        tmp
      end

      # Prepare a library for the specified directory.
      #
      # @param directory [String]
      # @param name [String, nil]
      # @return [void]
      def prepare directory, name = nil
        # No need to create a library without a directory. The generic library
        # will handle it.
        return if directory.nil?
        logger.info "Preparing library for #{directory}"
        path = ''
        path = normalize_separators(directory) unless directory.nil?
        begin
          workspace = Solargraph::Workspace.new(path, nil, options)
          lib = Solargraph::Library.new(workspace, name)
          lib.add_observer self
          libraries.push lib
          library_map lib
        rescue WorkspaceTooLargeError => e
          send_notification 'window/showMessage', {
            'type' => Solargraph::LanguageServer::MessageTypes::WARNING,
            'message' => e.message
          }
        end
      end

      # @return [String]
      def command_path
        options['commandPath'] || 'solargraph'
      end

      # Prepare multiple folders.
      #
      # @param array [Array<Hash{String => String}>]
      # @return [void]
      def prepare_folders array
        return if array.nil?
        array.each do |folder|
          prepare uri_to_file(folder['uri']), folder['name']
        end
      end

      # Remove a directory.
      #
      # @param directory [String]
      # @return [void]
      def remove directory
        logger.info "Removing library for #{directory}"
        # @param lib [Library]
        libraries.delete_if do |lib|
          next false if lib.workspace.directory != directory
          lib.delete_observer self
          true
        end
      end

      # @param array [Array<Hash>]
      # @return [void]
      def remove_folders array
        array.each do |folder|
          remove uri_to_file(folder['uri'])
        end
      end

      # @return [Array<String>]
      def folders
        libraries.map { |lib| lib.workspace.directory }
      end

      # Send a notification to the client.
      #
      # @param method [String] The message method
      # @param params [Hash] The method parameters
      # @return [void]
      def send_notification method, params
        response = {
          jsonrpc: "2.0",
          method: method,
          params: params
        }
        json = response.to_json
        envelope = "Content-Length: #{json.bytesize}\r\n\r\n#{json}"
        queue envelope
        logger.info "Server sent #{method}"
        logger.debug params
      end

      # Send a request to the client and execute the provided block to process
      # the response. If an ID is not provided, the host will use an auto-
      # incrementing integer.
      #
      # @param method [String] The message method
      # @param params [Hash] The method parameters
      # @param block [Proc] The block that processes the response
      # @yieldparam [Hash] The result sent by the client
      # @return [void]
      def send_request method, params, &block
        @request_mutex.synchronize do
          message = {
            jsonrpc: "2.0",
            method: method,
            params: params,
            id: @next_request_id
          }
          json = message.to_json
          requests[@next_request_id] = Request.new(@next_request_id, &block)
          envelope = "Content-Length: #{json.bytesize}\r\n\r\n#{json}"
          queue envelope
          @next_request_id += 1
          logger.debug params
        end
      end

      # Register the methods as capabilities with the client.
      # This method will avoid duplicating registrations and ignore methods
      # that were not flagged for dynamic registration by the client.
      #
      # @param methods [Array<String>] The methods to register
      # @return [void]
      def register_capabilities methods
        logger.debug "Registering capabilities: #{methods}"
        registrations = methods.select { |m| can_register?(m) and !registered?(m) }.map do |m|
          @registered_capabilities.add m
          {
            id: m,
            method: m,
            registerOptions: dynamic_capability_options[m]
          }
        end
        return if registrations.empty?
        send_request 'client/registerCapability', { registrations: registrations }
      end

      # Unregister the methods with the client.
      # This method will avoid duplicating unregistrations and ignore methods
      # that were not flagged for dynamic registration by the client.
      #
      # @param methods [Array<String>] The methods to unregister
      # @return [void]
      def unregister_capabilities methods
        logger.debug "Unregistering capabilities: #{methods}"
        unregisterations = methods.select{|m| registered?(m)}.map{ |m|
          @registered_capabilities.delete m
          {
            id: m,
            method: m
          }
        }
        return if unregisterations.empty?
        send_request 'client/unregisterCapability', { unregisterations: unregisterations }
      end

      # Flag a method as available for dynamic registration.
      #
      # @param method [String] The method name, e.g., 'textDocument/completion'
      # @return [void]
      def allow_registration method
        @dynamic_capabilities.add method
      end

      # True if the specified LSP method can be dynamically registered.
      #
      # @param method [String]
      # @return [Boolean]
      def can_register? method
        @dynamic_capabilities.include?(method)
      end

      # True if the specified method has been registered.
      #
      # @param method [String] The method name, e.g., 'textDocument/completion'
      # @return [Boolean]
      def registered? method
        @registered_capabilities.include?(method)
      end

      def synchronizing?
        !libraries.all?(&:synchronized?)
      end

      # @return [void]
      def stop
        return if @stopped
        @stopped = true
        message_worker.stop
        diagnoser.stop
        changed
        notify_observers
      end

      def stopped?
        @stopped
      end

      # Locate multiple pins that match a completion item. The first match is
      # based on the corresponding location in a library source if available.
      # Subsequent matches are based on path.
      #
      # @param params [Hash] A hash representation of a completion item
      # @return [Array<Pin::Base>]
      def locate_pins params
        return [] unless params['data'] && params['data']['uri']
        library = library_for(params['data']['uri'])
        # @type [Array<Pin::Base>]
        result = []
        if params['data']['location']
          location = Location.new(
            params['data']['location']['filename'],
            Range.from_to(
              params['data']['location']['range']['start']['line'],
              params['data']['location']['range']['start']['character'],
              params['data']['location']['range']['end']['line'],
              params['data']['location']['range']['end']['character']
            )
          )
          result.concat library.locate_pins(location).select{ |pin| pin.name == params['label'] }
        end
        if params['data']['path']
          result.concat library.path_pins(params['data']['path'])
          # @todo This exception is necessary because `Library#path_pins` does
          #   not perform a namespace method query, so the implicit `.new` pin
          #   might not exist.
          if result.empty? && params['data']['path'] =~ /\.new$/
            result.concat(library.path_pins(params['data']['path'].sub(/\.new$/, '#initialize')).map do |pin|
              next pin unless pin.name == 'initialize'

              Pin::Method.new(
                name: 'new',
                scope: :class,
                location: pin.location,
                parameters: pin.parameters,
                return_type: ComplexType.try_parse(params['data']['path']),
                comments: pin.comments,
                closure: pin.closure,
                source: :solargraph
              )
            end)
          end
        end
        # Selecting by both location and path can result in duplicate pins
        result.uniq { |p| [p.path, p.location] }
      end

      # @param uri [String]
      # @return [String]
      def read_text uri
        library = library_for(uri)
        filename = uri_to_file(uri)
        library.read_text(filename)
      end

      # @param uri [String]
      # @return [Hash]
      def formatter_config uri
        library = library_for(uri)
        library.workspace.config.formatter
      end

      # @param uri [String]
      # @param line [Integer]
      # @param column [Integer]
      # @return [Solargraph::SourceMap::Completion, nil]
      def completions_at uri, line, column
        library = library_for(uri)
        library.completions_at uri_to_file(uri), line, column
      end

      # @return [Bool] if has pending completion request
      def has_pending_completions?
        message_worker.messages.reverse_each.any? { |req| req['method'] == 'textDocument/completion' }
      end

      # @param uri [String]
      # @param line [Integer]
      # @param column [Integer]
      # @return [Array<Solargraph::Pin::Base>, nil]
      def definitions_at uri, line, column
        library = library_for(uri)
        library.definitions_at(uri_to_file(uri), line, column)
      end

      # @param uri [String]
      # @param line [Integer]
      # @param column [Integer]
      # @return [Array<Solargraph::Pin::Base>, nil]
      def type_definitions_at uri, line, column
        library = library_for(uri)
        library.type_definitions_at(uri_to_file(uri), line, column)
      end

      # @param uri [String]
      # @param line [Integer]
      # @param column [Integer]
      # @return [Array<Solargraph::Pin::Base>]
      def signatures_at uri, line, column
        library = library_for(uri)
        library.signatures_at(uri_to_file(uri), line, column)
      end

      # @param uri [String]
      # @param line [Integer]
      # @param column [Integer]
      # @param strip [Boolean] Strip special characters from variable names
      # @param only [Boolean] If true, search current file only
      # @return [Array<Solargraph::Location>]
      def references_from uri, line, column, strip: true, only: false
        library = library_for(uri)
        library.references_from(uri_to_file(uri), line, column, strip: strip, only: only)
      rescue FileNotFoundError, InvalidOffsetError => e
        Solargraph.logger.warn "[#{e.class}] #{e.message}"
        Solargraph.logger.debug e.backtrace
        []
      end

      # @param query [String]
      # @return [Array<Solargraph::Pin::Base>]
      def query_symbols query
        result = []
        (libraries + [generic_library]).each { |lib| result.concat lib.query_symbols(query) }
        result.uniq
      end

      # @param query [String]
      # @return [Array<String>]
      def search query
        result = []
        libraries.each { |lib| result.concat lib.search(query) }
        result
      end

      # @param query [String]
      # @return [Array]
      def document query
        result = []
        if libraries.empty?
          result.concat generic_library.document(query)
        else
          libraries.each { |lib| result.concat lib.document(query) }
        end
        result
      end

      # @param uri [String]
      # @return [Array<Solargraph::Pin::Base>]
      def document_symbols uri
        library = library_for(uri)
        # At this level, document symbols should be unique; e.g., a
        # module_function method should return the location for Module.method
        # or Module#method, but not both.
        library.document_symbols(uri_to_file(uri)).uniq(&:location)
      end

      # Send a notification to the client.
      #
      # @param text [String]
      # @param type [Integer] A MessageType constant
      # @return [void]
      def show_message text, type = LanguageServer::MessageTypes::INFO
        send_notification 'window/showMessage', {
          type: type,
          message: text
        }
      end

      # Send a notification with optional responses.
      #
      # @param text [String]
      # @param type [Integer] A MessageType constant
      # @param actions [Array<String>] Response options for the client
      # @param block The block that processes the response
      # @yieldparam [String] The action received from the client
      # @return [void]
      def show_message_request text, type, actions, &block
        send_request 'window/showMessageRequest', {
          type: type,
          message: text,
          actions: actions
        }, &block
      end

      # Get a list of IDs for server requests that are waiting for responses
      # from the client.
      #
      # @return [Array<Integer>]
      def pending_requests
        requests.keys
      end

      # @return [Hash{String => [Boolean,String]}]
      def default_configuration
        {
          'completion' => true,
          'hover' => true,
          'symbols' => true,
          'definitions' => true,
          'typeDefinitions' => true,
          'rename' => true,
          'references' => true,
          'autoformat' => false,
          'diagnostics' => true,
          'formatting' => false,
          'folding' => true,
          'highlights' => true,
          'logLevel' => 'warn'
        }
      end

      # @param uri [String]
      # @return [Array<Range>]
      def folding_ranges uri
        sources.find(uri).folding_ranges
      end

      # @return [void]
      def catalog
        return unless libraries.all?(&:mapped?)
        libraries.each(&:catalog)
      end

      # @return [Hash{String => Hash{String => Boolean}}]
      def client_capabilities
        @client_capabilities ||= {}
      end

      def client_supports_progress?
        client_capabilities['window'] && client_capabilities['window']['workDoneProgress']
      end

      private

      # @return [Array<Integer>]
      def cancelled
        @cancelled ||= []
      end

      # @return [MessageWorker]
      def message_worker
        @message_worker ||= MessageWorker.new(self)
      end

      # @return [Diagnoser]
      def diagnoser
        @diagnoser ||= Diagnoser.new(self)
      end

      # A hash of client requests by ID. The host uses this to keep track of
      # pending responses.
      #
      # @return [Hash{Integer => Solargraph::LanguageServer::Request}]
      def requests
        @requests ||= {}
      end

      # @param path [String]
      # @return [String]
      def normalize_separators path
        return path if File::ALT_SEPARATOR.nil?
        path.gsub(File::ALT_SEPARATOR, File::SEPARATOR)
      end

      # @param params [Hash]
      # @return [Source::Updater]
      def generate_updater params
        changes = []
        params['contentChanges'].each do |recvd|
          chng = check_diff(params['textDocument']['uri'], recvd)
          changes.push Solargraph::Source::Change.new(
            (chng['range'].nil? ?
              nil :
              Solargraph::Range.from_to(chng['range']['start']['line'], chng['range']['start']['character'], chng['range']['end']['line'], chng['range']['end']['character'])
            ),
            chng['text']
          )
        end
        Solargraph::Source::Updater.new(
          uri_to_file(params['textDocument']['uri']),
          params['textDocument']['version'],
          changes
        )
      end

      # @param uri [String]
      # @param change [Hash]
      # @return [Hash]
      def check_diff uri, change
        return change if change['range']
        source = sources.find(uri)
        return change if source.code.length + 1 != change['text'].length
        diffs = Diff::LCS.diff(source.code, change['text'])
        return change if diffs.length.zero? || diffs.length > 1 || diffs.first.length > 1
        # @sg-ignore push this upstream
        # @type [Diff::LCS::Change]
        diff = diffs.first.first
        return change unless diff.adding? && ['.', ':', '(', ',', ' '].include?(diff.element)
        position = Solargraph::Position.from_offset(source.code, diff.position)
        {
          'range' => {
            'start' => {
              'line' => position.line,
              'character' => position.character
            },
            'end' => {
              'line' => position.line,
              'character' => position.character
            }
          },
          'text' => diff.element
        }
      rescue Solargraph::FileNotFoundError
        change
      end

      # @return [Hash]
      def dynamic_capability_options
        @dynamic_capability_options ||= {
          # textDocumentSync: 2, # @todo What should this be?
          'textDocument/completion' => {
            resolveProvider: true,
            triggerCharacters: ['.', ':', '@']
          },
          # hoverProvider: true,
          # definitionProvider: true,
          'textDocument/signatureHelp' => {
            triggerCharacters: ['(', ',', ' ']
          },
          # documentFormattingProvider: true,
          'textDocument/onTypeFormatting' => {
            firstTriggerCharacter: '{',
            moreTriggerCharacter: ['(']
          },
          # documentSymbolProvider: true,
          # workspaceSymbolProvider: true,
          # workspace: {
            # workspaceFolders: {
              # supported: true,
              # changeNotifications: true
            # }
          # }
          'textDocument/definition' => {
            definitionProvider: true
          },
          'textDocument/typeDefinition' => {
            typeDefinitionProvider: true
          },
          'textDocument/references' => {
            referencesProvider: true
          },
          'textDocument/rename' => {
            renameProvider: prepare_rename? ? { prepareProvider: true } : true
          },
          'textDocument/documentSymbol' => {
            documentSymbolProvider: true
          },
          'workspace/symbol' => {
            workspaceSymbolProvider: true
          },
          'textDocument/formatting' => {
            formattingProvider: true
          },
          'textDocument/foldingRange' => {
            foldingRangeProvider: true
          },
          'textDocument/codeAction' => {
            codeActionProvider: true
          },
          'textDocument/documentHighlight' => {
            documentHighlightProvider: true
          }
        }
      end

      def prepare_rename?
        client_capabilities['rename'] && client_capabilities['rename']['prepareSupport']
      end

      # @param library [Library]
      # @return [void]
      def library_map library
        return if library.mapped?
        Thread.new { sync_library_map library }
      end

      # @param library [Library]
      # @param uuid [String, nil]
      # @return [void]
      def sync_library_map library
        total = library.workspace.sources.length
        progress = Progress.new('Mapping workspace')
        progress.begin "0/#{total} files", 0
        progress.send self
        while library.next_map
          pct = ((library.source_map_hash.keys.length.to_f / total) * 100).to_i
          progress.report "#{library.source_map_hash.keys.length}/#{total} files", pct
          progress.send self
        end
        progress.finish 'done'
        progress.send self
      end
    end
  end
end
