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

require 'base64'

module Jamf

  class Connection

    # A token used for a connection to either API
    class Token

      AUTH_RSRC_VERSION = 'v1'.freeze

      AUTH_RSRC = 'auth'.freeze

      NEW_TOKEN_RSRC = "#{AUTH_RSRC_VERSION}/#{AUTH_RSRC}/token".freeze

      KEEP_ALIVE_RSRC = "#{AUTH_RSRC_VERSION}/#{AUTH_RSRC}/keep-alive".freeze

      INVALIDATE_RSRC = "#{AUTH_RSRC_VERSION}/#{AUTH_RSRC}/invalidate-token".freeze

      JAMF_VERSION_RSRC_VERSION = 'v1'.freeze

      JAMF_VERSION_RSRC = "#{JAMF_VERSION_RSRC_VERSION}/jamf-pro-version".freeze

      OAUTH_RSRC = 'oauth'.freeze

      API_CLIENT_TOKEN_RSRC = "#{OAUTH_RSRC}/token".freeze

      API_CLIENT_GRANT_TYPE = 'client_credentials'.freeze

      # Recognize the tryitout server, cuz its /auth endpoint
      # is disabled, and it needs no tokens
      # TODO: MOVE THIS TO THE CONNECTION CLASS
      JAMF_TRYITOUT_HOST = "tryitout#{Jamf::Connection::JAMFCLOUD_DOMAIN}".freeze

      JAMF_TRYITOUT_TOKEN_BODY = {
        token: 'This is a fake token, tryitout.jamfcloud.com uses internal tokens',
        expires: 2_000_000_000_000
      }.freeze

      # Minimum seconds before expiration that the token will automatically
      # refresh. Used as the default if :refresh is not provided in the init
      # params
      MIN_REFRESH_BUFFER = 300

      # Used bu the last_refresh_result method
      REFRESH_RESULTS = {
        refreshed: 'Refreshed',
        refreshed_pw: 'Refresh failed, but new token created with cached pw',
        refresh_failed: 'Refresh failed, could not create new token with cached pw',
        refresh_failed_no_pw_fallback: 'Refresh failed, but pw_fallback was false',
        expired_refreshed: 'Expired, but new token created with cached pw',
        expired_failed: 'Expired, could not create new token with cached pw',
        expired_no_pw_fallback: 'Expired, but pw_fallback was false'
      }.freeze

      # @return [String] The user who generated this token
      attr_reader :user

      # @return [String] the SSL version being used
      attr_reader :ssl_version

      # @return [Boolean] are we verifying SSL certs?
      attr_reader :verify_cert
      alias verify_cert? verify_cert

      # @return [Hash] the ssl version and verify cert, to pass into faraday connections
      attr_reader :ssl_options

      # @return [String] The token data
      attr_reader :token
      alias token_string token
      alias auth_token token

      # @return [URI] The base API url, e.g. https://myjamf.jamfcloud.com/
      attr_reader :base_url

      # @return [Time] when was this Jamf::Connection::Token originally created?
      attr_reader :creation_time
      alias login_time creation_time

      # @return [Time] when was this token last refreshed?
      attr_reader :last_refresh

      # @return [Time]
      attr_reader :expires
      alias expiration expires

      # @return [Boolean] does this token automatically refresh itself before
      #   expiring?
      attr_reader :keep_alive
      alias keep_alive? keep_alive

      # @return [Boolean] Should the provided passwd be cached in memory, to be
      #   used to generate a new token, if a normal refresh fails?
      attr_reader :pw_fallback
      alias pw_fallback? pw_fallback

      # @return [Faraday::Response] The response object from instantiating
      #   a new Token object by creating a new token or validating a token
      #   string. This is not updated when refreshing a token, only when
      #   calling Token.new
      attr_reader :creation_http_response

      # @param params [Hash] The data for creating and maintaining the token
      #
      # @option params [String] :token_string An existing valid token string.
      #   If pw_fallback is true (the default) you will also need to provide
      #   the password for the original user in the pw: parameter. If you don't,
      #   pw_fallback will be false even if you set it to true explicitly.
      #
      # @option params [String, URI] :base_url The url for the Jamf Pro server
      #   including host and port, e.g. 'https://myjss.school.edu:8443/'
      #
      # @option params [String] :user (see Connection#initialize)
      #
      # @option params [String] :pw (see Connection#initialize)
      #
      # @option params [Integer] :timeout The timeout for creating or refreshing
      #   the token
      #
      # @option params [Boolean] :keep_alive (see Connection#connect)
      #
      # @option params [Integer] :refresh_buffer (see Connection#connect)
      #
      # @option params [Boolean] :pw_fallback (see Connection#connect)
      #
      # @option params [String, Symbol] :ssl_version (see Connection#connect)
      #
      # @option params [Boolean] :verify_cert (see Connection#connect)
      #
      ###########################################
      def initialize(**params)
        @valid = false
        parse_params(**params)

        if params[:token_string]
          @pw_fallback = false unless @pw
          init_from_token_string params[:token_string]

        elsif @client_id
          init_for_api_client

        elsif @user && @pw
          init_from_pw

        else
          raise ArgumentError, 'Must provide either user: & pw: or token:'
        end

        start_keep_alive if @keep_alive
        @creation_time = Time.now
      end # init

      # Initialize for an API client
      #################################
      def init_for_api_client
        data = {
          client_id: @client_id,
          client_secret: Base64.decode64(@pw),
          grant_type: API_CLIENT_GRANT_TYPE
        }

        resp = api_client_auth_connection(API_CLIENT_TOKEN_RSRC).post { |req| req.body = data }

        if resp.success?
          parse_token_from_api_client_auth resp
          @creation_http_response = resp
          whoami = token_connection(AUTH_RSRC, token: @token).get
          @user = "#{whoami.body[:account][:username]} (API Client)"
        elsif resp.status == 401
          raise Jamf::AuthenticationError, 'Incorrect client_id or client_secret'
        else
          # TODO: better error reporting here
          raise Jamf::AuthenticationError, "An error occurred while authenticating client_id: #{resp.body}"
        end
      ensure
        @pw = nil
      end

      # Initialize from password
      #################################
      def init_from_pw
        resp = token_connection(NEW_TOKEN_RSRC).post

        if resp.success?
          parse_token_from_response resp
          @last_refresh = Time.now
          @creation_http_response = resp
        elsif resp.status == 401
          raise Jamf::AuthenticationError, 'Incorrect name or password'
        else
          # TODO: better error reporting here
          raise Jamf::AuthenticationError, "An error occurred while authenticating: #{resp.body}"
        end
      ensure
        @pw = nil unless @pw_fallback
      end # init_from_pw

      # Initialize from token string
      #################################
      def init_from_token_string(str)
        resp = token_connection(AUTH_RSRC, token: str).get
        raise Jamf::InvalidDataError, 'Token is not valid' unless resp.success?

        @creation_http_response = resp
        @token = str
        @user = resp.body.dig :account, :username

        # if we were given a pw for the user, and expect to use it, validate it now
        if @pw && @pw_fallback
          resp = token_connection(NEW_TOKEN_RSRC).post
          raise Jamf::AuthenticationError, "Incorrect password provided for token string (user: #{@user})" unless resp.success?
        end

        # use this token to get a fresh one with a known expiration
        refresh
      end # init_from_token_string

      #################################
      def host
        @base_url.host
      end

      # @return [Integer]
      #################################
      def port
        @base_url.port
      end

      # @return [Gem::Version]
      #################################
      def jamf_version
        fetch_jamf_version unless @jamf_version
        @jamf_version
      end

      # @return [String]
      #################################
      def jamf_build
        fetch_jamf_version unless @jamf_build
        @jamf_build
      end

      # @return [Boolean]
      #################################
      def expired?
        return unless @expires

        Time.now >= @expires
      end

      # when is the next rerefresh going to happen, if we are set to keep alive?
      #
      # @return [Time, nil] the time of the next scheduled refresh, or nil if not keep_alive?
      def next_refresh
        return unless keep_alive?

        @expires - @refresh_buffer
      end

      # how many secs until the next refresh?
      # will return 0 during the actual refresh process.
      #
      # @return [Float, nil] Seconds until the next scheduled refresh, or nil if not keep_alive?
      #
      def secs_to_refresh
        return unless keep_alive?

        secs = next_refresh - Time.now
        secs.negative? ? 0 : secs
      end

      # Returns e.g. "1 week 6 days 23 hours 49 minutes 56 seconds"
      #
      # @return [String, nil]
      def time_to_refresh
        return unless keep_alive?

        Jamf.humanize_secs secs_to_refresh
      end

      # @return [Float]
      #################################
      def secs_remaining
        return unless @expires

        @expires - Time.now
      end

      # @return [String] e.g. "1 week 6 days 23 hours 49 minutes 56 seconds"
      #################################
      def time_remaining
        return unless @expires

        JSS.humanize_secs secs_remaining
      end

      # @return [Boolean]
      #################################
      def valid?
        @valid =
          if expired?
            false
          elsif !@token
            false
          else
            token_connection(AUTH_RSRC, token: @token).get.success?
          end
      end

      # What happened the last time we tried to refresh?
      # See REFRESH_RESULTS
      #
      # @return [String, nil] result or nil if never refreshed
      #################################
      def last_refresh_result
        REFRESH_RESULTS[@last_refresh_result]
      end

      # the Jamf account assciated with this token, which contains info about
      # privileges and Jamf acct group memberships and Jamf Acct settings
      #
      # @return [Jamf::OAPISchemas::AuthorizationV1] the Authorization object
      #################################
      def account
        return @account if @account

        resp = token_connection(AUTH_RSRC, token: @token).get
        return unless resp.success?

        @account = Jamf::OAPISchemas::AuthorizationV1.new resp.body
      end

      # Use this token to get a fresh one. If a pw is provided
      # try to use it to get a new token if a proper refresh fails.
      #
      # @param pw [String] Optional password to use if token refresh fails.
      #   Must be the correct passwd or the token's user (obviously)
      #
      # @return [Time] the new expiration time
      #
      #################################
      def refresh
        # already expired?
        if expired?
          # try the passwd if we have it
          return refresh_with_pw(:expired_refreshed, :expired_failed) if @pw

          # no passwd fallback? no chance!
          @last_refresh_result = :expired_no_pw_fallback
          raise Jamf::InvalidTokenError, 'Token has expired'
        end

        # Now try a normal refresh of our non-expired token
        keep_alive_token_resp = token_connection(KEEP_ALIVE_RSRC, token: @token).post

        if keep_alive_token_resp.success?
          parse_token_from_response keep_alive_token_resp
          @last_refresh_result = :refreshed
          @last_refresh = Time.now
          return expires
        end

        # if we're here, the normal refresh failed, so try the pw
        return refresh_with_pw(:refreshed_pw, :refresh_failed) if @pw

        # if we're here, no pw? no chance!
        @last_refresh_result = :refresh_failed_no_pw_fallback
        raise 'An error occurred while refreshing the token'
      end
      alias keep_alive refresh

      # Make this token invalid
      #################################
      def invalidate
        @valid = !token_connection(INVALIDATE_RSRC, token: @token).post.success?
        @pw = nil
        stop_keep_alive
      end
      alias destroy invalidate

      # creates a thread that loops forever, sleeping most of the time, but
      # waking up every 60 seconds to see if the token is expiring in the
      # next @refresh_buffer seconds.
      #
      # If so, the token is refreshed, and we keep looping and sleeping.
      #
      # Sets @keep_alive_thread to the Thread object
      #
      # @return [void]
      #################################
      def start_keep_alive
        return if @keep_alive_thread
        raise 'Token expired, cannot refresh' if expired?

        @keep_alive_thread =
          Thread.new do
            loop do
              sleep 60
              begin
                next if secs_remaining > @refresh_buffer

                refresh
              rescue
                # TODO: Some kind of error reporting
                next
              end
            end # loop
          end # thread
      end # start_keep_alive

      # Kills the @keep_alive_thread, if it exists, and sets
      # @keep_alive_thread to nil
      #
      # @return [void]
      #
      def stop_keep_alive
        return unless @keep_alive_thread

        @keep_alive_thread.kill if @keep_alive_thread.alive?
        @keep_alive_thread = nil
      end

      # Private instance methods
      #################################
      private

      # set values from params & defaults
      ###########################################
      def parse_params(**params)
        # This process of deleting suffixes will leave in place any
        # URL paths before the the CAPI_RSRC_BASE or JPAPI_RSRC_BASE
        # e.g.  https://my.jamf.server:8443/some/path/before/api
        # as is the case at some on-prem sites.
        baseurl = params[:base_url].to_s.dup
        baseurl.delete_suffix! '/'
        baseurl.delete_suffix! Jamf::Connection::CAPI_RSRC_BASE
        baseurl.delete_suffix! Jamf::Connection::JPAPI_RSRC_BASE
        baseurl.delete_suffix! '/'
        @base_url = URI.parse baseurl

        @timeout = params[:timeout] || Jamf::Connection::DFT_TIMEOUT

        if params[:client_id]
          @client_id = params[:client_id]
          params[:keep_alive] = false
          params[:refresh_buffer] = nil
          params[:pw_fallback] = false
        else
          @user = params[:user]
        end

        # when using an API client, the client secret can be
        # given in params[:client_secret] or params[:pw]
        params[:pw] ||= params[:client_secret]

        # @pw will be deleted after use if pw_fallback is false
        # It is stored as base64 merely for visual security in irb sessions
        # and the like.
        @pw = params[:pw] ? Base64.encode64(params[:pw]) : nil
        @pw_fallback = params[:pw_fallback].instance_of?(FalseClass) ? false : true

        # backwards compatibility
        params[:refresh_buffer] ||= params[:refresh]
        params[:refresh_buffer] = params[:refresh_buffer].to_i
        @refresh_buffer = params[:refresh_buffer] > MIN_REFRESH_BUFFER ? params[:refresh_buffer] : MIN_REFRESH_BUFFER

        @ssl_version = params[:ssl_version] || Jamf::Connection::DFT_SSL_VERSION
        @verify_cert = params[:verify_cert].instance_of?(FalseClass) ? false : true
        @ssl_options = { version: @ssl_version, verify: @verify_cert }

        @keep_alive = params[:keep_alive].instance_of?(FalseClass) ? false : true
      end

      # refresh a token using the pw cached when @pw_fallback is true
      #
      # @param success [Sumbol] the key from REFRESH_RESULTS to use when successful
      # @param failure [Sumbol] the key from REFRESH_RESULTS to use when not successful
      # @return [Time] the new expiration
      #################################
      def refresh_with_pw(success, failure)
        init_from_pw
        @last_refresh_result = success
        expires
      rescue => e
        @last_refresh_result = failure
        raise e, "#{e}. Status: :#{REFRESH_RESULTS[failure]}"
      end

      # @return [void]
      #################################
      def fetch_jamf_version
        resp = token_connection(JAMF_VERSION_RSRC, token: @token).get
        if resp.success?
          jamf_version, @jamf_build = resp.body[:version].split('-')
          @jamf_version = Gem::Version.new jamf_version
          return
        end

        raise Jamf::InvalidConnectionError, 'Unable to read Jamf version from the API'
      end

      # a generic, one-time Faraday connection for token
      # acquision & manipulation
      #################################
      def token_connection(rsrc, token: nil)
        Faraday.new("#{@base_url}/#{Jamf::Connection::JPAPI_RSRC_BASE}/#{rsrc}", ssl: @ssl_options) do |con|
          con.headers[:accept] = Jamf::Connection::MIME_JSON
          con.response :json, parser_options: { symbolize_names: true }
          con.options[:timeout] = @timeout
          con.options[:open_timeout] = @timeout
          if token
            con.request :authorization, 'Bearer', token
          else
            con.request :authorization, :basic, @user, Base64.decode64(@pw)
          end
          con.adapter :net_http
        end # Faraday.new
      end # token_connection

      # a generic, one-time Faraday connection for API Client token
      # acquision & manipulation
      #################################
      def api_client_auth_connection(rsrc)
        Faraday.new("#{@base_url}/#{Jamf::Connection::JPAPI_RSRC_BASE}/#{rsrc}", ssl: @ssl_options) do |fcnx|
          # activates the url_encoded request middleware, which converts a hash in the request body to url-encoded form data
          fcnx.request :url_encoded
          # activates the json response middleware, parsing all valid  response bodies with JSON.parse
          fcnx.response :json, parser_options: { symbolize_names: true }
          fcnx.adapter  :net_http
        end
      end # token_connection

      # Parse the API token data into instance vars.
      #################################
      def parse_token_from_response(resp)
        @token_response_body = resp.body
        @token = @token_response_body[:token]
        @expires = Jamf.parse_time(@token_response_body[:expires]).localtime
        @valid = true
      end

      # Parse the API Client token data into instance vars.
      #################################
      def parse_token_from_api_client_auth(resp)
        @token_response_body = resp.body
        @token = @token_response_body[:access_token]
        @expires = Time.now + @token_response_body[:expires_in] - 1
        @api_role_ids = @token_response_body[:scope].split(' ').map { |r| r.split(':').last }
        @valid = true
      end

    end # class Token

  end # class Connection

end # module Jamf
