# encoding: utf-8
#
# Licensed to the Software Freedom Conservancy (SFC) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The SFC licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.

require 'json'

module Selenium
  module WebDriver
    module Remote
      #
      # Low level bridge to the remote server, through which the rest of the API works.
      #
      # @api private
      #

      class W3CBridge
        include BridgeHelper
        include Atoms

        # TODO: constant shouldn't be modified in class
        COMMANDS = {}

        #
        # Defines a wrapper method for a command, which ultimately calls #execute.
        #
        # @param name [Symbol]
        #   name of the resulting method
        # @param verb [Symbol]
        #   the appropriate http verb, such as :get, :post, or :delete
        # @param url [String]
        #   a URL template, which can include some arguments, much like the definitions on the server.
        #   the :session_id parameter is implicitly handled, but the remainder will become required method arguments.
        #

        def self.command(name, verb, url)
          COMMANDS[name] = [verb, url.freeze]
        end

        attr_accessor :context, :http, :file_detector
        attr_reader :capabilities

        #
        # Initializes the bridge with the given server URL.
        #
        # @param url         [String] url for the remote server
        # @param http_client [Object] an HTTP client instance that implements the same protocol as Http::Default
        # @param desired_capabilities [Capabilities] an instance of Remote::Capabilities describing the capabilities you want
        #

        def initialize(opts = {})
          edge_check(opts)

          opts = opts.dup

          port = opts.delete(:port) || 4444
          http_client = opts.delete(:http_client) { Http::Default.new }
          desired_capabilities = opts.delete(:desired_capabilities) { W3CCapabilities.firefox }
          url = opts.delete(:url) { "http://#{Platform.localhost}:#{port}/wd/hub" }

          desired_capabilities = W3CCapabilities.send(desired_capabilities) if desired_capabilities.is_a? Symbol

          desired_capabilities[:marionette] = opts.delete(:marionette) unless opts[:marionette].nil?

          unless opts.empty?
            raise ArgumentError, "unknown option#{'s' if opts.size != 1}: #{opts.inspect}"
          end

          uri = url.is_a?(URI) ? url : URI.parse(url)
          uri.path += '/' unless uri.path =~ %r{\/$}

          http_client.server_url = uri

          @http = http_client
          @capabilities = create_session(desired_capabilities)
          @file_detector = nil
        end

        def browser
          @browser ||= (
          name = @capabilities.browser_name
          name ? name.tr(' ', '_').to_sym : 'unknown'
          )
        end

        def edge_check(opts)
          caps = opts[:desired_capabilities]
          return unless caps && caps[:browser_name] && caps[:browser_name] == 'MicrosoftEdge'
          require_relative '../edge/legacy_support'
          extend Edge::LegacySupport
        end

        def driver_extensions
          [
            DriverExtensions::HasInputDevices,
            DriverExtensions::UploadsFiles,
            DriverExtensions::TakesScreenshot,
            DriverExtensions::HasSessionId,
            DriverExtensions::Rotatable,
            DriverExtensions::HasTouchScreen,
            DriverExtensions::HasRemoteStatus,
            DriverExtensions::HasWebStorage
          ]
        end

        #
        # Returns the current session ID.
        #

        def session_id
          @session_id || raise(Error::WebDriverError, 'no current session exists')
        end

        def create_session(desired_capabilities)
          # TODO - Remove this when Mozilla fixes bug
          desired_capabilities[:browser_name] = 'firefox' if desired_capabilities[:browser_name] == 'Firefox'

          resp = raw_execute :newSession, {}, {desiredCapabilities: desired_capabilities}
          @session_id = resp['sessionId']
          return W3CCapabilities.json_create resp['value'] if @session_id

          raise Error::WebDriverError, 'no sessionId in returned payload'
        end

        def status
          jwp = Selenium::WebDriver::Remote::Bridge::COMMANDS[:status]
          self.class.command(:status, jwp.first, jwp.last)
          execute :status
        end

        def get(url)
          execute :get, {}, {url: url}
        end

        def implicit_wait_timeout=(milliseconds)
          timeout('implicit', milliseconds)
        end

        def script_timeout=(milliseconds)
          timeout('script', milliseconds)
        end

        def timeout(type, milliseconds)
          execute :setTimeout, {}, {type: type, ms: milliseconds}
        end

        #
        # alerts
        #

        def accept_alert
          execute :acceptAlert
        end

        def dismiss_alert
          execute :dismissAlert
        end

        def alert=(keys)
          execute :sendAlertText, {}, {handler: 'prompt', text: keys}
        end

        def alert_text
          execute :getAlertText
        end

        #
        # navigation
        #

        def go_back
          execute :back
        end

        def go_forward
          execute :forward
        end

        def url
          execute :getCurrentUrl
        end

        def title
          execute :getTitle
        end

        def page_source
          execute_script('var source = document.documentElement.outerHTML;' \
                            'if (!source) { source = new XMLSerializer().serializeToString(document); }' \
                            'return source;')
        end

        def switch_to_window(name)
          execute :switchToWindow, {}, {handle: name}
        end

        def switch_to_frame(id)
          id = find_element_by('id', id) if id.is_a? String
          execute :switchToFrame, {}, {id: id}
        end

        def switch_to_parent_frame
          execute :switchToParentFrame
        end

        def switch_to_default_content
          switch_to_frame nil
        end

        QUIT_ERRORS = [IOError].freeze

        def quit
          execute :deleteSession
          http.close
        rescue *QUIT_ERRORS
        end

        def close
          execute :closeWindow
        end

        def refresh
          execute :refresh
        end

        #
        # window handling
        #

        def window_handles
          execute :getWindowHandles
        end

        def window_handle
          execute :getWindowHandle
        end

        def resize_window(width, height, handle = :current)
          unless handle == :current
            raise Error::WebDriverError, 'Switch to desired window before changing its size'
          end
          execute :setWindowSize, {}, {width: width,
                                       height: height}
        end

        def maximize_window(handle = :current)
          unless handle == :current
            raise Error::UnsupportedOperationError, 'Switch to desired window before changing its size'
          end
          execute :maximizeWindow
        end

        def full_screen_window
          execute :fullscreenWindow
        end

        def window_size(handle = :current)
          unless handle == :current
            raise Error::UnsupportedOperationError, 'Switch to desired window before getting its size'
          end
          data = execute :getWindowSize

          Dimension.new data['width'], data['height']
        end

        def reposition_window(_x, _y, _handle = nil)
          raise Error::UnsupportedOperationError, 'The W3C standard does not currently support setting the Window Position'
        end

        def window_position(_handle = nil)
          raise Error::UnsupportedOperationError, 'The W3C standard does not currently support getting the Window Position'
        end

        def screenshot
          execute :takeScreenshot
        end

        #
        # HTML 5
        #

        def local_storage_item(key, value = nil)
          if value
            execute_script("localStorage.setItem('#{key}', '#{value}')")
          else
            execute_script("return localStorage.getItem('#{key}')")
          end
        end

        def remove_local_storage_item(key)
          execute_script("localStorage.removeItem('#{key}')")
        end

        def local_storage_keys
          execute_script('return Object.keys(localStorage)')
        end

        def clear_local_storage
          execute_script('localStorage.clear()')
        end

        def local_storage_size
          execute_script('return localStorage.length')
        end

        def session_storage_item(key, value = nil)
          if value
            execute_script("sessionStorage.setItem('#{key}', '#{value}')")
          else
            execute_script("return sessionStorage.getItem('#{key}')")
          end
        end

        def remove_session_storage_item(key)
          execute_script("sessionStorage.removeItem('#{key}')")
        end

        def session_storage_keys
          execute_script('return Object.keys(sessionStorage)')
        end

        def clear_session_storage
          execute_script('sessionStorage.clear()')
        end

        def session_storage_size
          execute_script('return sessionStorage.length')
        end

        def location
          raise Error::UnsupportedOperationError, 'The W3C standard does not currently support getting location'
        end

        def set_location(_lat, _lon, _alt)
          raise Error::UnsupportedOperationError, 'The W3C standard does not currently support setting location'
        end

        def network_connection
          raise Error::UnsupportedOperationError, 'The W3C standard does not currently support getting network connection'
        end

        def network_connection=(_type)
          raise Error::UnsupportedOperationError, 'The W3C standard does not currently support setting network connection'
        end

        #
        # javascript execution
        #

        def execute_script(script, *args)
          result = execute :executeScript, {}, {script: script, args: args}
          unwrap_script_result result
        end

        def execute_async_script(script, *args)
          result = execute :executeAsyncScript, {}, {script: script, args: args}
          unwrap_script_result result
        end

        #
        # cookies
        #

        def add_cookie(cookie)
          execute :addCookie, {}, {cookie: cookie}
        end

        def delete_cookie(name)
          execute :deleteCookie, name: name
        end

        # TODO: - write specs
        def cookie(name)
          execute :getCookie, name: name
        end

        def cookies
          execute :getAllCookies
        end

        def delete_all_cookies
          cookies.each { |cookie| delete_cookie(cookie['name']) }
        end

        #
        # actions
        #

        def click_element(element)
          execute :elementClick, id: element.values.first
        end

        def click
          execute :click, {}, {button: 0}
        end

        def double_click
          execute :doubleClick
        end

        def context_click
          execute :click, {}, {button: 2}
        end

        def mouse_down
          execute :mouseDown
        end

        def mouse_up
          execute :mouseUp
        end

        def mouse_move_to(element, x = nil, y = nil)
          params = {element: element}

          if x && y
            params[:xoffset] = x
            params[:yoffset] = y
          end

          execute :mouseMoveTo, {}, params
        end

        def send_keys_to_active_element(keys)
          send_keys_to_element(active_element, keys)
        end

        # TODO: - Implement file verification
        def send_keys_to_element(element, keys)
          execute :elementSendKeys, {id: element.values.first}, {value: keys.join('').split(//)}
        end

        def clear_element(element)
          execute :elementClear, id: element.values.first
        end

        def submit_element(element)
          form = find_element_by('xpath', "./ancestor-or-self::form", element)
          execute_script("var e = arguments[0].ownerDocument.createEvent('Event');" \
                            "e.initEvent('submit', true, true);" \
                            'if (arguments[0].dispatchEvent(e)) { arguments[0].submit() }', form.as_json)
        end

        def drag_element(element, right_by, down_by)
          execute :dragElement, {id: element.values.first}, {x: right_by, y: down_by}
        end

        def touch_single_tap(element)
          execute :touchSingleTap, {}, {element: element}
        end

        def touch_double_tap(element)
          execute :touchDoubleTap, {}, {element: element}
        end

        def touch_long_press(element)
          execute :touchLongPress, {}, {element: element}
        end

        def touch_down(x, y)
          execute :touchDown, {}, {x: x, y: y}
        end

        def touch_up(x, y)
          execute :touchUp, {}, {x: x, y: y}
        end

        def touch_move(x, y)
          execute :touchMove, {}, {x: x, y: y}
        end

        def touch_scroll(element, x, y)
          if element
            execute :touchScroll, {}, {element: element,
                                       xoffset: x,
                                       yoffset: y}
          else
            execute :touchScroll, {}, {xoffset: x, yoffset: y}
          end
        end

        def touch_flick(xspeed, yspeed)
          execute :touchFlick, {}, {xspeed: xspeed, yspeed: yspeed}
        end

        def touch_element_flick(element, right_by, down_by, speed)
          execute :touchFlick, {}, {element: element,
                                    xoffset: right_by,
                                    yoffset: down_by,
                                    speed: speed}
        end

        def screen_orientation=(orientation)
          execute :setScreenOrientation, {}, {orientation: orientation}
        end

        def screen_orientation
          execute :getScreenOrientation
        end

        #
        # element properties
        #

        def element_tag_name(element)
          execute :getElementTagName, id: element.values.first
        end

        def element_attribute(element, name)
          execute_atom :getAttribute, element, name
        end

        def element_property(element, name)
          execute :getElementProperty, id: element.values.first, name: name
        end

        def element_value(element)
          element_property element, 'value'
        end

        def element_text(element)
          execute :getElementText, id: element.values.first
        end

        def element_location(element)
          data = execute :getElementRect, id: element.values.first

          Point.new data['x'], data['y']
        end

        def element_location_once_scrolled_into_view(element)
          send_keys_to_element(element, [''])
          element_location(element)
        end

        def element_size(element)
          data = execute :getElementRect, id: element.values.first

          Dimension.new data['width'], data['height']
        end

        def element_enabled?(element)
          execute :isElementEnabled, id: element.values.first
        end

        def element_selected?(element)
          execute :isElementSelected, id: element.values.first
        end

        def element_displayed?(element)
          jwp = Selenium::WebDriver::Remote::Bridge::COMMANDS[:isElementDisplayed]
          self.class.command(:isElementDisplayed, jwp.first, jwp.last)
          execute :isElementDisplayed, id: element.values.first
        end

        def element_value_of_css_property(element, prop)
          execute :getElementCssValue, id: element.values.first, property_name: prop
        end

        #
        # finding elements
        #

        def active_element
          Element.new self, execute(:getActiveElement)
        end

        alias_method :switch_to_active_element, :active_element

        def find_element_by(how, what, parent = nil)
          how, what = convert_locators(how, what)

          id = if parent
                 execute :findChildElement, {id: parent.values.first}, {using: how, value: what}
               else
                 execute :findElement, {}, {using: how, value: what}
               end
          Element.new self, id
        end

        def find_elements_by(how, what, parent = nil)
          how, what = convert_locators(how, what)

          ids = if parent
                  execute :findChildElements, {id: parent.values.first}, {using: how, value: what}
                else
                  execute :findElements, {}, {using: how, value: what}
                end

          ids.map { |id| Element.new self, id }
        end

        private

        def convert_locators(how, what)
          case how
          when 'class name'
            how = 'css selector'
            what = ".#{escape_css(what)}"
          when 'id'
            how = 'css selector'
            what = "##{escape_css(what)}"
          when 'name'
            how = 'css selector'
            what = "*[name='#{escape_css(what)}']"
          when 'tag name'
            how = 'css selector'
          end
          [how, what]
        end

        #
        # executes a command on the remote server.
        #
        #
        # Returns the 'value' of the returned payload
        #

        def execute(*args)
          result = raw_execute(*args)
          result.payload.key?('value') ? result['value'] : result
        end

        #
        # executes a command on the remote server.
        #
        # @return [WebDriver::Remote::Response]
        #

        def raw_execute(command, opts = {}, command_hash = nil)
          verb, path = COMMANDS[command] || raise(ArgumentError, "unknown command: #{command.inspect}")
          path = path.dup

          path[':session_id'] = @session_id if path.include?(':session_id')

          begin
            opts.each do |key, value|
              path[key.inspect] = escaper.escape(value.to_s)
            end
          rescue IndexError
            raise ArgumentError, "#{opts.inspect} invalid for #{command.inspect}"
          end

          puts "-> #{verb.to_s.upcase} #{path}" if $DEBUG
          http.call verb, path, command_hash
        end

        def escaper
          @escaper ||= defined?(URI::Parser) ? URI::Parser.new : URI
        end

        ESCAPE_CSS_REGEXP = /(['"\\#.:;,!?+<>=~*^$|%&@`{}\-\[\]\(\)])/
        UNICODE_CODE_POINT = 30

        # Escapes invalid characters in CSS selector.
        # @see https://mathiasbynens.be/notes/css-escapes
        def escape_css(string)
          string = string.gsub(ESCAPE_CSS_REGEXP) { |match| "\\#{match}" }
          if !string.empty? && string[0] =~ /[[:digit:]]/
            string = "\\#{UNICODE_CODE_POINT + Integer(string[0])} #{string[1..-1]}"
          end

          string
        end
      end # W3CBridge
    end # Remote
  end # WebDriver
end # Selenium
