import logging
import os
import re
import requests
import subprocess
import sys
import time
import r_selenium.webdriver.chrome.service
import r_selenium.webdriver.chrome.webdriver
import r_selenium.webdriver.common.service
import r_selenium.webdriver.remote.command
from contextlib import suppress
from .cdp import CDP
from .cdp import PageElement
from .dprocess import start_detached
from .options import ChromeOptions
from .reactor import Reactor
from .webelement import WebElement

__all__ = (
    "Chrome",
    "ChromeOptions",
    "Patcher",
    "Reactor",
    "CDP",
    "find_chrome_executable",
)
IS_MAC = "darwin" in sys.platform
IS_POSIX = sys.platform.startswith(("darwin", "cygwin", "linux"))
logger = logging.getLogger("uc")
logger.setLevel(logging.getLogger().getEffectiveLevel())


class Chrome(r_selenium.webdriver.chrome.webdriver.WebDriver):
    """Controls chromedriver to drive a browser.
    The driver gets downloaded automatically."""
    _instances = set()
    session_id = None
    debug = False

    def __init__(
        self,
        options=None,
        user_data_dir=None,
        driver_executable_path=None,
        browser_executable_path=None,
        port=0,
        enable_cdp_events=False,
        log_level=0,
        headless=False,
        patch_driver=True,
        version_main=None,
        patcher_force_close=False,
        suppress_welcome=True,
        use_subprocess=True,
        debug=False,
        **kw,
    ):
        """
        Starts the Chrome service and creates a new instance of chromedriver.

        Parameters
        ----------

        options: (default: None)
            Takes an instance of ChromeOptions to customize browser behavior.

        user_data_dir:
            None (default) Create a temp profile directory for the browser.
            If user_data_dir is a path to a valid Chrome profile directory,
            use it and turn off the automatic removal mechanism at exit.

        driver_executable_path:
            None (default) Downloads and patches the new binary.

        browser_executable_path:
            None (default) Use find_chrome_executable().
            (If not specified, make sure Chrome is on the PATH.)

        port: (default: 0)
            Port you would like the service to run.
            If left as 0, a free port will be found.

        enable_cdp_events: (default: False)
            This enables the handling of wire messages.
            When enabled, you can subscribe to CDP events by using:

                driver.add_cdp_listener("Network.dataReceived", yourcallback)
                # yourcallback: callable that accepts exactly 1 dict parameter.

        log_level: (default: adapts to python global log level)

        headless: (default: False)
            Use headless mode.
            (Already handled by r_seleniumbase/core/browser_launcher.py)

        patch_driver: (default: True)
            Patches uc_driver to be undetectable if not already patched.

        version_main: (default: None)
            Overrides the browser version for older versions of Chrome.
            Eg: version_main=96
            (Useful when you have a newer driver, but an older browser.)

        patcher_force_close: (default: False)
            Instructs patcher to access the chromedriver binary.
            If the file is locked, it will force shutdown all instances.
            Setting this is not recommended, unless you know the implications.

        suppress_welcome: (default: True)
            Suppress the Chrome welcome screen that appears on first-time runs.

        use_subprocess: (default: True)
            Subprocess chromedriver/python: Don't make Chrome a parent process.
        """
        self.debug = debug
        self.patcher = None
        import fasteners
        from r_seleniumbase.fixtures import constants
        if patch_driver:
            uc_lock = fasteners.InterProcessLock(
                constants.MultiBrowser.DRIVER_FIXING_LOCK
            )
            with uc_lock:
                from .patcher import Patcher
                self.patcher = Patcher(
                    executable_path=driver_executable_path,
                    force=patcher_force_close,
                    version_main=version_main,
                )
                self.patcher.auto()
        if not options:
            options = ChromeOptions()
        try:
            if hasattr(options, "_session") and options._session is not None:
                # Prevent reuse of options.
                # (Probably a port overlap. Quit existing driver and continue.)
                logger.debug("You cannot reuse the ChromeOptions object")
                with suppress(Exception):
                    options._session.quit()
        except AttributeError:
            pass
        options._session = self
        debug_host = "127.0.0.1"
        debug_port = 9222
        special_port_free = False  # If the port isn't free, don't use 9222
        try:
            with requests.Session() as session:
                res = session.get(
                    "http://127.0.0.1:9222",
                    headers={"Connection": "close"},
                    timeout=2,
                )
                if res.status_code != 200:
                    raise Exception("The port is free! It will be used!")
        except Exception:
            # Use port 9222, which outputs to chrome://inspect/#devices
            special_port_free = True
        sys_argv = sys.argv
        arg_join = " ".join(sys_argv)
        from r_seleniumbase import config as sb_config
        if (
            (("-n" in sys.argv) or (" -n=" in arg_join) or ("-c" in sys.argv))
            or (hasattr(sb_config, "multi_proxy") and sb_config.multi_proxy)
            or not special_port_free
        ):
            debug_port = r_selenium.webdriver.common.service.utils.free_port()
        if hasattr(options, "_remote_debugging_port"):
            # The user chooses the port. Errors happen if the port is taken.
            debug_port = options._remote_debugging_port
        if not options.debugger_address:
            options.debugger_address = "%s:%d" % (debug_host, debug_port)
        if enable_cdp_events:
            options.set_capability(
                "goog:loggingPrefs", {"performance": "ALL", "browser": "ALL"}
            )
        options.add_argument("--remote-debugging-host=%s" % debug_host)
        options.add_argument("--remote-debugging-port=%s" % debug_port)
        if user_data_dir:
            options.add_argument("--user-data-dir=%s" % user_data_dir)
        language, keep_user_data_dir = None, bool(user_data_dir)
        # See if a custom user profile is specified in options
        for arg in options.arguments:
            if "lang" in arg:
                m = re.search("(?:--)?lang(?:[ =])?(.*)", arg)
                try:
                    language = m[1]
                except IndexError:
                    language = "en-US,en;q=0.9"
            if "user-data-dir" in arg:
                m = re.search("(?:--)?user-data-dir(?:[ =])?(.*)", arg)
                try:
                    user_data_dir = m[1]
                    keep_user_data_dir = True
                except IndexError:
                    pass
        if not user_data_dir:
            if hasattr(options, "user_data_dir") and getattr(
                options, "user_data_dir", None
            ):
                options.add_argument(
                    "--user-data-dir=%s" % options.user_data_dir
                )
                keep_user_data_dir = True
            else:
                import tempfile
                user_data_dir = os.path.normpath(tempfile.mkdtemp())
                keep_user_data_dir = False
                arg = "--user-data-dir=%s" % user_data_dir
                # Create a temporary folder for the user-data profile.
                options.add_argument(arg)
        if not language:
            with suppress(Exception):
                import locale
                language = locale.getlocale()[0].replace("_", "-")
            if (
                not language
                or "English" in language
                or "United States" in language
            ):
                language = "en-US"
        options.add_argument("--lang=%s" % language)
        if not options.binary_location:
            binary_location = (
                browser_executable_path or find_chrome_executable()
            )
            if binary_location:
                options.binary_location = binary_location
            else:
                # Improve the default error message in this situation.
                # Setting options.binary_location to None results in:
                #    "TypeError: Binary Location Must be a String"
                raise Exception("Chrome not found! Install it first!")
        self._delay = constants.UC.RECONNECT_TIME
        self.user_data_dir = user_data_dir
        self.keep_user_data_dir = keep_user_data_dir
        if suppress_welcome:
            options.arguments.extend(
                [
                    "--no-default-browser-check",
                    "--no-first-run",
                    "--no-service-autorun",
                    "--password-store=basic",
                    "--profile-directory=Default",
                ]
            )
        options.add_argument(
            "--log-level=%d" % log_level
            or divmod(logging.getLogger().getEffectiveLevel(), 10)[0]
        )
        if hasattr(options, 'handle_prefs'):
            options.handle_prefs(user_data_dir)
        with suppress(Exception):
            import json
            with open(
                os.path.join(
                    os.path.abspath(user_data_dir),
                    "Default",
                    "Preferences",
                ),
                encoding="utf-8",
                mode="r+",
                errors="ignore",
            ) as fs:
                config = json.load(fs)
                if (
                    "exit_type" not in config["profile"].keys()
                    or config["profile"]["exit_type"] is not None
                ):
                    config["profile"]["exit_type"] = None
                fs.seek(0, 0)
                fs.truncate()
                json.dump(config, fs)
        creationflags = 0
        if "win32" in sys.platform:
            creationflags = subprocess.CREATE_NO_WINDOW
        self.options = options
        uc_lock = fasteners.InterProcessLock(
            constants.MultiBrowser.DRIVER_FIXING_LOCK
        )
        with uc_lock:
            if not use_subprocess:
                self.browser_pid = start_detached(
                    options.binary_location, *options.arguments
                )
            else:
                gui_lock = fasteners.InterProcessLock(
                    constants.MultiBrowser.PYAUTOGUILOCK
                )
                with gui_lock:
                    browser = subprocess.Popen(
                        [options.binary_location, *options.arguments],
                        stdin=subprocess.PIPE,
                        stdout=subprocess.PIPE,
                        stderr=subprocess.PIPE,
                        close_fds=IS_POSIX,
                        creationflags=creationflags,
                    )
                    self.browser_pid = browser.pid
            service_ = None
            log_output = subprocess.PIPE
            if patch_driver:
                service_ = r_selenium.webdriver.chrome.service.Service(
                    executable_path=self.patcher.executable_path,
                    service_args=["--disable-build-check"],
                    port=port,
                    log_output=log_output,
                )
            else:
                service_ = r_selenium.webdriver.chrome.service.Service(
                    executable_path=driver_executable_path,
                    service_args=["--disable-build-check"],
                    port=port,
                    log_output=log_output,
                )
            if hasattr(service_, "creationflags"):
                setattr(service_, "creationflags", creationflags)
            if hasattr(service_, "creation_flags"):
                setattr(service_, "creation_flags", creationflags)
            try:
                super().__init__(options=options, service=service_)
            except OSError as e:
                if IS_MAC and "Bad CPU type in executable" in str(e):
                    print(str(e))
                    message = (
                        "Missing a macOS dependency:\n"
                        "Your Mac needs Rosetta 2 to use UC Mode!\n"
                        'Run: "softwareupdate --install-rosetta"\n'
                        "Info: "
                        "https://apple.stackexchange.com/a/408379/607628"
                    )
                    raise Exception(message)
                else:
                    raise
            self.reactor = None
            if enable_cdp_events:
                if logging.getLogger().getEffectiveLevel() == logging.DEBUG:
                    logging.getLogger(
                        "r_selenium.webdriver.remote.remote_connection"
                    ).setLevel(20)
                reactor = Reactor(self)
                reactor.start()
                self.reactor = reactor
            self._web_element_cls = WebElement

    def __getattribute__(self, item):
        if not super().__getattribute__("debug"):
            return super().__getattribute__(item)
        else:
            import inspect
            original = super().__getattribute__(item)
            if inspect.ismethod(original) and not inspect.isclass(original):
                def newfunc(*args, **kwargs):
                    return original(*args, **kwargs)
                return newfunc
            return original

    def __dir__(self):
        return object.__dir__(self)

    def _get_cdc_props(self):
        cdc_props = []
        with suppress(Exception):
            cdc_props = self.execute_script(
                """
                let objectToInspect = window,
                    result = [];
                while(objectToInspect !== null)
                { result = result.concat(
                    Object.getOwnPropertyNames(objectToInspect)
                  );
                  objectToInspect = Object.getPrototypeOf(objectToInspect); }
                return result.filter(i => i.match(/^[a-z]{3}_[a-z]{22}_.*/i))
                """
            )
        return cdc_props

    def _hook_remove_cdc_props(self, cdc_props):
        if len(cdc_props) < 1:
            return
        cdc_props_js_array = "[" + ", ".join(
            '"' + p + '"' for p in cdc_props
        ) + "]"
        self.execute_cdp_cmd(
            "Page.addScriptToEvaluateOnNewDocument",
            {
                "source": cdc_props_js_array + (
                    ".forEach(p => delete window[p]);"
                )
            },
        )

    def remove_cdc_props_as_needed(self):
        cdc_props = self._get_cdc_props()
        if len(cdc_props) > 0:
            self._hook_remove_cdc_props(cdc_props)
            time.sleep(0.05)

    def get(self, url):
        self.remove_cdc_props_as_needed()
        return super().get(url)

    def add_cdp_listener(self, event_name, callback):
        if (
            hasattr(self, "reactor")
            and self.reactor
            and self.reactor is not None
            and isinstance(self.reactor, Reactor)
        ):
            self.reactor.add_event_handler(event_name, callback)
            return self.reactor.handlers
        return False

    def clear_cdp_listeners(self):
        if (
            hasattr(self, "reactor")
            and self.reactor
            and isinstance(self.reactor, Reactor)
        ):
            self.reactor.handlers.clear()

    def window_new(self, url=None):
        self.execute(
            r_selenium.webdriver.remote.command.Command.NEW_WINDOW,
            {"type": "window"},
        )
        if url:
            self.remove_cdc_props_as_needed()
            return super().get(url)
        return None

    def tab_new(self, url):
        """Open url in a new tab."""
        if not hasattr(self, "cdp"):
            self.cdp = CDP(self.options)
        self.cdp.tab_new(str(url))

    def tab_list(self):
        if not hasattr(self, "cdp"):
            self.cdp = CDP(self.options)

        retval = self.get(self.cdp.endpoints["list"])
        return [PageElement(o) for o in retval]

    def reconnect(self, timeout=0.1):
        """This can be useful when sites use heavy detection methods:
        - Stops the chromedriver service that runs in the background.
        - Starts the chromedriver service that runs in the background.
        - Recreates the session."""
        if hasattr(self, "service"):
            with suppress(Exception):
                if self.service.is_connectable():
                    self.stop_client()
                    try:
                        self.service.send_remote_shutdown_command()
                    except TypeError:
                        pass
                    finally:
                        with suppress(Exception):
                            self.service._terminate_process()
            if isinstance(timeout, str):
                if timeout.lower() == "breakpoint":
                    breakpoint()  # To continue:
                    pass  # Type "c" & press ENTER!
            else:
                time.sleep(timeout)
            with suppress(Exception):
                self.service.start()
        with suppress(Exception):
            self.start_session()
            time.sleep(0.0075)
        with suppress(Exception):
            for window_handle in self.window_handles:
                self.switch_to.window(window_handle)
                if self.current_url.startswith("chrome-extension://"):
                    # https://issues.chromium.org/issues/396611138
                    # (Remove the Linux conditional when resolved)
                    # (So that close() is always called)
                    if "linux" in sys.platform:
                        self.close()
                    if self.service.is_connectable():
                        self.stop_client()
                        try:
                            self.service.send_remote_shutdown_command()
                        except TypeError:
                            pass
                        finally:
                            with suppress(Exception):
                                self.service._terminate_process()
                    self.service.start()
                    self.start_session()
                    time.sleep(0.003)
        with suppress(Exception):
            self.switch_to.window(self.window_handles[-1])
        self._is_connected = True

    def disconnect(self):
        """Stops the chromedriver service that runs in the background.
        To use driver methods again, you MUST call driver.connect()"""
        if hasattr(self, "service"):
            with suppress(Exception):
                if self.service.is_connectable():
                    self.stop_client()
                    time.sleep(0.003)
                    try:
                        self.service.send_remote_shutdown_command()
                    except TypeError:
                        pass
                    finally:
                        with suppress(Exception):
                            self.service._terminate_process()
        self._is_connected = False

    def connect(self):
        """Starts the chromedriver service that runs in the background
        and recreates the session."""
        if hasattr(self, "service"):
            with suppress(Exception):
                self.service.start()
        with suppress(Exception):
            self.start_session()
            time.sleep(0.0075)
        with suppress(Exception):
            for window_handle in self.window_handles:
                self.switch_to.window(window_handle)
                if self.current_url.startswith("chrome-extension://"):
                    # https://issues.chromium.org/issues/396611138
                    # (Remove the Linux conditional when resolved)
                    # (So that close() is always called)
                    if "linux" in sys.platform:
                        self.close()
                    if self.service.is_connectable():
                        self.stop_client()
                        try:
                            self.service.send_remote_shutdown_command()
                        except TypeError:
                            pass
                        finally:
                            with suppress(Exception):
                                self.service._terminate_process()
                    self.service.start()
                    self.start_session()
                    time.sleep(0.003)
        with suppress(Exception):
            self.switch_to.window(self.window_handles[-1])
        self._is_connected = True

    def start_session(self, capabilities=None):
        if not capabilities:
            capabilities = self.options.to_capabilities()
        super().start_session(capabilities)

    def quit(self):
        try:
            logger.debug("Terminating the UC browser")
            os.kill(self.browser_pid, 15)
            if "linux" in sys.platform:
                os.waitpid(self.browser_pid, 0)
                time.sleep(0.02)
            else:
                time.sleep(0.04)
        except (AttributeError, ChildProcessError, RuntimeError, OSError):
            time.sleep(0.05)
        except TimeoutError as e:
            logger.debug(e, exc_info=True)
        except Exception:
            pass
        with suppress(Exception):
            self.stop_client()
        with suppress(Exception):
            if hasattr(self, "command_executor") and self.command_executor:
                self.command_executor.close()

        # Remove instance reference to allow garbage collection
        Chrome._instances.discard(self)

        if hasattr(self, "service") and getattr(self.service, "process", None):
            logger.debug("Stopping webdriver service")
            with suppress(Exception):
                try:
                    self.service.send_remote_shutdown_command()
                except TypeError:
                    pass
                finally:
                    with suppress(Exception):
                        self.service._terminate_process()
        if (
            hasattr(self, "reactor")
            and self.reactor
            and hasattr(self.reactor, "event")
        ):
            logger.debug("Shutting down Reactor")
            with suppress(Exception):
                self.reactor.event.set()
                self.reactor.join(timeout=2)
            self.reactor = None
        if (
            hasattr(self, "keep_user_data_dir")
            and hasattr(self, "user_data_dir")
            and not self.keep_user_data_dir
        ):
            import shutil
            for _ in range(5):
                try:
                    shutil.rmtree(self.user_data_dir, ignore_errors=False)
                except FileNotFoundError:
                    pass
                except (RuntimeError, OSError, PermissionError) as e:
                    logger.debug(
                        "When removing the temp profile, a %s occured: "
                        "%s\nRetrying..."
                        % (e.__class__.__name__, e)
                    )
                else:
                    logger.debug(
                        "Successfully removed %s" % self.user_data_dir
                    )
                    break
                time.sleep(0.1)
        # Dereference Patcher so that it can start cleaning up as well.
        # This must come last, otherwise it will throw "in use" errors.
        self.patcher = None

    def __del__(self):
        with suppress(Exception):
            if "win32" in sys.platform:
                self.stop_client()
                self.command_executor.close()
            else:
                super().quit()
        with suppress(Exception):
            self.quit()

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.reconnect(timeout=self._delay)

    def __hash__(self):
        return hash(self.options.debugger_address)


def find_chrome_executable():
    from r_seleniumbase.core import detect_b_ver
    binary_location = detect_b_ver.get_binary_location("google-chrome", True)
    if os.path.exists(binary_location) and os.access(binary_location, os.X_OK):
        return os.path.normpath(binary_location)

    candidates = set()
    if IS_POSIX:
        for item in os.environ.get("PATH").split(os.pathsep):
            for subitem in (
                "google-chrome",
                "google-chrome-stable",
                "google-chrome-beta",
                "google-chrome-dev",
                "google-chrome-unstable",
                "chrome",
                "chromium",
                "chromium-browser",
            ):
                candidates.add(os.sep.join((item, subitem)))
        if "darwin" in sys.platform:
            gc = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
            candidates.update(
                [
                    gc, "/Applications/Chromium.app/Contents/MacOS/Chromium"
                ]
            )
    else:
        for item in map(
            os.environ.get,
            (
                "PROGRAMFILES",
                "PROGRAMFILES(X86)",
                "LOCALAPPDATA",
                "PROGRAMW6432",
            ),
        ):
            for subitem in (
                "Google/Chrome/Application",
                "Google/Chrome Beta/Application",
                "Google/Chrome Canary/Application",
            ):
                candidates.add(os.sep.join((item, subitem, "chrome.exe")))
    for candidate in candidates:
        if os.path.exists(candidate) and os.access(candidate, os.X_OK):
            return os.path.normpath(candidate)
    return None  # Browser not found!
