module RunLoop

  # @!visibility private
  module DeviceAgent

    # @!visibility private
    class Client

      require "run_loop/shell"
      include RunLoop::Shell

      require "run_loop/encoding"
      include RunLoop::Encoding

      require "run_loop/cache"
      require "run_loop/dylib_injector"

      class HTTPError < RuntimeError; end

      # @!visibility private
      #
      # These defaults may change at any time.
      #
      # You can override these values if they do not work in your environment.
      #
      # For cucumber users, the best place to override would be in your
      # features/support/env.rb.
      #
      # For example:
      #
      # RunLoop::DeviceAgent::Client::DEFAULTS[:http_timeout] = 60
      # RunLoop::DeviceAgent::Client::DEFAULTS[:device_agent_install_timeout] = 120
      DEFAULTS = {
        :port => 27753,
        :simulator_ip => "127.0.0.1",
        :http_timeout => (RunLoop::Environment.ci? || RunLoop::Environment.xtc?) ? 120 : 20,
        :route_version => "1.0",

        # Ignored in the XTC.
        # This key is subject to removal or changes
        :device_agent_install_timeout => RunLoop::Environment.ci? ? 240 : 120,
        # This value must always be false on the XTC.
        # This is should only be used by gem maintainers or very advanced users.
        :shutdown_device_agent_before_launch => false,

        # This value was derived empirically by typing hundreds of strings
        # using XCUIElement#typeText.  It corresponds to the DeviceAgent
        # constant CBX_DEFAULT_SEND_STRING_FREQUENCY which is 60.  _Decrease_
        # this value if you are timing out typing strings.
        :characters_per_second => 12
      }

      AUT_LAUNCHED_BY_RUN_LOOP_ARG = "LAUNCHED_BY_RUN_LOOP"

      # @!visibility private
      #
      # These defaults may change at any time.
      #
      # You can override these values if they do not work in your environment.
      #
      # For cucumber users, the best place to override would be in your
      # features/support/env.rb.
      #
      # For example:
      #
      # RunLoop::DeviceAgent::Client::WAIT_DEFAULTS[:timeout] = 30
      WAIT_DEFAULTS = {
        timeout: (RunLoop::Environment.ci? ||
                  RunLoop::Environment.xtc?) ? 30 : 15,
        # This key is subject to removal or changes.
        retry_frequency: 0.1,
        # This key is subject to removal or changes.
        exception_class: Timeout::Error
      }

      # @!visibility private
      def self.run(options={})
        simctl = options[:sim_control] || options[:simctl] || RunLoop::Simctl.new
        xcode = options[:xcode] || RunLoop::Xcode.new
        instruments = options[:instruments] || RunLoop::Instruments.new

        # Find the Device under test, the App under test, and reset options.
        device = RunLoop::Device.detect_device(options, xcode, simctl, instruments)
        app_details = RunLoop::DetectAUT.detect_app_under_test(options)
        reset_options = RunLoop::Core.send(:detect_reset_options, options)

        app = app_details[:app]
        bundle_id = app_details[:bundle_id]

        # process name and dylib path
        dylib_injection_details = Client.details_for_dylib_injection(device,
                                                                     options,
                                                                     app_details)

        default_options = {
            :xcode => xcode
        }

        merged_options = default_options.merge(options)

        if device.simulator? && app
          RunLoop::Core.expect_simulator_compatible_arch(device, app)

          if merged_options[:relaunch_simulator]
            RunLoop.log_debug("Detected :relaunch_simulator option; will force simulator to restart")
            RunLoop::CoreSimulator.quit_simulator
          end

          core_sim = RunLoop::CoreSimulator.new(device, app, merged_options)

          if reset_options
            core_sim.reset_app_sandbox
          end

          core_sim.install
        end

        cbx_launcher = Client.detect_cbx_launcher(merged_options, device)

        code_sign_identity = options[:code_sign_identity]
        if !code_sign_identity
          code_sign_identity = RunLoop::Environment::code_sign_identity
        end

        install_timeout = options.fetch(:device_agent_install_timeout,
                                                     DEFAULTS[:device_agent_install_timeout])
        shutdown_before_launch = options.fetch(:shutdown_device_agent_before_launch,
                                               DEFAULTS[:shutdown_device_agent_before_launch])
        aut_args = options.fetch(:args, [])
        aut_env = options.fetch(:env, {})

        if !aut_args.include?(AUT_LAUNCHED_BY_RUN_LOOP_ARG)
          aut_args << AUT_LAUNCHED_BY_RUN_LOOP_ARG
        end

        launcher_options = {
            code_sign_identity: code_sign_identity,
            device_agent_install_timeout: install_timeout,
            shutdown_device_agent_before_launch: shutdown_before_launch,
            dylib_injection_details: dylib_injection_details,
            aut_args: aut_args,
            aut_env: aut_env
        }

        xcuitest = RunLoop::DeviceAgent::Client.new(bundle_id, device,
                                                    cbx_launcher, launcher_options)
        xcuitest.launch

        if !RunLoop::Environment.xtc?
          cache = {
            :udid => device.udid,
            :app => bundle_id,
            :automator => :device_agent,
            :code_sign_identity => code_sign_identity,
            :launcher => cbx_launcher.name,
            :launcher_pid => xcuitest.launcher_pid,
            :launcher_options => xcuitest.launcher_options
          }
          RunLoop::Cache.default.write(cache)
        end
        xcuitest
      end

      # @!visibility private
      #
      # @param [RunLoop::Device] device the device under test
      def self.default_cbx_launcher(device)
        RunLoop::DeviceAgent::IOSDeviceManager.new(device)
      end

      # @!visibility private
      # @param [Hash] options the options passed by the user
      # @param [RunLoop::Device] device the device under test
      def self.detect_cbx_launcher(options, device)
        value = options[:cbx_launcher]
        if value
          if value == :xcodebuild
            RunLoop::DeviceAgent::Xcodebuild.new(device)
          elsif value == :ios_device_manager
            RunLoop::DeviceAgent::IOSDeviceManager.new(device)
          else
            raise(ArgumentError,
                  "Expected :cbx_launcher => #{value} to be :xcodebuild or :ios_device_manager")
          end
        else
          Client.default_cbx_launcher(device)
        end
      end

      def self.details_for_dylib_injection(device, options, app_details)
        dylib_path = RunLoop::DylibInjector.dylib_path_from_options(options)

        return nil if !dylib_path

        if device.physical_device?
          raise ArgumentError, %Q[

Detected :inject_dylib option when targeting a physical device:

  #{device}

Injecting the Calabash iOS Server is not supported on physical devices.

]
        end

        app = app_details[:app]
        bundle_id = app_details[:bundle_id]

        details = { dylib_path: dylib_path }

        if !app
          # Special case handling of the Settings.app
          if bundle_id == "com.apple.Preferences"
            details[:process_name] = "Preferences"
          else
            raise ArgumentError, %Q[

Detected :inject_dylib option, but the target application is a bundle identifier:

  app: #{bundle_id}

To use dylib injection, you must provide a path to an .app bundle.

]
          end
        else
          details[:process_name] = app.executable_name
        end
        details
      end

=begin
INSTANCE METHODS
=end
      attr_reader :bundle_id, :device, :cbx_launcher, :launcher_options, :launcher_pid

      # @!visibility private
      #
      # The app with `bundle_id` needs to be installed.
      #
      # @param [String] bundle_id The identifier of the app under test.
      # @param [RunLoop::Device] device The device under test.
      # @param [RunLoop::DeviceAgent::LauncherStrategy] cbx_launcher The entity that
      #  launches the CBXRunner.
      def initialize(bundle_id, device, cbx_launcher, launcher_options)
        @bundle_id = bundle_id
        @device = device
        @cbx_launcher = cbx_launcher
        @launcher_options = launcher_options

        if !@launcher_options[:device_agent_install_timeout]
          default = DEFAULTS[:device_agent_install_timeout]
          @launcher_options[:device_agent_install_timeout] = default
        end
      end

      # @!visibility private
      def to_s
        "#<DeviceAgent #{url} : #{bundle_id} : #{device} : #{cbx_launcher}>"
      end

      # @!visibility private
      def inspect
        to_s
      end

      # @!visibility private
      def launch
        start = Time.now
        launch_cbx_runner
        launch_aut
        elapsed = Time.now - start
        RunLoop.log_debug("Took #{elapsed} seconds to launch #{bundle_id} on #{device}")
        true
      end

      # @!visibility private
      def running?
        begin
          health(ping_options)
        rescue => _
          nil
        end
      end

      # @!visibility private
      def stop
        if RunLoop::Environment.xtc?
          RunLoop.log_error("Calling shutdown on the XTC is not supported.")
          return
        end

        begin
          shutdown
        rescue => _
          nil
        end
      end

      # @!visibility private
      #
      # Experimental!
      #
      # This will launch the other app using the same arguments and environment
      # as the AUT.
      def launch_other_app(bundle_id)
        launch_aut(bundle_id)
      end

      # @!visibility private
      def device_info
        options = http_options
        request = request("device")
        client = http_client(options)
        response = client.get(request)
        expect_300_response(response)
      end

      # @!visibility private
      def server_version
        options = http_options
        request = request("version")
        client = http_client(options)
        response = client.get(request)
        expect_300_response(response)
      end

      # @!visibility private
      def tree
        options = tree_http_options
        request = request("tree")
        client = http_client(options)
        response = client.get(request)
        expect_300_response(response)
      end

      # @!visibility private
      def keyboard_visible?
        options = http_options
        parameters = { :type => "Keyboard" }
        request = request("query", parameters)
        client = http_client(options)
        response = client.post(request)
        hash = expect_300_response(response)
        result = hash["result"]
        result.count != 0
      end

      # @!visibility private
      def clear_text
        # Tries to touch the keyboard delete key, but falls back on typing the
        # backspace character.
        options = enter_text_http_options("\b")
        parameters = {
          :gesture => "clear_text"
        }
        request = request("gesture", parameters)
        client = http_client(options)
        response = client.post(request)
        expect_300_response(response)
      end

      # @!visibility private
      def enter_text(string)
        if !keyboard_visible?
          raise RuntimeError, "Keyboard must be visible"
        end
        options = enter_text_http_options(string.to_s)
        parameters = {
          :gesture => "enter_text",
          :options => {
            :string => string.to_s
          }
        }
        request = request("gesture", parameters)
        client = http_client(options)
        response = client.post(request)
        expect_300_response(response)
      end

      # @!visibility private
      #
      # Some clients are performing keyboard checks _before_ calling #enter_text.
      #
      # 1. Removes duplicate check.
      # 2. It turns out DeviceAgent query can be very slow.
      def enter_text_without_keyboard_check(string)
        options = enter_text_http_options(string.to_s)
        parameters = {
          :gesture => "enter_text",
          :options => {
            :string => string.to_s
          }
        }
        request = request("gesture", parameters)
        client = http_client(options)
        response = client.post(request)
        expect_300_response(response)
      end

      # @!visibility private
      #
      # @example
      #  query({id: "login", :type "Button"})
      #
      #  query({marked: "login"})
      #
      #  query({marked: "login", type: "TextField"})
      #
      #  query({type: "Button", index: 2})
      #
      #  query({text: "Log in"})
      #
      #  query({id: "hidden button", :all => true})
      #
      #  # Escaping single quote is not necessary, but supported.
      #  query({text: "Karl's problem"})
      #  query({text: "Karl\'s problem"})
      #
      #  # Escaping double quote is not necessary, but supported.
      #  query({text: "\"To know is not enough.\""})
      #  query({text: %Q["To know is not enough."]})
      #
      #  # Equivalent to Calabash query("*")
      #  query({})
      #
      #  # Equivalent to Calabash query("all *")
      #  query({all: true})
      #
      # Querying for text with newlines is not supported yet.
      #
      # The query language supports the following keys:
      # * :marked - accessibilityIdentifier, accessibilityLabel, text, and value
      # * :id - accessibilityIdentifier
      # * :type - an XCUIElementType shorthand, e.g. XCUIElementTypeButton =>
      #   Button. See the link below for available types.  Note, however that
      #   some XCUIElementTypes are not available on iOS.
      # * :index - Applied after all other specifiers.
      # * :all - Filter the result by visibility. Defaults to false. See the
      #   discussion below about visibility.
      #
      # ### Visibility
      #
      # The rules for visibility are:
      #
      # 1. If any part of the view is visible, the visible.
      # 2. If the view has alpha 0, it is not visible.
      # 3. If the view has a size (0,0) it is not visible.
      # 4. If the view is not within the bounds of the screen, it is not visible.
      #
      # Visibility is determined using the "hitable" XCUIElement property.
      # XCUITest, particularly under Xcode 7, is not consistent about setting
      # the "hitable" property correctly.  Views that are not "hitable" might
      # respond to gestures.
      #
      # Regarding rule #1 - this is different from the Calabash iOS and Android
      # definition of visibility which requires the mid-point of the view to be
      # visible.
      #
      # ### Results
      #
      # Results are returned as an Array of Hashes.
      #
      # ```
      # [
      #  {
      #    "enabled": true,
      #    "id": "mostly hidden button",
      #    "hitable": true,
      #    "rect": {
      #      "y": 459,
      #      "x": 24,
      #      "height": 25,
      #      "width": 100
      #    },
      #    "label": "Mostly Hidden",
      #    "type": "Button",
      #    "hit_point": {
      #      "x": 25,
      #      "y": 460
      #    },
      #    "test_id": 1
      #  }
      # ]
      # ```
      #
      # @see http://masilotti.com/xctest-documentation/Constants/XCUIElementType.html
      # @param [Hash] uiquery A hash describing the query.
      # @return [Array<Hash>] An array of elements matching the `uiquery`.
      def query(uiquery)
        merged_options = {
          all: false
        }.merge(uiquery)

        allowed_keys = [:all, :id, :index, :marked, :text, :type]
        unknown_keys = uiquery.keys - allowed_keys
        if !unknown_keys.empty?
          keys = allowed_keys.map { |key| ":#{key}" }.join(", ")
          raise ArgumentError, %Q[
Unsupported key or keys found: '#{unknown_keys}'.

Allowed keys for a query are:

#{keys}

]
        end

        if _wildcard_query?(uiquery)
          elements = _flatten_tree
        else
          parameters = merged_options.dup.tap { |hs| hs.delete(:all) }
          if parameters.empty?
            keys = allowed_keys.map { |key| ":#{key}" }.join(", ")
            raise ArgumentError, %Q[
Query must contain at least one of these keys:

#{keys}

]
          end

          request = request("query", parameters)
          client = http_client(http_options)

          RunLoop.log_debug %Q[Sending query with parameters:

#{JSON.pretty_generate(parameters)}

]

          response = client.post(request)
          hash = expect_300_response(response)
          elements = hash["result"]
        end

        if merged_options[:all]
          elements
        else
          elements.select do |element|
            element["hitable"]
          end
        end
      end

      # @!visibility private
      def alert
        parameters = { :type => "Alert" }
        request = request("query", parameters)
        client = http_client(http_options)
        response = client.post(request)
        hash = expect_300_response(response)
        hash["result"]
      end

      # @!visibility private
      def alert_visible?
        !alert.empty?
      end

      # @!visibility private
      def springboard_alert
        request = request("springboard-alert")
        client = http_client(http_options)
        response = client.get(request)
        hash = expect_300_response(response)
        hash["result"]
      end

      # @!visibility private
      def springboard_alert_visible?
        !springboard_alert.empty?
      end

      # @!visibility private
      # @see #query
      def query_for_coordinate(uiquery)
        element = wait_for_view(uiquery)
        coordinate_from_query_result([element])
      end

      # @!visibility private
      #
      # :num_fingers
      # :duration
      # :repetitions
      # @see #query
      def touch(uiquery, options={})
        coordinate = query_for_coordinate(uiquery)
        perform_coordinate_gesture("touch", coordinate[:x], coordinate[:y], options)
      end

      # @!visibility private
      # @see #touch
      def touch_coordinate(coordinate, options={})
        x = coordinate[:x] || coordinate["x"]
        y = coordinate[:y] || coordinate["y"]
        touch_point(x, y, options)
      end

      # @!visibility private
      # @see #touch
      def touch_point(x, y, options={})
        perform_coordinate_gesture("touch", x, y, options)
      end

      # @!visibility private
      # @see #touch
      # @see #query
      def double_tap(uiquery, options={})
        coordinate = query_for_coordinate(uiquery)
        perform_coordinate_gesture("double_tap",
                                   coordinate[:x], coordinate[:y],
                                   options)
      end

      # @!visibility private
      # @see #touch
      # @see #query
      def two_finger_tap(uiquery, options={})
        coordinate = query_for_coordinate(uiquery)
        perform_coordinate_gesture("two_finger_tap",
                                   coordinate[:x], coordinate[:y],
                                   options)
      end

      # @!visibility private
      # @see #touch
      # @see #query
      def long_press(uiquery, options={})
        merged_options = {
          :duration => 1.1
        }.merge(options)

        coordinate = query_for_coordinate(uiquery)
        perform_coordinate_gesture("touch", coordinate[:x], coordinate[:y],
                                   merged_options)
      end

      # @!visibility private
      def rotate_home_button_to(position, sleep_for=1.0)
        orientation = normalize_orientation_position(position)
        parameters = {
          :orientation => orientation
        }
        request = request("rotate_home_button_to", parameters)
        client = http_client(http_options)
        response = client.post(request)
        json = expect_300_response(response)
        sleep(sleep_for)
        json
      end

      # @!visibility private
      def pan_between_coordinates(start_point, end_point, options={})
        default_options = {
          :num_fingers => 1,
          :duration => 0.5
        }

        merged_options = default_options.merge(options)

        parameters = {
          :gesture => "drag",
          :specifiers => {
            :coordinates => [start_point, end_point]
          },
          :options => merged_options
        }

        make_gesture_request(parameters)
      end

      # @!visibility private
      def perform_coordinate_gesture(gesture, x, y, options={})
        parameters = {
          :gesture => gesture,
          :specifiers => {
            :coordinate => {x: x, y: y}
          },
          :options => options
        }

        make_gesture_request(parameters)
      end

      # @!visibility private
      def make_gesture_request(parameters)

        RunLoop.log_debug %Q[Sending request to perform '#{parameters[:gesture]}' with:

#{JSON.pretty_generate(parameters)}

]
        request = request("gesture", parameters)
        client = http_client(http_options)
        response = client.post(request)
        expect_300_response(response)
      end

      # @!visibility private
      def coordinate_from_query_result(matches)

        if matches.nil? || matches.empty?
          raise "Expected #{hash} to contain some results"
        end

        rect = matches.first["rect"]
        h = rect["height"]
        w = rect["width"]
        x = rect["x"]
        y = rect["y"]

        touchx = x + (w/2.0)
        touchy = y + (h/2.0)

        new_rect = rect.dup
        new_rect[:center_x] = touchx
        new_rect[:center_y] = touchy

        RunLoop.log_debug(%Q[Rect from query:

#{JSON.pretty_generate(new_rect)}

])
        {:x => touchx,
         :y => touchy}
      end

      # @!visibility private
      def change_volume(up_or_down)
        string = up_or_down.to_s
        parameters = {
          :volume => string
        }
        request = request("volume", parameters)
        client = http_client(http_options)
        response = client.post(request)
        json = expect_300_response(response)
        # Set in the route
        sleep(0.2)
        json
      end

      # TODO: animation model
      def wait_for_animations
        sleep(0.5)
      end

      # @!visibility private
      def wait_for(timeout_message, options={}, &block)
        wait_options = WAIT_DEFAULTS.merge(options)
        timeout = wait_options[:timeout]
        exception_class = wait_options[:exception_class]
        with_timeout(timeout, timeout_message, exception_class) do
          loop do
            value = block.call
            return value if value
            sleep(wait_options[:retry_frequency])
          end
        end
      end

      # @!visibility private
      def wait_for_keyboard(timeout=WAIT_DEFAULTS[:timeout])
        options = WAIT_DEFAULTS.dup
        options[:timeout] = timeout
        message = %Q[

Timed out after #{timeout} seconds waiting for the keyboard to appear.

]
        wait_for(message, options) do
          keyboard_visible?
        end
      end

      # @!visibility private
      def wait_for_no_keyboard(timeout=WAIT_DEFAULTS[:timeout])
        options = WAIT_DEFAULTS.dup
        options[:timeout] = timeout
        message = %Q[

Timed out after #{timeout} seconds waiting for the keyboard to disappear.

]
        wait_for(message, options) do
          !keyboard_visible?
        end
      end

      # @!visibility private
      def wait_for_alert(timeout=WAIT_DEFAULTS[:timeout])
        options = WAIT_DEFAULTS.dup
        options[:timeout] = timeout
        message = %Q[

Timed out after #{timeout} seconds waiting for an alert to appear.

]
        wait_for(message, options) do
          alert_visible?
        end
      end

      # @!visibility private
      def wait_for_no_alert(timeout=WAIT_DEFAULTS[:timeout])
        options = WAIT_DEFAULTS.dup
        options[:timeout] = timeout
        message = %Q[

Timed out after #{timeout} seconds waiting for an alert to disappear.

]

        wait_for(message, options) do
          !alert_visible?
        end
      end

      # @!visibility private
      def wait_for_text_in_view(text, uiquery, options={})
        merged_options = WAIT_DEFAULTS.merge(options)

        begin
          wait_for("TMP", merged_options) do
            view = query(uiquery).first

            if view
              # Guard against this edge case:
              #
              # Text is "" and value or label keys do not exist in view which
              # implies that value or label was the empty string (see the
              # DeviceAgent JSONUtils and Facebook macros).
              if text == "" || text == nil
                view["value"] == nil && view["label"] == nil
              else
                [view["value"], view["label"]].any? { |elm| elm == text }
              end
            else
              false
            end
          end
        rescue merged_options[:exception_class] => e
          view = query(uiquery)
          if !view
            message = %Q[
Timed out wait after #{merged_options[:timeout]} seconds waiting for a view to match:

  #{uiquery}

]
          else
            message = %Q[
Timed out after #{merged_options[:timeout]} seconds waiting for a view matching:

  '#{uiquery}'

to have 'value' or 'label' matching text:

  '#{text}'

Found:

#{JSON.pretty_generate(view)}

]
          end
          fail(merged_options[:exception_class], message)
        end
      end

      # @!visibility private
      def wait_for_view(uiquery, options={})
        merged_options = WAIT_DEFAULTS.merge(options)

        unless merged_options[:message]
          message = %Q[

Waited #{merged_options[:timeout]} seconds for

#{uiquery}

to match a view.

]
          merged_options[:timeout_message] = message
        end

        result = nil
        wait_for(merged_options[:timeout_message], options) do
          result = query(uiquery)
          !result.empty?
        end

        result[0]
      end

      # @!visibility private
      def wait_for_no_view(uiquery, options={})
        merged_options = WAIT_DEFAULTS.merge(options)
        unless merged_options[:message]
          message = %Q[

Waited #{merged_options[:timeout]} seconds for

#{uiquery}

to match no views.

]
          merged_options[:timeout_message] = message
        end

        result = nil
        wait_for(merged_options[:timeout_message], options) do
          result = query(uiquery)
          result.empty?
        end

        result[0]
      end

      # @!visibility private
      class PrivateWaitTimeoutError < RuntimeError ; end

      # @!visibility private
      def with_timeout(timeout, timeout_message,
                       exception_class=WAIT_DEFAULTS[:exception_class], &block)
        if timeout_message.nil? ||
          (timeout_message.is_a?(String) && timeout_message.empty?)
          raise ArgumentError, 'You must provide a timeout message'
        end

        unless block_given?
          raise ArgumentError, 'You must provide a block'
        end

        # Timeout.timeout will never timeout if the given `timeout` is zero.
        # We will raise an exception if the timeout is zero.
        # Timeout.timeout already raises an exception if `timeout` is negative.
        if timeout == 0
          raise ArgumentError, 'Timeout cannot be 0'
        end

        message = if timeout_message.is_a?(Proc)
          timeout_message.call({timeout: timeout})
        else
          timeout_message
        end

        failed = false

        begin
          Timeout.timeout(timeout, PrivateWaitTimeoutError) do
            return block.call
          end
        rescue PrivateWaitTimeoutError => _
          # If we raise Timeout here the stack trace will be cluttered and we
          # wish to show the user a clear message, avoiding
          # "`rescue in with_timeout':" in the stack trace.
          failed = true
        end

        if failed
          fail(exception_class, message)
        end
      end

      # @!visibility private
      def fail(*several_variants)
        arg0 = several_variants[0]
        arg1 = several_variants[1]

        if arg1.nil?
          exception_type = RuntimeError
          message = arg0
        else
          exception_type = arg0
          message = arg1
        end

        raise exception_type, message
      end

=begin
PRIVATE
=end
      private

      attr_reader :http_client

      # @!visibility private
      def xcrun
        RunLoop::Xcrun.new
      end

      # @!visibility private
      def url
        @url ||= detect_device_agent_url
      end

      # @!visibility private
      def detect_device_agent_url
        url_from_environment ||
          url_for_simulator ||
          url_from_device_endpoint ||
          url_from_device_name
      end

      # @!visibility private
      def url_from_environment
        url = RunLoop::Environment.device_agent_url
        return if url.nil?

        if url.end_with?("/")
          url
        else
          "#{url}/"
        end
      end

      # @!visibility private
      def url_for_simulator
        if device.simulator?
          "http://#{DEFAULTS[:simulator_ip]}:#{DEFAULTS[:port]}/"
        else
          nil
        end
      end

      # @!visibility private
      def url_from_device_endpoint
        calabash_endpoint = RunLoop::Environment.device_endpoint
        if calabash_endpoint
          base = calabash_endpoint.split(":")[0..1].join(":")
          "#{base}:#{DEFAULTS[:port]}/"
        else
          nil
        end
      end

      # @!visibility private
      # TODO This block is not well tested
      # TODO extract to a module; Calabash can use to detect device endpoint
      def url_from_device_name
        # Transforms the default "Joshua's iPhone" to a DNS name.
        device_name = device.name.gsub(/[']/, "").gsub(/[\s]/, "-")

        # Replace diacritic markers and unknown characters.
        transliterated = transliterate(device_name).tr("?", "")

        # Anything that cannot be transliterated is a ?
        replaced = transliterated.tr("?", "")

        "http://#{replaced}.local:#{DEFAULTS[:port]}/"
      end

      # @!visibility private
      def server
        @server ||= RunLoop::HTTP::Server.new(url)
      end

      # @!visibility private
      def http_client(options={})
        if !@http_client
          @http_client = RunLoop::HTTP::RetriableClient.new(server, options)
        else
          # If the options are different, create a new client
          if options[:retries] != @http_client.retries ||
            options[:timeout] != @http_client.timeout ||
            options[:interval] != @http_client.interval
            reset_http_client!
            @http_client = RunLoop::HTTP::RetriableClient.new(server, options)
          else
          end
        end
        @http_client
      end

      # @!visibility private
      def reset_http_client!
        if @http_client
          @http_client.reset_all!
          @http_client = nil
        end
      end

      # @!visibility private
      def versioned_route(route)
        "#{DEFAULTS[:route_version]}/#{route}"
      end

      # @!visibility private
      def request(route, parameters={})
        versioned = versioned_route(route)
        RunLoop::HTTP::Request.request(versioned, parameters)
      end

      # @!visibility private
      def ping_options
        @ping_options ||= { :timeout => 0.5, :retries => 1 }
      end

      # @!visibility private
      def http_options
        if cbx_launcher.name == :xcodebuild
          timeout = DEFAULTS[:http_timeout] * 2
          {
            :timeout => timeout,
            :interval => 0.1,
            :retries => (timeout/0.1).to_i
          }
        else
          {
            :timeout => DEFAULTS[:http_timeout],
            :interval => 0.1,
            :retries => (DEFAULTS[:http_timeout]/0.1).to_i
          }
        end
      end

      # @!visibility private
      #
      # Tree can take a very long time.
      def tree_http_options
        timeout = DEFAULTS[:http_timeout] * 6
        {
          :timeout => timeout,
          :interval => 0.1,
          :retries => (timeout/0.1).to_i
        }
      end

      # @!visibility private
      #
      # A patch while we are trying to figure out what is wrong with text entry.
      def enter_text_http_options(string)
        characters = string.length + 1
        characters_per_second = DEFAULTS[:characters_per_second]
        to_type_timeout = [characters/characters_per_second, 2.0].max
        timeout = (DEFAULTS[:http_timeout] * 3) + to_type_timeout
        {
          :timeout => timeout,
          :interval => 0.1,
          :retries => (timeout/0.1).to_i
        }
      end

      # @!visibility private
      def server_pid
        options = http_options
        request = request("pid")
        client = http_client(options)
        response = client.get(request)
        expect_300_response(response)
      end

      # @!visibility private
      def session_identifier
        options = http_options
        request = request("sessionIdentifier")
        client = http_client(options)
        response = client.get(request)
        expect_300_response(response)
      end

      # @!visibility private
      def session_delete
        # https://xamarin.atlassian.net/browse/TCFW-255
        # httpclient is unable to send a valid DELETE
        args = ["curl", "-X", "DELETE", %Q[#{url}#{versioned_route("session")}]]

        begin
          run_shell_command(args, {:log_cmd => true, :timeout => 10})
        rescue Shell::TimeoutError => _
          RunLoop.log_debug("Timed out calling DELETE session/ after 10 seconds")
        end
      end

      # @!visibility private
      def shutdown

        if RunLoop::Environment.xtc?
          RunLoop.log_error("Calling shutdown on the XTC is not supported.")
          return
        end

        begin
          if !running?
            RunLoop.log_debug("DeviceAgent-Runner is not running")
          else
            session_delete

            request = request("shutdown")
            client = http_client(ping_options)
            response = client.post(request)
            hash = expect_300_response(response)
            message = hash["message"]

            RunLoop.log_debug(%Q[DeviceAgent-Runner says, "#{message}"])

            now = Time.now
            poll_until = now + 10.0
            stopped = false
            while Time.now < poll_until
              stopped = !running?
              break if stopped
              sleep(0.1)
            end

            RunLoop.log_debug("Waited for #{Time.now - now} seconds for DeviceAgent to shutdown")
          end
        rescue RunLoop::DeviceAgent::Client::HTTPError => e
          RunLoop.log_debug("DeviceAgent-Runner shutdown error: #{e.message}")
        ensure
          if @launcher_pid
            term_options = { :timeout => 1.5 }
            kill_options = { :timeout => 1.0 }

            process_name = cbx_launcher.name
            pid = @launcher_pid.to_i

            term = RunLoop::ProcessTerminator.new(pid, "TERM", process_name, term_options)
            if !term.kill_process
              kill = RunLoop::ProcessTerminator.new(pid, "KILL", process_name, kill_options)
              kill.kill_process
            end

            if process_name == :xcodebuild
              sleep(10)
            end
          end
        end
        hash
      end

      # @!visibility private
      def health(options={})
        merged_options = http_options.merge(options)
        request = request("health")
        client = http_client(merged_options)
        response = client.get(request)
        hash = expect_300_response(response)
        status = hash["status"]
        RunLoop.log_debug(%Q[DeviceAgent says, "#{status}"])
        hash
      end

      # @!visibility private
      def cbx_runner_stale?
        return false if RunLoop::Environment.xtc?
        return false if cbx_launcher.name == :xcodebuild
        return false if !running?

        version_info = server_version
        running_version_timestamp = version_info[:bundle_version].to_i

        app = RunLoop::App.new(cbx_launcher.runner.runner)
        plist_buddy = RunLoop::PlistBuddy.new
        version_timestamp = plist_buddy.plist_read("CFBundleVersion", app.info_plist_path).to_i

        if running_version_timestamp == version_timestamp
          RunLoop.log_debug("The running DeviceAgent version is the same as the version on disk")
          false
        else
          RunLoop.log_debug("The running DeviceAgent version is not the same as the version on disk")
          true
        end
      end

      # @!visibility private
      def launch_cbx_runner
        options = launcher_options

        if options[:shutdown_device_agent_before_launch]
          RunLoop.log_debug("Launch options insist that the DeviceAgent be shutdown")
          shutdown
        end

        if cbx_runner_stale?
          RunLoop.log_debug("The DeviceAgent that is running is stale; shutting down")
          shutdown
        end

        if running?
          RunLoop.log_debug("DeviceAgent is already running")
          return true
        end

        start = Time.now
        RunLoop.log_debug("Waiting for DeviceAgent to launch...")
        @launcher_pid = cbx_launcher.launch(options)

        begin
          timeout = options[:device_agent_install_timeout] * 1.5
          health_options = {
            :timeout => timeout,
            :interval => 0.1,
            :retries => (timeout/0.1).to_i
          }
          health(health_options)
        rescue RunLoop::HTTP::Error => _
          raise %Q[

Could not connect to the DeviceAgent service.

device: #{device}
   url: #{url}

To diagnose the problem tail the launcher log file:

$ tail -1000 -F #{cbx_launcher_log_file}

]
        end

        RunLoop.log_debug("Took #{Time.now - start} launch and respond to /health")
        true
      end

      # @!visibility private
      def launch_aut(bundle_id = @bundle_id)
        # This check needs to be done _before_ the DeviceAgent is launched.
        if device.simulator?
          # Yes, we could use iOSDeviceManager to check, I dont understand the
          # behavior yet - does it require the simulator be launched?
          # CoreSimulator can check without launching the simulator.
          installed = CoreSimulator.app_installed?(device, bundle_id)
        else
          if cbx_launcher.name == :xcodebuild
            # :xcodebuild users are on their own.
            RunLoop.log_debug("Detected :xcodebuild launcher; skipping app installed check")
            installed = true
          else
            # Too slow for most devices
            # https://jira.xamarin.com/browse/TCFW-273
            # installed = cbx_launcher.app_installed?(bundle_id)
            installed = true
          end
        end

        if !installed
          raise RuntimeError, %Q[
The app you are trying to launch is not installed on the target device:

bundle identifier: #{bundle_id}
           device: #{device}

Please install it.

]
        end

        retries = 5

        # Launch arguments and environment arguments cannot be nil
        # The public interface Client.run has a guard against this, but
        # internal callers to do not.
        aut_args = launcher_options.fetch(:aut_args, [])
        aut_env = launcher_options.fetch(:aut_env, {})
        begin
          client = http_client(http_options)
          request = request("session",
                            {
                              :bundle_id => bundle_id,
                              :launchArgs => aut_args,
                              :environment => aut_env
                            })
          response = client.post(request)
          RunLoop.log_debug("Launched #{bundle_id} on #{device}")
          RunLoop.log_debug("#{response.body}")

          expect_300_response(response)

          # Dylib injection.  DeviceAgent.run checks the arguments.
          dylib_injection_details = launcher_options[:dylib_injection_details]
          if dylib_injection_details
            process_name = dylib_injection_details[:process_name]
            dylib_path = dylib_injection_details[:dylib_path]
            injector = RunLoop::DylibInjector.new(process_name, dylib_path)
            injector.retriable_inject_dylib
          end
        rescue => e
          retries = retries - 1
          if !RunLoop::Environment.xtc?
            if retries >= 0
              if !running?
                RunLoop.log_debug("The DeviceAgent stopped running after POST /session; retrying")
                launch_cbx_runner
              else
                RunLoop.log_debug("Failed to launch the AUT: #{bundle_id}; retrying")
              end
              retry
            end
          end

          raise e.class, %Q[

Could not launch #{bundle_id} on #{device}:

#{e.message}

Something went wrong.

]
        end
      end

      # @!visibility private
      def response_body_to_hash(response)
        body = response.body
        begin
          JSON.parse(body)
        rescue TypeError, JSON::ParserError => _
          raise RunLoop::DeviceAgent::Client::HTTPError, %Q[
Could not parse response from server:

body => "#{body}"

If the body empty, the DeviceAgent has probably crashed.

]
        end
      end

      # @!visibility private
      def expect_300_response(response)
        body = response_body_to_hash(response)
        if response.status_code < 400 && !body["error"]
          return body
        end

        reset_http_client!

        if response.status_code >= 400
          raise RunLoop::DeviceAgent::Client::HTTPError,
                %Q[
Expected status code < 400, found #{response.status_code}.

Server replied with:

#{body}

]
        else
          raise RunLoop::DeviceAgent::Client::HTTPError,
                %Q[
Expected JSON response with no error, but found

#{body["error"]}

]
        end
      end

      # @!visibility private
      def normalize_orientation_position(position)
        if position.is_a?(Symbol)
          orientation_for_position_symbol(position)
        elsif position.is_a?(Fixnum)
          position
        else
          raise ArgumentError, %Q[
Expected #{position} to be a Symbol or Fixnum but found #{position.class}

]
        end
      end

      # @!visibility private
      def orientation_for_position_symbol(position)
        symbol = position.to_sym

        case symbol
          when :down, :bottom
            return 1
          when :up, :top
            return 2
          when :right
            return 3
          when :left
            return 4
          else
            raise ArgumentError, %Q[
Could not coerce '#{position}' into a valid orientation.

Valid values are: :down, :up, :right, :left, :bottom, :top

]
        end
      end

      # @!visibility private
      def cbx_launcher_log_file
        if cbx_launcher.name == :ios_device_manager
          # The location of the iOSDeviceManager logs has changed
          File.join(RunLoop::Environment.user_home_directory,
                    ".calabash", "iOSDeviceManager", "logs", "current.log")
        else
          cbx_launcher.class.log_file
        end
      end

      # @!visibility private
      # Private method.  Do not call.
      # Flattens the result of `tree`.
      def _flatten_tree
        result = []
        _flatten_tree_helper(tree, result)
        result
      end

      # @!visibility private
      # Private method.  Do not call.
      def _flatten_tree_helper(tree, accumulator_array)
        element_in_tree = {}
        tree.each_pair do |key, value|
          if key != "children"
            element_in_tree[key] = value
          end
        end
        accumulator_array.push(element_in_tree)

        if tree.key?("children")
          tree["children"].each do |subtree|
            _flatten_tree_helper(subtree, accumulator_array)
          end
        end
      end

      # @!visibility private
      # Private method.  Do not call.
      def _dismiss_springboard_alerts
        request = request("dismiss-springboard-alerts")
        client = http_client(http_options)
        response = client.post(request)
        expect_300_response(response)
      end

      # @!visibility private
      # Private method.  Do not call.
      def _wildcard_query?(uiquery)
        return true if uiquery.empty?
        return false if uiquery.count != 1

        uiquery.has_key?(:all)
      end

    end
  end
end