### Copyright 2018 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 D3

  # Client
  class Client < JSS::Client

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

    ### Install one or more packages from the command-line
    ### by basename, or edition (or name, filename, id)
    ###
    ### This method is only used by manual installs (i.e. not
    ### automated via d3 sync)
    ###
    ### Basenames always gets the live pkg for that basename
    ###
    ### Editions install the package regardless of status.
    ###
    ### @param pkgs[Array<String,Int>] The packages to install
    ###
    ### @param options[OpenStruct] the d3 client cli options
    ###
    ### @return [void]
    ###
    def self.install(pkgs, options)
      pkgs = [pkgs] if pkgs.is_a? String

      pkgs.each do |pkg_to_search|
        begin
          # get a D3::Package object
          desired_pkg = D3::Package.find_package(pkg_to_search)

          raise JSS::NoSuchItemError, "No d3 package matching #{pkg_to_search}" unless desired_pkg
          raise D3::InstallError, "The package for #{desired_pkg.edition} is missing from the JSS" if desired_pkg.missing?

          if options.custom_expiration
            D3.log "Sorry #{desired_pkg.edition} is not expirable. A d3 admin needs to add an expiration path.", :warn if desired_pkg.expiration_paths.empty?
            break
          end

          curr_rcpt = D3::Client::Receipt.all[desired_pkg.basename]

          # If we were asked to freeze_on_install and the currently installed is
          # same edition, freeze it anyway.
          # Use force to freeze if the currently installed is newer.
          #
          if curr_rcpt && curr_rcpt.id >= desired_pkg.id && options.freeze_on_install
            if options.force || curr_rcpt.id == desired_pkg.id
              freeze_receipts([curr_rcpt.basename]) unless curr_rcpt.frozen?
              D3.log "Freezing previously installed #{curr_rcpt.edition}", :warn
              break
            end # if options.force elsif curr_rcpt.id == desired_pkg.id
            D3.log "Cannot freeze previously installed #{curr_rcpt.edition} (#{curr_rcpt.status}) It is newer than #{desired_pkg.edition}. Use --force if needed.", :warn
            break
          end

          # many things can be forced
          # things that are defined in the pkg itself
          # (exclusions, prohibiting procs, oses, cpus)
          # are checked in the Package#install method
          # These things are the responsibility of the
          # client.
          unless options.force
            # deprecated pkgs need force for installing
            desired_pkg.check_for_deprecated
            # skipped pkgs need force for installing
            desired_pkg.check_for_skipped
            # same or newer?
            desired_pkg.check_for_newer_version
          end # unless options.force

          if curr_rcpt
            D3.log("Un-freezing #{curr_rcpt.edition} by installing #{desired_pkg.edition}", :warn) if curr_rcpt.frozen?
            if desired_pkg.id == curr_rcpt.id
              D3.log("Re-installing #{desired_pkg.edition}(#{desired_pkg.status})", :warn)
            elsif desired_pkg.id < curr_rcpt.id
              D3.log("Rolling back #{curr_rcpt.edition}(#{curr_rcpt.status}) to #{desired_pkg.edition}(#{desired_pkg.status})", :warn)
            else
              D3.log("Updating #{curr_rcpt.edition}(#{curr_rcpt.status}) to #{desired_pkg.edition}(#{desired_pkg.status})", :warn)
            end
          end # if curr rcpt

          cloud = cloud_dist_point_to_use(pkg: desired_pkg)

          desired_pkg.install(
            force: options.force,
            admin: get_admin(desired_pkg, options),
            puppywalk: options.puppies,
            expiration: options.custom_expiration,
            verbose: options.verbose,
            alt_download_url: cloud
          )

          freeze_receipts([desired_pkg.basename]) if options.freeze_on_install

          D3.log "Finished installing #{desired_pkg.edition}(#{desired_pkg.status})", :info

        rescue JSS::MissingDataError, JSS::NoSuchItemError, JSS::InvalidDataError, D3::InstallError
          D3.log "Skipping installation of #{pkg_to_search}: #{$ERROR_INFO}", :error
          D3.log_backtrace
        rescue D3::PreInstallError
          D3.log "There was an error with the pre-install script for #{desired_pkg.edition}: #{$ERROR_INFO}", :error
          D3.log_backtrace
        rescue D3::PostInstallError
          D3.log "There was an error with the post-install script for #{desired_pkg.edition}: #{$ERROR_INFO} NOTE: it was installed, but may have problems.", :error
          D3.log_backtrace
        end # begin
      end # args.each
    end # install

    ### uninstall one or more packages from the commandline
    ###
    ### @param pkgs[Array<String,Int>] The packages to uninstall
    ###
    ### @return [void]
    ###
    def self.uninstall(rcpts, options)
      rcpts = [rcpts] if rcpts.is_a? String
      rcpts.each do |rcpt_to_remove|
        begin

          rcpt = D3::Client::Receipt.find_receipt rcpt_to_remove
          raise D3::UninstallError, "No receipt for '#{rcpt_to_remove}', can't uninstall." unless rcpt

          D3.log "Uninstalling #{rcpt.edition}...", :info
          rcpt.uninstall options.verbose, options.force
          D3.log "Finished uninstalling #{rcpt.edition}.", :info

        rescue JSS::MissingDataError, D3::UninstallError, JSS::InvalidDataError
          D3.log "Skipping uninstall of #{rcpt_to_remove}: #{$ERROR_INFO}", :error
          D3.log_backtrace
          next
        end # begin
      end # rcpts.each
    end # uninstall_manual

    ### Return a valid, possibly-default, admin name for
    ### installing a package. Since the admin name is stored in
    ### the packages in the puppy-q, use that one if it's there.
    ###
    ### @param pkg[D3::Package] the pkg being installed, which might contain an
    ###   admin name
    ###
    ### @return [String] a valid admin name to use for the install
    ###
    def self.get_admin(pkg_to_install, options)
      # is this puppy already in the queue? If so,
      # the queue has our admin_name
      if options.puppies && D3::PUPPY_Q.q[pkg_to_install.basename]

        admin = D3::PUPPY_Q.q[pkg_to_install.basename].admin
        admin ||= D3::DFT_PUPPY_ADMIN

      # not a puppy pkg
      else
        # start with the cli options
        admin = options.admin
        # then do a lookup if no cli option
        admin ||= D3.admin

      end # if @options.puppies

      admin
    end # get admin name

    ### Remove one or more puppies from the puppy queue
    ###
    ### @param puppies[String, Array<String>] the basenames to remove from the queue
    ###
    ### @return [void]
    ###
    def self.dequeue_puppies(puppies)
      puppies = [puppies] if puppies.is_a? String
      puppies = D3::PUPPY_Q.pups if puppies.include? 'all'
      puppies.each do |pup|
        unless the_puppy = D3::PUPPY_Q.q[pup]
          D3.log "No pkg for basename '#{pup}' in the puppy queue.", :warn
          next
        end # unless
        begin
          D3.log "Removing '#{the_puppy.edition}' from the puppy queue.", :warn
          D3::PUPPY_Q - the_puppy
        rescue
          D3.log "Couldn't remove #{the_puppy.edition} from the puppy queue: #{$ERROR_INFO}", :error
        end # begin
      end
    end

    ### Sync this machine
    ###
    ### @param options[OpenStruct] the options from the commandline
    ###
    ### @return [void]
    ###
    def self.sync(options = OpenStruct.new)
      D3::Client.set_env :sync
      D3.log 'Starting sync', :warn

      begin
        # update rcpts
        update_rcpts

        # clean out any invalid puppies from the queue
        clean_doghouse

        # install puppies now?
        do_puppy_queue_installs_from_sync options

        # updates/patches?
        update_installed_pkgs options

        # new auto-installs?
        do_auto_installs options

        # expirations
        do_expirations

        # removie receipts w/ missing packages on the server
        # This must happen AFTER update_installed_pkgs
        # so that the basename gets any updates on the server
        # before removing the recetip (which wouild prevent
        # updates)
        clean_missing_receipts

        D3.log 'Finished sync', :warn
      ensure
        D3::Client.unset_env :sync
      end
    end # def sync

    ### Update any receipt data that might be changed in the matching package
    ### on the server, including:
    ### - status
    ### - pre- or post-remove scripts
    ### - removability
    ### - prohibiting processes
    ### - expiration details
    ###
    ### Also update
    ### - last usage for expiring pkgs
    ###
    ### @return [void]
    ###
    def self.update_rcpts
      D3.log 'Updating receipts', :warn

      D3::Client::Receipt.all.each do |basename, rcpt|
        pkgdata = D3::Package.find_package rcpt.edition, :hash

        unless pkgdata
          # if this pkg is missing, mark it so...
          D3.log "Receipt '#{rcpt.edition}' is missing from d3. Updating receipt.", :info
          rcpt.status = :missing
          rcpt.update
          next
        end
        need_update = false

        # Are we rolling back to a prev version?
        # If the pkgdata[:status] is :pilot and the
        # rcpt.status is NOT :pilot, then we are.
        rolling_back = (pkgdata[:status] == :pilot) && (rcpt.status != :pilot)

        # status
        unless rolling_back
          if rcpt.status != pkgdata[:status]
            # update the status
            rcpt.status = pkgdata[:status]
            D3.log "Updating status for #{rcpt.edition} to #{pkgdata[:status]}", :info
            need_update = true
          end # if
        end # unless

        # pre-remove script
        if rcpt.pre_remove_script_id != pkgdata[:pre_remove_script_id]
          rcpt.pre_remove_script_id = pkgdata[:pre_remove_script_id]
          D3.log "Updating pre-remove script for #{rcpt.edition}", :info
          need_update = true
        end # if

        # post-remove script
        if rcpt.post_remove_script_id != pkgdata[:post_remove_script_id]
          rcpt.post_remove_script_id = pkgdata[:post_remove_script_id]
          D3.log "Updating post-remove script for #{rcpt.edition}", :info
          need_update = true
        end # if

        # removability
        if rcpt.removable? != pkgdata[:removable]
          rcpt.removable = pkgdata[:removable]
          D3.log "Updating removability for #{rcpt.edition}", :info
          unless rcpt.removable?
            rcpt.expiration = 0
            D3.log "#{rcpt.edition} is not expirable now that it's not removable", :info
          end
          need_update = true
        end # if

        # expiration
        if rcpt.removable?

          unless rcpt.expiration_paths_match? pkgdata[:expiration_paths]
            rcpt.expiration_paths = pkgdata[:expiration_paths]
            D3.log "Updating expiration path(s) for #{rcpt.edition}", :info
            need_update = true
          end # if

          if (rcpt.expiration != pkgdata[:expiration].to_i) && !rcpt.custom_expiration
            rcpt.expiration = pkgdata[:expiration].to_i
            D3.log "Updating expiration for #{rcpt.edition}", :info
            need_update = true
          end # if
        end # if removable

        # prohibiting_processes
        # make sure they are all arrays as of v3.0.12
        unless rcpt.prohibiting_processes.is_a? Array
          rcpt.prohibiting_processes = JSS.to_s_and_a(rcpt.prohibiting_processes)[:arrayform]
          need_update = true
        end
        if rcpt.prohibiting_processes.sort != pkgdata[:prohibiting_processes].sort
          rcpt.prohibiting_processes = pkgdata[:prohibiting_processes]
          D3.log "Updating prohibiting_processes for #{rcpt.edition}", :info
          need_update = true
        end # if

        rcpt.update if need_update
      end # each do basename, rcpt
    end # update_rcpts

    ### remove any invalid puppies from the queue
    ### invalid = id is no longer in d3, or status is missing
    ###
    ### @return [void]
    ###
    def self.clean_doghouse
      D3.log 'Checking for invalid puppies in the queue', :warn
      D3::PUPPY_Q.pending_puppies.each do |basename, pup|
        unless D3::Package.all_ids.include? pup.id
          D3.log "Removing #{pup.edition} from puppy queue: no longer in d3", :info
          D3::PUPPY_Q - pup
          next
        end
        if D3::Package.missing_data.keys.include? pup.id
          D3.log "Removing #{pup.edition} from puppy queue: status is 'missing'", :info
          D3::PUPPY_Q - pup
        end
      end
    end

    ### Install any new live pkgs scoped for autoinstall on this machine.
    ###
    ### @param options[OpenStruct] the options from the commandline
    ###
    ### @return [void]
    ###
    def self.do_auto_installs(options)
      verbose = options.verbose
      force = options.force || D3.forced?
      D3.log 'Checking for new packages to auto-install', :warn
      D3::Client.set_env :auto_install
      begin # for ensure below
        D3::Client::Receipt.basenames :refresh

        # loop through the groups for this machine
        auto_groups = D3::Client.computer_groups.dup
        auto_groups.unshift D3::STANDARD_AUTO_GROUP
        auto_groups.each do |group|
          # this is the intersection of all pkg ids that get auto-installed
          # for the group, and all live pkg ids...
          # meaning this machine should have these pkg ids.
          live_ids_for_group = (D3::Package.live_data.keys & D3::Package.auto_install_ids_for_group(group))

          live_ids_for_group.each do |live_id|
            # skip those not available
            next unless available_pkg_ids.include? live_id

            auto_install_basename = D3::Package.live_data[live_id][:basename]

            # skip if this basename is installed already - it'll be handled with
            # the update_installed_pkgs method during sync.
            next if D3::Client::Receipt.all.keys.include? auto_install_basename

            new_pkg = D3::Package.fetch id: live_id

            if new_pkg.reboot?
              queued_id = puppy_in_queue new_pkg.basename
              if queued_id && queued_id >= new_pkg.id
                D3.log "Skipping auto-install of puppy-package #{new_pkg.edition}, there's a newer one in the queue already", :info
                next
              end # if queued_id && queued_id >= new_pkg.id
            end #  if new_pkg.reboot?

            begin
              D3.log "Auto-installing #{new_pkg.basename} for group '#{group}'", :info
              cloud = cloud_dist_point_to_use(pkg: new_pkg)
              new_pkg.install(
                admin: D3::AUTO_INSTALL_ADMIN,
                verbose: verbose,
                force: force,
                puppywalk: options.puppies,
                alt_download_url: cloud
              )
              D3.log "Auto-installed #{new_pkg.basename}", :warn
            rescue JSS::MissingDataError, JSS::InvalidDataError, D3::InstallError
              D3.log "Skipping auto-install of #{new_pkg.edition}: #{$ERROR_INFO}", :error
              D3.log_backtrace
            rescue D3::PreInstallError
              D3.log "There was an error with the pre-install script for #{new_pkg.edition}: #{$ERROR_INFO}", :error
              D3.log_backtrace
            rescue D3::PostInstallError
              D3.log "There was an error with the post-install script for #{new_pkg.edition}: #{$ERROR_INFO} NOTE: #{new_pkg.edition} was installed, but may not work.", :error
              D3.log_backtrace
            end # begin
          end # live_ids_for_group.each do |live_id|
        end # each group
      ensure
        D3::Client.unset_env :auto_install
      end
    end

    ### remove any receipts for packages that are missing from the server
    ###
    ###
    def self.clean_missing_receipts
      D3.log 'Checking for receipts no longer in d3', :warn
      D3::Client::Receipt.all.values.select { |r| r.status == :missing }.each do |mrcpt|
        D3.log "Removing receipt for missing edition #{mrcpt.edition}", :info
        D3::Client::Receipt.remove_receipt mrcpt.basename
        D3.log "Removed receipt for missing edition #{mrcpt.edition}", :info
      end
    end

    ### Update any currently installed basenames to the currently live one
    ### skipping any basenames currently frozen
    ###
    ### @param options[OpenStruct] the options from the commandline
    ###
    ### @return [void]
    ###
    def self.update_installed_pkgs(options)
      verbose = options.verbose
      force = options.force || D3.forced?
      D3.log 'Checking for updates to installed packages', :warn
      D3::Client.set_env :auto_update
      begin # see ensure below

        # get the current list of live basenames and the ids of the live editions
        live_basenames_to_ids = D3::Package.basenames_to_live_ids

        # loop through the install pkgs
        D3::Client::Receipt.all.values.each do |rcpt|
          # is there a live pkg for this basename?
          if live_basenames_to_ids[rcpt.basename]
            live_id = live_basenames_to_ids[rcpt.basename]
            live_pkg_data = D3::Package.package_data[live_id]
          else
            D3.log "Skipping update for #{rcpt.edition}: no currently live package for basename", :info
            next
          end

          # are we rolling back? Not if the recpt is in pilot.
          if live_pkg_data[:id] < rcpt.id

            if rcpt.pilot?
              D3.log "Skipping rollback of #{live_pkg_data[:edition]}, #{rcpt.edition} is in pilot", :info
              next
            else
              rollback = true
            end
          # no we aren't rolling back
          else
            # skip unless the live id is higher than the rcpt id
            unless live_pkg_data[:id] > rcpt.id
              D3.log "No update for #{rcpt.edition}", :debug
              next
            end
          end

          # skip any frozen receipts
          if rcpt.frozen?
            D3.log "Skipping update check for #{rcpt.edition}(#{rcpt.status}): currently frozen on this machine.", :warn
            next
          end

          # check the puppy queue if needed
          if live_pkg_data[:reboot]
            queued_id = puppy_in_queue(live_pkg_data[:basename])
            if queued_id && queued_id >= live_pkg_data[:id]
              D3.log "Skipping auto-update of puppy-queue item #{live_pkg_data[:edition]}, there's a newer one in the queue already", :info
              next
            end # if queued_id && queued_id >= live_pkg.id
          end #  if live_pkg.reboot?

          # mention rollbacks
          if rollback
            D3.log "Rolling back #{rcpt.edition} (#{rcpt.status}) to older live #{live_pkg_data[:edition]}.", :warn
          else
            D3.log "Updating #{rcpt.edition} (#{rcpt.status}) to #{live_pkg_data[:edition]} (#{live_pkg_data[:status]})", :warn
          end

          # are we bringing over a custom expiration period?
          expiration = rcpt.custom_expiration ? rcpt.expiration : nil

          # heres the pkg
          live_pkg = D3::Package.fetch id: live_basenames_to_ids[rcpt.basename]

          begin
            cloud = cloud_dist_point_to_use(pkg: live_pkg)
            live_pkg.install(
              admin: rcpt.admin,
              expiration: expiration,
              verbose: verbose,
              force: force,
              puppywalk: options.puppies,
              alt_download_url: cloud
            )
            D3.log "Done updating #{rcpt.edition} (#{rcpt.status}) to #{live_pkg.edition} (#{live_pkg.status})", :info
          rescue JSS::MissingDataError, JSS::InvalidDataError, D3::InstallError
            D3.log "Skipping update of #{rcpt.edition} to #{live_pkg.edition}: #{$ERROR_INFO}", :error
            D3.log_backtrace
          rescue D3::PreInstallError
            D3.log "There was an error with the pre-install script for #{live_pkg.edition}: #{$ERROR_INFO}", :error
            D3.log_backtrace
          rescue D3::PostInstallError
            D3.log "There was an error with the post-install script for #{live_pkg.edition}: #{$ERROR_INFO} NOTE: #{live_pkg.edition} was installed, but may not work.", :error
            D3.log_backtrace
          end # begin
        end # D3::Client::Receipt.all.values.each
      ensure
        D3::Client.unset_env :auto_update
      end # begin..ensure
    end # update installed pkgs

    ### Freeze one or more receipts
    ###
    ### @param basenames[Array] the basenames of the rcpts to freeze
    ###
    ### @return [void]
    ###
    def self.freeze_receipts(basenames)
      basenames.each do |bn|
        rcpt = D3::Client::Receipt.all[bn]
        next unless rcpt
        if rcpt.frozen
          D3.log "Can't freeze receipt for #{rcpt.edition}: already frozen.", :warn
          next
        end
        rcpt.freeze
        rcpt.update
        D3.log "Freezing receipt for #{rcpt.edition}, will not auto-update during sync", :warn
      end
    end # freeze receipts

    ### Thaw one or more receipts
    ###
    ### @param basenames[Array] the basenames of the rcpts to thaw
    ###
    ### @return [void]
    ###
    def self.thaw_receipts(basenames)
      basenames.each do |bn|
        rcpt = D3::Client::Receipt.all[bn]
        next unless rcpt
        unless rcpt.frozen
          D3.log "Can't thaw receipt for #{rcpt.edition}: not frozen.", :warn
          next
        end
        rcpt.thaw
        rcpt.update
        D3.log "Thawing receipt for #{rcpt.edition}, will resume auto-update during sync", :warn
      end
    end # thaw_receipts

    ### forget one or more receipts, and their matching apple pkg receipts
    ###
    ### @param basenames[Array] the basenames of the rcpts to forget
    ###
    ### @return [void]
    ###
    def self.forget_receipts(basenames)
      basenames.each do |bn|
        rcpt = D3::Client::Receipt.all[bn]
        next unless rcpt
        rcpt.apple_pkg_ids.each { |ar| system "/usr/sbin/pkgutil --forget '#{ar}'" }
        D3::Client::Receipt.remove_receipt bn
        D3.log "Receipt for #{rcpt.edition} has been forgotten", :warn
      end
    end # thaw_receipts

    ### Do any pending puppy installs right now, because we're
    ### syncing and --puppies option was given
    ###
    def self.do_puppy_queue_installs_from_sync(options)
      return unless options.puppies
      return if D3::PUPPY_Q.q.empty?
      D3.log 'Installing all pkgs from puppy-queue during sync with --puppies', :info
      D3::PUPPY_Q.q.each do |basename, puppy|
        begin
          D3.log "Installing #{puppy.edition} from puppy-queue during sync with --puppies", :debug
          new_pkg = D3::Package.fetch id: puppy.id
          cloud = cloud_dist_point_to_use(pkg: new_pkg)
          new_pkg.install(
            admin: puppy.admin,
            verbose: options.verbose,
            force: puppy.force,
            puppywalk: true,
            expiration: puppy.expiration,
            alt_download_url: cloud
          )

          D3::PUPPY_Q - puppy
        rescue JSS::NoSuchItemError
          D3.log "Skipping install of #{new_pkg.edition} from queue:\n   no longer in d3.", :error
          D3.log_backtrace
          D3::PUPPY_Q - puppy
        rescue JSS::MissingDataError, JSS::InvalidDataError, D3::InstallError
          D3.log "Skipping install of #{new_pkg.edition} from queue: #{$ERROR_INFO}", :error
          D3.log_backtrace
        rescue D3::PreInstallError
          D3.log "There was an error with the pre-install script for #{new_pkg.edition}: #{$ERROR_INFO}", :error
          D3.log_backtrace
        rescue D3::PostInstallError
          D3.log "There was an error with the post-install script for #{new_pkg.edition}: #{$ERROR_INFO} NOTE: #{new_pkg.edition} was installed, but may not work.", :error
          D3.log_backtrace
          D3::PUPPY_Q - puppy
        end # begin
      end # each do puppy
    end

    ### Was the --no-puppy-notification option given by the admin?
    ###
    ### @return [Boolean]
    ###
    def self.puppy_notification_ok_with_admin?
      @@puppy_notification_ok_with_admin
    end

    ### Set the --no-puppy-notification option as given by the admin
    ###
    ### @param bool[Boolean] did the admin give the option?
    ###
    ### @return [void]
    ###
    def self.puppy_notification_ok_with_admin= (bool)
      @@puppy_notification_ok_with_admin = bool
    end

    ### Expire any pkgs that are due for expiration,
    ### and if any are expired, run the expiration policy.
    ###
    ### @param verbose[Boolean] should operations be verbose?
    ###
    ### @param force[Boolean] should operations be forced?
    ###
    ### @return [void]
    ###
    def self.do_expirations (verbose = false, force = D3.forced?)
      @@editions_expired = []
      D3.log 'Starting expiration check', :warn

      D3::Client::Receipt.all.values.each do |rcpt|
        begin
          # rcpt.expire only does anything if expiration is appropriate.
          expired_edition = rcpt.expire verbose, force
          @@editions_expired << expired_edition if expired_edition
        rescue
          D3.log "There was an error expiring #{rcpt.edition}: #{$ERROR_INFO}", :error
          D3.log_backtrace
        end
      end

      return true if @@editions_expired.empty?

      D3::Client.set_env :finished_expirations, @@editions_expired.join(' ')

      if policy = D3::CONFIG.client_expiration_policy
        D3.run_policy policy, :expiration, verbose
      end # if D3::CONFIG.client exp policy

      D3::Client.unset_env :finished_expirations
      @@editions_expired = []
    end

    ### An array of package ids that are available for installing
    ### or piloting (i.e. not excluded, right OS, right cpu) for this machine.
    ###
    ### @param refresh[Boolean] re-read the data from the server?
    ###
    ### @return [Array<Integer>]
    ###
    def self.available_pkg_ids(refresh = false)
      @@available_pkg_ids = nil if refresh
      return @@available_pkg_ids if @@available_pkg_ids

      computer_groups(:refresh) if refresh

      my_cpu = `/usr/bin/uname -p`
      my_os = `/usr/bin/sw_vers -productVersion`.chomp

      @@available_pkg_ids = []

      D3::Package.package_data.values.each do |pkg|
        next unless JSS.os_ok? pkg[:oses], my_os
        next unless JSS.processor_ok? pkg[:required_processor], my_cpu
        @@available_pkg_ids << pkg[:id] if (pkg[:excluded_groups] & computer_groups).empty?
      end # do pkg
      @@available_pkg_ids
    end

    ### An array of JSS::ComputerGroup names to which this computer belongs
    ###
    ### @param refresh[Boolean] re-read the data from the server?
    ###
    ### @return [Array<String>] the JSS groups to which this machine belongs
    ###
    def self.computer_groups(refresh = false)
      @@computer_groups = nil if refresh
      return @@computer_groups if @@computer_groups
      @@computer_groups = JSS::Computer.fetch(udid: JSS::Client.udid).computer_groups
    end

    ### The cloud dist point to use for installs
    ### Returns a URL if:
    ###   - current dist point isn't reachable for downloads
    ###   - D3::CONFIG.client_try_cloud_distpoint is true
    ###   - a Cloud Dist Point is defined in the JSS
    ###
    ### otherwise nil
    ###
    ### @return [String, nil] The download url for the cloud dist point,
    ###   if we should use one, or nil.
    ###
    def self.cloud_dist_point_to_use(refresh = false, pkg: nil)
      raise 'You must provide a pkg' unless pkg.is_a? D3::Package

      @@cloud_dist_url == :unknown if refresh

      mdp = JSS::DistributionPoint.my_distribution_point

      # Should we try a cloud distribution point if the primary is unavailable ?
      unless D3::CONFIG.client_try_cloud_distpoint
        D3.log "Config is not to try cloud, using only Distribution Point '#{mdp.name}'", :info
        return @@cloud_dist_url = nil
      end

      # Can we reach the primary distribution point? Return nil if true.
      if mdp.reachable_for_download?(get_ro_pass(:http)) || mdp.reachable_for_download?(get_ro_pass(:dist))
        D3.log "Distribution Point '#{mdp.name}' is reachable, no need for cloud", :info
        return @@cloud_dist_url = nil
      end

      # Get the cloud distribution point defined in the JSS
      cloud_url = cloud_distribution_point_url
      unless cloud_url
        D3.log 'No cloud distribution URL found.', :info
        return @@cloud_dist_url = nil
      end
      # Make sure the package is available on the cloud distribution point
      pkg_available = validate_pkg_in_cloud(cloud_url, pkg) if cloud_url
      unless pkg_available
        D3.log "#{pkg.edition} is not available in the cloud", :info
        return @@cloud_dist_url = nil
      end

      @@cloud_dist_url = cloud_url
    end # self.cloud_dist_point_to_use

    # given a cloud url and a D3::Package
    # is the pkg available at that url?
    #
    # @return [Boolean]
    #
    def self.validate_pkg_in_cloud(url, pkg)
      full_url = "#{url}/#{pkg.filename}"
      dummy_install_target = Pathname.new('/Volumes/' + Time.now.asctime)
      jamf_cmd = "#{JSS::Client::JAMF_BINARY} install -package #{Shellwords.escape pkg.filename} -path #{Shellwords.escape full_url} -target #{Shellwords.escape dummy_install_target}/ -showProgress -verbose"
      Open3.popen2e(jamf_cmd) do |_stdin, stdout_err, wait_thr|
        stdout_err.each do |line|
          if /^\d*+\.\d*+% / =~ line
            Process.kill('KILL', wait_thr.pid)
            return true
          end # end if /^\d*+\.\d*+% / =~ line
        end # end stdout_err.each do |line|
        return false
      end # end Open3.popen2e(jamf_cmd) do |_stdin, stdout_err, wait_thr|
    end # def self.validate_pkg_in_cloud pkg

    ### Is a Cloud Distribution Point available for pkg downloads?
    ### If so, return the url for downloading pkg files
    ### (the filename will be appended during install)
    ###
    ### @return [String, nil]
    ###
    def self.cloud_distribution_point_url
      result = JSS::DB_CNX.db.query 'SELECT download_url, cdn_url FROM cloud_distribution_point'
      urls = result.fetch
      result.free
      return nil if urls.nil?
      return nil if urls[0].empty? || urls[1].empty?
      urls[0].empty? ? urls[1] : urls[0]
    end

    ### Given a basename, is any edition of it in the puppy queue?
    ### If so, return the id of the queued pkg, else return nil
    ###
    ### @param basename[String]
    ###
    ### @return [Integer, nil] The id of the queued package for the basename, if any
    ###
    def self.puppy_in_queue(basename)
      pup = D3::PUPPY_Q.queue[basename]
      return nil unless pup
      pup.id
    end # basename in puppy queue

    ### get the executable path of the current foreground GUI app. NOTE, if you
    ### have fast user switching on, or multi-user screensharing,
    ### this only gets the one currenly using the physical console
    ###
    ### @return [Pathname, nil] the path to the executable of the current foreground app, nil if none
    ###
    def self.foreground_executable_path
      lsai = '/usr/bin/lsappinfo'
      ls_app_id = `#{lsai} front`.chomp
      return nil if ls_app_id == '[ NULL ] '

      raw = `#{lsai} info -only executablepath '#{ls_app_id}'`.chomp
      return nil if raw.empty?

      path = raw.split(/=\s*"/).last
      return nil unless path
      path.chomp!('"')
      path.empty? ? nil : Pathname.new(path)
    end

  end # class

end # module D3
