#!/usr/bin/ruby
### Copyright 2016 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.
###
###

# == Synopsis
#  d3admin - a commandline tool for creating and maintaining d3 pkgs in Jamf Pro
#
# == Usage
#   d3admin [options] action target....
#
#   For help use: d3admin help
#
# == Author
#   Chris Lasell <d3@pixar.com>
#
# == Copyright
#   Copyright (c) 2016 Pixar Animation Studios.

############
# Modules, Libraries, etc
############

# Load libraries
require 'd3'

######################
# The Script Object
######################
class App

  ### Setup
  def initialize
    ### parse the commandline
    parse_commandline

    # can't  be run as root other than help, search or report - no keychain, among other things
    raise "d3admin can't make server changes as root" if JSS.superuser? && (!@action =~ /^[hsr]/)
  end # initialize

  ### Run
  def run
    if @action == 'help'
      show_help @options.helptype
      return
    end

    # run config if it hasn't ever run
    unless D3::Admin::Prefs.prefs[:last_config] || JSS.superuser?
      puts
      puts '********  INITIAL D3ADMIN CONFIGURATION  ********'
      puts
      config
      # but dont run it again if that's the action chosen
      return if @action =~ /^c/
    end

    ### our admin is us
    @admin = @options.admin || ENV['USER']

    # admin can't be a badmin
    if (D3.badmins.include? @admin) && (D3::Admin::ACTIONS_NEEDING_ADMIN.include? @action)
      raise D3::PermissionError, "d3admin cannot do '#{@action}' as #{@admin}."
    end

    # config before connecting
    if @action =~ /^c/ && !JSS.superuser?
      config
      return
    end

    ### connect to the server, prompting for info as needed
    if @admin == 'root'
      D3::Client.connect
    else
      D3::Admin::Auth.connect
    end

    case @action

    when /^a/ then
      add_pilot_package

    when /^e/ then
      edit_package

    when /^l/ then
      make_package_live

    when /^d/ then
      delete_package

    when /^i/ then
      show_package_info

    when /^s/ then
      search

    when /^r/ then
      show_report

    else
      raise ArgumentError, "#{D3::Admin::Help::USAGE}\nUnknown action, must be one of #{D3::Admin::ACTIONS.join(', ')}"

    end # case
  end # run

  ### Parse the command line
  ###
  ### @return [void]
  ###
  def parse_commandline
    # Debugging file? if so, always set debug.
    ARGV << '--debug' if D3::DEBUG_FILE.exist?

    # this holds everything that comes from the commandline
    # or from prompting the user
    @options = OpenStruct.new

    # cli option defaults
    @options.helptype = :help

    # --status can be given multiple times, or can be a comma-separated list.
    # the results end up in this array
    @options.status = []

    ### see Admin::CLIOpts
    opt_arry = D3::Admin::OPTIONS.values.map { |o| o[:cli] }
    opts = GetoptLong.new(*opt_arry)

    opts.each do |opt, arg|
      case opt

      # General
      when '--help'
        @action = 'help'
        @options.helptype = :help
        break
      when '--extended-help'
        @action = 'help'
        @options.helptype = :extended_help
        break
      when '--debug'
        D3::Admin.debug = true
      when '--d3-version'
        @action = 'help'
        @options.helptype = :show_d3_version
        break
      when '--walkthru'
        @options.walkthru = true
      when '--auto-confirm'
        @options.auto_confirm = true
      when '--admin'
        @options.admin = arg

      # Search and Report
      when '--status'
        @options.status +=  arg.split(/,\s*/)
      when '--queue'
        @options.report_q = true
      when '--frozen'
        @options.report_frozen = true
      when '--computers'
        @options.report_computers = true
      when '--groups'
        @options.search_groups = true

      # Add/Edit
      when '--import'
        @options.import = true
        @options.import_from = arg.empty? ? nil : arg
      when '--no-inherit'
        @options.no_inherit = true
      when '--basename'
        @options.basename = arg
      when '--version'
        @options.version = arg
      when '--revision'
        @options.revision = arg
      when '--description'
        @options.description = arg
      when '--package-name'
        @options.package_name = arg
      when '--filename'
        @options.filename = arg
      when '--edition'
        @options.edition = arg
      when '--source-path'
        @options.source_path = arg
      when '--dmg'
        @options.package_build_type = 'd'
      when '--preserve-owners'
        @options.pkg_preserve_owners = 'y'
      when '--pkg-id'
        @options.pkg_identifier = arg
      when '--workspace'
        @options.workspace = arg
      when '--pre-install'
        @options.pre_install = arg
      when '--post-install'
        @options.post_install = arg
      when '--pre-remove'
        @options.pre_remove = arg
      when '--post-remove'
        @options.post_remove = arg
      when '--auto-groups'
        @options.auto_groups = arg
      when '--excluded-groups'
        @options.excluded_groups = arg
      when '--prohibiting-processes'
        @options.prohibiting_processes = arg
      when '--cpu_type'
        @options.cpu_type = arg
      when '--category'
        @options.category = arg

      when '--reboot'
        # dft is no, so if arg is empty or /^n(o)$/i, it should be nil,
        # otherwise must be /^y(es)$/i
        if arg.empty? || arg =~ /^no?$/i
          @options.reboot =  'n'
        elsif arg =~ /^y(es)?$/i
          @options.reboot =  'y'
        else
          raise ArgumentError, "--reboot must be 'y' or 'n'"
        end

      when '--remove-first'
        # dft is no, so if arg is empty or /^n(o)$/i, it should be nil,
        # otherwise must be /^y(es)$/i
        if arg.empty? || arg =~ /^no?$/i
          @options.remove_first =  'n'
        elsif arg =~ /^y(es)?$/i
          @options.remove_first =  'y'
        else
          raise ArgumentError, "--remove-first must be 'y' or 'n'"
        end

      when '--removable'
        # dft is yes, so if arg is empty or /^y(es)$/i, it should be nil,
        # otherwise must be  /^n(o)$/i
        if arg.empty? || arg =~ /^y(es)?$/i
          @options.removable =  'y'
        elsif arg =~ /^no?$/i
          @options.removable =  'n'
        else
          raise ArgumentError, "--removable must be 'y' or 'n'"
        end

      when '--oses'
        @options.oses = arg
      when '--expiration'
        @options.expiration = arg
      when '--expiration-path', '--expiration_paths'
        @options.expiration_paths = arg

      # Delete
      when '--keep-scripts'
        @options.keep_scripts = 'y'
      when '--keep-in-jss'
        @options.keep_in_jss = 'y'

      end # case
    end # opts.each
    return if @action == 'help'

    # the action is always the first thing in ARGV
    # after the options have been removed
    action_arg = ARGV.shift

    unless action_arg
      raise ArgumentError, "#{D3::Admin::Help::USAGE}\nAction, must be one of #{D3::Admin::ACTIONS.join(', ')}"
    end

    # one or more letters is all we need to specify the action...
    @action = D3::Admin::ACTIONS.select { |a| a.start_with? action_arg }.first

    unless @action && (D3::Admin::ACTIONS.include? @action)
      raise ArgumentError, "#{D3::Admin::Help::USAGE}\nAction, must be one of #{D3::Admin::ACTIONS.join(', ')}"
    end

    # anything remaining in ARGV is one or more targets
    # to work on.
    @targets = ARGV
  end # parse_commandline

  ### Show the help message
  ###
  ### @return [void]
  ###
  def show_help(type)
    text = case type
           when :help
             D3::Admin::Help.help_text
           when :extended_help
             D3::Admin::Help.extended_help_text
           when :show_d3_version
             d3_version_text
           end
    D3.less_text text
    true
  end # show help

  ### the d3 version text to spew
  ###
  ### @return [String] the text
  def d3_version_text
    <<-ENDVERS
D3 module version: #{D3::VERSION}
JSS module version: #{JSS::VERSION}
ENDVERS
  end

  ### Add a new pilot package from an existing
  ### JSS package
  def import_pilot_pkg_from_jss
    if @options.walkthru
      @options.import_from = D3::Admin::Interactive.get_value :import unless @options.import_from
      @options.version = D3::Admin::Interactive.get_value :version
      @options.revision = D3::Admin::Interactive.get_value :revision
    else
      raise JSS::MissingDataError, 'A version must be provided with --version' unless @options.version
      raise JSS::MissingDataError, 'A revision must be provided with --revision' unless @options.revision
    end # if walk thru

    imported_pkg = D3::Package.import(@options.import_from,
                                      basename: @options.basename,
                                      version: @options.version,
                                      revision: @options.revision,
                                      dist_pw: D3::Admin::Auth.rw_credentials(:dist)[:password])
    imported_pkg.admin = @admin
    edit_package imported_pkg
  end

  ### Add a new package to d3 for piloting
  ### In this method, the following are OpenStructs:
  ### - @options holds the value from the commandline
  ### - default_options holds the default values, either as
  ###     defined in D3::Admin::OPTIONS or inherited
  ###     from an older package
  ### - new_package_options holds the validated values for
  ###     making the new package
  ###
  ### @return [void]
  ###
  def add_pilot_package
    ## we must have a basename before going farther
    @options.basename = @targets.first
    if @options.walkthru
      @options.basename ||= D3::Admin::Interactive.get_basename
    else
      raise ArgumentError, 'A basename must be provided as a target. Use -H for help' unless @options.basename
    end

    if @options.import
      import_pilot_pkg_from_jss
      return
    end

    puts 'Adding a new pilot package to d3...'

    # this holds the validated data used for making the new pkg
    new_package_options = OpenStruct.new

    # now figure out our default values..
    default_options = D3::Admin::Add.get_default_options(@options.basename, @options.no_inherit)

    # get all new package options into new_package_options
    #  via walkthru ...
    if @options.walkthru
      new_package_options = D3::Admin::Add.loop_thru_add_walkthru default_options

    # via the commandline
    else

      # if we were given a new version but not a new revision,
      # set the new revision to 1
      if @options[:version] && @options[:version] !=  default_options[:version]
        @options[:revision] ||= 1
        @options[:edition] ||=  "#{@options.basename}-#{@options[:version]}-#{@options[:revision]}"
        @options.package_name ||= @options[:edition]
        @options.filename ||= "#{@options[:edition]}.#{default_options.package_build_type}"

      end

      # for any options that weren't given on the commandline, use the defaults
      default_options.each_pair { |opt, val| @options[opt] ||= val }

      new_package_options = D3::Admin::Add.add_pilot_cli(@options)

      ##########################

    end # if @options.walkthru

    new_package_options.edition = "#{new_package_options.basename}-#{new_package_options.version}-#{new_package_options.revision}"

    # make a summary of the new values to display for confirmation, if no walkthru
    unless @options.walkthru
      lines = []

      opts_to_confirm = D3::Admin::Add::NEW_PKG_OPTIONS
      opts_to_confirm += D3::Admin::Add::BUILD_OPTIONS if new_package_options.package_build_type
      opts_to_confirm += D3::Admin::Add::PKG_OPTIONS if new_package_options.package_build_type == :pkg

      # D3::Admin::Add::NEW_PKG_OPTIONS.each do |opt|
      opts_to_confirm.each do |opt|
        lbl = D3::Admin::OPTIONS[opt][:label]
        if D3::Admin::OPTIONS[opt][:display_conversion]
          disp = D3::Admin::OPTIONS[opt][:display_conversion].call(new_package_options[opt])
        else
          disp = new_package_options[opt]
        end
        lines << "#{lbl}: #{disp}"
      end
      settings_conf_disp = "\n******* New d3 Package Settings *******\n"
      settings_conf_disp += "Edition: #{new_package_options.edition}\n"
      settings_conf_disp += "Basename: #{new_package_options.basename}\n"
      settings_conf_disp +=  lines.join "\n"
      settings_conf_disp +=  "\n"
      puts settings_conf_disp
    end

    # Get confirmation
    confirm "Create a new package '#{new_package_options.edition}' in d3\nwith settings shown above."

    D3::Admin::Add.add_new_package new_package_options

    puts 'Done!'
    puts "To pilot it, run 'sudo d3 install #{new_package_options.edition}' on a test machine."
    puts "To make it live, run 'd3admin live #{new_package_options.edition}' on your machine."
  end # add pilot

  ### Make a package live.
  ###
  ### @return [void]
  ###
  def make_package_live
    pkg_id = get_pkg_from_cli_or_prompt

    if pkg_id.nil?
      puts 'No edition given to make live or no matching package found'
      return
    end
    pkg = D3::Package.fetch id: pkg_id

    if pkg.status == :live
      puts "Doh, '#{pkg.edition}' is already live"
      return
    end

    # is this a rollback?
    rollback_warning = ''
    if pkg.skipped? || pkg.deprecated?
      rollback_warning = "\n\nWARNING: You're rolling back to an older edition!\n"
      rollback_warning += "ALL non-frozen installs of '#{pkg.basename}' will be downgraded to this edition! \n"
    end

    # is the prev. live pkg in use in any policies?
    policy_warning = ''
    pkg_id_being_deprecated = D3::Package.basenames_to_live_ids[pkg.basename]
    if pkg_id_being_deprecated
      outgoing_pkg = D3::Package.fetch(id: pkg_id_being_deprecated)
      pols_used_by_old_pkg = outgoing_pkg.policy_ids
      unless pols_used_by_old_pkg.empty?
        names = pols_used_by_old_pkg.map { |pid| JSS::Policy.map_all_ids_to(:name)[pid] }.join(', ')
        policy_warning = "\n\nWARNING: the current live package is in use by these Jamf Pro Policies:\n"
        policy_warning += " #{names}\n"
        policy_warning += 'You might want to update them to use the new live package.'
      end # unless empty
    end # if pkg_id_being_deprecated

    confirm "Make #{pkg.edition} live for basename '#{pkg.basename}'#{rollback_warning}#{policy_warning}", ''

    pkg.make_live @admin

    puts 'Done!'
    puts "New installs of basename '#{pkg.basename}' will get #{pkg.version}-#{pkg.revision}"
    puts 'Existing installs will be updated at the next d3 sync.'
  end

  ### Edit an existing or importing package
  ###
  ### @param pkg[D3::Package, nil] A possibly unsaved D3::Package to edit.
  ###   This is usually used for importing JSS pkgs into d3.
  ###
  ### @return [void]
  ###
  def edit_package(pkg = nil)
    unless pkg
      pkg_id = get_pkg_from_cli_or_prompt
      pkg = pkg_id ? D3::Package.fetch(id: pkg_id) : nil
    end
    if pkg.nil?
      puts 'No targets given to edit or no matching package found'
      return
    end

    if @options.walkthru
      changes_to_make = D3::Admin::Edit.loop_thru_editing_walkthru pkg
    else
      changes_to_make = D3::Admin::Edit.validate_cli_edits(@options)
    end # if @options.walkthru

    # Confirm the edition is still good if vers or rev changed
    changes_to_make = D3::Admin::Edit.check_new_edition pkg, changes_to_make

    # Confirm the auto and excl groups don't conflict with each other
    D3::Admin::Edit.check_for_new_group_overlaps pkg, changes_to_make

    # exit if no changes on edit
    if changes_to_make.empty? && !@options.import
      puts 'No changes to make!'
      return
    end

    # confirm
    if @options.import
      confirm_heading = "Import #{pkg.edition}, JSS id #{pkg.id}, into d3"
      conf_deets = 'with these non-default settings...'
    else
      confirm_heading = "Make changes to #{pkg.edition}, JSS id #{pkg.id}"
      conf_deets = 'Here are the changes...'
    end

    D3::Admin::Edit::EDITING_OPTIONS.each do |opt|
      next unless changes_to_make.keys.include? opt
      new_val = changes_to_make[opt]
      opt_def = D3::Admin::OPTIONS[opt]
      label = opt_def[:label]
      if opt_def[:display_conversion]
        new_val_display = opt_def[:display_conversion].call(new_val)
      else
        new_val_display = new_val
      end
      new_val_display = new_val_display.to_s.empty? ? 'none' : new_val_display
      conf_deets += "\n#{label}: #{new_val_display}"
    end
    confirm confirm_heading, conf_deets

    # Save if importing
    pkg.save if @options.import

    # make the changes
    D3::Admin::Edit.process_edits pkg, changes_to_make

    puts @options.import ? 'Done: the package has been imported to d3.' : 'Done! Your changes have been saved.'
  end # edit_pkg

  ### delete a pkg, optionallay archiving it
  ###
  ### @return [void]
  ###
  def delete_package
    pkg_id = get_pkg_from_cli_or_prompt

    if pkg_id.nil?
      puts 'No targets given to delete or no matching package found'
      return
    end

    # check to see if the pkg is missing
    if D3::Package.package_data[pkg_id][:status] == :missing || (!JSS::Package.all_ids.include? pkg_id)
      delete_missing_package pkg_id
      return
    else
      pkg = D3::Package.fetch id: pkg_id
    end

    got_scripts = !pkg.script_ids.values.empty?

    if got_scripts

      if @options.walkthru
        @options.keep_scripts = D3::Admin::Interactive.get_value(:get_keep_scripts, default = 'n', check_method = :validate_yes_no)

        @options.keep_in_jss = D3::Admin::Interactive.get_value(:get_keep_in_jss, default = 'n', check_method = :validate_yes_no)
      end # if @options.walkthru

      if @options.keep_scripts
        deets = ' - Keeping pre- or post- scripts in the JSS'
      else
        deets = ' - Deleting pre- or post- scripts not used elsewhere'
      end # if @options.keep_scripts

    else
      deets = ' - No pre- or post- scripts to delete'
    end # if got_scripts

    if @options.keep_in_jss
      deets += "\n - Leaving the Jamf Pro package in the JSS"
    else
      deets += "\n - Deleting the Jamf Pro package as well as the d3 data"
    end

    confirm "DELETE #{pkg.edition}, JSS id #{pkg.id}\nDist.Point Filename: #{pkg.filename}", deets

    if @options.keep_scripts && got_scripts
      puts 'Scanning for other packages or policies using  pre- or post- scripts before deleting...'
    end

    script_actions = pkg.delete(
      admin: @admin,
      keep_scripts: @options.keep_scripts,
      keep_in_jss: @options.keep_in_jss,
      rwpw: D3::Admin::Auth.rw_credentials(:dist)[:password]
    )

    unless script_actions.empty?
      puts "Here's what happened to the scripts:"
      puts script_actions.join "\n"
    end
    puts "Done! #{pkg.edition} has been deleted"
  end # delete package

  ### Delete a package that's missing from the JSS
  ###
  ###
  def delete_missing_package (pkgid)
    pkg_data = D3::Package.package_data[pkgid]
    script_ids = [pkg_data[:pre_install_script_id], pkg_data[:post_install_script_id], pkg_data[:pre_remove_script_id], pkg_data[:post_remove_script_id]].compact

    if script_ids.empty?
      deets = ' - No pre- or post- scripts to delete'
    else
      if @options.keep_scripts
        deets = ' - Keeping pre- or post- scripts in the JSS'
      else
        deets = ' - Deleting pre- or post- scripts not in use elsewhere'
      end # if @options.keep_scripts
    end # if script_ids.empty?

    confirm "DELETE #{pkg_data[:edition]}\n** Already missing from the JSS **", deets

    JSS::DB_CNX.db.query "DELETE FROM #{D3::Database::PACKAGE_TABLE[:table_name]} WHERE package_id = #{pkgid}"
    puts "Package #{pkg_data[:edition]} deleted."

    if !@options.keep_scripts && !script_ids.empty?
      puts 'Scanning for other packages or policies using  pre- or post- scripts before deleting...'
      policy_scripts = D3.policy_scripts
      script_ids.each do |victim_script_id|
        next unless JSS::Script.all_ids.include? victim_script_id

        victim_script_name = JSS::Script.map_all_ids_to(:name)[victim_script_id]
        pol_users = []
        policy_scripts.each do |pol, pol_scripts|
          if pol_scripts.include? victim_script_id
            puts "Script '#{victim_script_name}' in use by policy '#{pol}'"
            pol_users << pol
          end
        end # policy scripts.each
        d3_users = (D3::Package.packages_for_script(victim_script_id) - [pkgid])
        d3_users.each { |pid| puts "Script '#{victim_script_name}' in use by d3 edition '#{D3::Package.ids_to_editions[pid]}'" }
        if pol_users.empty? && d3_users.empty?
          JSS::Script.fetch(id: victim_script_id).delete
          puts "Deleted script '#{victim_script_name}'"
        end
      end # do script id
    end # if @options.keep_scripts && (not script_ids.empty?)
  end # delete_missing_package(pkgid)

  ### Get an existing pkg id from the user via prompt (if walkthru) or the
  ### first thing listed in @targets. We don't instantiate the pkg here
  ### because it might be :missing in the JSS.
  ###
  ### @return [Ingeter, nil] an existing d3 pkg id if one was chosen
  ###
  def get_pkg_from_cli_or_prompt
    if @options.walkthru && @targets.first.nil?
      D3::Admin::Interactive.get_value :get_existing_package, nil, :validate_existing_package
    else
      pkg_data = D3::Package.find_package(@targets.first, :hash)
      pkg_data ? pkg_data[:id] : nil
    end
  end

  ### Get confirmation before making any changes to a pkg.
  ###
  ### This method just taks a string of text to show the user
  ### and shows it below a standard, attention-getting header line
  ### It then waits for the user to type y or n and exits if n was typed.
  ###
  ### @param action[String] a textual representation of what we're confirming
  ###
  ### @param details[String] The details requireing confirmation
  ###
  ###
  def confirm(action, details = nil)
    puts
    puts '*****************************************'
    puts "Ahoy there! You are about to:\n#{action}"
    puts '*****************************************'
    puts details if details

    if @options.auto_confirm
      puts 'auto-confirmed! Here we go.....'
    else
      puts
      reply = Readline.readline('Are you SURE? (y/n): ', false)
      return true if reply =~ /^y/i
      puts 'Cancelled - wise choice!'
      exit 0
    end # if autoconfirm
  end # confirm

  ### show details about an existing package
  ###
  ### @return [void]
  ###
  def show_package_info
    pkg_id = get_pkg_from_cli_or_prompt

    if pkg_id.nil?
      puts 'No targets given or no matching package found'
      return
    end

    pkg = D3::Package.fetch id: pkg_id

    pkg_deets = <<-ENDDEETS
*****************************************
Details about #{pkg.edition}
JSS id: #{pkg.id}, Status: #{pkg.status}
****************************************
ENDDEETS
    pkg_deets += "Basename: #{pkg.basename}\n"
    pkg_deets += "Version: #{pkg.version}\n"
    pkg_deets += "Revision: #{pkg.revision}\n"
    pkg_deets += "Added to on: #{pkg.added_date.strftime '%Y-%m-%d'}\n"
    pkg_deets += "Added by: #{pkg.added_by}\n"
    if pkg.release_date
      pkg_deets += "Released on: #{pkg.release_date.strftime '%Y-%m-%d'}\n"
      pkg_deets += "Released by: #{pkg.released_by}\n"
    end

    done = [:basename, :version, :revision, :added_by, :added_date, :released_by, :release_date]
    D3::Admin::Edit::EDITING_OPTIONS.each do |opt|
      next if done.include? opt
      label = D3::Admin::OPTIONS[opt][:label]
      if D3::Admin::OPTIONS[opt][:display_conversion]
        val_display = D3::Admin::OPTIONS[opt][:display_conversion].call(pkg.send(opt))
      else
        val_display = pkg.send opt
      end
      val_display = val_display.to_s.empty? ? 'none' : val_display
      pkg_deets += "#{label}: #{val_display}\n"
    end

    puts pkg_deets
    puts
  end # show_package_info

  ### search for and print a list of packages in d3
  ###
  ### @return [void]
  ###
  def search
    D3::Admin::Report.connect_for_reports

    if @options.walkthru
      # prompt for search type?
      search_for = D3::Admin::Interactive.prompt_for_data(
        desc: "SEARCH PACKAGES BY?\nAre you searching for packages by basename, or by scoped groups?\nEnter 'b' or 'g'",
        prompt: 'Basenames or Groups',
        default: 'b',
        required: false
      )
      @options.search_groups = true if search_for =~ /^g/i

      @targets = [D3::Admin::Interactive.get_search_target] if @targets.empty?
      @options.status = D3::Admin::Interactive.get_status_for_filter.split(/,\s*/) if @options.status.empty?
    end # if @options.walkthru

    @options.status = [] if @options.status.include? 'all'

    # show all pkgs or group scope
    if @targets.empty? || @targets.include?('all')
      if @options.search_groups
        D3::Admin::Report.list_all_pkgs_with_scope @options.status
      else
        D3::Admin::Report.list_packages nil, @options.status
      end
      return
    end

    # show specific basenames or group scopes
    @targets.each do |search_target|
      # Groups?
      if @options.search_groups
        found_groups = JSS::ComputerGroup.all_names.select { |gn| gn =~ /#{search_target}/ }
        if found_groups.empty?
          puts "No computer groups in Jamf Pro match '#{search_target}'"
        else
          found_groups.each { |fgn|
            D3::Admin::Report.list_scoped_installs fgn, @options.status, :auto
            D3::Admin::Report.list_scoped_installs fgn, @options.status, :excluded
          } # found_groups.each
        end # if found_groups.empty?

      # basenames
      else
        found_basenames = D3::Package.all_basenames.select { |bn| bn =~ /#{search_target}/ }
        if found_basenames.empty?
          puts "No basenames in d3 match '#{search_target}'"
        else
          found_basenames.each { |fbn| D3::Admin::Report.list_packages fbn, @options.status }
        end # if found_basenames.empty?
      end # if @options.search_groups
    end # @targets.each do |search_target|
  end # def search

  ### show a report
  ###
  ### @return [void]
  ###
  def show_report
    D3::Admin::Report.connect_for_reports

    # targets are basenames or computer names

    if @options.walkthru

      # prompt for target?
      if @targets.empty?
        one_or_all = D3::Admin::Interactive.prompt_for_data(
          desc: "ALL COMPUTERS?\nAre you reporting for a single computer or all computers?\nEnter 'all' for all computers, '1' for one",
          prompt: 'All or 1',
          default: 'all',
          required: false
        )

        # basename report across computers
        if one_or_all == 'all'
          @targets = [(D3::Admin::Interactive.get_value :get_basename, nil, :validate_basename)]

        # reporting single computers
        else
          @options.report_computers = true
          computer_id = D3::Admin::Interactive.get_value :get_computer, nil, :validate_computer
          @targets = [JSS::Computer.map_all_ids_to(:name)[computer_id]]
        end
      end # if targets empty

      # prompt for rcpts or puppies
      rcpts_or_pups = D3::Admin::Interactive.prompt_for_data(
        desc: "RECEIPTS OR PUPPIES?\nShould the report list receipts or pending puppytime installs?\nEnter 'r' for receipts, 'p' for puppies",
        prompt: 'Receipts or puppies',
        required: false,
        default: 'r'
      )
      @options.report_q = rcpts_or_pups =~ /^p/i ?  true : false

      # prompt for status or frozen
      @options.status = D3::Admin::Interactive.get_status_for_filter(:with_frozen).split(/,\s*/) if @options.status.empty?

    end # if @options.walkthru

    @options.status = [] if @options.status.include? 'all'

    # pass the --frozen option with the --status options, since
    # its treated like a pseudo-status, and thats where walkthru puts it too
    @options.status << 'frozen' if @options.report_frozen

    reporting = @options.report_q ? :puppies : :receipts

    if @targets.empty?
      puts 'No basename or computer name given for report'
      return
    end

    @targets.each do |target|
      # report puppies
      if reporting == :puppies
        if @options.report_computers
          D3::Admin::Report.report_single_puppy_queue target, @options.status
        else
          D3::Admin::Report.report_puppy_queues target, @options.status
        end

      # report receipts
      else
        if @options.report_computers
          D3::Admin::Report.report_single_computer_receipts target, @options.status
        else
          D3::Admin::Report.report_basename_receipts target, @options.status
        end

      end # if reporting puppies

      # blank line between reports, for clarity
      puts
    end # targets each
  end # show_report

  ### Run the config, saving hostnames, usernames and pws
  ### as needed
  ###
  ### @param display[Boolean] show the current admin config rather than
  ###   set it.
  ###
  ### @return [void]
  ###
  def config
    if @targets.include? 'display'
      D3::Admin::Prefs.display_config
    else
      D3::Admin::Prefs.config @targets, @options
    end
  end # config

end # class App

### save terminal state incase user interrupts during readline or less
if $stdin.tty?
  stty_save = `stty -g`.chomp
  trap('SIGINT') do
    puts "\nCancelled! Woot!"
    system('stty', stty_save)
    exit 0
  end
end

begin
  app = App.new
  app.run
rescue
  puts "An error occurred: #{$ERROR_INFO.class}: #{$ERROR_INFO}!"
  puts $ERROR_POSITION if D3::Admin.debug
  exit 1
ensure
  if D3::Admin::Auth.connected?
    # JSS::DistributionPoint.master_distribution_point.unmount if JSS::DistributionPoint.master_distribution_point.mounted?
    D3::Admin::Auth.disconnect
  end
end # begin