module Authoreyes
  module Parser
    # For examples and the modeled data model, see the
    # README[link:files/README_rdoc.html].
    #
    # Also, see role definition methods
    # * AuthorizationRulesReader#role,
    # * AuthorizationRulesReader#includes,
    # * AuthorizationRulesReader#title,
    # * AuthorizationRulesReader#description
    #
    # Methods for rule definition in roles
    # * AuthorizationRulesReader#has_permission_on,
    # * AuthorizationRulesReader#to,
    # * AuthorizationRulesReader#if_attribute,
    # * AuthorizationRulesReader#if_permitted_to
    #
    # Methods to be used in if_attribute statements
    # * AuthorizationRulesReader#contains,
    # * AuthorizationRulesReader#does_not_contain,
    # * AuthorizationRulesReader#intersects_with,
    # * AuthorizationRulesReader#is,
    # * AuthorizationRulesReader#is_not,
    # * AuthorizationRulesReader#is_in,
    # * AuthorizationRulesReader#is_not_in,
    # * AuthorizationRulesReader#lt,
    # * AuthorizationRulesReader#lte,
    # * AuthorizationRulesReader#gt,
    # * AuthorizationRulesReader#gte
    #
    # And privilege definition methods
    # * PrivilegesReader#privilege,
    # * PrivilegesReader#includes
    #
    class AuthorizationRulesParser
      attr_reader :roles, :role_hierarchy, :auth_rules,
                  :role_descriptions, :role_titles, :omnipotent_roles # :nodoc:

      def initialize # :nodoc:
        @current_role = nil
        @current_rule = nil
        @roles = []
        @omnipotent_roles = []
        # higher_role => [lower_roles]
        @role_hierarchy = {}
        @role_titles = {}
        @role_descriptions = {}
        @auth_rules = ::Authoreyes::Authorization::AuthorizationRuleSet.new
      end

      def initialize_copy(from) # :nodoc:
        [
          :roles,
          :role_hierarchy,
          :auth_rules,
          :role_descriptions,
          :role_titles,
          :omnipotent_roles
        ].each do |attribute|
          instance_variable_set(:"@#{attribute}", from.send(attribute).clone)
        end
      end

      def append_role(role, options = {}) # :nodoc:
        @roles << role unless @roles.include? role
        @role_titles[role] = options[:title] if options[:title]
        @role_descriptions[role] =
          options[:description] if options[:description]
      end

      # Defines the authorization rules for the given +role+ in the
      # following block.
      #   role :admin do
      #     has_permissions_on ...
      #   end
      #
      def role(role, options = {})
        append_role role, options
        @current_role = role
        yield
      ensure
        @current_role = nil
      end

      # Roles may inherit all the rights from subroles.  The given +roles+
      # become subroles of the current block's role.
      #   role :admin do
      #     includes :user
      #     has_permission_on :employees, :to => [:update, :create]
      #   end
      #   role :user do
      #     has_permission_on :employees, :to => :read
      #   end
      #
      def includes(*roles)
        raise DSLError, 'includes only in role blocks' if @current_role.nil?
        @role_hierarchy[@current_role] ||= []
        @role_hierarchy[@current_role] += roles.flatten
      end

      # Allows the definition of privileges to be allowed for the current role,
      # either in a has_permission_on block or directly in one call.
      #   role :admin
      #     has_permission_on :employees, :to => :read
      #     has_permission_on [:employees, :orders], :to => :read
      #     has_permission_on :employees do
      #       to :create
      #       if_attribute ...
      #     end
      #     has_permission_on :employees, :to => :delete do
      #       if_attribute ...
      #     end
      #   end
      # The block form allows to describe restrictions on the permissions
      # using if_attribute.  Multiple has_permission_on statements are
      # OR'ed when evaluating the permissions.  Also, multiple if_attribute
      # statements in one block are OR'ed if no :+join_by+ option is given
      # (see below).  To AND conditions, either set :+join_by+ to :and or place
      # them in one if_attribute statement.
      #
      # Available options
      # [:+to+]
      #   A symbol or an array of symbols representing the privileges that
      #   should be granted in this statement.
      # [:+join_by+]
      #   Join operator to logically connect the constraint statements inside
      #   of the has_permission_on block.  May be :+and+ or :+or+.
      #   Defaults to :+or+.
      #
      def has_permission_on(*args)
        options = args.extract_options!
        context = args.flatten

        raise DSLError, 'has_permission_on only allowed in role blocks' if @current_role.nil?
        options = { to: [], join_by: :or }.merge(options)

        privs = options[:to]
        privs = [privs] unless privs.is_a?(Array)
        raise DSLError, 'has_permission_on either needs a block or :to option' if !block_given? && privs.empty?

        file, line = file_and_line_number_from_call_stack
        rule = ::Authoreyes::Authorization::AuthorizationRule.new(@current_role, privs, context, options[:join_by],
                                                                  source_file: file, source_line: line)
        @auth_rules << rule
        if block_given?
          @current_rule = rule
          yield
          raise DSLError, "has_permission_on block
            content specifies no privileges" if rule.privileges.empty?
          # TODO: ensure?
          @current_rule = nil
        end
      end

      # Removes any permission checks for the current role.
      #   role :admin
      #     has_omnipotence
      #   end
      def has_omnipotence
        raise DSLError, 'has_omnipotence only allowed in role blocks' if @current_role.nil?
        @omnipotent_roles << @current_role
      end

      # Sets a description for the current role.  E.g.
      #   role :admin
      #     description "To be assigned to administrative personnel"
      #     has_permission_on ...
      #   end
      def description(text)
        raise DSLError, 'description only allowed in role blocks' if @current_role.nil?
        role_descriptions[@current_role] = text
      end

      # Sets a human-readable title for the current role.  E.g.
      #   role :admin
      #     title "Administrator"
      #     has_permission_on ...
      #   end
      def title(text)
        raise DSLError, 'title only allowed in role blocks' if @current_role.nil?
        role_titles[@current_role] = text
      end

      # Used in a has_permission_on block, to may be used to specify privileges
      # to be assigned to the current role under the conditions specified in
      # the current block.
      #   role :admin
      #     has_permission_on :employees do
      #       to :create, :read, :update, :delete
      #     end
      #   end
      def to(*privs)
        raise DSLError, 'to only allowed in has_permission_on blocks' if @current_rule.nil?
        @current_rule.append_privileges(privs.flatten)
      end

      # In a has_permission_on block, if_attribute specifies conditions
      # of dynamic parameters that have to be met for the user to meet the
      # privileges in this block.  Conditions are evaluated on the context
      # object.  Thus, the following allows CRUD for branch admins only on
      # employees that belong to the same branch as the current user.
      #   role :branch_admin
      #     has_permission_on :employees do
      #       to :create, :read, :update, :delete
      #       if_attribute :branch => is { user.branch }
      #     end
      #   end
      # In this case, is is the operator for evaluating the condition.  Another
      # operator is contains for collections.  In the block supplied to the
      # operator, +user+ specifies the current user for whom the condition
      # is evaluated.
      #
      # Conditions may be nested:
      #   role :company_admin
      #     has_permission_on :employees do
      #       to :create, :read, :update, :delete
      #       if_attribute :branch => { :company => is {user.branch.company} }
      #     end
      #   end
      #
      # has_many and has_many through associations may also be nested.
      # Then, at least one item in the association needs to fulfill the
      # subsequent condition:
      #   if_attribute :company => { :branches => { :manager => { :last_name => is { user.last_name } } }
      # Beware of possible performance issues when using has_many associations in
      # permitted_to? checks.  For
      #   permitted_to? :read, object
      # a check like
      #   object.company.branches.any? { |branch| branch.manager ... }
      # will be executed.  with_permission_to scopes construct efficient SQL
      # joins, though.
      #
      # Multiple attributes in one :if_attribute statement are AND'ed.
      # Multiple if_attribute statements are OR'ed if the join operator for the
      # has_permission_on block isn't explicitly set.  Thus, the following would
      # require the current user either to be of the same branch AND the employee
      # to be "changeable_by_coworker".  OR the current user has to be the
      # employee in question.
      #   has_permission_on :employees, :to => :manage do
      #     if_attribute :branch => is {user.branch}, :changeable_by_coworker => true
      #     if_attribute :id => is {user.id}
      #   end
      # The join operator for if_attribute rules can explicitly set to AND, though.
      # See has_permission_on for details.
      #
      # Arrays and fixed values may be used directly as hash values:
      #   if_attribute :id   => 1
      #   if_attribute :type => "special"
      #   if_attribute :id   => [1,2]
      #
      def if_attribute(attr_conditions_hash)
        raise DSLError, 'if_attribute only in has_permission blocks' if @current_rule.nil?
        parse_attribute_conditions_hash!(attr_conditions_hash)
        @current_rule.append_attribute ::Authoreyes::Authorization::Attribute.new(attr_conditions_hash)
      end

      # if_permitted_to allows the has_permission_on block to depend on
      # permissions on associated objects.  By using it, the authorization
      # rules may be a lot DRYer.  E.g.:
      #
      #   role :branch_manager
      #     has_permission_on :branches, :to => :manage do
      #       if_attribute :employees => contains { user }
      #     end
      #     has_permission_on :employees, :to => :read do
      #       if_permitted_to :read, :branch
      #       # instead of
      #       # if_attribute :branch => { :employees => contains { user } }
      #     end
      #   end
      #
      # if_permitted_to associations may be nested as well:
      #   if_permitted_to :read, :branch => :company
      #
      # You can even use has_many associations as target.  Then, it is checked
      # if the current user has the required privilege on *any* of the target objects.
      #   if_permitted_to :read, :branch => :employees
      # Beware of performance issues with permission checks.  In the current implementation,
      # all employees are checked until the first permitted is found.
      # with_permissions_to, on the other hand, constructs more efficient SQL
      # instead.
      #
      # To check permissions based on the current object, the attribute has to
      # be left out:
      #   has_permission_on :branches, :to => :manage do
      #     if_attribute :employees => contains { user }
      #   end
      #   has_permission_on :branches, :to => :paint_green do
      #     if_permitted_to :update
      #   end
      # Normally, one would merge those rules into one.  Dividing makes sense
      # if additional if_attribute are used in the second rule or those rules
      # are applied to different roles.
      #
      # Options:
      # [:+context+]
      #   When using with_permissions_to, the target context of the if_permitted_to
      #   statement is inferred from the last reflections target class.  Still,
      #   you may override this algorithm by setting the context explicitly.
      #     if_permitted_to :read, :home_branch, :context => :branches
      #     if_permitted_to :read, :branch => :main_company, :context => :companies
      #
      def if_permitted_to(privilege, attr_or_hash = nil, options = {})
        raise DSLError, 'if_permitted_to only in has_permission blocks' if @current_rule.nil?
        options[:context] ||= attr_or_hash.delete(:context) if attr_or_hash.is_a?(Hash)
        # only :context option in attr_or_hash:
        attr_or_hash = nil if attr_or_hash.is_a?(Hash) && attr_or_hash.empty?
        @current_rule.append_attribute ::Authoreyes::Authorization::AttributeWithPermission.new(privilege,
                                                                                                attr_or_hash, options[:context])
      end

      # In an if_attribute statement, is says that the value has to be
      # met exactly by the if_attribute attribute.  For information on the block
      # argument, see if_attribute.
      def is(&block)
        [:is, block]
      end

      # The negation of is.
      def is_not(&block)
        [:is_not, block]
      end

      # In an if_attribute statement, contains says that the value has to be
      # part of the collection specified by the if_attribute attribute.
      # For information on the block argument, see if_attribute.
      def contains(&block)
        [:contains, block]
      end

      # The negation of contains.  Currently, query rewriting is disabled
      # for does_not_contain.
      def does_not_contain(&block)
        [:does_not_contain, block]
      end

      # In an if_attribute statement, intersects_with requires that at least
      # one of the values has to be part of the collection specified by the
      # if_attribute attribute.  The value block needs to evaluate to an
      # Enumerable.  For information on the block argument, see if_attribute.
      def intersects_with(&block)
        [:intersects_with, block]
      end

      # In an if_attribute statement, is_in says that the value has to
      # contain the attribute value.
      # For information on the block argument, see if_attribute.
      def is_in(&block)
        [:is_in, block]
      end

      # The negation of is_in.
      def is_not_in(&block)
        [:is_not_in, block]
      end

      # Less than
      def lt(&block)
        [:lt, block]
      end

      # Less than or equal to
      def lte(&block)
        [:lte, block]
      end

      # Greater than
      def gt(&block)
        [:gt, block]
      end

      # Greater than or equal to
      def gte(&block)
        [:gte, block]
      end

      private

      def parse_attribute_conditions_hash!(hash)
        merge_hash = {}
        hash.each do |key, value|
          if value.is_a?(Hash)
            parse_attribute_conditions_hash!(value)
          elsif !value.is_a?(Array)
            merge_hash[key] = [:is, proc { value }]
          elsif value.is_a?(Array) && !value[0].is_a?(Symbol)
            merge_hash[key] = [:is_in, proc { value }]
          end
        end
        hash.merge!(merge_hash)
      end

      def file_and_line_number_from_call_stack
        caller_parts = caller(2).first.split(':')
        [caller_parts[0] == '(eval)' ? nil : caller_parts[0],
         caller_parts[1] && caller_parts[1].to_i]
      end
    end
  end
end
