# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

import argparse
import copy
from datetime import datetime
import os
import io
import logging

import pytest
from requests.structures import CaseInsensitiveDict
from selenium import webdriver
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
from selenium.webdriver.support.event_firing_webdriver import \
    EventFiringWebDriver

from . import drivers

LOGGER = logging.getLogger(__name__)


SUPPORTED_DRIVERS = CaseInsensitiveDict({
    'BrowserStack': webdriver.Remote,
    'CrossBrowserTesting': webdriver.Remote,
    'Chrome': webdriver.Chrome,
    'Edge': webdriver.Edge,
    'Firefox': webdriver.Firefox,
    'IE': webdriver.Ie,
    'PhantomJS': webdriver.PhantomJS,
    'Remote': webdriver.Remote,
    'Safari': webdriver.Safari,
    'SauceLabs': webdriver.Remote,
    'TestingBot': webdriver.Remote})


def _merge(a, b):
    """ merges b and a configurations.
        Based on http://bit.ly/2uFUHgb
     """
    for key in b:
        if key in a:
            if isinstance(a[key], dict) and isinstance(b[key], dict):
                _merge(a[key], b[key], [] + [str(key)])
            elif a[key] == b[key]:
                pass  # same leaf value
            elif isinstance(a[key], list):
                if isinstance(b[key], list):
                    a[key].extend(b[key])
                else:
                    a[key].append(b[key])
            else:
                # b wins
                a[key] = b[key]
        else:
            a[key] = b[key]
    return a


def pytest_addhooks(pluginmanager):
    from . import hooks
    method = getattr(pluginmanager, 'add_hookspecs', None)
    if method is None:
        method = pluginmanager.addhooks
    method(hooks)


@pytest.fixture(scope='session')
def session_capabilities(pytestconfig):
    """Returns combined capabilities from pytest-variables and command line"""
    driver = pytestconfig.getoption('driver').upper()
    capabilities = getattr(DesiredCapabilities, driver, {}).copy()
    if driver == 'REMOTE':
        browser = capabilities.get('browserName', '').upper()
        capabilities.update(getattr(DesiredCapabilities, browser, {}))
    capabilities.update(pytestconfig._capabilities)
    return capabilities


@pytest.fixture
def capabilities(request, driver_class, chrome_options, firefox_options,
                 session_capabilities):
    """Returns combined capabilities"""
    capabilities = copy.deepcopy(session_capabilities)  # make a copy
    if driver_class == webdriver.Remote:
        browser = capabilities.get('browserName', '').upper()
        key, options = (None, None)
        if browser == 'CHROME':
            key = getattr(chrome_options, 'KEY', 'goog:chromeOptions')
            options = chrome_options.to_capabilities()
            if key not in options:
                key = 'chromeOptions'
        elif browser == 'FIREFOX':
            key = firefox_options.KEY
            options = firefox_options.to_capabilities()
        if all([key, options]):
            capabilities[key] = _merge(
                capabilities.get(key, {}), options.get(key, {}))
    capabilities.update(get_capabilities_from_markers(request.node))
    return capabilities


def get_capabilities_from_markers(node):
    # get_marker is deprecated since pytest 3.6
    # https://docs.pytest.org/en/latest/mark.html#marker-revamp-and-iteration
    try:
        capabilities = dict()
        for level, mark in node.iter_markers_with_node('capabilities'):
            LOGGER.debug('{0} marker <{1.name}> '
                         'contained kwargs <{1.kwargs}>'.
                         format(level.__class__.__name__, mark))
            capabilities.update(mark.kwargs)
        LOGGER.info('Capabilities from markers: {}'.format(capabilities))
        return capabilities
    except AttributeError:
        # backwards-compat
        # can be removed when minimum req pytest is 3.6
        capabilities = node.get_marker('capabilities')
        return capabilities.kwargs if capabilities else {}


@pytest.fixture
def driver_args():
    """Return arguments to pass to the driver service"""
    return None


@pytest.fixture
def driver_kwargs(request, capabilities, chrome_options, driver_args,
                  driver_class, driver_log, driver_path, firefox_options,
                  firefox_profile, pytestconfig):
    kwargs = {}
    driver = getattr(drivers, pytestconfig.getoption('driver').lower())
    kwargs.update(driver.driver_kwargs(
        capabilities=capabilities,
        chrome_options=chrome_options,
        driver_args=driver_args,
        driver_log=driver_log,
        driver_path=driver_path,
        firefox_options=firefox_options,
        firefox_profile=firefox_profile,
        host=pytestconfig.getoption('host'),
        port=pytestconfig.getoption('port'),
        service_log_path=None,
        request=request,
        test='.'.join(split_class_and_test_names(request.node.nodeid))))
    pytestconfig._driver_log = driver_log
    return kwargs


@pytest.fixture(scope='session')
def driver_class(request):
    driver = request.config.getoption('driver')
    if driver is None:
        raise pytest.UsageError('--driver must be specified')
    return SUPPORTED_DRIVERS[driver]


@pytest.fixture
def driver_log(tmpdir):
    """Return path to driver log"""
    return str(tmpdir.join('driver.log'))


@pytest.fixture
def driver_path(request):
    return request.config.getoption('driver_path')


@pytest.yield_fixture
def driver(request, driver_class, driver_kwargs):
    """Returns a WebDriver instance based on options and capabilities"""
    driver = driver_class(**driver_kwargs)

    event_listener = request.config.getoption('event_listener')
    if event_listener is not None:
        # Import the specified event listener and wrap the driver instance
        mod_name, class_name = event_listener.rsplit('.', 1)
        mod = __import__(mod_name, fromlist=[class_name])
        event_listener = getattr(mod, class_name)
        if not isinstance(driver, EventFiringWebDriver):
            driver = EventFiringWebDriver(driver, event_listener())

    request.node._driver = driver
    yield driver
    driver.quit()


@pytest.yield_fixture
def selenium(driver):
    yield driver


@pytest.hookimpl(trylast=True)
def pytest_configure(config):
    capabilities = config._variables.get('capabilities', {})
    capabilities.update({k: v for k, v in config.getoption('capabilities')})
    config.addinivalue_line(
        'markers', 'capabilities(kwargs): add or change existing '
        'capabilities. specify capabilities as keyword arguments, for example '
        'capabilities(foo=''bar'')')
    if hasattr(config, '_metadata'):
        config._metadata['Driver'] = config.getoption('driver')
        config._metadata['Capabilities'] = capabilities
        if all((config.getoption('host'), config.getoption('port'))):
            config._metadata['Server'] = '{0}:{1}'.format(
                config.getoption('host'),
                config.getoption('port'))
    config._capabilities = capabilities


def pytest_report_header(config, startdir):
    driver = config.getoption('driver')
    if driver is not None:
        return 'driver: {0}'.format(driver)


@pytest.mark.hookwrapper
def pytest_runtest_makereport(item, call):
    outcome = yield
    report = outcome.get_result()
    summary = []
    extra = getattr(report, 'extra', [])
    driver = getattr(item, '_driver', None)
    xfail = hasattr(report, 'wasxfail')
    failure = (report.skipped and xfail) or (report.failed and not xfail)
    when = item.config.getini('selenium_capture_debug').lower()
    capture_debug = when == 'always' or (when == 'failure' and failure)
    if capture_debug:
        exclude = item.config.getini('selenium_exclude_debug').lower()
        if 'logs' not in exclude:
            # gather logs that do not depend on a driver instance
            _gather_driver_log(item, summary, extra)
        if driver is not None:
            # gather debug that depends on a driver instance
            if 'url' not in exclude:
                _gather_url(item, report, driver, summary, extra)
            if 'screenshot' not in exclude:
                _gather_screenshot(item, report, driver, summary, extra)
            if 'html' not in exclude:
                _gather_html(item, report, driver, summary, extra)
            if 'logs' not in exclude:
                _gather_logs(item, report, driver, summary, extra)
            # gather debug from hook implementations
            item.config.hook.pytest_selenium_capture_debug(
                item=item, report=report, extra=extra)
    if driver is not None:
        # allow hook implementations to further modify the report
        item.config.hook.pytest_selenium_runtest_makereport(
            item=item, report=report, summary=summary, extra=extra)
    if summary:
        report.sections.append(('pytest-selenium', '\n'.join(summary)))
    report.extra = extra


def _gather_url(item, report, driver, summary, extra):
    try:
        url = driver.current_url
    except Exception as e:
        summary.append('WARNING: Failed to gather URL: {0}'.format(e))
        return
    pytest_html = item.config.pluginmanager.getplugin('html')
    if pytest_html is not None:
        # add url to the html report
        extra.append(pytest_html.extras.url(url))
    summary.append('URL: {0}'.format(url))


def _gather_screenshot(item, report, driver, summary, extra):
    try:
        screenshot = driver.get_screenshot_as_base64()
    except Exception as e:
        summary.append('WARNING: Failed to gather screenshot: {0}'.format(e))
        return
    pytest_html = item.config.pluginmanager.getplugin('html')
    if pytest_html is not None:
        # add screenshot to the html report
        extra.append(pytest_html.extras.image(screenshot, 'Screenshot'))


def _gather_html(item, report, driver, summary, extra):
    try:
        html = driver.page_source
    except Exception as e:
        summary.append('WARNING: Failed to gather HTML: {0}'.format(e))
        return
    pytest_html = item.config.pluginmanager.getplugin('html')
    if pytest_html is not None:
        # add page source to the html report
        extra.append(pytest_html.extras.text(html, 'HTML'))


def _gather_logs(item, report, driver, summary, extra):
    pytest_html = item.config.pluginmanager.getplugin('html')
    try:
        types = driver.log_types
    except Exception as e:
        # note that some drivers may not implement log types
        summary.append('WARNING: Failed to gather log types: {0}'.format(e))
        return
    for name in types:
        try:
            log = driver.get_log(name)
        except Exception as e:
            summary.append('WARNING: Failed to gather {0} log: {1}'.format(
                name, e))
            return
        if pytest_html is not None:
            extra.append(pytest_html.extras.text(
                format_log(log), '%s Log' % name.title()))


def _gather_driver_log(item, summary, extra):
    pytest_html = item.config.pluginmanager.getplugin('html')
    if hasattr(item.config, '_driver_log') and \
       os.path.exists(item.config._driver_log):
        if pytest_html is not None:
            with io.open(item.config._driver_log, 'r', encoding='utf8') as f:
                extra.append(pytest_html.extras.text(f.read(), 'Driver Log'))
            summary.append('Driver log: {0}'.format(item.config._driver_log))


def format_log(log):
    timestamp_format = '%Y-%m-%d %H:%M:%S.%f'
    entries = [u'{0} {1[level]} - {1[message]}'.format(
        datetime.utcfromtimestamp(entry['timestamp'] / 1000.0).strftime(
            timestamp_format), entry).rstrip() for entry in log]
    log = '\n'.join(entries)
    return log


def split_class_and_test_names(nodeid):
    """Returns the class and method name from the current test"""
    names = nodeid.split('::')
    names[0] = names[0].replace('/', '.')
    names = [x.replace('.py', '') for x in names if x != '()']
    classnames = names[:-1]
    classname = '.'.join(classnames)
    name = names[-1]
    return classname, name


class DriverAction(argparse.Action):

    def __call__(self, parser, namespace, values, option_string=None):
        setattr(namespace, self.dest, values)
        driver = getattr(drivers, values.lower())
        # set the default host and port if specified in the driver module
        namespace.host = namespace.host or getattr(driver, 'HOST', None)
        namespace.port = namespace.port or getattr(driver, 'PORT', None)


def pytest_addoption(parser):
    _capture_choices = ('never', 'failure', 'always')
    parser.addini('selenium_capture_debug',
                  help='when debug is captured {0}'.format(_capture_choices),
                  default=os.getenv('SELENIUM_CAPTURE_DEBUG', 'failure'))
    parser.addini('selenium_exclude_debug',
                  help='debug to exclude from capture',
                  default=os.getenv('SELENIUM_EXCLUDE_DEBUG'))

    _auth_choices = ('none', 'token', 'hour', 'day')
    parser.addini('saucelabs_job_auth',
                  help='Authorization options for the Sauce Labs job: {0}'.
                  format(_auth_choices),
                  default=os.getenv('SAUCELABS_JOB_AUTH', 'none'))

    group = parser.getgroup('selenium', 'selenium')
    group._addoption('--driver',
                     action=DriverAction,
                     choices=SUPPORTED_DRIVERS,
                     help='webdriver implementation.',
                     metavar='str')
    group._addoption('--driver-path',
                     metavar='path',
                     help='path to the driver executable.')
    group._addoption('--capability',
                     action='append',
                     default=[],
                     dest='capabilities',
                     metavar=('key', 'value'),
                     nargs=2,
                     help='additional capabilities.')
    group._addoption('--event-listener',
                     metavar='str',
                     help='selenium eventlistener class, e.g. '
                          'package.module.EventListenerClassName.')
    group._addoption('--host',
                     metavar='str',
                     help='host that the selenium server is listening on, '
                          'which will default to the cloud provider default '
                          'or localhost.')
    group._addoption('--port',
                     type=int,
                     metavar='num',
                     help='port that the selenium server is listening on, '
                          'which will default to the cloud provider default '
                          'or localhost.')
