### Copyright 2017 Pixar

###
###    Licensed under the Apache License, Version 2.0 (the "Apache License")
###    with the following modification; you may not use this file except in
###    compliance with the Apache License and the following modification to it:
###    Section 6. Trademarks. is deleted and replaced with:
###
###    6. Trademarks. This License does not grant permission to use the trade
###       names, trademarks, service marks, or product names of the Licensor
###       and its affiliates, except as required to comply with Section 4(c) of
###       the License and to reproduce the content of the NOTICE file.
###
###    You may obtain a copy of the Apache License at
###
###        http://www.apache.org/licenses/LICENSE-2.0
###
###    Unless required by applicable law or agreed to in writing, software
###    distributed under the Apache License with the above modification is
###    distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
###    KIND, either express or implied. See the Apache License for the specific
###    language governing permissions and limitations under the Apache License.
###
###

###
module JSS

  module Scopable

  #####################################
  ### Classes
  #####################################

    ###
    ### This class represents a Scope in the JSS, as can be applied to Scopable objects like
    ### Policies, Profiles, etc. Instances of this class are generally used as the value of the @scope attribute
    ### of those objects.
    ###
    ### Scope data comes from the API as a hash within the overall object data. The main keys of the hash
    ### define the included targets of the scope. A sub-hash defines limitations on those inclusions,
    ### and another sub-hash defines explicit exclusions.
    ###
    ### This class provides methods for adding, removing, or fully replacing the
    ### various parts of the scope's inclusions, limitations, and exclusions.
    ###
    ### @todo Implement simple LDAP queries using the defined {LDAPServer}s to confirm the
    ###   existance of users or groups used in limitations and exclusions. As things are now
    ###   if you add invalid user or group names, you'll get a 409 conflict error when you try
    ###   to save your changes to the JSS.
    ###
    ### @see JSS::Scopable
    ###
    class Scope

      #####################################
      ### Mix-Ins
      #####################################

      #####################################
      ### Class Methods
      #####################################

      #####################################
      ### Class Constants
      #####################################

      ### These are the classes that Scopes can use for defining a scope,
      ### keyed by appropriate symbols.
      SCOPING_CLASSES ={
        :computers => JSS::Computer,
        :computer => JSS::Computer,
        :computer_groups => JSS::ComputerGroup,
        :computer_group => JSS::ComputerGroup,
        :mobile_devices => JSS::MobileDevice,
        :mobile_device => JSS::MobileDevice,
        :mobile_device_groups => JSS::MobileDeviceGroup,
        :mobile_device_group => JSS::MobileDeviceGroup,
        :buildings => JSS::Building,
        :building => JSS::Building,
        :departments => JSS::Department,
        :department => JSS::Department,
        :network_segments => JSS::NetworkSegment,
        :network_segment => JSS::NetworkSegment,
        :users => JSS::User,
        :user => JSS::User,
        :user_groups => JSS::UserGroup,
        :user_group => JSS::UserGroup
      }

      ### Some things get checked in LDAP as well as the JSS
      LDAP_USER_KEYS = [:user, :users]
      LDAP_GROUP_KEYS = [:user_groups, :user_group]
      CHECK_LDAP_KEYS = LDAP_USER_KEYS + LDAP_GROUP_KEYS

      ### This hash maps the availble Scope Target keys from SCOPING_CLASSES to
      ### their corresponding target group keys from SCOPING_CLASSES.
      TARGETS_AND_GROUPS = {:computers => :computer_groups, :mobile_devices => :mobile_device_groups }

      ### These can be part of the base inclusion list of the scope,
      ### along with the appropriate target and target group keys
      INCLUSIONS = [:buildings, :departments]

      ### These can limit the inclusion list
      LIMITATIONS = [:network_segments, :users, :user_groups]

      ### any of them can be excluded
      EXCLUSIONS = INCLUSIONS + LIMITATIONS

      ### Here's a default scope as it might come from the API.
      DEFAULT_SCOPE = {
        :all_computers => true,
        :all_mobile_devices => true,
        :limitations => {},
        :exclusions => {}
      }



      ######################
      ### Attributes
      ######################

      ### @return [JSS::APIObject subclass]
      ###
      ### A reference to the object that contains this Scope
      ###
      ### For telling it when a change is made and an update needed
      attr_accessor :container

      ### @return [Boolean] should we expect a potential 409 Conflict
      ###   if we can't connect to LDAP servers for verification?
      attr_accessor :unable_to_verify_ldap_entries

      ### what type of target is this scope for? Computers or Mobiledevices?
      attr_reader :target_class

      ### @return [Hash<Array>]
      ###
      ### The items which form the base scope of included targets
      ###
      ### This is the group of targets to which the limitations and exclusions apply.
      ### they keys are:
      ### - :targets
      ### - :target_groups
      ### - :departments
      ### - :buildings
      ### and the values are Arrays of names of those things.
      ###
      attr_reader :inclusions

      ### @return [Boolean]
      ###
      ### Does this scope cover all targets?
      ###
      ### If this is true, the @inclusions Hash is ignored, and all
      ### targets in the JSS form the base scope.
      ###
      attr_reader :all_targets



      ### @return [Hash<Array>]
      ###
      ### The items in these arrays are the limitations applied to targets in the @inclusions .
      ###
      ### The arrays of names are:
      ### - :network_segments
      ### - :users
      ### - :user_groups
      ###
      attr_reader :limitations

      ### @return [Hash<Array>]
      ###
      ### The items in these arrays are the exclusions applied to targets in the @inclusions .
      ###
      ### The arrays of names are:
      ### - :targets
      ### - :target_groups
      ### - :departments
      ### - :buildings
      ### - :network_segments
      ### - :users
      ### - :user_groups
      ###
      attr_reader :exclusions


      #####################################
      ### Public Instance Methods
      #####################################

      ###
      ### If api_scope is empty, a default scope, scoped to all targets, is created, and can be modified
      ### as needed.
      ###
      ### @param target_key[Symbol] the kind of thing we're scopeing, one of {TARGETS_AND_GROUPS}
      ###
      ### @param api_scope[Hash] the JSON :scope data from an API query that is scopable, e.g. a Policy.
      ###
      def initialize(target_key, api_scope = nil)
        api_scope ||= DEFAULT_SCOPE
        raise JSS::InvalidDataError, "The target class of a Scope must be one of the symbols :#{TARGETS_AND_GROUPS.keys.join(', :')}" unless TARGETS_AND_GROUPS.keys.include? target_key

        @target_key = target_key
        @target_class = SCOPING_CLASSES[@target_key]
        @group_key = TARGETS_AND_GROUPS[@target_key]
        @group_class = SCOPING_CLASSES[@group_key]

        @inclusion_keys = [@target_key, @group_key] + INCLUSIONS
        @exclusion_keys = [@target_key, @group_key] + EXCLUSIONS

        @all_key = "all_#{target_key}".to_sym
        @all_targets = api_scope[@all_key]

        ### Everything gets mapped from an Array of Hashes to an Array of names (or an empty array)
        ### since names are all that really matter when submitting the scope.
        @inclusions = {}
        @inclusion_keys.each{|k| @inclusions[k] = api_scope[k] ? api_scope[k].map{|n| n[:name]} :  [] }

        @limitations = {}
        if api_scope[:limitations]
          LIMITATIONS.each{|k| @limitations[k] = api_scope[:limitations][k] ? api_scope[:limitations][k].map{|n| n[:name]} :  [] }
        end

        @exclusions = {}
        if api_scope[:exclusions]
          @exclusion_keys.each{|k| @exclusions[k] = api_scope[:exclusions][k] ? api_scope[:exclusions][k].map{|n| n[:name]} :  [] }
        end

        @container = nil

      end  # init

      ###
      ### Set the scope's inclusions to all targets.
      ###
      ### By default, the limitations and exclusions remain.
      ### If a non-false parameter is provided, they will be removed also.
      ###
      ### @param clear[Boolean] Should the limitations and exclusions be removed also?
      ###
      ### @return [void]
      ###
      def include_all(clear = false)
        @inclusions = {}
        @inclusion_keys.each{|k| @inclusions[k] = []}
        @all_targets = true
        if clear
          @limitations = {}
          LIMITATIONS.each{|k| @limitations[k] = []}

          @exclusions = {}
          @exclusion_keys.each{|k| @exclusions[k] = []}
        end
        @container.should_update if @container
      end


      ###
      ### Replace a list of item names for inclusion in this scope.
      ###
      ### The list must be an Array of names of items of the Class represented by the key.
      ### Each will be checked for existence in the JSS, and an exception raised if the item doesn't exist.
      ###
      ### @param key[Symbol] the key from #{SCOPING_CLASSES} for the kind of items being included, :computer, :building, etc...
      ###
      ### @param list[Array] the names of the items being added
      ###
      ### @example
      ###   set_inclusion(:computers, ['kimchi','mantis'])
      ###
      ### @return [void]
      ###
      def set_inclusion(key, list)
        raise JSS::InvalidDataError, "Inclusion key must be one of :#{@inclusion_keys.join(', :')}" unless @inclusion_keys.include? key
        raise JSS::InvalidDataError, "List must be an Array of #{key} names, it may be empty." unless list.kind_of? Array

        return nil if list.sort == @inclusions[key].sort

        # emptying the list?
        if list.empty?
          @inclusion[key] = list
          # if ALL the @inclusion keys are empty, then set all targets to true.
          @all_targets =  @inclusions.values.reject{|a| a.nil? or a.empty?}.empty?
          @container.should_update if @container
          return list
        end

        ### check the names
        list.each do |name|
          raise JSS::NoSuchItemError, "No existing #{key} with name '#{name}'" unless check_name key, name
          raise JSS::AlreadyExistsError, "Can't set #{key} scope to '#{name}' because it's already an explicit exclusion." if @exclusions[key] and @exclusions[key].include? name
        end # each

        @inclusions[key] = list
        @all_targets = false
        @container.should_update if @container
      end # sinclude_in_scope


      ###
      ### Add a single item for this inclusion in this scope.
      ###
      ### The item name will be checked for existence in the JSS, and an exception raised if the item doesn't exist.
      ###
      ### @param key[Symbol] the key from #{SCOPING_CLASSES} for the kind of item being added, :computer, :building, etc...
      ###
      ### @param item[String] the name of the item being added
      ###
      ### @example
      ###   add_inclusion(:computer, "mantis")
      ###
      ### @return [void]
      ###
      def add_inclusion (key, item)
        raise JSS::InvalidDataError, "Inclusion key must be one of :#{@inclusion_keys.join(', :')}" unless @inclusion_keys.include? key
        raise JSS::InvalidDataError, "Item must be a #{key} name." unless item.kind_of? String

        return nil if @inclusions[key] and @inclusions[key].include? item

        ### check the name
        raise JSS::NoSuchItemError, "No existing #{key} with name '#{item}'" unless check_name key, item
        raise JSS::AlreadyExistsError, "Can't set #{key} scope to '#{item}' because it's already an explicit exclusion." if @exclusions[key] and @exclusions[key].include? item


        @inclusions[key] << item
        @all_targets = false
        @container.should_update if @container
      end

      ###
      ### Remove a single item for this scope.
      ###
      ### @param key[Symbol] the key from #{SCOPING_CLASSES} for the kind of item being removed, :computer, :building, etc...
      ###
      ### @param item[String] the name of the item being removed
      ###
      ### @example
      ###   remove_inclusion(:computer, "mantis")
      ###
      ### @return [void]
      ###
      def remove_inclusion (key, item)
        raise JSS::InvalidDataError, "Inclusion key must be one of :#{@inclusion_keys.join(', :')}" unless @inclusion_keys.include? key
        raise JSS::InvalidDataError, "Item must be a #{key} name." unless item.kind_of? String

        return nil unless @inclusions[key] and @inclusions[key].include? item

        @inclusions[key] -= [item]

        # if ALL the @inclusion keys are empty, then set all targets to true.
        @all_targets =  @inclusions.values.reject{|a| a.nil? or a.empty?}.empty?

        @container.should_update if @container
      end


      ###
      ### Replace a limitation list for this scope.
      ###
      ### The list must be an Array of names of items of the Class represented by the key.
      ### Each will be checked for existence in the JSS, and an exception raised if the item doesn't exist.
      ###
      ### @param key[Symbol] the type of items being set as limitations, :network_segments, :users, etc...
      ###
      ### @param list[Array] the names of the items being set as limitations
      ###
      ### @example
      ###   set_limitation(:network_segments, ['foo','bar'])
      ###
      ### @return [void]
      ###
      ### @todo  handle ldap user group lookups
      ###
      def set_limitation (key, list)
        raise JSS::InvalidDataError, "Limitation key must be one of :#{LIMITATIONS.join(', :')}" unless LIMITATIONS.include? key
        raise JSS::InvalidDataError, "List must be an Array of #{key} names, it may be empty." unless list.kind_of? Array
        return nil if list.sort == @limitations[key].sort

        if list.empty?
          @limitations[key] = []
          @container.should_update if @container
          return list
        end

        ### check the names
        list.each do |name|
          raise JSS::NoSuchItemError, "No existing #{key} with name '#{name}'" unless check_name key,  name
          raise JSS::AlreadyExistsError, "Can't set #{key} limitation for '#{name}' because it's already an explicit exclusion." if @exclusions[key] and @exclusions[key].include? name
        end # each

        @limitations[key] = list
        @container.should_update if @container
      end # limit scope


      ###
      ### Add a single item for limiting this scope.
      ###
      ### The item name will be checked for existence in the JSS, and an exception raised if the item doesn't exist.
      ###
      ### @param key[Symbol] the type of item being added, :computer, :building, etc...
      ###
      ### @param item[String] the name of the item being added
      ###
      ### @example
      ###   add_limitation(:network_segments, "foo")
      ###
      ### @return [void]
      ###
      ### @todo  handle ldap user/group lookups
      ###
      def add_limitation (key, item)
        raise JSS::InvalidDataError, "Limitation key must be one of :#{LIMITATIONS.join(', :')}" unless LIMITATIONS.include? key
        raise JSS::InvalidDataError, "Item must be a #{key} name." unless item.kind_of? String

        return nil if @limitations[key] and @limitations[key].include? item

        ### check the name
        raise JSS::NoSuchItemError, "No existing #{key} with name '#{item}'" unless check_name key, item
        raise JSS::AlreadyExistsError, "Can't set #{key} limitation for '#{name}' because it's already an explicit exclusion." if @exclusions[key] and @exclusions[key].include? item


        @limitations[key] << item
        @container.should_update if @container
      end

      ###
      ### Remove a single item for limiting this scope.
      ###
      ### @param key[Symbol] the type of item being removed, :computer, :building, etc...
      ###
      ### @param item[String] the name of the item being removed
      ###
      ### @example
      ###   remove_limitation(:network_segments, "foo")
      ###
      ### @return [void]
      ###
      ### @todo  handle ldap user/group lookups
      ###
      def remove_limitation( key, item)
        raise JSS::InvalidDataError, "Limitation key must be one of :#{LIMITATIONS.join(', :')}" unless LIMITATIONS.include? key
        raise JSS::InvalidDataError, "Item must be a #{key} name." unless item.kind_of? String

        return nil unless @limitations[key] and @limitations[key].include? item

        @limitations[key] -= [item]
        @container.should_update if @container
      end



      ###
      ### Replace an exclusion list for this scope
      ###
      ### The list must be an Array of names of items of the Class being excluded from the scope
      ### Each will be checked for existence in the JSS, and an exception raised if the item doesn't exist.
      ###
      ### @param key[Symbol] the type of item being excluded, :computer, :building, etc...
      ###
      ### @param list[Array] the names of the items being added
      ###
      ### @example
      ###   set_exclusion(:network_segments, ['foo','bar'])
      ###
      ### @return [void]
      ###
      def set_exclusion (key, list)
        raise JSS::InvalidDataError, "Exclusion key must be one of :#{@exclusion_keys.join(', :')}" unless @exclusion_keys.include? key
        raise JSS::InvalidDataError, "List must be an Array of #{key} names, it may be empty." unless list.kind_of? Array
        return nil if list.sort == @exclusions[key].sort

        if list.empty?
          @exclusions[key] = []
          @container.should_update if @container
          return list
        end

        ### check the names
        list.each do |name|
          raise JSS::NoSuchItemError, "No existing #{key} with name '#{name}'" unless check_name key, name
          case key
            when *@inclusion_keys
              raise JSS::AlreadyExistsError, "Can't exclude #{key} '#{name}' because it's already explicitly included." if  @inclusions[key] and @inclusions[key].include? name
            when *LIMITATIONS
              raise JSS::AlreadyExistsError, "Can't exclude #{key} '#{name}' because it's already an explicit limitation." if @limitations[key] and @limitations[key].include? name
          end

        end # each

        @exclusions[key] = list
        @container.should_update if @container
      end # limit scope

      ###
      ### Add a single item for exclusions of this scope.
      ###
      ### The item name will be checked for existence in the JSS, and an exception raised if the item doesn't exist.
      ###
      ### @param key[Symbol] the type of item being added to the exclusions, :computer, :building, etc...
      ###
      ### @param item[String] the name of the item being added
      ###
      ### @example
      ###   add_exclusion(:network_segments, "foo")
      ###
      ### @return [void]
      ###
      def add_exclusion (key, item)
        raise JSS::InvalidDataError, "Exclusion key must be one of :#{@exclusion_keys.join(', :')}" unless @exclusion_keys.include? key
        raise JSS::InvalidDataError, "Item must be a #{key} name." unless item.kind_of? String

        return nil if @exclusions[key] and @exclusions[key].include? item

        ### check the name
        raise JSS::NoSuchItemError, "No existing #{key} with name '#{item}'" unless check_name key, item
        raise JSS::AlreadyExistsError, "Can't exclude #{key} scope to '#{item}' because it's already explicitly included." if @inclusions[key] and @inclusions[key].include? item
        raise JSS::AlreadyExistsError, "Can't exclude #{key} '#{item}' because it's already an explicit limitation." if @limitations[key] and @limitations[key].include? item

        @exclusions[key] << item
        @container.should_update if @container
      end

      ###
      ### Remove a single item for exclusions of this scope
      ###
      ### @param key[Symbol] the type of item being removed from the excludions, :computer, :building, etc...
      ###
      ### @param item[String] the name of the item being removed
      ###
      ### @example
      ###   remove_exclusion(:network_segments, "foo")
      ###
      ### @return [void]
      ###
      def remove_exclusion (key, item)
        raise JSS::InvalidDataError, "Exclusion key must be one of :#{@exclusion_keys.join(', :')}" unless @exclusion_keys.include? key
        raise JSS::InvalidDataError, "Item must be a #{key} name." unless item.kind_of? String

        return nil unless @exclusions[key] and @exclusions[key].include? item

        @exclusions[key] -= [item]
        @container.should_update if @container
      end

      ###
      ### @api private
      ### Return a REXML Element containing the current state of the Scope
      ### for adding into the XML of the container.
      ###
      ### @return [REXML::Element]
      ###
      def scope_xml
        scope = REXML::Element.new "scope"
        scope.add_element(@all_key.to_s).text = @all_targets

        @inclusions.each do |klass,list|
          list_as_hash = list.map{|i| {:name => i} }
          scope << SCOPING_CLASSES[klass].xml_list( list_as_hash, :name)
        end

        limitations = scope.add_element('limitations')
        @limitations.each do |klass,list|
          list_as_hash = list.map{|i| {:name => i} }
          limitations << SCOPING_CLASSES[klass].xml_list( list_as_hash, :name)
        end

        exclusions = scope.add_element('exclusions')
        @exclusions.each do |klass,list|
          list_as_hash = list.map{|i| {:name => i} }
          exclusions << SCOPING_CLASSES[klass].xml_list( list_as_hash, :name)
        end
        return scope
      end #scope_xml


      ### Aliases

      alias all_targets? all_targets


      #####################################
      ### Private Instance Methods
      #####################################
      private

      ###
      ### Given a name of some class of item to be used in the scope, check that it
      ### exists in the JSS.
      ###
      ### @return [Boolean] does the name exist for the key in JSS or LDAP?
      ###
      def check_name(key, name)

        found_in_jss = SCOPING_CLASSES[key].all_names(api: @api).include?(name)

        return true if found_in_jss

        return false unless CHECK_LDAP_KEYS.include?(key)

        begin
          return JSS::LDAPServer.user_in_ldap?(name, api: @api) if LDAP_USER_KEYS.include?(key)
          return JSS::LDAPServer.group_in_ldap?(name, api: @api) if LDAP_GROUP_KEYS.include?(key)

        # if an ldap server isn't connected, make a note of it and return true
        rescue JSS::InvalidConnectionError
          @unable_to_verify_ldap_entries = true
          return true
        end # begin

        return false
      end



    end # class Scope
  end #module Scopable
end # module