# frozen_string_literal: true

##
# An +ActiveModel+ validator to check passwords against the Pwned Passwords API.
#
# @example Validate a password on a +User+ model with the default options.
#     class User < ApplicationRecord
#       validates :password, not_pwned: true
#     end
#
# @example Validate a password on a +User+ model with a custom error message.
#     class User < ApplicationRecord
#       validates :password, not_pwned: { message: "has been pwned %{count} times" }
#     end
#
# @example Validate a password on a +User+ model that allows the password to have been breached once.
#     class User < ApplicationRecord
#       validates :password, not_pwned: { threshold: 1 }
#     end
#
# @example Validate a password on a +User+ model, handling API errors in various ways
#     class User < ApplicationRecord
#       # The record is marked as invalid on network errors
#       # (error message "could not be verified against the past data breaches".)
#       validates :password, not_pwned: { on_error: :invalid }
#
#       # The record is marked as invalid on network errors with custom error.
#       validates :password, not_pwned: { on_error: :invalid, error_message: "might be pwned" }
#
#       # An error is raised on network errors.
#       # This means that `record.valid?` will raise `Pwned::Error`.
#       # Not recommended to use in production.
#       validates :password, not_pwned: { on_error: :raise_error }
#
#       # Call custom proc on error. For example, capture errors in Sentry,
#       # but do not mark the record as invalid.
#       validates :password, not_pwned: {
#         on_error: ->(record, error) { Raven.capture_exception(error) }
#       }
#     end
#
# @since 1.2.0
class NotPwnedValidator < ActiveModel::EachValidator
  ##
  # The default behaviour of this validator in the case of an API failure. The
  # default will mean that if the API fails the object will not be marked
  # invalid.
  DEFAULT_ON_ERROR = :valid

  ##
  # The default threshold for whether a breach is considered pwned. The default
  # is 0, so any password that appears in a breach will mark the record as
  # invalid.
  DEFAULT_THRESHOLD = 0

  ##
  # Validates the +value+ against the Pwned Passwords API. If the +pwned_count+
  # is higher than the optional +threshold+ then the record is marked as
  # invalid.
  #
  # In the case of an API error the validator will either mark the
  # record as valid or invalid. Alternatively it will run an associated proc or
  # re-raise the original error.
  #
  # The validation will short circuit and return with no errors added if the
  # password is blank. The +Pwned::Password+ initializer expects the password to
  # be a string and will throw a +TypeError+ if it is +nil+. Also, technically
  # the empty string is not a password that is reported to be found in data
  # breaches, so returns +false+, short circuiting that using +value.blank?+
  # saves us a trip to the API.
  #
  # @param record [ActiveModel::Validations] The object being validated
  # @param attribute [Symbol] The attribute on the record that is currently
  #   being validated.
  # @param value [String] The value of the attribute on the record that is the
  #   subject of the validation
  def validate_each(record, attribute, value)
    return if value.blank?
    begin
      pwned_check = Pwned::Password.new(value, request_options)
      if pwned_check.pwned_count > threshold
        record.errors.add(attribute, :not_pwned, **options.merge(count: pwned_check.pwned_count))
      end
    rescue Pwned::Error => error
      case on_error
      when :invalid
        record.errors.add(attribute, :pwned_error, **options.merge(message: options[:error_message]))
      when :valid
        # Do nothing, consider the record valid
      when Proc
        on_error.call(record, error)
      else
        raise
      end
    end
  end

  private

  def on_error
    options[:on_error] || DEFAULT_ON_ERROR
  end

  def request_options
    options[:request_options] || {}
  end

  def threshold
    threshold = options[:threshold] || DEFAULT_THRESHOLD
    raise TypeError, "#{self.class.to_s} option 'threshold' must be of type Integer" unless threshold.is_a? Integer
    threshold
  end
end

##
# The version 1.1.0 validator that uses `pwned` in the validate method.
# This has been updated to the above `not_pwned` validator to be clearer what
# is being validated.
#
# This class is being maintained for backwards compatibility but will be
# removed
#
# @example Validate a password on a +User+ model with the default options.
#     class User < ApplicationRecord
#       validates :password, pwned: true
#     end
#
# @deprecated use the +NotPwnedValidator+ instead.
#
# @since 1.1.0
class PwnedValidator < NotPwnedValidator
end
