app/models/pod.rb
# frozen_string_literal: true

class Pod < ApplicationRecord
  # a pod is active if it is online or was online less than 14 days ago
  ACTIVE_DAYS = 14.days

  enum status: %i(
    unchecked
    no_errors
    dns_failed
    net_failed
    ssl_failed
    http_failed
    version_failed
    unknown_error
  )

  ERROR_MAP = {
    ConnectionTester::AddressFailure  => :dns_failed,
    ConnectionTester::DNSFailure      => :dns_failed,
    ConnectionTester::NetFailure      => :net_failed,
    ConnectionTester::SSLFailure      => :ssl_failed,
    ConnectionTester::HTTPFailure     => :http_failed,
    ConnectionTester::NodeInfoFailure => :version_failed
  }.freeze

  # this are only the most common errors, the rest will be +unknown_error+
  CURL_ERROR_MAP = {
    couldnt_resolve_host:         :dns_failed,
    couldnt_connect:              :net_failed,
    operation_timedout:           :net_failed,
    ssl_cipher:                   :ssl_failed,
    ssl_cacert:                   :ssl_failed,
    redirected_to_other_hostname: :http_failed
  }.freeze

  # use -1 as port for default ports
  # we can't use the real default port (80/443) because we need to handle them
  # like both are the same and not both can exist at the same time.
  # we also can't use nil, because databases don't handle NULL in unique indexes
  # (except postgres >= 15 with "NULLS NOT DISTINCT").
  DEFAULT_PORT = -1
  DEFAULT_PORTS = [URI::HTTP::DEFAULT_PORT, URI::HTTPS::DEFAULT_PORT].freeze

  has_many :people

  scope :check_failed, lambda {
    where(arel_table[:status].gt(Pod.statuses[:no_errors])).where.not(status: Pod.statuses[:version_failed])
  }

  scope :active, -> {
    where(["offline_since is null or offline_since > ?", DateTime.now.utc - ACTIVE_DAYS])
  }

  validate :not_own_pod

  class << self
    def find_or_create_by(opts) # Rename this method to not override an AR method
      uri = URI.parse(opts.fetch(:url))
      port = DEFAULT_PORTS.include?(uri.port) ? DEFAULT_PORT : uri.port
      find_or_initialize_by(host: uri.host.downcase, port: port).tap do |pod|
        pod.ssl ||= (uri.scheme == "https")
        pod.save
      end
    end

    # don't consider a failed version reading to be fatal
    def offline_statuses
      [Pod.statuses[:dns_failed],
       Pod.statuses[:net_failed],
       Pod.statuses[:ssl_failed],
       Pod.statuses[:http_failed],
       Pod.statuses[:unknown_error]]
    end

    def check_all!
      Pod.find_in_batches(batch_size: 20) {|batch| batch.each(&:test_connection!) }
    end

    def check_scheduled!
      Pod.where(scheduled_check: true).find_each(&:test_connection!)
    end
  end

  def offline?
    Pod.offline_statuses.include?(Pod.statuses[status])
  end

  # a pod is active if it is online or was online recently
  def active?
    !offline? || offline_since.try {|date| date > DateTime.now.utc - ACTIVE_DAYS }
  end

  def to_s
    "#{id}:#{host}"
  end

  def schedule_check_if_needed
    update_column(:scheduled_check, true) if offline? && !scheduled_check
  end

  def test_connection!
    result = ConnectionTester.check uri.to_s
    logger.debug "tested pod: '#{uri}' - #{result.inspect}"

    transaction do
      update_from_result(result)
    end
  end

  # @param path [String]
  # @return [String]
  def url_to(path)
    uri.tap {|uri| uri.path = path }.to_s
  end

  def update_offline_since
    if offline?
      self.offline_since ||= DateTime.now.utc
    else
      self.offline_since = nil
    end
  end

  private

  def update_from_result(result)
    self.status = status_from_result(result)
    update_offline_since
    logger.warn "#{uri} OFFLINE: #{result.failure_message}" if offline?

    attributes_from_result(result)
    touch(:checked_at)
    self.scheduled_check = false

    save
  end

  def attributes_from_result(result)
    self.ssl ||= result.ssl
    self.error = result.error? ? result.failure_message[0..254] : nil
    self.software = result.software_version[0..254] if result.software_version.present?
    self.response_time = result.rt
  end

  def status_from_result(result)
    if result.error?
      ERROR_MAP.fetch(result.error.class, :unknown_error)
    else
      :no_errors
    end
  end

  # @return [URI]
  def uri
    @uri ||= (ssl ? URI::HTTPS : URI::HTTP).build(host: host, port: real_port)
    @uri.dup
  end

  def real_port
    if port == DEFAULT_PORT
      ssl ? URI::HTTPS::DEFAULT_PORT : URI::HTTP::DEFAULT_PORT
    else
      port
    end
  end

  def not_own_pod
    pod_uri = AppConfig.pod_uri
    pod_port = DEFAULT_PORTS.include?(pod_uri.port) ? DEFAULT_PORT : pod_uri.port
    errors.add(:base, "own pod not allowed") if pod_uri.host.downcase == host && pod_port == port
  end
end
