# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:

# Copyright 2014 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser.  If not, see <http://www.gnu.org/licenses/>.

"""The main browser widgets."""

import itertools

from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QTimer, QUrl
from PyQt5.QtWidgets import QApplication
from PyQt5.QtWebKit import QWebSettings
from PyQt5.QtWebKitWidgets import QWebView, QWebPage

from qutebrowser.config import config
from qutebrowser.keyinput import modeman
from qutebrowser.utils import message, log, usertypes, utils, qtutils, objreg
from qutebrowser.browser import webpage, hints, webelem
from qutebrowser.commands import cmdexc


LoadStatus = usertypes.enum('LoadStatus', ['none', 'success', 'error', 'warn',
                                           'loading'])


tab_id_gen = itertools.count(0)


class WebView(QWebView):

    """One browser tab in TabbedBrowser.

    Our own subclass of a QWebView with some added bells and whistles.

    Attributes:
        hintmanager: The HintManager instance for this view.
        progress: loading progress of this page.
        scroll_pos: The current scroll position as (x%, y%) tuple.
        statusbar_message: The current javscript statusbar message.
        inspector: The QWebInspector used for this webview.
        load_status: loading status of this page (index into LoadStatus)
        open_target: Where to open the next tab ("normal", "tab", "tab_bg")
        viewing_source: Whether the webview is currently displaying source
                        code.
        registry: The ObjectRegistry associated with this tab.
        tab_id: The tab ID of the view.
        _cur_url: The current URL (accessed via cur_url property).
        _has_ssl_errors: Whether SSL errors occured during loading.
        _zoom: A NeighborList with the zoom levels.
        _old_scroll_pos: The old scroll position.
        _force_open_target: Override for open_target.
        _check_insertmode: If True, in mouseReleaseEvent we should check if we
                           need to enter/leave insert mode.
        _win_id: The window ID of the view.

    Signals:
        scroll_pos_changed: Scroll percentage of current tab changed.
                            arg 1: x-position in %.
                            arg 2: y-position in %.
        linkHovered: QWebPages linkHovered signal exposed.
        load_status_changed: The loading status changed
        url_text_changed: Current URL string changed.
    """

    scroll_pos_changed = pyqtSignal(int, int)
    linkHovered = pyqtSignal(str, str, str)
    load_status_changed = pyqtSignal(str)
    url_text_changed = pyqtSignal(str)

    def __init__(self, win_id, parent=None):
        super().__init__(parent)
        self._win_id = win_id
        self.load_status = LoadStatus.none
        self._check_insertmode = False
        self.inspector = None
        self.scroll_pos = (-1, -1)
        self.statusbar_message = ''
        self._old_scroll_pos = (-1, -1)
        self.open_target = usertypes.ClickTarget.normal
        self._force_open_target = None
        self._zoom = None
        self._has_ssl_errors = False
        self.init_neighborlist()
        objreg.get('config').changed.connect(self.init_neighborlist)
        self._cur_url = None
        self.cur_url = QUrl()
        self.progress = 0
        self.registry = objreg.ObjectRegistry()
        self.tab_id = next(tab_id_gen)
        tab_registry = objreg.get('tab-registry', scope='window',
                                  window=win_id)
        tab_registry[self.tab_id] = self
        objreg.register('webview', self, registry=self.registry)
        page = webpage.BrowserPage(win_id, self)
        self.setPage(page)
        hintmanager = hints.HintManager(win_id, self.tab_id, self)
        hintmanager.mouse_event.connect(self.on_mouse_event)
        hintmanager.set_open_target.connect(self.set_force_open_target)
        objreg.register('hintmanager', hintmanager, registry=self.registry)
        page.linkHovered.connect(self.linkHovered)
        page.mainFrame().loadStarted.connect(self.on_load_started)
        self.urlChanged.connect(self.on_url_changed)
        page.mainFrame().loadFinished.connect(self.on_load_finished)
        page.scrollRequested.connect(self.on_scroll_requested)
        # Calculate scroll position once
        self.on_scroll_requested()
        self.loadProgress.connect(lambda p: setattr(self, 'progress', p))
        self.page().statusBarMessage.connect(
            lambda msg: setattr(self, 'statusbar_message', msg))
        self.page().networkAccessManager().sslErrors.connect(
            lambda *args: setattr(self, '_has_ssl_errors', True))
        self.viewing_source = False
        self.setZoomFactor(float(config.get('ui', 'default-zoom')) / 100)

    def __repr__(self):
        url = utils.elide(self.url().toDisplayString(), 50)
        return utils.get_repr(self, tab_id=self.tab_id, url=url)

    def __del__(self):
        # Explicitely releasing the page here seems to prevent some segfaults
        # when quitting.
        # Copied from:
        # https://code.google.com/p/webscraping/source/browse/webkit.py#325
        try:
            self.setPage(None)
        except RuntimeError:
            # It seems sometimes Qt has already deleted the QWebView and we
            # get: RuntimeError: wrapped C/C++ object of type WebView has been
            # deleted
            pass

    def _set_load_status(self, val):
        """Setter for load_status."""
        if not isinstance(val, LoadStatus):
            raise TypeError("Type {} is no LoadStatus member!".format(val))
        log.webview.debug("load status for {}: {}".format(repr(self), val))
        self.load_status = val
        self.load_status_changed.emit(val.name)

    @pyqtSlot(str, str)
    def on_config_changed(self, section, option):
        """Reinitialize the zoom neighborlist if related config changed."""
        if section == 'ui' and option in ('zoom-levels', 'default-zoom'):
            self.init_neighborlist()

    def init_neighborlist(self):
        """Initialize the _zoom neighborlist."""
        levels = config.get('ui', 'zoom-levels')
        self._zoom = usertypes.NeighborList(
            levels, mode=usertypes.NeighborList.Modes.block)
        self._zoom.fuzzyval = config.get('ui', 'default-zoom')

    def _mousepress_backforward(self, e):
        """Handle back/forward mouse button presses.

        Args:
            e: The QMouseEvent.
        """
        if e.button() == Qt.XButton1:
            # Back button on mice which have it.
            if self.page().history().canGoBack():
                self.back()
            else:
                message.error(self._win_id, "At beginning of history.",
                              immediately=True)
        elif e.button() == Qt.XButton2:
            # Forward button on mice which have it.
            if self.page().history().canGoForward():
                self.forward()
            else:
                message.error(self._win_id, "At end of history.",
                              immediately=True)

    def _mousepress_insertmode(self, e):
        """Switch to insert mode when an editable element was clicked.

        Args:
            e: The QMouseEvent.
        """
        pos = e.pos()
        frame = self.page().frameAt(pos)
        if frame is None:
            # This happens when we click inside the webview, but not actually
            # on the QWebPage - for example when clicking the scrollbar
            # sometimes.
            log.mouse.debug("Clicked at {} but frame is None!".format(pos))
            return
        # You'd think we have to subtract frame.geometry().topLeft() from the
        # position, but it seems QWebFrame::hitTestContent wants a position
        # relative to the QWebView, not to the frame. This makes no sense to
        # me, but it works this way.
        hitresult = frame.hitTestContent(pos)
        if hitresult.isNull():
            # For some reason, the whole hitresult can be null sometimes (e.g.
            # on doodle menu links). If this is the case, we schedule a check
            # later (in mouseReleaseEvent) which uses webelem.focus_elem.
            log.mouse.debug("Hitresult is null!")
            self._check_insertmode = True
            return
        try:
            elem = webelem.WebElementWrapper(hitresult.element())
        except webelem.IsNullError:
            # For some reason, the hitresult element can be a null element
            # sometimes (e.g. when clicking the timetable fields on
            # http://www.sbb.ch/ ). If this is the case, we schedule a check
            # later (in mouseReleaseEvent) which uses webelem.focus_elem.
            log.mouse.debug("Hitresult element is null!")
            self._check_insertmode = True
            return
        if ((hitresult.isContentEditable() and elem.is_writable()) or
                elem.is_editable()):
            log.mouse.debug("Clicked editable element!")
            modeman.maybe_enter(self._win_id, usertypes.KeyMode.insert,
                                'click')
        else:
            log.mouse.debug("Clicked non-editable element!")
            if config.get('input', 'auto-leave-insert-mode'):
                modeman.maybe_leave(self._win_id, usertypes.KeyMode.insert,
                                    'click')

    def mouserelease_insertmode(self):
        """If we have an insertmode check scheduled, handle it."""
        if not self._check_insertmode:
            return
        self._check_insertmode = False
        try:
            elem = webelem.focus_elem(self.page().currentFrame())
        except webelem.IsNullError:
            log.mouse.warning("Element vanished!")
            return
        if elem.is_editable():
            log.mouse.debug("Clicked editable element (delayed)!")
            modeman.maybe_enter(self._win_id, usertypes.KeyMode.insert,
                                'click-delayed')
        else:
            log.mouse.debug("Clicked non-editable element (delayed)!")
            if config.get('input', 'auto-leave-insert-mode'):
                modeman.maybe_leave(self._win_id, usertypes.KeyMode.insert,
                                    'click-delayed')

    def _mousepress_opentarget(self, e):
        """Set the open target when something was clicked.

        Args:
            e: The QMouseEvent.
        """
        if self._force_open_target is not None:
            self.open_target = self._force_open_target
            self._force_open_target = None
            log.mouse.debug("Setting force target: {}".format(
                self.open_target))
        elif (e.button() == Qt.MidButton or
              e.modifiers() & Qt.ControlModifier):
            background_tabs = config.get('tabs', 'background-tabs')
            if e.modifiers() & Qt.ShiftModifier:
                background_tabs = not background_tabs
            if background_tabs:
                self.open_target = usertypes.ClickTarget.tab_bg
            else:
                self.open_target = usertypes.ClickTarget.tab
            log.mouse.debug("Middle click, setting target: {}".format(
                self.open_target))
        else:
            self.open_target = usertypes.ClickTarget.normal
            log.mouse.debug("Normal click, setting normal target")

    def shutdown(self):
        """Shut down the webview."""
        # We disable javascript because that prevents some segfaults when
        # quitting it seems.
        log.destroy.debug("Shutting down {!r}.".format(self))
        settings = self.settings()
        settings.setAttribute(QWebSettings.JavascriptEnabled, False)
        self.stop()
        download_manager = objreg.get('download-manager', scope='window',
                                      window=self._win_id)
        nam = self.page().networkAccessManager()
        if download_manager.has_downloads_with_nam(nam):
            nam.setParent(download_manager)
        else:
            nam.shutdown()

    def openurl(self, url):
        """Open a URL in the browser.

        Args:
            url: The URL to load as QUrl
        """
        qtutils.ensure_valid(url)
        urlstr = url.toDisplayString()
        log.webview.debug("New title: {}".format(urlstr))
        self.titleChanged.emit(urlstr)
        self.cur_url = url
        self.url_text_changed.emit(url.toDisplayString())
        self.load(url)
        if url.scheme() == 'qute':
            frame = self.page().mainFrame()
            frame.javaScriptWindowObjectCleared.connect(self.add_js_bridge)

    def add_js_bridge(self):
        """Add the javascript bridge for qute:... pages."""
        frame = self.sender()
        if frame.url().scheme() == 'qute':
            bridge = objreg.get('js-bridge')
            frame.addToJavaScriptWindowObject('qute', bridge)

    def zoom_perc(self, perc, fuzzyval=True):
        """Zoom to a given zoom percentage.

        Args:
            perc: The zoom percentage as int.
            fuzzyval: Whether to set the NeighborLists fuzzyval.
        """
        if fuzzyval:
            self._zoom.fuzzyval = int(perc)
        if perc < 0:
            raise cmdexc.CommandError("Can't zoom {}%!".format(perc))
        self.setZoomFactor(float(perc) / 100)
        message.info(self._win_id, "Zoom level: {}%".format(perc))

    def zoom(self, offset):
        """Increase/Decrease the zoom level.

        Args:
            offset: The offset in the zoom level list.
        """
        level = self._zoom.getitem(offset)
        self.zoom_perc(level, fuzzyval=False)

    @pyqtSlot('QUrl')
    def on_url_changed(self, url):
        """Update cur_url when URL has changed.

        If the URL is invalid, we just ignore it here.
        """
        if url.isValid():
            self.cur_url = url
            self.url_text_changed.emit(url.toDisplayString())

    @pyqtSlot('QMouseEvent')
    def on_mouse_event(self, evt):
        """Post a new mouseevent from a hintmanager."""
        log.modes.debug("Hint triggered, focusing {!r}".format(self))
        self.setFocus()
        QApplication.postEvent(self, evt)

    @pyqtSlot()
    def on_load_started(self):
        """Leave insert/hint mode and set vars when a new page is loading."""
        self.progress = 0
        self.viewing_source = False
        self._has_ssl_errors = False
        self._set_load_status(LoadStatus.loading)

    @pyqtSlot()
    def on_load_finished(self):
        """Handle auto-insert-mode after loading finished.

        We don't take loadFinished's ok argument here as it always seems to be
        true when the QWebPage has an ErrorPageExtension implemented.
        See https://github.com/The-Compiler/qutebrowser/issues/84
        """
        ok = not self.page().error_occured
        if ok and not self._has_ssl_errors:
            self._set_load_status(LoadStatus.success)
        elif ok:
            self._set_load_status(LoadStatus.warn)
        else:
            self._set_load_status(LoadStatus.error)
        if not config.get('input', 'auto-insert-mode'):
            return
        mode_manager = objreg.get('mode-manager', scope='window',
                                  window=self._win_id)
        cur_mode = mode_manager.mode()
        if cur_mode == usertypes.KeyMode.insert or not ok:
            return
        frame = self.page().currentFrame()
        try:
            elem = webelem.WebElementWrapper(frame.findFirstElement(':focus'))
        except webelem.IsNullError:
            log.webview.debug("Focused element is null!")
            return
        log.modes.debug("focus element: {}".format(repr(elem)))
        if elem.is_editable():
            modeman.maybe_enter(self._win_id, usertypes.KeyMode.insert,
                                'load finished')

    @pyqtSlot()
    def on_scroll_requested(self):
        """Recalculate scroll percentage when the page got scrolled.

        If necessary, we emit scroll_pos_changed so the statusbar percentage
        updates.
        """
        QTimer.singleShot(0, self.update_scroll_perc)

    @pyqtSlot()
    def update_scroll_perc(self):
        """Update the scroll position after on_scroll_requested."""
        frame = self.page().mainFrame()
        new_pos = (frame.scrollBarValue(Qt.Horizontal),
                   frame.scrollBarValue(Qt.Vertical))
        if self._old_scroll_pos != new_pos:
            self._old_scroll_pos = new_pos
            m = (frame.scrollBarMaximum(Qt.Horizontal),
                 frame.scrollBarMaximum(Qt.Vertical))
            perc = (round(100 * new_pos[0] / m[0]) if m[0] != 0 else 0,
                    round(100 * new_pos[1] / m[1]) if m[1] != 0 else 0)
            self.scroll_pos = perc
            self.scroll_pos_changed.emit(*perc)

    @pyqtSlot(str)
    def set_force_open_target(self, target):
        """Change the forced link target. Setter for _force_open_target.

        Args:
            target: A string to set self._force_open_target to.
        """
        t = getattr(usertypes.ClickTarget, target)
        log.webview.debug("Setting force target to {}/{}".format(target, t))
        self._force_open_target = t

    def createWindow(self, wintype):
        """Called by Qt when a page wants to create a new window.

        This function is called from the createWindow() method of the
        associated QWebPage, each time the page wants to create a new window of
        the given type. This might be the result, for example, of a JavaScript
        request to open a document in a new window.

        Args:
            wintype: This enum describes the types of window that can be
                     created by the createWindow() function.

                     QWebPage::WebBrowserWindow: The window is a regular web
                                                 browser window.
                     QWebPage::WebModalDialog: The window acts as modal dialog.

        Return:
            The new QWebView object.
        """
        if wintype == QWebPage.WebModalDialog:
            log.webview.warning("WebModalDialog requested, but we don't "
                                "support that!")
        tabbed_browser = objreg.get('tabbed-browser', scope='window',
                                    window=self._win_id)
        return tabbed_browser.tabopen(background=False)

    def mousePressEvent(self, e):
        """Extend QWidget::mousePressEvent().

        This does the following things:
            - Check if a link was clicked with the middle button or Ctrl and
              set the open_target attribute accordingly.
            - Emit the editable_elem_selected signal if an editable element was
              clicked.

        Args:
            e: The arrived event.

        Return:
            The superclass return value.
        """
        if e.button() in (Qt.XButton1, Qt.XButton2):
            self._mousepress_backforward(e)
            super().mousePressEvent(e)
            return
        self._mousepress_insertmode(e)
        self._mousepress_opentarget(e)
        super().mousePressEvent(e)

    def mouseReleaseEvent(self, e):
        """Extend mouseReleaseEvent to enter insert mode if needed."""
        super().mouseReleaseEvent(e)
        # We want to make sure we check the focus element after the WebView is
        # updated completely.
        QTimer.singleShot(0, self.mouserelease_insertmode)
