# Copyright (C) 2019-2024 Valéry Febvre
# SPDX-License-Identifier: GPL-3.0-only or GPL-3.0-or-later
# Author: Valéry Febvre <vfebvre@easter-eggs.com>

from gettext import gettext as _
import inspect
import logging
import os
import platform
import time
import tzlocal
from urllib.parse import urlsplit

try:
    from curl_cffi import requests as crequests
except Exception:
    crequests = None
import gi
import requests

gi.require_version('Adw', '1')
gi.require_version('WebKit', '6.0')

from gi.repository import Adw
from gi.repository import Gio
from gi.repository import GLib
from gi.repository import GObject
from gi.repository import Gtk
from gi.repository import WebKit

from komikku.servers.exceptions import CfBypassError
from komikku.servers.utils import get_session_cookies
from komikku.utils import get_cache_dir
from komikku.utils import REQUESTS_TIMEOUT

CF_RELOAD_MAX = 3
DEBUG = False

logger = logging.getLogger('komikku.webview')


@Gtk.Template.from_resource('/info/febvre/Komikku/ui/webview.ui')
class WebviewPage(Adw.NavigationPage):
    __gtype_name__ = 'WebviewPage'
    __gsignals__ = {
        'canceled': (GObject.SignalFlags.RUN_FIRST, None, ()),
    }

    toolbarview = Gtk.Template.Child('toolbarview')
    title = Gtk.Template.Child('title')

    cf_request = None  # Current CF request
    cf_requests = []  # List of pending CF requests
    exited = True  # Whether webview has been exited (page has been popped)
    exited_auto = False  # Whether webview has been automatically left (no user interaction)
    handlers_ids = []  # List of handlers IDs (connected events)
    handlers_webview_ids = []  # List of hendlers IDs (connected events to WebKit.WebView)
    lock = False  # Whether webview is locked (in use)

    def __init__(self, window):
        Adw.NavigationPage.__init__(self)

        self.window = window

        self.connect('hidden', self.on_hidden)

        # User agent: Gnome Web like
        cpu_arch = platform.machine()
        session_type = GLib.getenv('XDG_SESSION_TYPE').capitalize()
        custom_part = f'{session_type}; Linux {cpu_arch}'  # noqa: E702
        self.user_agent = f'Mozilla/5.0 ({custom_part}) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'

        # Settings
        self.settings = WebKit.Settings.new()
        self.settings.set_enable_developer_extras(DEBUG)
        self.settings.set_enable_write_console_messages_to_stdout(DEBUG)
        self.settings.set_enable_dns_prefetching(True)

        # Enable extra features
        all_feature_list = self.settings.get_all_features()
        if DEBUG:
            experimental_feature_list = self.settings.get_experimental_features()
            development_feature_list = self.settings.get_development_features()
            experimental_features = [
                experimental_feature_list.get(index).get_identifier() for index in range(experimental_feature_list.get_length())
            ]
            development_features = [
                development_feature_list.get(index).get_identifier() for index in range(development_feature_list.get_length())
            ]

            # Categories: Security, Animation, JavaScript, HTML, Other, DOM, Privacy, Media, Network, CSS
            for index in range(all_feature_list.get_length()):
                feature = all_feature_list.get(index)
                if feature.get_identifier() in experimental_features:
                    type = 'Experimental'
                elif feature.get_identifier() in development_features:
                    type = 'Development'
                else:
                    type = 'Stable'
                if feature.get_category() == 'Other' and not feature.get_default_value():
                    print('ID: {0}, Default: {1}, Category: {2}, Details: {3}, type: {4}'.format(
                        feature.get_identifier(),
                        feature.get_default_value(),
                        feature.get_category(),
                        feature.get_details(),
                        type
                    ))

        extra_features_enabled = (
            'AllowDisplayOfInsecureContent',
            'AllowRunningOfInsecureContent',
            'JavaScriptCanAccessClipboard',
        )
        for index in range(all_feature_list.get_length()):
            feature = all_feature_list.get(index)
            if feature.get_identifier() in extra_features_enabled and not feature.get_default_value():
                self.settings.set_feature_enabled(feature, True)

        # Context
        self.web_context = WebKit.WebContext(time_zone_override=tzlocal.get_localzone_name())
        self.web_context.set_cache_model(WebKit.CacheModel.DOCUMENT_VIEWER)
        self.web_context.set_preferred_languages(['en-US', 'en'])

        # Network session
        self.network_session = WebKit.NetworkSession.new(
            os.path.join(get_cache_dir(), 'webview', 'data'),
            os.path.join(get_cache_dir(), 'webview', 'cache')
        )
        self.network_session.get_website_data_manager().set_favicons_enabled(True)
        self.network_session.set_itp_enabled(False)
        self.network_session.get_cookie_manager().set_accept_policy(WebKit.CookieAcceptPolicy.ALWAYS)
        self.network_session.get_cookie_manager().set_persistent_storage(
            os.path.join(get_cache_dir(), 'webview', 'cookies.sqlite'),
            WebKit.CookiePersistentStorage.SQLITE
        )

        # Create Webview
        self.webkit_webview = WebKit.WebView(
            web_context=self.web_context,
            network_session=self.network_session,
            settings=self.settings
        )

        self.toolbarview.set_content(self.webkit_webview)
        self.window.navigationview.add(self)

    def close_page(self, blank=True):
        self.disconnect_all_signals()

        if blank:
            self.webkit_webview.stop_loading()
            GLib.idle_add(self.webkit_webview.load_uri, 'about:blank')

        def do_next():
            if not self.exited:
                return GLib.SOURCE_CONTINUE

            self.lock = False
            self.pop_cf_request()

            return GLib.SOURCE_REMOVE

        if self.cf_request:
            # Wait page is exited to unlock and load next pending CF request (if exists)
            GLib.idle_add(do_next)
        else:
            self.exited = True
            self.lock = False

        logger.debug('Page closed')

    def connect_signal(self, *args):
        handler_id = self.connect(*args)
        self.handlers_ids.append(handler_id)

    def connect_webview_signal(self, *args):
        handler_id = self.webkit_webview.connect(*args)
        self.handlers_webview_ids.append(handler_id)

    def disconnect_all_signals(self):
        for handler_id in self.handlers_ids:
            self.disconnect(handler_id)

        self.handlers_ids = []

        for handler_id in self.handlers_webview_ids:
            self.webkit_webview.disconnect(handler_id)

        self.handlers_webview_ids = []

    def exit(self):
        if self.window.page != self.props.tag:
            # Page has already been popped or has never been pushed (no CF chanllenge)
            # No need to wait `hidden` event to flag it as exited
            self.exited = True
            return

        self.exited_auto = True
        self.window.navigationview.pop()

    def load_page(self, uri=None, cf_request=None, user_agent=None, auto_load_images=True):
        if self.lock or not self.exited:
            # Already in use or page exiting is not ended (pop animation not ended)
            return False

        self.exited = False
        self.exited_auto = False
        self.lock = True

        self.webkit_webview.get_settings().set_user_agent(user_agent or self.user_agent)
        self.webkit_webview.get_settings().set_auto_load_images(auto_load_images)

        self.cf_request = cf_request
        if self.cf_request:
            self.connect_webview_signal('load-changed', self.cf_request.on_load_changed)
            self.connect_webview_signal('load-failed', self.cf_request.on_load_failed)
            self.connect_webview_signal('notify::title', self.cf_request.on_title_changed)
            uri = self.cf_request.url

        logger.debug('Load page %s', uri)

        GLib.idle_add(self.webkit_webview.load_uri, uri)

        return True

    def on_hidden(self, _page):
        self.exited = True

        if not self.exited_auto:
            if self.cf_request:
                # Webview has been left via a user interaction (back button, <ESC> key)
                self.cf_request.cancel()
            else:
                self.emit('canceled')

        if self.cf_request and self.cf_request.error:
            # Cancel all pending CF requests with same URL if challenge was not completed
            for cf_request in self.cf_requests[:]:
                if cf_request.url == self.cf_request.url:
                    cf_request.cancel()
                    self.cf_requests.remove(cf_request)

        if not self.exited_auto:
            self.close_page()

    def pop_cf_request(self):
        if not self.cf_requests:
            return

        if self.load_page(cf_request=self.cf_requests[0]):
            self.cf_requests.pop(0)

    def push_cf_request(self, cf_request):
        self.cf_requests.append(cf_request)
        self.pop_cf_request()

    def show(self):
        self.window.navigationview.push(self)


class CompleteChallenge:
    """Allows user to complete a captcha using the Webview

    Several calls to this decorator can be concurrent. But only one will be honored at a time.
    """

    def __call__(self, func):
        self.func = func

        def wrapper(*args, **kwargs):
            bound_args = inspect.signature(self.func).bind(*args, **kwargs)
            args_dict = dict(bound_args.arguments)

            self.server = args_dict['self']
            self.url = self.server.bypass_cf_url or self.server.base_url

            if not self.server.has_cf and not self.server.has_captcha:
                return self.func(*args, **kwargs)

            # Test CF challenge cookie
            if self.server.has_cf and not self.server.has_captcha:
                if self.server.session is None:
                    # Try loading a previous session
                    self.server.load_session()

                if self.server.session:
                    logger.debug(f'{self.server.id}: Previous session found')

                    # Locate CF challenge cookie
                    cf_cookie_found = False
                    for cookie in get_session_cookies(self.server.session):
                        if cookie.name == 'cf_clearance':
                            logger.debug(f'{self.server.id}: Session has CF challenge cookie')
                            cf_cookie_found = True
                            break

                    if cf_cookie_found:
                        # Check session validity
                        logger.debug(f'{self.server.id}: Checking session...')
                        r = self.server.session_get(self.url)
                        if r.ok:
                            logger.debug(f'{self.server.id}: Session OK')
                            return self.func(*args, **kwargs)

                        logger.debug(f'{self.server.id}: Session KO ({r.status_code})')
                    else:
                        logger.debug(f'{self.server.id}: Session has no CF challenge cookie')

            self.cf_reload_count = 0
            self.done = False
            self.error = None
            self.load_event = None
            self.load_event_finished_timeout = 10
            self.load_events_monitor_id = None
            self.load_events_monitor_ts = None

            self.webview = Gio.Application.get_default().window.webview
            self.webview.push_cf_request(self)

            while not self.done and self.error is None:
                time.sleep(1)

            if self.error:
                logger.warning(self.error)
                raise CfBypassError()
            else:
                return self.func(*args, **kwargs)

        return wrapper

    def cancel(self):
        self.error = 'Captcha challenge bypass aborted'

    def monitor_challenge(self):
        # Detect captcha via JavaScript in current page
        # - Cloudflare challenge
        # - Google ReCAPTCHA
        # - AreYouHuman2 (2/3 images to identify)
        # - Challange (browser identification, no user interaction)
        #
        # - A captcha is detected: change title to 'cf_captcha', 're_captcha',...
        # - No challenge found: change title to 'ready'
        # - An error occurs during challenge: change title to 'error'
        js = """
            function check() {
                let intervalID = setInterval(() => {
                    if (document.getElementById('challenge-error-title')) {
                        // CF error: Browser is outdated?
                        document.title = 'error';
                        clearInterval(intervalID);
                    }
                    else if (document.querySelector('.ray-id') || document.querySelector('style').innerText.indexOf('ray-id') > 0) {
                        document.title = 'cf_captcha';
                    }
                    else if (document.querySelector('.g-recaptcha')) {
                        document.title = 're_captcha';
                    }
                    else if (document.querySelector('#formVerify')) {
                        document.title = 'ayh2_captcha';
                    }
                    else if (document.querySelector('script[src*="challange"]')) {
                        document.title = 'challange_captcha';
                    }
                    else {
                        document.title = 'ready';
                        clearInterval(intervalID);
                    }
                }, 100);
            };

            if (document.readyState === 'loading') {
                document.addEventListener('DOMContentLoaded', check);
            }
            else {
                check();
            }
        """
        self.webview.webkit_webview.evaluate_javascript(js, -1)

    def on_load_changed(self, _webkit_webview, event):
        self.load_event = event
        logger.debug(f'Load changed: {event} {self.webview.webkit_webview.get_uri()}')

        if event != WebKit.LoadEvent.REDIRECTED and '__cf_chl_tk' in self.webview.webkit_webview.get_uri():
            # Challenge has been passed
            # Disable images auto-load
            logger.debug('Disable images automatic loading')
            self.webview.webkit_webview.get_settings().set_auto_load_images(False)

        elif event == WebKit.LoadEvent.COMMITTED:
            self.monitor_challenge()

    def on_load_failed(self, _webkit_webview, _event, uri, _gerror):
        self.error = f'Captcha challenge bypass failure: {uri}'

        self.webview.exit()
        self.webview.close_page()

    def on_title_changed(self, _webkit_webview, _title):
        title = self.webview.webkit_webview.props.title
        logger.debug(f'Title changed: {title}')

        if title == 'error':
            # CF error message detected
            # settings or a features related?
            self.error = 'CF challenge bypass error'
            self.webview.exit()
            self.webview.close_page()
            return

        if title.endswith('_captcha'):
            if title == 'cf_captcha':
                self.cf_reload_count += 1
            if self.cf_reload_count > CF_RELOAD_MAX:
                self.error = 'Max CF reload exceeded'
                self.webview.exit()
                self.webview.close_page()
                return

            if title == 'cf_captcha':
                logger.debug(f'{self.server.id}: CF captcha detected, try #{self.cf_reload_count}')
            elif title == 're_captcha':
                logger.debug(f'{self.server.id}: ReCAPTCHA detected')
            elif title == 'ayh2_captcha':
                logger.debug(f'{self.server.id}: AreYouHuman2 detected')
            elif title == 'challange_captcha':
                logger.debug(f'{self.server.id}: Challange detected')

            # Show webview, user must complete a CAPTCHA
            if self.webview.window.page != self.webview.props.tag:
                self.webview.title.set_title(_('Please complete CAPTCHA'))
                self.webview.title.set_subtitle(self.server.name)
                self.webview.show()

        if title != 'ready':
            return

        # Challenge has been passed
        # Exit from webview if end of challenge has not been detected in on_load_changed()
        # Webview should not be closed, we need to store cookies first
        self.webview.exit()

        logger.debug(f'{self.server.id}: Page loaded, getting cookies...')
        self.webview.network_session.get_cookie_manager().get_cookies(self.server.base_url, None, self.on_get_cookies_finished, None)

    def on_get_cookies_finished(self, cookie_manager, result, _user_data):
        if self.server.http_client == 'requests':
            self.server.session = requests.Session()

        elif crequests is not None and self.server.http_client == 'curl_cffi':
            self.server.session = crequests.Session(
                allow_redirects=True,
                impersonate='chrome',
                timeout=(REQUESTS_TIMEOUT, REQUESTS_TIMEOUT * 2)
            )

        else:
            self.error = f'{self.server.id}: Failed to copy Webview cookies in session (no HTTP client found)'
            self.webview.close_page()
            return

        # Set default headers
        self.server.session.headers.update(self.server.headers or {'User-Agent': self.webview.user_agent})

        # Copy libsoup cookies in session cookies jar
        cf_cookie_found = False
        rcookies = []
        for cookie in cookie_manager.get_cookies_finish(result):
            rcookies.append(requests.cookies.create_cookie(
                name=cookie.get_name(),
                value=cookie.get_value(),
                domain=cookie.get_domain(),
                path=cookie.get_path(),
                expires=cookie.get_expires().to_unix() if cookie.get_expires() else None,
                rest={'HttpOnly': cookie.get_http_only()},
                secure=cookie.get_secure(),
            ))
            if cookie.get_name() == 'cf_clearance':
                cf_cookie_found = True

        if not cf_cookie_found:
            # Server don't used Cloudflare (temporarily or not at all)
            # Create a fake `cf_clearance` cookie, so as not to try to detect the challenge next time
            rcookies.append(requests.cookies.create_cookie(
                name='cf_clearance',
                value='74k3',
                domain=urlsplit(self.url).netloc,
                path='/',
            ))

        for rcookie in rcookies:
            if self.server.http_client == 'requests':
                self.server.session.cookies.set_cookie(rcookie)

            elif self.server.http_client == 'curl_cffi':
                self.server.session.cookies.jar.set_cookie(rcookie)

        logger.debug(f'{self.server.id}: Webview cookies successully copied in session')
        self.server.save_session()

        self.done = True
        self.webview.close_page()


def eval_js(code):
    error = None
    res = None
    webview = Gio.Application.get_default().window.webview

    def load_page():
        if not webview.load_page(uri='about:blank'):
            return True

        webview.connect_webview_signal('load-changed', on_load_changed)

        if DEBUG:
            webview.show()

    def on_evaluate_javascript_finish(_webkit_webview, result, _user_data=None):
        nonlocal error
        nonlocal res

        try:
            js_result = webview.webkit_webview.evaluate_javascript_finish(result)
        except GLib.GError:
            error = 'Failed to eval JS code'
        else:
            if js_result.is_string():
                res = js_result.to_string()

            if res is None:
                error = 'Failed to eval JS code'

        webview.close_page()

    def on_load_changed(_webkit_webview, event):
        if event != WebKit.LoadEvent.FINISHED:
            return

        webview.webkit_webview.evaluate_javascript(code, -1, None, None, None, on_evaluate_javascript_finish)

    GLib.timeout_add(100, load_page)

    while res is None and error is None:
        time.sleep(1)

    if error:
        logger.warning(error)
        raise requests.exceptions.RequestException()

    return res


def get_page_html(url, user_agent=None, wait_js_code=None, with_cookies=False):
    cookies = None
    error = None
    html = None
    webview = Gio.Application.get_default().window.webview

    def load_page():
        if not webview.load_page(uri=url, user_agent=user_agent, auto_load_images=False):
            return True

        webview.connect_webview_signal('load-changed', on_load_changed)
        webview.connect_webview_signal('load-failed', on_load_failed)
        webview.connect_webview_signal('notify::title', on_title_changed)

        if DEBUG:
            webview.show()

    def on_get_cookies_finished(cookie_manager, result, _user_data):
        nonlocal cookies

        rcookies = []
        # Get libsoup cookies
        for cookie in cookie_manager.get_cookies_finish(result):
            rcookie = requests.cookies.create_cookie(
                name=cookie.get_name(),
                value=cookie.get_value(),
                domain=cookie.get_domain(),
                path=cookie.get_path(),
                expires=cookie.get_expires().to_unix() if cookie.get_expires() else None,
                rest={'HttpOnly': cookie.get_http_only()},
                secure=cookie.get_secure(),
            )
            rcookies.append(rcookie)

        cookies = rcookies

        webview.close_page()

    def on_get_html_finish(_webkit_webview, result, _user_data=None):
        nonlocal error
        nonlocal html

        js_result = webview.webkit_webview.evaluate_javascript_finish(result)
        if js_result:
            html = js_result.to_string()

        if html is not None:
            if with_cookies:
                logger.debug('Page loaded, getting cookies...')
                webview.network_session.get_cookie_manager().get_cookies(url, None, on_get_cookies_finished, None)
            else:
                webview.close_page()
        else:
            error = f'Failed to get chapter page html: {url}'
            webview.close_page()

    def on_load_changed(_webkit_webview, event):
        if event != WebKit.LoadEvent.FINISHED:
            return

        if wait_js_code:
            # Wait that everything needed has been loaded
            webview.webkit_webview.evaluate_javascript(wait_js_code, -1)
        else:
            webview.webkit_webview.evaluate_javascript('document.documentElement.outerHTML', -1, None, None, None, on_get_html_finish)

    def on_load_failed(_webkit_webview, _event, _uri, _gerror):
        nonlocal error

        error = f'Failed to load chapter page: {url}'

        webview.close_page()

    def on_title_changed(_webkit_webview, _title):
        nonlocal error

        if webview.webkit_webview.props.title == 'ready':
            # Everything we need has been loaded, we can retrieve page HTML
            webview.webkit_webview.evaluate_javascript('document.documentElement.outerHTML', -1, None, None, None, on_get_html_finish)

        elif webview.webkit_webview.props.title == 'abort':
            error = f'Failed to get chapter page html: {url}'
            webview.close_page()

    GLib.timeout_add(100, load_page)

    while (html is None or (with_cookies and cookies is None)) and error is None:
        time.sleep(1)

    if error:
        logger.warning(error)
        raise requests.exceptions.RequestException()

    return html if not with_cookies else (html, cookies)


def get_tracker_access_token(url, app_redirect_url, user_agent=None):
    """Use webview to request a client access token to a tracker

    User will be asked to approve client permission.
    If user is not logged in, it will first be taken to the standard login page.

    :param url: Authorization request URL
    :param app_redirect_url: App redirection URL
    :param user_agent: User agent (optional)
    """

    error = None
    redirect_url = None
    webview = Gio.Application.get_default().window.webview

    def load_page():
        if not webview.load_page(uri=url, user_agent=user_agent):
            return False

        webview.connect_signal('canceled', on_canceled)
        webview.connect_webview_signal('load-changed', on_load_changed)
        webview.connect_webview_signal('load-failed', on_load_failed)

        # We assume that this function is always called from preferences
        # Preferences dialog must be closed before opening webview page
        webview.window.preferences.close()
        webview.show()

        return True

    def on_canceled(self):
        nonlocal error
        error = 'canceled'

    def on_load_changed(_webkit_webview, event):
        nonlocal redirect_url

        uri = _webkit_webview.get_uri()
        if event == WebKit.LoadEvent.REDIRECTED and uri.startswith(app_redirect_url):
            redirect_url = uri
            webview.exit()
            webview.close_page()

    def on_load_failed(_webkit_webview, _event, _uri, _gerror):
        nonlocal error
        error = 'failed'
        webview.exit()
        webview.close_page()

    if not load_page():
        error = 'locked'

    while redirect_url is None and error is None:
        time.sleep(1)

    if error != 'locked':
        # We assume that this function is always called from preferences
        # Preferences dialog must be re-opened after closing webview page
        webview.window.preferences.present(webview.window)

    return redirect_url, error
