# frozen_string_literal: true

require 'base64'

require 'dry/monads/result'
require 'dry/matcher/result_matcher'

require_relative './config'

# Include and interact with `CryptIdent` to add authentication to a
# Hanami controller action.
#
# Note the emphasis on *controller action*; this module interacts with session
# data, which is quite theoretically possible in an Interactor but practically
# *quite* the PITA. YHBW.
#
# @author Jeff Dickey
# @version 0.2.2
module CryptIdent
  # Generate a Password Reset Token
  #
  # Password Reset Tokens are useful for verifying that the person requesting a
  # Password Reset for an existing User is sufficiently likely to be the person
  # who Registered that User or, if not, that no compromise or other harm is
  # done.
  #
  # Typically, this is done by sending a link through email or other such medium
  # to the address previously associated with the User purportedly requesting
  # the Password Reset. `CryptIdent` *does not* automate generation or sending
  # of the email message. What it *does* provide is a method to generate a new
  # Password Reset Token to be embedded into an HTML anchor link within an email
  # that you construct, and then another method (`#reset_password`) to actually
  # change the password given a valid, correct token.
  #
  # It also implements an expiry system, such that if the confirmation of the
  # Password Reset request is not completed within a configurable time, that the
  # token is no longer valid (and so cannot be later reused by unauthorised
  # persons).
  #
  # This method *requires* a block, to which a `result` indicating success or
  # failure is yielded. That block **must** in turn call **both**
  # `result.success` and `result.failure` to handle success and failure results,
  # respectively. On success, the block yielded to by `result.success` is called
  # and passed a `user:` parameter, which is identical to the `user` parameter
  # passed in to `#generate_reset_token` *except* that the `:token` and
  # `:password_reset_expires_at` attributes have been updated to reflect the
  # token request. An updated record matching that `:user` Entity will also have
  # been saved to the Repository.
  #
  # On failure, the `result.failure` call will yield three parameters: `:code`,
  # `:current_user`, and `:name`, and will be set as follows:
  #
  # If the `:code` value is `:user_logged_in`, that indicates that the
  # `current_user` parameter to this method represented a Registered User. In
  # this event, the `:current_user` value passed in to the `result.failure` call
  # will be the same User Entity passed into the method, and the `:name` value
  # will be `:unassigned`.
  #
  # If the `:code` value is `:user_not_found`, the named User was not found in
  # the Repository. The `:current_user` parameter will be the Guest User Entity,
  # and the `:name` parameter to the `result.failure` block will be the
  # `user_name` value passed into the method.
  # @yieldparam result [Dry::Matcher::Evaluator] Indicates whether the attempt
  #               to generate a new Reset Token succeeded or failed. The lock
  #               **must** call **both** `result.success` and `result.failure`
  #               methods, where the block passed to `result.success` accepts a
  #               parameter for `user:`, which is a User Entity with the
  #               specified `name` value as well as non-`nil` values for its
  #               `:token` and `:password_reset_expires_at` attributes. The
  #               block passed to `result.failure` accepts parameters for
  #               `code:`, `current_user:`, and `name` as described above.
  # @yieldreturn (void) Use the `result.success` and `result.failure`
  #               method-call blocks to retrieve data from the method.
  #
  # @since 0.1.0
  # @authenticated Must not be Authenticated.
  # @param [String] user_name The name of the User for whom a Password Reset
  #                 Token is to be generated.
  # @param [User, Hash] current_user Entity representing the currently
  #                 Authenticated User Entity. This **must** be a Registered
  #                 User, either as an Entity or as a Hash of attributes.
  # @return (void)
  # @example Demonstrating a (refactorable) Controller Action Class #call method
  #
  #   def call(params)
  #     config = CryptIdent.config
  #     # Remember that reading an Entity stored in session data will in fact
  #     #   return a *Hash of its attribute values*. This is acceptable.
  #     other_params = { current_user: session[:current_user] }
  #     generate_reset_token(params[:name], other_params) do |result|
  #       result.success do |user:|
  #         @user = user
  #         flash[config.success_key] = 'Request for #{user.name} sent'
  #       end
  #       result.failure do |code:, current_user:, name:| do
  #         respond_to_error(code, current_user, name)
  #       end
  #     end
  #   end
  #
  #   private
  #
  #   def respond_to_error(code, current_user, name)
  #     # ...
  #   end
  # @session_data
  #   `:current_user` **must not** be a Registered User.
  # @ubiq_lang
  #   - Authentication
  #   - Guest User
  #   - Password Reset Token
  #   - Registered User
  def generate_reset_token(user_name, current_user: nil)
    other_params = { current_user: current_user }
    GenerateResetToken.new.call(user_name, other_params) do |result|
      yield result
    end
  end

  # Generate Reset Token for non-Authenticated User
  #
  # This class *is not* part of the published API.
  # @private
  class GenerateResetToken
    include Dry::Monads::Result::Mixin
    include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)

    LogicError = Class.new(RuntimeError)

    def initialize
      @current_user = nil
      @user_name = :unassigned
    end

    # rubocop:disable Naming/RescuedExceptionsVariableName
    def call(user_name, current_user: nil)
      init_ivars(user_name, current_user)
      Success(user: updated_user)
    rescue LogicError => err
      # rubocop:disable Security/MarshalLoad
      error_data = Marshal.load(err.message)
      # rubocop:enable Security/MarshalLoad
      Failure(error_data)
    end
    # rubocop:enable Naming/RescuedExceptionsVariableName

    private

    attr_reader :current_user, :user_name

    def current_user_or_guest
      guest_user = CryptIdent.config.repository.guest_user
      current_user = @current_user || guest_user
      # This will convert a Hash of attributes to an Entity instance. It leaves
      # an actual Entity value unmolested.
      @current_user = guest_user.class.new(current_user)
    end

    def init_ivars(user_name, current_user)
      @current_user = current_user
      @user_name = user_name
    end

    def new_token
      token_length = CryptIdent.config.token_bytes
      clear_text_token = SecureRandom.alphanumeric(token_length)
      Base64.strict_encode64(clear_text_token)
    end

    def update_repo(user)
      CryptIdent.config.repository.update(user.id, updated_attribs)
    end

    def updated_attribs
      prea = Time.now + CryptIdent.config.reset_expiry
      { token: new_token, password_reset_expires_at: prea }
    end

    def updated_user
      validate_current_user
      update_repo(user_by_name)
    end

    def find_user_by_name
      # will be `nil` if no match found
      CryptIdent.config.repository.find_by_name(user_name)
    end

    def user_by_name
      found_user = find_user_by_name
      raise LogicError, user_not_found_error unless found_user

      found_user
    end

    def user_logged_in_error
      Marshal.dump(code: :user_logged_in, current_user: current_user,
                   name: :unassigned)
    end

    def user_not_found_error
      Marshal.dump(code: :user_not_found, current_user: current_user,
                   name: user_name)
    end

    def validate_current_user
      return current_user if current_user_or_guest.guest?

      raise LogicError, user_logged_in_error
    end
  end
  # Leave the class visible durinig Gem development and testing; hide in an app
  private_constant :GenerateResetToken if Hanami.respond_to?(:env?)
end