# SPDX-License-Identifier: Apache-2.0
#
#  The OpenSearch Contributors require contributions made to
#  this file be licensed under the Apache-2.0 license or a
#  compatible open source license.
#
#  Modifications Copyright OpenSearch Contributors. See
#  GitHub history for details.

require "concurrent/atomic/atomic_reference"

module LogStash; module Outputs; class OpenSearch; class HttpClient;
  class Pool
    class NoConnectionAvailableError < Error; end
    class BadResponseCodeError < Error
      attr_reader :url, :response_code, :request_body, :response_body

      def initialize(response_code, url, request_body, response_body)
        @response_code = response_code
        @url = url
        @request_body = request_body
        @response_body = response_body
      end

      def message
        "Got response code '#{response_code}' contacting OpenSearch at URL '#{@url}'"
      end
    end
    class HostUnreachableError < Error;
      attr_reader :original_error, :url

      def initialize(original_error, url)
        @original_error = original_error
        @url = url
      end

      def message
        "OpenSearch Unreachable: [#{@url}][#{original_error.class}] #{original_error.message}"
      end
    end

    attr_reader :logger, :adapter, :sniffing, :sniffer_delay, :resurrect_delay, :healthcheck_path, :sniffing_path, :bulk_path
    attr_reader :default_server_major_version

    ROOT_URI_PATH = '/'.freeze

    DEFAULT_OPTIONS = {
      :healthcheck_path => ROOT_URI_PATH,
      :sniffing_path => "/_nodes/http",
      :bulk_path => "/_bulk",
      :scheme => 'http',
      :resurrect_delay => 5,
      :sniffing => false,
      :sniffer_delay => 10,
    }.freeze

    def initialize(logger, adapter, initial_urls=[], options={})
      @logger = logger
      @adapter = adapter
      @metric = options[:metric]
      @initial_urls = initial_urls
      
      raise ArgumentError, "No URL Normalizer specified!" unless options[:url_normalizer]
      @url_normalizer = options[:url_normalizer]
      DEFAULT_OPTIONS.merge(options).tap do |merged|
        @bulk_path = merged[:bulk_path]
        @sniffing_path = merged[:sniffing_path]
        @healthcheck_path = merged[:healthcheck_path]
        @resurrect_delay = merged[:resurrect_delay]
        @sniffing = merged[:sniffing]
        @sniffer_delay = merged[:sniffer_delay]
        @default_server_major_version = merged[:default_server_major_version]
      end

      # Used for all concurrent operations in this class
      @state_mutex = Mutex.new

      # Holds metadata about all URLs
      @url_info = {}
      @stopping = false

      @last_version = Concurrent::AtomicReference.new
    end

    def start
      update_initial_urls
      start_resurrectionist
      start_sniffer if @sniffing
    end

    def update_initial_urls
      update_urls(@initial_urls)
    end

    def close
      @state_mutex.synchronize { @stopping = true }

      logger.debug  "Stopping sniffer"
      stop_sniffer

      logger.debug  "Stopping resurrectionist"
      stop_resurrectionist

      logger.debug  "Waiting for in use manticore connections"
      wait_for_in_use_connections

      logger.debug("Closing adapter #{@adapter}")
      @adapter.close
    end

    def wait_for_in_use_connections
      until in_use_connections.empty?
        logger.info "Blocked on shutdown to in use connections #{@state_mutex.synchronize {@url_info}}"
        sleep 1
      end
    end

    def in_use_connections
      @state_mutex.synchronize { @url_info.values.select {|v| v[:in_use] > 0 } }
    end

    def alive_urls_count
      @state_mutex.synchronize { @url_info.values.select {|v| v[:state] == :alive }.count }
    end

    def url_info
      @state_mutex.synchronize { @url_info }
    end

    def urls
      url_info.keys
    end

    def until_stopped(task_name, delay)
      last_done = Time.now
      until @state_mutex.synchronize { @stopping }
        begin
          now = Time.now
          if (now - last_done) >= delay
            last_done = now
            yield
          end
          sleep 1
        rescue => e
          logger.warn(
            "Error while performing #{task_name}",
            :error_message => e.message,
            :class => e.class.name,
            :backtrace => e.backtrace
            )
        end
      end
    end

    def start_sniffer
      @sniffer = Thread.new do
        until_stopped("sniffing", sniffer_delay) do
          begin
            sniff!
          rescue NoConnectionAvailableError => e
            @state_mutex.synchronize { # Synchronize around @url_info
              logger.warn("OpenSearch output attempted to sniff for new connections but cannot. No living connections are detected. Pool contains the following current URLs", :url_info => @url_info) }
          end
        end
      end
    end

    # Sniffs the cluster then updates the internal URLs
    def sniff!
      update_urls(check_sniff)
    end

    SNIFF_RE_URL  = /([^\/]*)?\/?([^:]*):([0-9]+)/
    # Sniffs and returns the results. Does not update internal URLs!
    def check_sniff
      _, url_meta, resp = perform_request(:get, @sniffing_path)
      @metric.increment(:sniff_requests)
      parsed = LogStash::Json.load(resp.body)
      nodes = parsed['nodes']
      if !nodes || nodes.empty?
        @logger.warn("Sniff returned no nodes! Will not update hosts.")
        return nil
      else
        sniff(nodes)
      end
    end
    
    def major_version(version_string)
      version_string.split('.').first.to_i
    end
    
    def sniff(nodes)
      nodes.map do |id,info|
        # Skip master-only nodes
        next if info["roles"] && info["roles"] == ["master"]
        address_str_to_uri(info["http"]["publish_address"]) if info["http"]
      end.compact
    end

    def address_str_to_uri(addr_str)
      matches = addr_str.match(SNIFF_RE_URL)
      if matches
        host = matches[1].empty? ? matches[2] : matches[1]
        ::LogStash::Util::SafeURI.new("#{host}:#{matches[3]}")
      end
    end

    def stop_sniffer
      @sniffer.join if @sniffer
    end

    def sniffer_alive?
      @sniffer ? @sniffer.alive? : nil
    end

    def start_resurrectionist
      @resurrectionist = Thread.new do
        until_stopped("resurrection", @resurrect_delay) do
          healthcheck!
        end
      end
    end

    def health_check_request(url)
      logger.debug("Running health check to see if an OpenSearch connection is working", url: url.sanitized.to_s, path: @healthcheck_path)
      perform_request_to_url(url, :head, @healthcheck_path)
    end

    def healthcheck!
      # Try to keep locking granularity low such that we don't affect IO...
      @state_mutex.synchronize { @url_info.select {|url,meta| meta[:state] != :alive } }.each do |url,meta|
        begin
          health_check_request(url)
          # If no exception was raised it must have succeeded!
          logger.warn("Restored connection to OpenSearch instance", url: url.sanitized.to_s)
          # We reconnected to this node, check its version
          version = get_version(url)
          @state_mutex.synchronize do
            meta[:version] = version
            set_last_version(version, url)
            meta[:state] = :alive
          end
        rescue HostUnreachableError, BadResponseCodeError => e
          logger.warn("Attempted to resurrect connection to dead OpenSearch instance, but got an error", url: url.sanitized.to_s, exception: e.class, message: e.message)
        end
      end
    end

    def stop_resurrectionist
      @resurrectionist.join if @resurrectionist
    end

    def resurrectionist_alive?
      @resurrectionist ? @resurrectionist.alive? : nil
    end

    def perform_request(method, path, params={}, body=nil)
      with_connection do |url, url_meta|
        resp = perform_request_to_url(url, method, path, params, body)
        [url, url_meta, resp]
      end
    end

    [:get, :put, :post, :delete, :patch, :head].each do |method|
      define_method(method) do |path, params={}, body=nil|
        _, _, response = perform_request(method, path, params, body)
        response
      end
    end

    def perform_request_to_url(url, method, path, params={}, body=nil)
      res = @adapter.perform_request(url, method, path, params, body)
    rescue *@adapter.host_unreachable_exceptions => e
      raise HostUnreachableError.new(e, url), "Could not reach host #{e.class}: #{e.message}"
    end

    def normalize_url(uri)
      u = @url_normalizer.call(uri)
      if !u.is_a?(::LogStash::Util::SafeURI)
        raise "URL Normalizer returned a '#{u.class}' rather than a SafeURI! This shouldn't happen!"
      end
      u
    end

    def update_urls(new_urls)
      return if new_urls.nil?
      
      # Normalize URLs
      new_urls = new_urls.map(&method(:normalize_url))

      # Used for logging nicely
      state_changes = {:removed => [], :added => []}
      @state_mutex.synchronize do
        # Add new connections
        new_urls.each do |url|
          # URI objects don't have real hash equality! So, since this isn't perf sensitive we do a linear scan
          unless @url_info.keys.include?(url)
            state_changes[:added] << url
            add_url(url)
          end
        end

        # Delete connections not in the new list
        @url_info.each do |url,_|
          unless new_urls.include?(url)
            state_changes[:removed] << url
            remove_url(url)
          end
        end
      end

      if state_changes[:removed].size > 0 || state_changes[:added].size > 0
        logger.info? && logger.info("OpenSearch pool URLs updated", :changes => state_changes)
      end
      
      # Run an inline health check anytime URLs are updated
      # This guarantees that during startup / post-startup
      # sniffing we don't have idle periods waiting for the
      # periodic sniffer to allow new hosts to come online
      healthcheck! 
    end
    
    def size
      @state_mutex.synchronize { @url_info.size }
    end

    def add_url(url)
      @url_info[url] ||= empty_url_meta
    end

    def remove_url(url)
      @url_info.delete(url)
    end

    def empty_url_meta
      {
        :in_use => 0,
        :state => :unknown
      }
    end

    def with_connection
      url, url_meta = get_connection

      # Custom error class used here so that users may retry attempts if they receive this error
      # should they choose to
      raise NoConnectionAvailableError, "No Available connections" unless url
      yield url, url_meta
    rescue HostUnreachableError => e
      # Mark the connection as dead here since this is likely not transient
      mark_dead(url, e)
      raise e
    rescue BadResponseCodeError => e
      # These aren't discarded from the pool because these are often very transient
      # errors
      raise e
    ensure
      return_connection(url)
    end

    def mark_dead(url, error)
      @state_mutex.synchronize do
        meta = @url_info[url]
        # In case a sniff happened removing the metadata just before there's nothing to mark
        # This is an extreme edge case, but it can happen!
        return unless meta
        logger.warn("Marking url as dead. Last error: [#{error.class}] #{error.message}",
                    :url => url, :error_message => error.message, :error_class => error.class.name)
        meta[:state] = :dead
        meta[:last_error] = error
        meta[:last_errored_at] = Time.now
      end
    end

    def url_meta(url)
      @state_mutex.synchronize do
        @url_info[url]
      end
    end

    def get_connection
      @state_mutex.synchronize do
        # The goal here is to pick a random connection from the least-in-use connections
        # We want some randomness so that we don't hit the same node over and over, but
        # we also want more 'fair' behavior in the event of high concurrency
        eligible_set = nil
        lowest_value_seen = nil
        @url_info.each do |url,meta|
          meta_in_use = meta[:in_use]
          next if meta[:state] == :dead

          if lowest_value_seen.nil? || meta_in_use < lowest_value_seen
            lowest_value_seen = meta_in_use
            eligible_set = [[url, meta]]
          elsif lowest_value_seen == meta_in_use
            eligible_set << [url, meta]
          end
        end

        return nil if eligible_set.nil?

        pick, pick_meta = eligible_set.sample
        pick_meta[:in_use] += 1

        [pick, pick_meta]
      end
    end

    def return_connection(url)
      @state_mutex.synchronize do
        info = @url_info[url]
        info[:in_use] -= 1 if info # Guard against the condition where the connection has already been deleted
      end
    end

    def get_version(url)
      response = perform_request_to_url(url, :get, ROOT_URI_PATH)
      if response.code != 404 && !response.body.empty?
        return LogStash::Json.load(response.body)["version"]["number"] # e.g. "7.10.0"
      end
      if @default_server_major_version.nil?
        @logger.error("Failed to get version from health_check endpoint and default_server_major_version is not configured.")
        raise "get_version failed! no default_server_major_version configured."
      end
      "#{default_server_major_version}.0.0"
    end

    def last_version
      @last_version.get
    end

    def maximum_seen_major_version
      @state_mutex.synchronize { @maximum_seen_major_version }
    end

    private

    # @private executing within @state_mutex
    def set_last_version(version, url)
      @last_version.set(version)

      major = major_version(version)
      if @maximum_seen_major_version.nil?
        @logger.info("Cluster version determined (#{version})", version: major)
        set_maximum_seen_major_version(major)
      elsif major > @maximum_seen_major_version
        warn_on_higher_major_version(major, url)
        @maximum_seen_major_version = major
      end
    end

    def set_maximum_seen_major_version(major)
      @maximum_seen_major_version = major
    end

    def warn_on_higher_major_version(major, url)
      @logger.warn("Detected a node with a higher major version than previously observed, " +
                   "this could be the result of an OpenSearch cluster upgrade",
                   previous_major: @maximum_seen_major_version, new_major: major, node_url: url.sanitized.to_s)
    end

  end
end; end; end; end;
