require "weak_ref"

require "./error"

module DB
  class Pool(T)
    record Options,
      # initial number of connections in the pool
      initial_pool_size : Int32 = 1,
      # maximum amount of connections in the pool (Idle + InUse). 0 means no maximum.
      max_pool_size : Int32 = 0,
      # maximum amount of idle connections in the pool
      max_idle_pool_size : Int32 = 1,
      # seconds to wait before timeout while doing a checkout
      checkout_timeout : Float64 = 5.0,
      # maximum amount of retry attempts to reconnect to the db. See `Pool#retry`
      retry_attempts : Int32 = 1,
      # seconds to wait before a retry attempt
      retry_delay : Float64 = 0.2 do
      def self.from_http_params(params : HTTP::Params, default = Options.new)
        Options.new(
          initial_pool_size: params.fetch("initial_pool_size", default.initial_pool_size).to_i,
          max_pool_size: params.fetch("max_pool_size", default.max_pool_size).to_i,
          max_idle_pool_size: params.fetch("max_idle_pool_size", default.max_idle_pool_size).to_i,
          checkout_timeout: params.fetch("checkout_timeout", default.checkout_timeout).to_f,
          retry_attempts: params.fetch("retry_attempts", default.retry_attempts).to_i,
          retry_delay: params.fetch("retry_delay", default.retry_delay).to_f,
        )
      end
    end

    # Pool configuration

    # initial number of connections in the pool
    @initial_pool_size : Int32
    # maximum amount of connections in the pool (Idle + InUse)
    @max_pool_size : Int32
    # maximum amount of idle connections in the pool
    @max_idle_pool_size : Int32
    # seconds to wait before timeout while doing a checkout
    @checkout_timeout : Float64
    # maximum amount of retry attempts to reconnect to the db. See `Pool#retry`
    @retry_attempts : Int32
    # seconds to wait before a retry attempt
    @retry_delay : Float64

    # Pool state

    # total of open connections managed by this pool
    @total = [] of T
    # connections available for checkout
    @idle = Set(T).new
    # connections waiting to be stablished (they are not in *@idle* nor in *@total*)
    @inflight : Int32

    # Sync state

    # communicate that a connection is available for checkout
    @availability_channel : Channel(Nil)
    # global pool mutex
    @mutex : Mutex

    @[Deprecated("Use `#new` with DB::Pool::Options instead")]
    def initialize(initial_pool_size = 1, max_pool_size = 0, max_idle_pool_size = 1, checkout_timeout = 5.0,
                   retry_attempts = 1, retry_delay = 0.2, &factory : -> T)
      initialize(
        Options.new(
          initial_pool_size: initial_pool_size, max_pool_size: max_pool_size,
          max_idle_pool_size: max_idle_pool_size, checkout_timeout: checkout_timeout,
          retry_attempts: retry_attempts, retry_delay: retry_delay),
        &factory)
    end

    def initialize(pool_options : Options = Options.new, &@factory : -> T)
      @initial_pool_size = pool_options.initial_pool_size
      @max_pool_size = pool_options.max_pool_size
      @max_idle_pool_size = pool_options.max_idle_pool_size
      @checkout_timeout = pool_options.checkout_timeout
      @retry_attempts = pool_options.retry_attempts
      @retry_delay = pool_options.retry_delay

      @availability_channel = Channel(Nil).new
      @inflight = 0
      @mutex = Mutex.new

      @initial_pool_size.times { build_resource }
    end

    # close all resources in the pool
    def close : Nil
      @total.each &.close
      @total.clear
      @idle.clear
    end

    record Stats,
      open_connections : Int32,
      idle_connections : Int32,
      in_flight_connections : Int32,
      max_connections : Int32

    # Returns stats of the pool
    def stats
      Stats.new(
        open_connections: @total.size,
        idle_connections: @idle.size,
        in_flight_connections: @inflight,
        max_connections: @max_pool_size,
      )
    end

    def checkout : T
      res = sync do
        resource = nil

        until resource
          resource = if @idle.empty?
                       if can_increase_pool?
                         @inflight += 1
                         begin
                           r = unsync { build_resource }
                         ensure
                           @inflight -= 1
                         end
                         r
                       else
                         unsync { wait_for_available }
                         # The wait for available can unlock
                         # multiple fibers waiting for a resource.
                         # Although only one will pick it due to the lock
                         # in the end of the unsync, the pick_available
                         # will return nil
                         pick_available
                       end
                     else
                       pick_available
                     end
        end

        @idle.delete resource

        resource
      end

      if res.responds_to?(:before_checkout)
        res.before_checkout
      end
      res
    end

    def checkout(&block : T ->)
      connection = checkout

      begin
        yield connection
      ensure
        release connection
      end
    end

    def release(resource : T) : Nil
      idle_pushed = false

      sync do
        if resource.responds_to?(:closed?) && resource.closed?
          @total.delete(resource)
        elsif can_increase_idle_pool
          @idle << resource
          if resource.responds_to?(:after_release)
            resource.after_release
          end
          idle_pushed = true
        else
          resource.close
          @total.delete(resource)
        end
      end

      if idle_pushed
        select
        when @availability_channel.send(nil)
        else
        end
      end
    end

    # :nodoc:
    # Will retry the block if a `ConnectionLost` exception is thrown.
    # It will try to reuse all of the available connection right away,
    # but if a new connection is needed there is a `retry_delay` seconds delay.
    def retry
      current_available = 0

      sync do
        current_available = @idle.size
        # if the pool hasn't reach the max size, allow 1 attempt
        # to make a new connection if needed without sleeping
        current_available += 1 if can_increase_pool?
      end

      (current_available + @retry_attempts).times do |i|
        begin
          sleep @retry_delay if i >= current_available
          return yield
        rescue e : PoolResourceLost(T)
          # if the connection is lost it will be closed by
          # the exception to release resources
          # we still need to remove it from the known pool.
          sync { delete(e.resource) }
        rescue e : PoolResourceRefused
          # a ConnectionRefused means a new connection
          # was intended to be created
          # nothing to due but to retry soon
        end
      end
      raise PoolRetryAttemptsExceeded.new
    end

    # :nodoc:
    def each_resource
      sync do
        @idle.each do |resource|
          yield resource
        end
      end
    end

    # :nodoc:
    def is_available?(resource : T)
      @idle.includes?(resource)
    end

    # :nodoc:
    def delete(resource : T)
      @total.delete(resource)
      @idle.delete(resource)
    end

    private def build_resource : T
      resource = @factory.call
      sync do
        @total << resource
        @idle << resource
      end
      resource
    end

    private def can_increase_pool?
      @max_pool_size == 0 || @total.size + @inflight < @max_pool_size
    end

    private def can_increase_idle_pool
      @idle.size < @max_idle_pool_size
    end

    private def pick_available
      @idle.first?
    end

    private def wait_for_available
      select
      when @availability_channel.receive
      when timeout(@checkout_timeout.seconds)
        raise DB::PoolTimeout.new("Could not check out a connection in #{@checkout_timeout} seconds")
      end
    end

    private def sync
      @mutex.lock
      begin
        yield
      ensure
        @mutex.unlock
      end
    end

    private def unsync
      @mutex.unlock
      begin
        yield
      ensure
        @mutex.lock
      end
    end
  end
end
