### 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.
###
###

###
module JSS

  ### Module Variables
  #####################################

  ### Module Methods
  #####################################

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

  ### This class represents a Casper/JSS Client computer, on which
  ### this code is running.
  ###
  ### Since the class represents the current machine, there's no need
  ### to make an instance of it, all methods are class methods.
  ###
  ### At the moment, only Macintosh computers are supported.
  ###
  ###
  class Client

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

    ### The Pathname to the jamf binary executable
    ### As of El Capitan (OS X 10.11) the location has moved.
    ORIG_JAMF_BINARY = Pathname.new '/usr/sbin/jamf'
    ELCAP_JAMF_BINARY = Pathname.new '/usr/local/jamf/bin/jamf'
    JAMF_BINARY = ELCAP_JAMF_BINARY.executable? ? ELCAP_JAMF_BINARY : ORIG_JAMF_BINARY

    ### The Pathname to the jamfHelper executable
    JAMF_HELPER = Pathname.new '/Library/Application Support/JAMF/bin/jamfHelper.app/Contents/MacOS/jamfHelper'

    ### The window_type options for jamfHelper
    JAMF_HELPER_WINDOW_TYPES = {
      hud: 'hud',
      utility: 'utility',
      util: 'utility',
      full_screen: 'fs',
      fs: 'fs'
    }.freeze

    ### The possible window positions for jamfHelper
    JAMF_HELPER_WINDOW_POSITIONS = [nil, :ul, :ll, :ur, :lr].freeze

    ### The available buttons in jamfHelper
    JAMF_HELPER_BUTTONS =  [1, 2].freeze

    ### The possible alignment positions in jamfHelper
    JAMF_HELPER_ALIGNMENTS =  [:right, :left, :center, :justified, :natural].freeze

    ### The Pathname to the preferences plist used by the jamf binary
    JAMF_PLIST = Pathname.new '/Library/Preferences/com.jamfsoftware.jamf.plist'

    ### The Pathname to the JAMF support folder
    JAMF_SUPPORT_FOLDER = Pathname.new '/Library/Application Support/JAMF'

    ### The JAMF receipts folder, where package installs are tracked.
    RECEIPTS_FOLDER = JAMF_SUPPORT_FOLDER + 'Receipts'

    ### The JAMF downloads folder
    DOWNLOADS_FOLDER = JAMF_SUPPORT_FOLDER + 'Downloads'

    ### These jamf commands don't need root privs (most do)
    ROOTLESS_JAMF_COMMANDS = [
      :about,
      :checkJSSConnection,
      :getARDFields,
      :getComputerName,
      :help,
      :listUsers,
      :version
    ].freeze

    #####################################
    ### Class Variables
    #####################################

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

    ### Get the current IP address as a String.
    ###
    ### This handy code doesn't acutally make a UDP connection,
    ### it just starts to set up the connection, then uses that to get
    ### the local IP.
    ###
    ### Lifted gratefully from
    ### http://coderrr.wordpress.com/2008/05/28/get-your-local-ip-address/
    ###
    ### @return [String] the current IP address.
    ###
    def self.my_ip_address
      ### turn off reverse DNS resolution temporarily
      ### @note the 'socket' library has already been required by 'rest-client'
      orig = Socket.do_not_reverse_lookup
      Socket.do_not_reverse_lookup = true

      UDPSocket.open do |s|
        s.connect '192.168.0.0', 1
        s.addr.last
      end
    ensure
      Socket.do_not_reverse_lookup = orig
    end

    ### Who's logged in to the console right now?
    ###
    ### @return [String, nil] the username of the current console user, or nil if none.
    ###
    def self.console_user
      cmd = '/usr/sbin/scutil'
      qry = 'show State:/Users/ConsoleUser'
      Open3.popen2e(cmd) do |cmdin, cmdouterr, _wait_thr|
        cmdin.puts qry
        cmdin.close
        out = cmdouterr.read
        user = out.lines.select { |l| l =~ /^\s+Name\s*:/ }.first.to_s.split(/\s*:\s*/).last
        return user.nil? ? user : user.chomp
      end # do
    end

    ### Is the jamf binary installed?
    ###
    ### @return [Boolean] is the jamf binary installed?
    ###
    def self.installed?
      JAMF_BINARY.executable?
    end

    ### What version of the jamf binary is installed?
    ###
    ### @return [String,nil] the version of the jamf binary installed on this client, nil if not installed
    ###
    def self.jamf_version
      installed? ? run_jamf(:version).chomp.split('=')[1] : nil
    end

    ### the URL to the jss for this client
    ###
    ### @return [String] the url to the JSS for this client
    ###
    def self.jss_url
      @url = jamf_plist['jss_url']
      return nil if @url.nil?
      @url =~ %r{(https?)://(.+):(\d+)/}
      @protocol = Regexp.last_match(1)
      @server = Regexp.last_match(2)
      @port = Regexp.last_match(3)
      @url
    end

    ### The JSS server hostname for this client
    ###
    ### @return [String] the JSS server for this client
    ###
    def self.jss_server
      jss_url
      @server
    end

    ### The protocol for JSS connections for this client
    ###
    ### @return [String] the protocol to the JSS for this client, "http" or "https"
    ###
    def self.jss_protocol
      jss_url
      @protocol
    end

    ### The port number for JSS connections for this client
    ###
    ### @return [Integer] the port to the JSS for this client
    ###
    def self.jss_port
      jss_url
      @port ? @port.to_i : 80
    end

    ### The contents of the JAMF plist
    ###
    ### @return [Hash] the parsed contents of the JAMF_PLIST if it exists, an empty hash if not
    ###
    def self.jamf_plist
      return {} unless JAMF_PLIST.file?
      Plist.parse_xml `/usr/libexec/PlistBuddy -x -c print #{Shellwords.escape JSS::Client::JAMF_PLIST.to_s}`
    end

    ### All the JAMF receipts on this client
    ###
    ### @return [Array<Pathname>] an array of Pathnames for all regular files in the jamf receipts folder
    ###
    def self.receipts
      raise JSS::NoSuchItemError, "The JAMF Receipts folder doesn't exist on this computer." unless RECEIPTS_FOLDER.exist?
      RECEIPTS_FOLDER.children.select(&:file?)
    end

    ### Is the JSS available right now?
    ###
    ### @return [Boolean] is the JSS available now?
    ###
    def self.jss_available?
      run_jamf :checkJSSConnection, '-retry 1'
      $CHILD_STATUS.exitstatus.zero?
    end

    ### The JSS::Computer object for this computer
    ###
    ### @return [JSS::Computer,nil] The JSS record for this computer, nil if not in the JSS
    ###
    def self.jss_record
      JSS::Computer.new udid: udid
    rescue JSS::NoSuchItemError
      nil
    end

    ### The UUID for this computer via system_profiler
    ###
    ### @return [String] the UUID/UDID for this computer
    ###
    def self.udid
      hardware_data['platform_UUID']
    end

    ### The serial number for this computer via system_profiler
    ###
    ### @return [String] the serial number for this computer
    ###
    def self.serial_number
      hardware_data['serial_number']
    end

    ### The parsed HardwareDataType output from system_profiler
    ###
    ### @return [Hash] the HardwareDataType data from the system_profiler command
    ###
    def self.hardware_data
      raw = `/usr/sbin/system_profiler SPHardwareDataType -xml 2>/dev/null`
      Plist.parse_xml(raw)[0]['_items'][0]
    end

    ### Run an arbitrary jamf binary command.
    ###
    ### @note Most jamf commands require superuser/root privileges.
    ###
    ### @param command[String,Symbol] the jamf binary command to run
    ###   The command is the single jamf command that comes after the/usr/bin/jamf.
    ###
    ### @param args[String,Array] the arguments passed to the jamf command.
    ###   This is to be passed to Kernel.` (backtick), after being combined with the
    ###   jamf binary and the jamf command
    ###
    ### @param verbose[Boolean] Should the stdout & stderr of the jamf binary be sent to
    ###  the current stdout in realtime, as well as returned as a string?
    ###
    ### @return [String] the stdout & stderr of the jamf binary.
    ###
    ### @example
    ###   These two are equivalent:
    ###
    ###     JSS::Client.run_jamf "recon", "-assetTag 12345 -department 'IT Support'"
    ###
    ###     JSS::Client.run_jamf :recon, ['-assetTag', '12345', '-department', 'IT Support'"]
    ###
    ###
    ### The details of the Process::Status for the jamf binary process can be captured from $?
    ### immediately after calling. (See Process::Status)
    ###
    def self.run_jamf(command, args = nil, verbose = false)
      raise JSS::UnmanagedError, 'The jamf binary is not installed on this computer.' unless installed?
      raise JSS::UnsupportedError, 'You must have root privileges to run that jamf binary command' unless \
        ROOTLESS_JAMF_COMMANDS.include?(command.to_sym) || JSS.superuser?

      cmd = case args
            when nil
              "#{JAMF_BINARY} #{command}"
            when String
              "#{JAMF_BINARY} #{command} #{args}"
            when Array
              ([JAMF_BINARY.to_s, command] + args).join(' ').to_s
            else
              raise JSS::InvalidDataError, 'args must be a String or Array of Strings'
            end # case

      cmd += ' -verbose' if verbose && (!cmd.include? ' -verbose')
      puts "Running: #{cmd}" if verbose

      output = []
      IO.popen("#{cmd} 2>&1") do |proc|
        while line = proc.gets
          output << line
          puts line if verbose
        end
      end
      install_out = output.join('')
      install_out.force_encoding('UTF-8') if install_out.respond_to? :force_encoding
      install_out
    end # run_jamf

    ### A wrapper for the jamfHelper command, which can display a window on the client machine.
    ###
    ### The first parameter must be a symbol defining what kind of window to display. The options are
    ### - :hud - creates an Apple "Heads Up Display" style window
    ### - :utility or :util -  creates an Apple "Utility" style window
    ### - :fs or :full_screen or :fullscreen - creates a full screen window that restricts all user input
    ###   WARNING: Remote access must be used to unlock machines in this mode
    ###
    ### The remaining options Hash can contain any of the options listed. See below for descriptions.
    ###
    ### The value returned is the Integer exitstatus/stdout (both are the same) of the jamfHelper command.
    ### The meanings of those integers are:
    ###
    ### - 0 - Button 1 was clicked
    ### - 1 - The Jamf Helper was unable to launch
    ### - 2 - Button 2 was clicked
    ### - 3 - Process was started as a launchd task
    ### - XX1 - Button 1 was clicked with a value of XX seconds selected in the drop-down
    ### - XX2 - Button 2 was clicked with a value of XX seconds selected in the drop-down
    ### - 239 - The exit button was clicked
    ### - 240 - The "ProductVersion" in sw_vers did not return 10.5.X, 10.6.X or 10.7.X
    ### - 243 - The window timed-out with no buttons on the screen
    ### - 250 - Bad "-windowType"
    ### - 254 - Cancel button was select with delay option present
    ### - 255 - No "-windowType"
    ###
    ### See also /Library/Application\ Support/JAMF/bin/jamfHelper.app/Contents/MacOS/jamfHelper -help
    ###
    ### @note the -startlaunchd and -kill options are not available in this implementation, since
    ###   they don't work at the moment (casper 9.4).
    ###   -startlaunchd seems to be required to NOT use launchd, and when it's ommited, an error is generated
    ###   about the launchd plist permissions being incorrect.
    ###
    ### @param window_type[Symbol]  The type of window to display
    ###
    ### @param opts[Hash] the options for the window
    ###
    ### @option opts :window_position [Symbol,nil] one of [ nil, :ul, :ll. :ur, :lr ]
    ###   Positions window in the upper right, upper left, lower right or lower left of the user's screen
    ###   If no input is given, the window defaults to the center of the screen
    ###
    ### @option opts :title [String]
    ###   Sets the window's title to the specified string
    ###
    ### @option opts :heading  [String]
    ###   Sets the heading of the window to the specified string
    ###
    ### @option opts :align_heading [Symbol] one of  [:right, :left, :center, :justified, :natural]
    ###   Aligns the heading to the specified alignment
    ###
    ### @option opts :description [String]
    ###   Sets the main contents of the window to the specified string
    ###
    ### @option opts :align_description [Symbol] one of  [:right, :left, :center, :justified, :natural]
    ###   Aligns the description to the specified alignment
    ###
    ### @option opts :icon [String,Pathname]
    ###   Sets the windows image field to the image located at the specified path
    ###
    ### @option opts :icon_size [Integer]
    ###   Changes the image frame to the specified pixel size
    ###
    ### @option opts :full_screen_icon [any value]
    ###   Scales the "icon" to the full size of the window.
    ###   Note: Only available in full screen mode
    ###
    ### @option opts :button1 [String]
    ###   Creates a button with the specified label
    ###
    ### @option opts :button2 [String]
    ###   Creates a second button with the specified label
    ###
    ### @option opts :default_button [Integer]  either 1 or 2
    ###   Sets the default button of the window to the specified button. The Default Button will respond to "return"
    ###
    ### @option opts :cancel_button [Integer]  either 1 or 2
    ###   Sets the cancel button of the window to the specified button. The Cancel Button will respond to "escape"
    ###
    ### @option opts :timeout [Integer]
    ###   Causes the window to timeout after the specified amount of seconds
    ###   Note: The timeout will cause the default button, button 1 or button 2 to be selected (in that order)
    ###
    ### @option opts :show_delay_options [String,Array<Integer>] A String of comma-separated Integers, or an Array of Integers.
    ###   Enables the "Delay Options Mode". The window will display a dropdown with the values passed through the string
    ###
    ### @option opts :countdown [any value]
    ###   Displays a string notifying the user when the window will time out
    ###
    ### @option opts :align_countdown [Symbol] one of  [:right, :left, :center, :justified, :natural]
    ###   Aligns the countdown to the specified alignment
    ###
    ### @option opts :lock_hud [any value]
    ###   Removes the ability to exit the HUD by selecting the close button
    ###
    ### @return [Integer] the exit status of the jamfHelper command. See above.
    ###
    def self.jamf_helper(window_type = :hud, opts = {})
      raise JSS::UnmanagedError, 'The jamfHelper app is not installed properly on this computer.' unless JAMF_HELPER.executable?

      unless JAMF_HELPER_WINDOW_TYPES.include? window_type
        raise JSS::InvalidDataError, "The first parameter must be a window type, one of :#{JAMF_HELPER_WINDOW_TYPES.keys.join(', :')}."
      end

      # start building the arg array

      args = ['-startlaunchd', '-windowType', JAMF_HELPER_WINDOW_TYPES[window_type]]

      opts.keys.each do |opt|
        case opt
        when :window_position
          raise JSS::InvalidDataError, ":window_position must be one of :#{JAMF_HELPER_WINDOW_POSITIONS.join(', :')}." unless \
            JAMF_HELPER_WINDOW_POSITIONS.include? opts[opt].to_sym
          args << '-windowPosition'
          args << opts[opt].to_s

        when :title
          args << '-title'
          args << opts[opt].to_s

        when :heading
          args << '-heading'
          args << opts[opt].to_s

        when :align_heading
          raise JSS::InvalidDataError, ":align_heading must be one of :#{JAMF_HELPER_ALIGNMENTS.join(', :')}." unless \
            JAMF_HELPER_ALIGNMENTS.include? opts[opt].to_sym
          args << '-alignHeading'
          args << opts[opt].to_s

        when :description
          args << '-description'
          args << opts[opt].to_s

        when :align_description
          raise JSS::InvalidDataError, ":align_description must be one of :#{JAMF_HELPER_ALIGNMENTS.join(', :')}." unless \
            JAMF_HELPER_ALIGNMENTS.include? opts[opt].to_sym
          args << '-alignDescription'
          args << opts[opt].to_s

        when :icon
          args << '-icon'
          args << opts[opt].to_s

        when :icon_size
          args << '-iconSize'
          args << opts[opt].to_s

        when :full_screen_icon
          args << '-fullScreenIcon'

        when :button1
          args << '-button1'
          args << opts[opt].to_s

        when :button2
          args << '-button2'
          args << opts[opt].to_s

        when :default_button
          raise JSS::InvalidDataError, ":default_button must be one of #{JAMF_HELPER_BUTTONS.join(', ')}." unless \
            JAMF_HELPER_BUTTONS.include? opts[opt]
          args << '-defaultButton'
          args << opts[opt].to_s

        when :cancel_button
          raise JSS::InvalidDataError, ":cancel_button must be one of #{JAMF_HELPER_BUTTONS.join(', ')}." unless \
            JAMF_HELPER_BUTTONS.include? opts[opt]
          args << '-cancelButton'
          args << opts[opt].to_s

        when :timeout
          args << '-timeout'
          args << opts[opt].to_s

        when :show_delay_options
          args << '-showDelayOptions'
          args << JSS.to_s_and_a(opts[opt])[:arrayform].join(', ')

        when :countdown
          args << '-countdown'

        when :align_countdown
          raise JSS::InvalidDataError, ":align_countdown must be one of :#{JAMF_HELPER_ALIGNMENTS.join(', :')}." unless \
            JAMF_HELPER_ALIGNMENTS.include? opts[opt].to_sym
          args << '-alignCountdown'
          args << opts[opt].to_s

        when :lock_hud
          args << '-lockHUD'
        end # case opt
      end # each do opt

      system JAMF_HELPER.to_s, *args
      $CHILD_STATUS.exitstatus
    end # def self.jamf_helper

  end # class Client

end # module