# frozen_string_literal: true

require "screening_model"

# A ScreenedIpAddress record represents an IP address or subnet that is being watched,
# and possibly blocked from creating accounts.
class ScreenedIpAddress < ActiveRecord::Base
  include ScreeningModel

  default_action :block

  validates :ip_address, ip_address_format: true, presence: true
  after_validation :check_for_match, if: :will_save_change_to_ip_address?

  ROLLED_UP_BLOCKS = [
    # IPv4
    [4, 32, 24],
    # IPv6
    [6, (65..128).to_a, 64],
    [6, 64, 60],
    [6, 60, 56],
    [6, 56, 52],
    [6, 52, 48],
  ]

  def self.watch(ip_address, opts = {})
    match_for_ip_address(ip_address) ||
      create(opts.slice(:action_type).merge(ip_address: ip_address))
  end

  def check_for_match
    if self.errors[:ip_address].blank?
      matched = self.class.match_for_ip_address(self.ip_address)
      if matched && matched.action_type == self.action_type
        self.errors.add(:ip_address, :ip_address_already_screened)
      end
    end
  end

  # In Rails 4.0.0, validators are run to handle invalid assignments to inet columns (as they should).
  # In Rails 4.0.1, an exception is raised before validation happens, so we need this hack for
  # inet/cidr columns:
  def ip_address=(val)
    if val.nil?
      self.errors.add(:ip_address, :invalid)
      return
    end

    if val.is_a?(IPAddr)
      write_attribute(:ip_address, val)
      return
    end

    v = IPAddr.handle_wildcards(val)

    if v.nil?
      self.errors.add(:ip_address, :invalid)
      return
    end

    write_attribute(:ip_address, v)

    # this gets even messier, Ruby 1.9.2 raised a different exception to Ruby 2.0.0
    # handle both exceptions
  rescue ArgumentError, IPAddr::InvalidAddressError
    self.errors.add(:ip_address, :invalid)
  end

  # Return a string with the ip address and mask in standard format. e.g., "127.0.0.0/8".
  def ip_address_with_mask
    ip_address.try(:to_cidr_s)
  end

  def self.match_for_ip_address(ip_address)
    # The <<= operator on inet columns means "is contained within or equal to".
    #
    # Read more about PostgreSQL's inet data type here:
    #
    #   http://www.postgresql.org/docs/9.1/static/datatype-net-types.html
    #   http://www.postgresql.org/docs/9.1/static/functions-net.html
    ip_address = IPAddr === ip_address ? ip_address.to_cidr_s : ip_address.to_s
    order("masklen(ip_address) DESC").find_by("? <<= ip_address", ip_address)
  end

  def self.should_block?(ip_address)
    exists_for_ip_address_and_action?(ip_address, actions[:block])
  end

  def self.is_allowed?(ip_address)
    exists_for_ip_address_and_action?(ip_address, actions[:do_nothing])
  end

  def self.exists_for_ip_address_and_action?(ip_address, action_type, opts = {})
    b = match_for_ip_address(ip_address)
    found = !!b && b.action_type == action_type
    b.record_match! if found && opts[:record_match] != false
    found
  end

  def self.block_admin_login?(user, ip_address)
    return false unless SiteSetting.use_admin_ip_allowlist
    return false if user.nil?
    return false if !user.admin?
    return false if ScreenedIpAddress.where(action_type: actions[:allow_admin]).count == 0
    return true if ip_address.nil?
    !exists_for_ip_address_and_action?(ip_address, actions[:allow_admin], record_match: false)
  end

  def self.subnets(family, from_masklen, to_masklen)
    sql = <<~SQL
      WITH ips_and_subnets AS (
        SELECT ip_address,
               network(inet(host(ip_address) || '/' || :to_masklen))::text subnet
        FROM screened_ip_addresses
        WHERE family(ip_address) = :family AND
              masklen(ip_address) IN (:from_masklen) AND
              action_type = :blocked
      )
      SELECT subnet
      FROM ips_and_subnets
      GROUP BY subnet
      HAVING COUNT(*) >= :min_ban_entries_for_roll_up
    SQL

    DB.query_single(
      sql,
      family: family,
      from_masklen: from_masklen,
      to_masklen: to_masklen,
      blocked: ScreenedIpAddress.actions[:block],
      min_ban_entries_for_roll_up: SiteSetting.min_ban_entries_for_roll_up,
    )
  end

  def self.roll_up(current_user = Discourse.system_user)
    ROLLED_UP_BLOCKS.each do |family, from_masklen, to_masklen|
      ScreenedIpAddress
        .subnets(family, from_masklen, to_masklen)
        .map do |subnet|
          next if ScreenedIpAddress.where("? <<= ip_address", subnet).exists?

          old_ips =
            ScreenedIpAddress
              .where(action_type: ScreenedIpAddress.actions[:block])
              .where("ip_address << ?", subnet)
              .where("family(ip_address) = ?", family)
              .where("masklen(ip_address) IN (?)", from_masklen)

          sum_match_count, max_last_match_at, min_created_at =
            old_ips.pick("SUM(match_count), MAX(last_match_at), MIN(created_at)")

          ScreenedIpAddress.create!(
            ip_address: subnet,
            match_count: sum_match_count,
            last_match_at: max_last_match_at,
            created_at: min_created_at,
          )

          StaffActionLogger.new(current_user).log_roll_up(subnet, old_ips.map(&:ip_address))
          old_ips.delete_all
        end
    end
  end
end

# == Schema Information
#
# Table name: screened_ip_addresses
#
#  id            :integer          not null, primary key
#  ip_address    :inet             not null
#  action_type   :integer          not null
#  match_count   :integer          default(0), not null
#  last_match_at :datetime
#  created_at    :datetime         not null
#  updated_at    :datetime         not null
#
# Indexes
#
#  index_screened_ip_addresses_on_ip_address     (ip_address) UNIQUE
#  index_screened_ip_addresses_on_last_match_at  (last_match_at)
#