# MIT License
#
# Copyright (c) 2015-2021 Iakiv Kramarenko
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

from __future__ import annotations

import atexit
import itertools
import multiprocessing
import os
import time
import warnings
from functools import lru_cache
from typing import Optional, TypeVar, Generic, Callable, Union

from selenium.common.exceptions import WebDriverException
from selenium.webdriver import ChromeOptions, Chrome, Firefox, Remote
from selenium.webdriver.remote.webdriver import WebDriver
from webdriver_manager.chrome import ChromeDriverManager
from webdriver_manager.firefox import GeckoDriverManager

from selene.common.fp import pipe
from selene.common.helpers import on_error_return_false
from selene.core.configuration import Config
from selene.core.exceptions import TimeoutException
from selene.core.wait import Wait
from selene.support.webdriver import Help

T = TypeVar('T')


class Source(Generic[T]):
    def __init__(self, value: T = None):
        self._value = value

    def put(self, value: T):
        self._value = value

    def clear(self):
        self._value = None

    @property
    def value(self) -> T:
        return self._value


class _LazyDriver:
    def __init__(self, config: SharedConfig):
        self._config = config
        self._closed: Optional[bool] = None
        self._stored: Optional[WebDriver] = None

    @property
    def _set_driver(self) -> Callable[[], WebDriver]:
        return self._config.set_driver

    @property
    def _hold_browser_open(self):
        return self._config.hold_browser_open

    def has_webdriver_started(self):
        return self._stored is not None

    def has_browser_alive(self):
        return Help(self._stored).has_browser_still_alive()

    @property
    def instance(self) -> WebDriver:
        if self._closed:
            raise RuntimeError(
                'Webdriver has been closed. '
                'You need to call open(url) '
                'to open a browser again.'
            )

        if not self.has_webdriver_started():
            raise RuntimeError(
                f'No webdriver is bound to current process: '
                f'{multiprocessing.current_process().pid}. '
                f'You need to call .open(url) first.'
            )

        return self._stored

    def create(self) -> WebDriver:
        # self.quit()
        # this is not needed because with call _source.quit() on config.set_driver = ...

        self._stored = self._set_driver()

        def quit_if_not_asked_to_hold():
            if not self._hold_browser_open:
                self.quit()

        atexit.register(quit_if_not_asked_to_hold)

        self._closed = False

        return self._stored

    def get_or_create(self) -> WebDriver:
        if self.has_browser_alive():
            return self._stored

        if self.has_webdriver_started():
            self.quit()

        return self.create()

    def quit(self):
        if self.has_webdriver_started() and not self._closed:
            try:  # todo: do we need this try/except?
                self._stored.quit()
            except WebDriverException:
                pass

            self._closed = True


# noinspection PyDataclass
class SharedConfig(Config):

    # todo: consider using SharedConfig object to be used as config only once... on init at first call to browser...
    #       i.e. do not allow config.* = ... after first call to e.g. browser.open, etc...
    #       since anyway this is a bad habit, better to use browser.with_(timeout=...), element.with_(...), etc.
    def __init__(
        self,
        # Config
        driver: Optional[WebDriver] = None,
        timeout: int = 4,  # todo: consider removing defaults
        base_url: str = '',
        set_value_by_js: bool = False,
        type_by_js: bool = False,
        wait_for_no_overlap_found_by_js: bool = False,
        window_width: Optional[int] = None,
        window_height: Optional[int] = None,
        hook_wait_failure: Optional[
            Callable[[TimeoutException], Exception]
        ] = None,
        log_outer_html_on_failure: bool = False,
        # SharedConfig
        set_driver: Callable[[], WebDriver] = None,
        source: _LazyDriver = None,  # todo: type should not be _LazyDriver but something like Source ...
        browser_name: str = 'chrome',  # todo: rename to config.type? config.name? config.browser?
        hold_browser_open: bool = False,
        save_screenshot_on_failure: bool = True,
        save_page_source_on_failure: bool = True,
        poll_during_waits: int = 100,
        counter=None,  # default is set below
        reports_folder: Optional[str] = None,  # default is set below
        last_screenshot: Union[Optional[str], Source[str]] = None,
        last_page_source: Union[Optional[str], Source[str]] = None,
    ):

        self._browser_name = browser_name
        self._hold_browser_open = hold_browser_open
        self._source = source or _LazyDriver(self)
        self._log_outer_html_on_failure = log_outer_html_on_failure

        if driver and not set_driver:
            """
            each time we are setting not None driver,
            we have to not forget to call self._source.create()
            todo: how can we improve cohesion here? and reduce risks?
            """
            self._set_driver = lambda: driver
            self._source.create()
        else:
            auto_set_driver = (
                lambda: self._set_chrome_or_firefox_from_webdriver_manager()
            )
            self._set_driver = set_driver or auto_set_driver

        self._save_screenshot_on_failure = save_screenshot_on_failure
        self._save_page_source_on_failure = save_page_source_on_failure
        # todo consider to deprecate _poll_during_waits
        self._poll_during_waits = poll_during_waits
        # todo: deprecate _counter?
        self._counter = counter or itertools.count(
            start=int(round(time.time() * 1000))
        )
        self._reports_folder = reports_folder or os.path.join(
            os.path.expanduser('~'),
            '.selene',
            'screenshots',
            str(next(self._counter)),
        )
        self._last_screenshot = (
            last_screenshot
            if isinstance(last_screenshot, Source)
            else Source(last_screenshot)
        )
        self._last_page_source = (
            last_page_source
            if isinstance(last_page_source, Source)
            else Source(last_page_source)
        )
        super().__init__(
            driver=driver,
            timeout=timeout,
            base_url=base_url,
            set_value_by_js=set_value_by_js,
            type_by_js=type_by_js,
            wait_for_no_overlap_found_by_js=wait_for_no_overlap_found_by_js,
            window_width=window_width,
            window_height=window_height,
            hook_wait_failure=hook_wait_failure,
            log_outer_html_on_failure=log_outer_html_on_failure,
        )

    def _set_chrome_or_firefox_from_webdriver_manager(self):
        # todo: consider simplifying implementation to simple if-else

        # todo: do we need here pass self.desired_capabilities too?

        def get_chrome():
            return Chrome(
                executable_path=ChromeDriverManager().install(),
                options=ChromeOptions(),
            )

        def get_firefox():
            return Firefox(executable_path=GeckoDriverManager().install())

        # set_remote = lambda: Remote()  # todo: do we really need it? :)

        # todo: think on something like:
        #             'remote': lambda: Remote(**self.browser_name)
        #         }.get(self.browser_name if isinstance(self.browser_name, str) else 'remote')()

        return {'chrome': get_chrome, 'firefox': get_firefox}.get(
            self.browser_name
        )()

    # --- Config.driver new "shared logic" --- #

    @property
    def driver(self) -> WebDriver:

        return self._source.instance

        # in the past we forced browser to restart
        # if the config.browser_name was changed to a new version...
        # now we removed this to keep things KISS
        # and potentially less conflict with e.g. Appium...
        # we also checked browser session_id ... why?
        # if stored and \
        #       stored.session_id and \
        #       stored.name == self.browser_name:
        #     return stored

    def get_or_create_driver(self) -> WebDriver:
        return self._source.get_or_create()

    def quit_driver(self):
        warnings.warn(
            'shared.config.quit_driver is deprecated, '
            'use shared.config.reset_driver instead',
            DeprecationWarning,
        )
        self.reset_driver()

    def reset_driver(self):
        self.set_driver = (
            lambda: self._set_chrome_or_firefox_from_webdriver_manager()
        )

    @driver.setter
    def driver(self, value: WebDriver):
        # todo: why the following field is called like a setter but we assign a getter to it? o_O
        self.set_driver = (
            lambda: value
        )  # it's pretty weird that we set value here
        self._source.create()  # to be used in this create under the hood...
        # too much of magic :( todo: consider refactoring

    @property
    def set_driver(self):
        return self._set_driver

    @set_driver.setter
    def set_driver(self, value: Callable[[], WebDriver]):
        self._source.quit()  # todo: do we need to quit it? or better check if it's same or not?
        self._set_driver = value

    # todo: consider accepting also hub url as "browser"
    #       because in case of "remote" mode, we will not need the common "name" like
    #       chrome or ff
    #       but we would pass the same name somewhere in caps... to choose correct "platform"
    #       so... then browser_name is kind of incorrect name becomes...
    #       why then not rename browser_name here to just browser...
    #       then... technically it might be possible to write something like:
    #           browser.config.browser = ... :)
    #              how can we make it impossible?
    #              or what else better name can we choose?

    def generate_filename(self, prefix='', suffix=''):
        path = self.reports_folder
        next_id = next(self.counter)
        filename = f'{prefix}{next_id}{suffix}'
        file = os.path.join(path, f'{filename}')

        folder = os.path.dirname(file)
        if not os.path.exists(folder) and folder:
            os.makedirs(folder)

        return file

    def _inject_screenshot_and_page_source_pre_hooks(self, hook):
        # todo: consider moving hooks to class methods accepting config as argument
        #       or refactor somehow to eliminate all times defining hook fns
        def save_and_log_screenshot(error: TimeoutException) -> Exception:
            path = Help(self.driver).save_screenshot(
                self.generate_filename(suffix='.png')
            )
            self.last_screenshot = path
            return TimeoutException(
                error.msg
                + f'''
Screenshot: file://{path}'''
            )

        def save_and_log_page_source(error: TimeoutException) -> Exception:
            filename = (
                self.last_screenshot.replace('.png', '.html')
                if self.last_screenshot
                else self.generate_filename(suffix='.html')
            )
            path = Help(self.driver).save_page_source(filename)
            self.last_page_source = path
            return TimeoutException(
                error.msg
                + f'''
PageSource: file://{path}'''
            )

        return pipe(
            save_and_log_screenshot
            if self.save_screenshot_on_failure
            else None,
            save_and_log_page_source
            if self.save_page_source_on_failure
            else None,
            hook,
        )

    # todo: do we really need this overwritten clone?
    def wait(self, entity):
        hook = self._inject_screenshot_and_page_source_pre_hooks(
            self.hook_wait_failure
        )
        return Wait(entity, at_most=self.timeout, or_fail_with=hook)

    # --- Config.* added setters --- #

    @Config.timeout.setter
    def timeout(self, value: int):
        self._timeout = value

    @Config.base_url.setter
    def base_url(self, value: str):
        self._base_url = value

    @Config.set_value_by_js.setter
    def set_value_by_js(self, value: bool):
        self._set_value_by_js = value

    @Config.type_by_js.setter
    def type_by_js(self, value: bool):
        self._type_by_js = value

    @Config.wait_for_no_overlap_found_by_js.setter
    def wait_for_no_overlap_found_by_js(self, value: bool):
        self._wait_for_no_overlap_found_by_js = value

    @Config.window_width.setter
    def window_width(self, value: Optional[int]):
        self._window_width = value

    @Config.window_height.setter
    def window_height(self, value: Optional[int]):
        self._window_height = value

    @Config.log_outer_html_on_failure.setter
    def log_outer_html_on_failure(self, value: bool):
        self._log_outer_html_on_failure = value

    # not sure why this code does not let the base property being overwritten o_O :(
    # maybe because @Config.hook_wait_failure.setter is defined afterwards mixing things out
    # @Config.hook_wait_failure.getter   # both this
    # @property                          # and this did not work
    # def hook_wait_failure(self) -> Callable[[TimeoutException], Exception]:
    #     return self._inject_screenshot_and_page_source_pre_hooks(self._hook_wait_failure)
    # finally decided to implement needed pre-hooks injection in the self.wait(entity) method

    @Config.hook_wait_failure.setter
    def hook_wait_failure(
        self, value: Callable[[TimeoutException], Exception]
    ):
        default = lambda e: e
        self._hook_wait_failure = value or default

    # --- SharedConfig.* new props --- #

    @property
    def last_screenshot(self) -> str:
        return self._last_screenshot.value

    @last_screenshot.setter
    def last_screenshot(self, value: str):
        self._last_screenshot.put(value)

    @property
    def last_page_source(self) -> str:
        return self._last_page_source.value

    @last_page_source.setter
    def last_page_source(self, value: str):
        self._last_page_source.put(value)

    @property
    def hold_browser_open(self) -> bool:
        return self._hold_browser_open

    @hold_browser_open.setter
    def hold_browser_open(self, value: bool):
        self._hold_browser_open = value

    @property
    def save_screenshot_on_failure(self) -> bool:
        return self._save_screenshot_on_failure

    @save_screenshot_on_failure.setter
    def save_screenshot_on_failure(self, value: bool):
        self._save_screenshot_on_failure = value

    @property
    def save_page_source_on_failure(self) -> bool:
        return self._save_page_source_on_failure

    @save_page_source_on_failure.setter
    def save_page_source_on_failure(self, value: bool):
        self._save_page_source_on_failure = value

    @property
    def browser_name(self) -> str:
        # todo: consider renaming to... config.name? config.executor?
        # seems like renaming to config.name is a bad idea,
        # because config can be accessed from element.config too
        # then element.config.name would be irrelevant
        # element.config.executor - yet might be ok...
        # but should not then executor accept and remote url of the hub?
        return self._browser_name

    @browser_name.setter
    def browser_name(self, value: str):
        self._browser_name = value
        # todo: should we kill current driver if browser_name is changed? (now it's killed on next driver ask)
        #       or should we open one more? so afterwards the user can switch...
        #       what about making such "mode" also configurable? ;)

    # todo: consider deprecating changing opts like timeout after driver was created
    #       make driver be recreated in such cases
    #       but again... what about making this configurable too? as a "mode"...

    # --- consider to deprecate --- #

    @property
    def cash_elements(self) -> bool:
        warnings.warn(
            'browser.cash_elements does not work now, '
            'and probably will be renamed when implemented',
            FutureWarning,
        )
        return False

    @cash_elements.setter
    def cash_elements(self, value: bool):
        warnings.warn(
            'browser.cash_elements does not work now, '
            'and probably will be renamed when implemented',
            FutureWarning,
        )
        pass

    @property
    def start_maximized(self):
        warnings.warn(
            'browser.start_maximized does not work now, '
            'and probably will be deprecated or renamed when implemented',
            FutureWarning,
        )
        return False

    @start_maximized.setter
    def start_maximized(self, value):
        warnings.warn(
            'browser.start_maximized does not work now, '
            'and probably will be deprecated or renamed when implemented',
            FutureWarning,
        )
        pass

    @property
    def desired_capabilities(self):
        warnings.warn(
            'browser.desired_capabilities does not work now, '
            'and probably will be deprecated completely',
            FutureWarning,
        )
        return None

    @desired_capabilities.setter
    def desired_capabilities(self, value):
        warnings.warn(
            'browser.desired_capabilities does not work now, '
            'and probably will be deprecated completely',
            FutureWarning,
        )

    @property
    def poll_during_waits(self) -> int:
        warnings.warn(
            'browser.poll_during_waits might be deprecated',
            PendingDeprecationWarning,
        )
        return self._poll_during_waits or 100

    @poll_during_waits.setter
    def poll_during_waits(self, value: int):
        warnings.warn(
            'browser.poll_during_waits= might be deprecated',
            PendingDeprecationWarning,
        )
        self._poll_during_waits = value

    @property
    def counter(self):
        return self._counter

    @counter.setter
    def counter(self, value):
        self._counter = value

    # todo: do we need something predefined like selenide Configuration.reportsUrl ?
    @property
    def reports_folder(self) -> str:
        return self._reports_folder

    @reports_folder.setter
    def reports_folder(self, value):
        self._reports_folder = value
