"""Tools to help getting selenium and widgetastic browser instance to run UI
tests.
"""
import base64
import logging
import os
import time
import urllib
from datetime import datetime
from urllib.parse import unquote

import selenium
from fauxfactory import gen_string
from selenium import webdriver
from wait_for import wait_for
from widgetastic.browser import Browser
from widgetastic.browser import DefaultPlugin

from airgun import settings

try:
    import docker
except ImportError:
    # Let it fail later if not installed
    docker = None

try:
    import sauceclient
except ImportError:
    # Optional requirement, airgun will report results back to saucelabs if
    # installed
    sauceclient = None


LOGGER = logging.getLogger(__name__)


class DockerBrowserError(Exception):
    """Indicates any issue with DockerBrowser."""


def _sauce_ondemand_url(saucelabs_user, saucelabs_key):
    """Get sauce ondemand URL for a given user and key.

    :param str saucelabs_user: saucelabs username
    :param str saucelabs_key: saucelabs access key
    :return: string representing saucelabs ondemand URL
    """
    return 'http://{0}:{1}@ondemand.saucelabs.com:80/wd/hub'.format(
        saucelabs_user, saucelabs_key)


class SeleniumBrowserFactory(object):
    """Factory which creates selenium browser of desired provider (selenium,
    docker or saucelabs). Creates all required capabilities, passes certificate
    checks and applies other workarounds. It is also capable of finalizing the
    browser when it's not needed anymore (closes the browser, stops docker
    container, sends test results to saucelabs etc).

    Usage::

        # init factory
        factory = SeleniumBrowserFactory(test_name=test_name)

        # get factory browser
        selenium_browser = factory.get_browser()

        # navigate to desired url
        # [...]

        # perform post-init steps (e.g. skipping certificate error screen)
        factory.post_init()

        # perform your test steps
        # [...]

        # perform factory clean-up
        factory.finalize(passed)

    """

    def __init__(self, provider=None, browser=None, test_name=None, session_cookie=None):
        """Initializes factory with either specified or fetched from settings
        values.

        :param str optional provider: Browser provider name. One of
            ('selenium', 'docker', 'saucelabs', 'remote'). If none specified -
            :attr:`settings.selenium.browser` is used.
        :param str optional browser: Browser name. One of ('chrome', 'firefox',
            'ie', 'edge', 'phantomjs'). Not required for ``docker``
            provider as it currently supports firefox only. If none specified -
            :attr:`settings.selenium.webdriver` is used.
        :param str optional test_name: Name of the test using this factory. It
            is useful for `saucelabs` provider to update saucelabs job name, or
            for `docker` provider to create container with meaningful name, not
            used otherwise.
        :param requests.sessions.Session optional session_cookie: session object to be used
            to bypass login
        """
        self.provider = provider or settings.selenium.browser
        self.browser = browser or settings.selenium.webdriver
        self.test_name = test_name
        self._session = session_cookie
        self._docker = None
        self._webdriver = None

    def get_browser(self):
        """Returns selenium webdriver instance of selected ``provider`` and
        ``browser``.

        :return: selenium webdriver instance
        :raises: ValueError: If wrong ``provider`` or ``browser`` specified.
        """
        if self.provider == 'selenium':
            return self._get_selenium_browser()
        elif self.provider == 'saucelabs':
            return self._get_saucelabs_browser()
        elif self.provider == 'docker':
            return self._get_docker_browser()
        elif self.provider == 'remote':
            return self._get_remote_browser()
        else:
            raise ValueError(
                '"{}" browser is not supported. Please use one of {}'
                .format(
                    self.provider,
                    ('selenium', 'saucelabs', 'docker', 'remote')
                )
            )

    def post_init(self):
        """Perform all required post-init tweaks and workarounds. Should be
        called _after_ proceeding to desired url.

        :return: None
        """
        # Workaround 'Certificate Error' screen on Microsoft Edge
        if (
                self.browser == 'edge' and
                ('Certificate Error' in self._webdriver.title or
                    'Login' not in self._webdriver.title)):
            self._webdriver.get(
                "javascript:document.getElementById('invalidcert_continue')"
                ".click()"
            )
        # Workaround maximize_window() not working with chrome in docker
        if not (self.provider == 'docker' and
                self.browser == 'chrome'):
            self._webdriver.maximize_window()

    def finalize(self, passed=True):
        """Finalize browser - close browser window, report results to saucelabs
        or close docker container if needed.

        :param bool passed: Boolean value indicating whether test passed
            or not. Is only used for ``saucelabs`` provider.
        :return: None
        """
        if self.provider == 'selenium' or self.provider == 'remote':
            self._webdriver.quit()
            return
        elif self.provider == 'saucelabs':
            self._webdriver.quit()
            return self._finalize_saucelabs_browser(passed)
        elif self.provider == 'docker':
            return self._finalize_docker_browser()

    def _set_session_cookie(self):
        """Add the session cookie (if provided) to the webdriver
        """
        if self._session:
            # webdriver doesn't allow to add cookies unless we land on the target domain
            # let's navigate to its invalid page to get it loaded ASAP
            self._webdriver.get('https://{0}/404'.format(settings.satellite.hostname))
            self._webdriver.add_cookie(
                {'name': '_session_id', 'value': self._session.cookies.get_dict()['_session_id']}
            )

    def _get_selenium_browser(self):
        """Returns selenium webdriver instance of selected ``browser``.

        Note: should not be called directly, use :meth:`get_browser` instead.

        :raises: ValueError: If wrong ``browser`` specified.
        """
        kwargs = {}
        binary = settings.selenium.webdriver_binary
        browseroptions = settings.selenium.browseroptions

        if self.browser == 'chrome':
            if binary:
                kwargs.update({'executable_path': binary})
            options = webdriver.ChromeOptions()
            prefs = {'download.prompt_for_download': False}
            options.add_experimental_option("prefs", prefs)
            options.add_argument('disable-web-security')
            options.add_argument('ignore-certificate-errors')
            if browseroptions:
                for opt in browseroptions.split(';'):
                    options.add_argument(opt)
            kwargs.update({'options': options})
            self._webdriver = webdriver.Chrome(**kwargs)
        elif self.browser == 'firefox':
            if binary:
                kwargs.update({'executable_path': binary})
            self._webdriver = webdriver.Firefox(**kwargs)
        elif self.browser == 'ie':
            if binary:
                kwargs.update({'executable_path': binary})
            self._webdriver = webdriver.Ie(**kwargs)
        elif self.browser == 'edge':
            if binary:
                kwargs.update({'executable_path': binary})
            capabilities = webdriver.DesiredCapabilities.EDGE.copy()
            capabilities['acceptSslCerts'] = True
            capabilities['javascriptEnabled'] = True
            kwargs.update({'capabilities': capabilities})
            self._webdriver = webdriver.Edge(**kwargs)
        elif self.browser == 'phantomjs':
            self._webdriver = webdriver.PhantomJS(
                service_args=['--ignore-ssl-errors=true'])
        if self._webdriver is None:
            raise ValueError(
                '"{}" webdriver is not supported. Please use one of {}'
                .format(
                    self.browser,
                    ('chrome', 'firefox', 'ie', 'edge', 'phantomjs')
                )
            )
        self._set_session_cookie()
        return self._webdriver

    def _get_saucelabs_browser(self):
        """Returns saucelabs webdriver instance of selected ``browser``.

        Note: should not be called directly, use :meth:`get_browser` instead.

        :raises: ValueError: If wrong ``browser`` specified.
        """
        self._webdriver = webdriver.Remote(
            command_executor=_sauce_ondemand_url(
                settings.selenium.saucelabs_user,
                settings.selenium.saucelabs_key
            ),
            desired_capabilities=self._get_webdriver_capabilities()
        )
        self._set_session_cookie()
        idle_timeout = settings.webdriver_desired_capabilities.idleTimeout
        if idle_timeout:
            self._webdriver.command_executor.set_timeout(int(idle_timeout))
        return self._webdriver

    def _get_docker_browser(self):
        """Returns webdriver running in docker container. Currently only
        firefox and chrome are supported.

        Note: should not be called directly, use :meth:`get_browser` instead.
        """
        kwargs = {}
        if self.test_name:
            kwargs.update({'name': self.test_name})
        self._docker = DockerBrowser(**kwargs)
        if self.browser == 'chrome':
            self._docker._image = 'selenium/standalone-chrome'
            self._docker._capabilities = \
                webdriver.DesiredCapabilities.CHROME.copy()
            self._docker._capabilities.update({'args': 'start-maximized'})
        elif self.browser == 'firefox':
            self._docker._image = 'selenium/standalone-firefox'
            self._docker._capabilities = \
                webdriver.DesiredCapabilities.FIREFOX.copy()
        else:
            raise ValueError(
                '"{}" webdriver in docker container is currently not'
                'supported. Please use one of {}'
                .format(self.browser, ('chrome', 'firefox'))
            )
        if settings.webdriver_desired_capabilities:
            self._docker._capabilities.update(
                vars(settings.webdriver_desired_capabilities))
        self._docker.start()
        self._webdriver = self._docker.webdriver
        self._set_session_cookie()
        return self._webdriver

    def _get_remote_browser(self):
        """Returns remote webdriver instance of selected ``browser``.

        Note: should not be called directly, use :meth:`get_browser` instead.
        """
        self._webdriver = webdriver.Remote(
            command_executor=settings.selenium.command_executor,
            desired_capabilities=self._get_webdriver_capabilities()
        )
        self._set_session_cookie()

        idle_timeout = settings.webdriver_desired_capabilities.idleTimeout
        if idle_timeout:
            self._webdriver.command_executor.set_timeout(int(idle_timeout))
        return self._webdriver

    def _get_webdriver_capabilities(self):
        """Returns webdriver capabilities of selected ``browser``.

        Note: should not be called directly, use :meth:`_get_remote_browser`
        or :meth:`_get_saucelabs_browser` instead.

        :raises: ValueError: If wrong ``browser`` specified.
        """
        if self.browser == 'chrome':
            desired_capabilities = webdriver.DesiredCapabilities.CHROME.copy()
            enable_downloading = {
                'chromeOptions': {
                    'args': ['disable-web-security', 'ignore-certificate-errors'],
                    'prefs': {'download.prompt_for_download': False}
                }
            }
            desired_capabilities.update(enable_downloading)
        elif self.browser == 'firefox':
            desired_capabilities = webdriver.DesiredCapabilities.FIREFOX.copy()
        elif self.browser == 'ie':
            desired_capabilities = (
                webdriver.DesiredCapabilities.INTERNETEXPLORER.copy())
        elif self.browser == 'edge':
            desired_capabilities = webdriver.DesiredCapabilities.EDGE.copy()
            desired_capabilities['acceptSslCerts'] = True
            desired_capabilities['javascriptEnabled'] = True
        else:
            raise ValueError(
                '"{}" webdriver capabilities is currently not supported. '
                'Please use one of {}'
                .format(self.browser, ('chrome', 'firefox', 'ie', 'edge'))
            )
        if settings.webdriver_desired_capabilities:
            desired_capabilities.update(
                vars(settings.webdriver_desired_capabilities))

        desired_capabilities.update({'name': self.test_name})

        return desired_capabilities

    def _finalize_saucelabs_browser(self, passed):
        """SauceLabs has no way to determine whether test passed or failed
        automatically, so we explicitly 'tell' it.

        Note: should not be called directly, use :meth:`finalize` instead.

        :param bool passed: Bool value indicating whether test passed or not.
        """
        client = sauceclient.SauceClient(
            settings.selenium.saucelabs_user, settings.selenium.saucelabs_key)
        LOGGER.debug(
            'Updating SauceLabs job "%s": name "%s" and status "%s"',
            self._webdriver.session_id,
            self.test_name,
            'passed' if passed else 'failed'
        )
        kwargs = {'passed': passed}
        # do not pass test name if it's not set
        if self.test_name:
            kwargs.update({'name': self.test_name})
        client.jobs.update_job(self._webdriver.session_id, **kwargs)

    def _finalize_docker_browser(self):
        """Stops docker container.

        Note: should not be called directly, use :meth:`finalize` instead.
        """
        self._docker.stop()


class DockerBrowser(object):
    """Provide a browser instance running inside a docker container.

    Usage::

        # either as context manager
        with DockerBrowser() as browser:
            # [...]

        # or with manual :meth:`start` and :meth:`stop` calls.
        docker_browser = DockerBrowser()
        docker_browser.start()
        # [...]
        docker_browser.stop()

    """

    def __init__(self, name=None, image=None, capabilities=None):
        """Ensure ``docker-py`` package is installed.

        :param str optional name: name for docker container.
        :raises: airgun.browser.DockerBrowserError: if ``docker-py`` package is
            not installed.
        """
        if docker is None:
            raise DockerBrowserError(
                'Package docker-py is not installed. Install it in order to '
                'use DockerBrowser.'
            )
        self.webdriver = None
        self._capabilities = capabilities
        self._image = image
        self.container = None
        self._client = None
        self._name = name or gen_string('alphanumeric')
        self._started = False

    def start(self):
        """Start all machinery needed to run a browser inside a docker
        container.
        """
        if self._started:
            return
        self._init_client()
        self._create_container()
        self._init_webdriver()
        self._started = True

    def stop(self):
        """Quit the browser, remove docker container and close docker client.
        """
        self._quit_webdriver()
        self._remove_container()
        self._close_client()
        self.webdriver = None
        self.container = None
        self._client = None
        self._started = False

    def _init_webdriver(self):
        """Init the selenium Remote webdriver."""
        if self.webdriver or not self.container:
            return
        exception = None
        # An exception can be raised while the container is not ready
        # yet. Give up to 10 seconds for a container being ready.
        for _ in range(20):
            try:
                self.webdriver = webdriver.Remote(
                    command_executor='http://127.0.0.1:{0}/wd/hub'.format(
                        self.container['HostPort']),
                    desired_capabilities=self._capabilities
                )
            except Exception as err:
                # Capture the raised exception for later usage and wait
                # a few for the next attempt.
                exception = err
                time.sleep(.5)
            else:
                # Connection succeeded time to leave the for loop
                break
        else:
            # Reraise the captured exception.
            raise DockerBrowserError(
                'Failed to connect the webdriver to the containerized '
                'selenium.'
            ) from exception

    def _quit_webdriver(self):
        """Quit the selenium remote webdriver."""
        if not self.webdriver:
            return
        self.webdriver.quit()

    def _init_client(self):
        """Init docker client.

        Make sure that docker service to be published under the
        ``unix://var/run/docker.sock`` unix socket.

        Use auto for version in order to allow docker client to automatically
        figure out the server version.
        """
        if self._client:
            return
        self._client = docker.Client(
            base_url='unix://var/run/docker.sock', version='auto')

    def _close_client(self):
        """Close docker Client."""
        if not self._client:
            return
        self._client.close()

    def _create_container(self):
        """Create a docker container running a ``standalone-firefox`` or
        ``standalone-chrome`` selenium.

        Make sure to have the image ``selenium/standalone-firefox`` or
        ``selenium/standalone-chrome`` already pulled, preferably in the
        same version as the selenium-module.
        """
        if self.container:
            return
        image_version = selenium.__version__
        if not self._client.images(
                name=self._get_image_name(image_version)):
            LOGGER.warning('Could not find docker-image for your'
                           'selium-version "%s"; trying with "latest"',
                           self._get_image_name(image_version))
            image_version = 'latest'
            if not self._client.images(
                    name=self._get_image_name(image_version)):
                raise DockerBrowserError(
                    'Could not find docker-image "%s"; please pull it' %
                    self._get_image_name(image_version)
                )
        # Grab only the test name, get rid of square brackets from parametrize
        # and add some random chars. E.g. 'test_positive_create_0_abc'
        container_name = '{}_{}'.format(
            self._name.split('.')[-1].replace('[', '_').strip(']'),
            gen_string('alphanumeric', 3)
        )
        self.container = self._client.create_container(
            detach=True,
            environment={
                'SCREEN_WIDTH': '1920',
                'SCREEN_HEIGHT': '1080',
            },
            host_config=self._client.create_host_config(
                publish_all_ports=True),
            image=self._get_image_name(image_version),
            name=container_name,
            ports=[4444],
        )
        LOGGER.debug('Starting container with ID "%s"', self.container['Id'])
        self._client.start(self.container['Id'])
        self.container.update(
            self._client.port(self.container['Id'], 4444)[0])

    def _remove_container(self):
        """Turn off and clean up container from system."""
        if not self.container:
            return
        LOGGER.debug('Stopping container with ID "%s"', self.container['Id'])
        self._client.stop(self.container['Id'])
        self._client.wait(self.container['Id'])
        self._client.remove_container(self.container['Id'], force=True)

    def __enter__(self):
        """Setup docker browser when used as context manager."""
        self.start()
        return self

    def __exit__(self, *exc):
        """Perform all cleanups when used as context manager."""
        self.stop()

    def _get_image_name(self, version):
        """Returns docker-image's name and version (aka tag)"""
        return '%s:%s' % (self._image, version)


class AirgunBrowserPlugin(DefaultPlugin):
    """Plug-in for :class:`AirgunBrowser` which adds satellite-specific
    JavaScript to make sure page is loaded completely. Checks for absence of
    jQuery, AJAX, Angular requests, absence of spinner indicating loading
    progress and ensures ``document.readyState`` is "complete".
    """

    ENSURE_PAGE_SAFE = '''
        function jqueryInactive() {
         return (typeof jQuery === "undefined") ? true : jQuery.active < 1
        }
        function ajaxInactive() {
         return (typeof Ajax === "undefined") ? true :
            Ajax.activeRequestCount < 1
        }
        function angularNoRequests() {
         if (typeof angular === "undefined") {
           return true
         } else if (typeof angular.element(
             document).injector() === "undefined") {
           injector = angular.injector(["ng"]);
           return injector.get("$http").pendingRequests.length < 1
         } else {
           return angular.element(document).injector().get(
             "$http").pendingRequests.length < 1
         }
        }
        function spinnerInvisible() {
         spinner = document.getElementById("vertical-spinner")
         return (spinner === null) ? true : spinner.style["display"] == "none"
        }
        function reactLoadingInvisible() {
         react = document.querySelector("#reactRoot .loading-state")
         return react === null
        }
        function anySpinnerInvisible() {
         spinners = Array.prototype.slice.call(
          document.querySelectorAll('.spinner')
          ).filter(function (item,index) {
            return item.offsetWidth > 0 || item.offsetHeight > 0
             || item.getClientRects().length > 0;
           }
          );
         return spinners.length === 0
        }
        return {
            jquery: jqueryInactive(),
            ajax: ajaxInactive(),
            angular: angularNoRequests(),
            spinner: spinnerInvisible(),
            any_spinner: anySpinnerInvisible(),
            react: reactLoadingInvisible(),
            document: document.readyState == "complete",
        }
        '''

    def ensure_page_safe(self, timeout='30s'):
        """Ensures page is fully loaded.
        Default timeout was 10s, this changes it to 30s.
        """
        super().ensure_page_safe(timeout)

    def before_click(self, element, locator=None):
        """Invoked before clicking on an element. Ensure page is fully loaded
        before clicking.
        """
        self.ensure_page_safe()

    def after_click(self, element, locator=None):
        """Invoked after clicking on an element. Ensure page is fully loaded
        before proceeding further.
        """
        # plugin.ensure_page_safe() is invoked from browser click.
        # we should not invoke it a second time, this can conflict with
        # ignore_ajax=True usage from browser click
        pass


class AirgunBrowser(Browser):
    """A wrapper around :class:`widgetastic.browser.Browser` which injects
    :class:`airgun.session.Session` and :class:`AirgunBrowserPlugin`.
    """

    def __init__(self, selenium, session, extra_objects=None):
        """Pass webdriver instance, session and other extra objects (if any).

        :param selenium: :class:`selenium.webdriver.remote.webdriver.WebDriver`
            instance.
        :param session: :class:`airgun.session.Session` instance.
        :param extra_objects: any extra objects you want to include.
        """
        extra_objects = extra_objects or {}
        extra_objects.update({'session': session})
        super(AirgunBrowser, self).__init__(
            selenium,
            plugin_class=AirgunBrowserPlugin,
            extra_objects=extra_objects)
        self.window_handle = selenium.current_window_handle

    def get_client_datetime(self):
        """Make Javascript call inside of browser session to get exact current
        date and time. In that way, we will be isolated from any issue that can
        happen due different environments where test automation code is
        executing and where browser session is opened. That should help us to
        have successful run for docker containers or separated virtual machines
        When calling .getMonth() you need to add +1 to display the correct
        month. Javascript count always starts at 0, so calling .getMonth() in
        May will return 4 and not 5.

        :return: Datetime object that contains data for current date and time
            on a client
        """
        script = ('var currentdate = new Date(); return ({0} + "-" + {1} + '
                  '"-" + {2} + " : " + {3} + ":" + {4});').format(
            'currentdate.getFullYear()',
            '(currentdate.getMonth()+1)',
            'currentdate.getDate()',
            'currentdate.getHours()',
            'currentdate.getMinutes()',
        )
        client_datetime = self.execute_script(script)
        return datetime.strptime(client_datetime, '%Y-%m-%d : %H:%M')

    def get_downloads_list(self):
        """Open browser's downloads screen and return a list of downloaded
        files.

        :return: list of strings representing file URIs
        """
        if settings.selenium.webdriver != 'chrome':
            raise NotImplementedError('Currently only chrome is supported')
        downloads_uri = 'chrome://downloads'
        if not self.url.startswith(downloads_uri):
            self.url = downloads_uri
        return self.execute_script("""
            return downloads.Manager.get().items_
              .filter(e => e.state === "COMPLETE")
              .map(e => e.file_url || e.fileUrl);
        """)

    def get_file_content(self, uri):
        """Get file content by its URI from browser's downloads page.

        :return: bytearray representing file content
        :raises Exception: when error code instead of file content received
        """
        # See https://stackoverflow.com/a/47164044/3552063
        if settings.selenium.webdriver != 'chrome':
            raise NotImplementedError('Currently only chrome is supported')
        elem = self.selenium.execute_script(
            "var input = window.document.createElement('INPUT'); "
            "input.setAttribute('type', 'file'); "
            "input.onchange = function (e) { e.stopPropagation() }; "
            "return window.document.documentElement.appendChild(input); "
        )

        # it must be local absolute path, without protocol
        elem.send_keys(unquote(uri[7:]))

        result = self.selenium.execute_async_script(
            "var input = arguments[0], callback = arguments[1]; "
            "var reader = new FileReader(); "
            "reader.onload = function (ev) { callback(reader.result) }; "
            "reader.onerror = function (ex) { callback(ex.message) }; "
            "reader.readAsDataURL(input.files[0]); "
            "input.remove(); ",
            elem)

        if not result.startswith('data:'):
            raise Exception("Failed to get file content: %s" % result)

        return base64.b64decode(result[result.find('base64,') + 7:])

    def save_downloaded_file(self, file_uri=None, save_path=None):
        """Save local or remote browser's automatically downloaded file to
        specified local path. Useful when you don't know exact file name or
        path where file was downloaded or you're using remote driver with no
        access to worker's filesystem (e.g. saucelabs).

        Usage example::

            view.widget_which_triggers_file_download.click()
            path = self.browser.save_downloaded_file()
            with open(file_path, newline='') as csvfile:
                reader = csv.DictReader(csvfile)
                for row in reader:
                    # process file contents


        :param str optional file_uri: URI of file. If not specified - browser's
            latest downloaded file will be selected
        :param str optional save_path: local path where the file should be
            saved. If not specified - ``temp_dir`` from airgun settings will be
            used in case of remote session or just path to saved file in case
            local one.
        """
        current_url = self.url
        files, _ = wait_for(
            self.browser.get_downloads_list,
            timeout=60,
            delay=1,
        )
        if not file_uri:
            file_uri = files[0]
        if (not save_path and settings.selenium.browser == 'selenium'):
            # if test is running locally, there's no need to save the file once
            # again except when explicitly asked to
            file_path = urllib.parse.unquote(
                urllib.parse.urlparse(file_uri).path)
        else:
            if not save_path:
                save_path = settings.airgun.tmp_dir
            content = self.get_file_content(file_uri)
            filename = urllib.parse.unquote(os.path.basename(file_uri))
            with open(os.path.join(save_path, filename), 'wb') as f:
                f.write(content)
            file_path = os.path.join(save_path, filename)
        self.url = current_url
        self.plugin.ensure_page_safe()
        return file_path