# frozen_string_literal: true

require 'securerandom'

require 'bcrypt'

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

# 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.0
module CryptIdent
  # Persist a new User to a Repository based on passed-in attributes, where the
  # resulting Entity (on success) contains a  `:password_hash` attribute
  # containing the encrypted value of a **random** Clear-Text Password; any
  # `password` value within `attribs` is ignored.
  #
  # The 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 the newly-created User Entity.
  #
  # If the call fails, the `result.success` block is yielded to, and passed a
  # `code:` parameter, which will contain one of the following symbols:
  #
  # * `:current_user_exists` indicates that the method was called with a
  # Registered User as the `current_user` parameter.
  # * `:user_already_created` indicates that the specified `name` attribute
  # matches a record that already exists in the underlying Repository.
  # * `:user_creation_failed` indicates that the Repository was unable to create
  # the new User for some other reason, such as an internal error.
  #
  # **NOTE** that the incoming `params` are expected to have been whitelisted at
  # the Controller Action Class level.
  #
  # @since 0.1.0
  # @authenticated Must not be Authenticated.
  # @param [Hash] attribs Hash-like object of attributes for new User Entity and
  #               record. **Must** include `name` and  any other attributes
  #               required by the underlying database schema. Any `password`
  #               attribute will be ignored.
  # @param [User, nil] current_user Entity representing the current
  #               Authenticated User, or the Guest User. A value of `nil` is
  #               treated as though the Guest User had been specified.
  # @return (void) Use the `result` yield parameter to determine results.
  # @yieldparam result [Dry::Matcher::Evaluator] Indicates whether the attempt
  #               to create a new User succeeded or failed. Block **must**
  #               call **both** `result.success` and `result.failure` methods,
  #               where the block passed to `result.success` accepts a parameter
  #               for `user:` (which is the newly-created User Entity). The
  #               block passed to `result.failure` accepts a parameter for
  #               `code:`, which is a Symbol reporting the reason for the
  #               failure (as described above).
  # @example in a Controller Action Class
  #   def call(_params)
  #     sign_up(params, current_user: session[:current_user]) do |result|
  #       result.success do |user:|
  #         @user = user
  #         message = "#{user.name} successfully created. You may sign in now."
  #         flash[CryptIdent.config.success_key] = message
  #         redirect_to routes.root_path
  #       end
  #
  #       result.failure do |code:|
  #         # `#error_message_for` is a method on the same class, not shown
  #         failure_key = CryptIdent.config.failure_key
  #         flash[failure_key] = error_message_for(code, params)
  #       end
  #     end
  #   end
  # @session_data
  #   `:current_user` **must not** be a Registered User.
  # @ubiq_lang
  #   - Authentication
  #   - Clear-Text Password
  #   - Entity
  #   - Guest User
  #   - Registered User
  def sign_up(attribs, current_user:)
    SignUp.new.call(attribs, current_user: current_user) do |result|
      yield result
    end
  end

  # Reworked sign-up logic for `CryptIdent`, per Issue #9
  #
  # This class *is not* part of the published API.
  # @private
  class SignUp
    include Dry::Monads::Result::Mixin
    include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)

    def call(attribs, current_user:)
      return failure_for(:current_user_exists) if current_user?(current_user)

      create_result(all_attribs(attribs))
    end

    private

    def all_attribs(attribs)
      new_attribs.merge(attribs)
    end

    # XXX: This has a Flog score of 9.8. Truly simplifying PRs welcome.
    def create_result(attribs_in)
      user = CryptIdent.config.repository.create(attribs_in)
      success_for(user)
    rescue Hanami::Model::UniqueConstraintViolationError
      failure_for(:user_already_created)
    rescue Hanami::Model::Error
      failure_for(:user_creation_failed)
    end

    def current_user?(user)
      guest_user = CryptIdent.config.guest_user
      user ||= guest_user
      !guest_user.class.new(user).guest?
    end

    def failure_for(code)
      Failure(code: code)
    end

    def hashed_password(password_in)
      password = password_in.to_s.strip
      password = SecureRandom.alphanumeric(64) if password.empty?
      ::BCrypt::Password.create(password)
    end

    def new_attribs
      prea = Time.now + CryptIdent.config.reset_expiry
      {
        password_hash: hashed_password(nil),
        password_reset_expires_at: prea,
        token: new_token
      }
    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 success_for(user)
      Success(user: user)
    end
  end
  # Leave the class visible durinig Gem development and testing; hide in an app
  private_constant :SignUp if Hanami.respond_to?(:env?)
end