#!/usr/local/bin/python3.12


__license__ = 'GPL v3'
__copyright__ = '2014, Kovid Goyal <kovid at kovidgoyal.net>'

import sys
from qt.core import (
    QWidget, QTimer, QStackedLayout, QLabel, QScrollArea, QVBoxLayout,
    QPainter, Qt, QPalette, QRect, QSize, QSizePolicy, pyqtSignal,
    QColor, QMenu, QApplication, QIcon, QUrl)

from calibre.constants import FAKE_HOST, FAKE_PROTOCOL
from calibre.gui2.tweak_book import editors, actions, tprefs
from calibre.gui2.tweak_book.editor.themes import get_theme, theme_color
from calibre.gui2.tweak_book.editor.text import default_font_family
from css_selectors import parse, SelectorError


lowest_specificity = (-sys.maxsize, 0, 0, 0, 0, 0)


class Heading(QWidget):  # {{{

    toggled = pyqtSignal(object)
    context_menu_requested = pyqtSignal(object, object)

    def __init__(self, text, expanded=True, parent=None):
        QWidget.__init__(self, parent)
        self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
        self.setCursor(Qt.CursorShape.PointingHandCursor)
        self.text = text
        self.expanded = expanded
        self.hovering = False
        self.do_layout()

    @property
    def lines_for_copy(self):
        return [self.text]

    def do_layout(self):
        try:
            f = self.parent().font()
        except AttributeError:
            return
        f.setBold(True)
        self.setFont(f)

    def mousePressEvent(self, ev):
        if ev.button() == Qt.MouseButton.LeftButton:
            ev.accept()
            self.expanded ^= True
            self.toggled.emit(self)
            self.update()
        else:
            return QWidget.mousePressEvent(self, ev)

    @property
    def rendered_text(self):
        return ('▾' if self.expanded else '▸') + '\xa0' + self.text

    def sizeHint(self):
        fm = self.fontMetrics()
        sz = fm.boundingRect(self.rendered_text).size()
        return sz

    def paintEvent(self, ev):
        p = QPainter(self)
        p.setClipRect(ev.rect())
        bg = self.palette().color(QPalette.ColorRole.AlternateBase)
        if self.hovering:
            bg = bg.lighter(115)
        p.fillRect(self.rect(), bg)
        try:
            p.drawText(self.rect(), Qt.AlignmentFlag.AlignLeft|Qt.AlignmentFlag.AlignVCenter|Qt.TextFlag.TextSingleLine, self.rendered_text)
        finally:
            p.end()

    def enterEvent(self, ev):
        self.hovering = True
        self.update()
        return QWidget.enterEvent(self, ev)

    def leaveEvent(self, ev):
        self.hovering = False
        self.update()
        return QWidget.leaveEvent(self, ev)

    def contextMenuEvent(self, ev):
        self.context_menu_requested.emit(self, ev)
# }}}


class Cell:  # {{{

    __slots__ = ('rect', 'text', 'right_align', 'color_role', 'override_color', 'swatch', 'is_overriden')

    SIDE_MARGIN = 5
    FLAGS = Qt.AlignmentFlag.AlignVCenter | Qt.TextFlag.TextSingleLine | Qt.TextFlag.TextIncludeTrailingSpaces

    def __init__(self, text, rect, right_align=False, color_role=QPalette.ColorRole.WindowText, swatch=None, is_overriden=False):
        self.rect, self.text = rect, text
        self.right_align = right_align
        self.is_overriden = is_overriden
        self.color_role = color_role
        self.override_color = None
        self.swatch = swatch
        if swatch is not None:
            self.swatch = QColor(swatch[0], swatch[1], swatch[2], int(255 * swatch[3]))

    def draw(self, painter, width, palette):
        flags = self.FLAGS | (Qt.AlignmentFlag.AlignRight if self.right_align else Qt.AlignmentFlag.AlignLeft)
        rect = QRect(self.rect)
        if self.right_align:
            rect.setRight(width - self.SIDE_MARGIN)
        painter.setPen(palette.color(self.color_role) if self.override_color is None else self.override_color)
        br = painter.drawText(rect, flags, self.text)
        if self.swatch is not None:
            r = QRect(br.right() + self.SIDE_MARGIN // 2, br.top() + 2, br.height() - 4, br.height() - 4)
            painter.fillRect(r, self.swatch)
            br.setRight(r.right())
        if self.is_overriden:
            painter.setPen(palette.color(QPalette.ColorRole.WindowText))
            painter.drawLine(br.left(), br.top() + br.height() // 2, br.right(), br.top() + br.height() // 2)
# }}}


class Declaration(QWidget):

    hyperlink_activated = pyqtSignal(object)
    context_menu_requested = pyqtSignal(object, object)

    def __init__(self, html_name, data, is_first=False, parent=None):
        QWidget.__init__(self, parent)
        self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
        self.data = data
        self.is_first = is_first
        self.html_name = html_name
        self.lines_for_copy = []
        self.do_layout()
        self.setMouseTracking(True)

    def do_layout(self):
        fm = self.fontMetrics()
        bounding_rect = lambda text: fm.boundingRect(0, 0, 10000, 10000, Cell.FLAGS, text)
        line_spacing = 2
        side_margin = Cell.SIDE_MARGIN
        self.rows = []
        ypos = line_spacing + (1 if self.is_first else 0)
        if 'href' in self.data:
            name = self.data['href']
            if isinstance(name, list):
                name = self.html_name
            br1 = bounding_rect(name)
            sel = self.data['selector'] or ''
            if self.data['type'] == 'inline':
                sel = 'style=""'
            br2 = bounding_rect(sel)
            self.hyperlink_rect = QRect(side_margin, ypos, br1.width(), br1.height())
            self.rows.append([
                Cell(name, self.hyperlink_rect, color_role=QPalette.ColorRole.Link),
                Cell(sel, QRect(br1.right() + side_margin, ypos, br2.width(), br2.height()), right_align=True)
            ])
            ypos += max(br1.height(), br2.height()) + 2 * line_spacing
            self.lines_for_copy.append(name + ' ' + sel)

        for prop in self.data['properties']:
            text = prop.name + ':\xa0'
            br1 = bounding_rect(text)
            vtext = prop.value + '\xa0' + ('!' if prop.important else '') + prop.important
            br2 = bounding_rect(vtext)
            self.rows.append([
                Cell(text, QRect(side_margin, ypos, br1.width(), br1.height()), color_role=QPalette.ColorRole.LinkVisited, is_overriden=prop.is_overriden),
                Cell(vtext, QRect(br1.right() + side_margin, ypos, br2.width(), br2.height()), swatch=prop.color, is_overriden=prop.is_overriden)
            ])
            self.lines_for_copy.append(text + vtext)
            if prop.is_overriden:
                self.lines_for_copy[-1] += ' [overridden]'
            ypos += max(br1.height(), br2.height()) + line_spacing
        self.lines_for_copy.append('--------------------------\n')

        self.height_hint = ypos + line_spacing
        self.width_hint = max(row[-1].rect.right() + side_margin for row in self.rows) if self.rows else 0

    def sizeHint(self):
        return QSize(self.width_hint, self.height_hint)

    def paintEvent(self, ev):
        p = QPainter(self)
        p.setClipRect(ev.rect())
        palette = self.palette()
        p.setPen(palette.color(QPalette.ColorRole.WindowText))
        if not self.is_first:
            p.drawLine(0, 0, self.width(), 0)
        try:
            for row in self.rows:
                for cell in row:
                    p.save()
                    try:
                        cell.draw(p, self.width(), palette)
                    finally:
                        p.restore()

        finally:
            p.end()

    def mouseMoveEvent(self, ev):
        if hasattr(self, 'hyperlink_rect'):
            pos = ev.pos()
            hovering = self.hyperlink_rect.contains(pos)
            self.update_hover(hovering)
            cursor = Qt.CursorShape.ArrowCursor
            for r, row in enumerate(self.rows):
                for cell in row:
                    if cell.rect.contains(pos):
                        cursor = Qt.CursorShape.PointingHandCursor if cell.rect is self.hyperlink_rect else Qt.CursorShape.IBeamCursor
                    if r == 0:
                        break
                if cursor != Qt.CursorShape.ArrowCursor:
                    break
            self.setCursor(cursor)
        return QWidget.mouseMoveEvent(self, ev)

    def mousePressEvent(self, ev):
        if hasattr(self, 'hyperlink_rect') and ev.button() == Qt.MouseButton.LeftButton:
            pos = ev.pos()
            if self.hyperlink_rect.contains(pos):
                self.emit_hyperlink_activated()
        return QWidget.mousePressEvent(self, ev)

    def emit_hyperlink_activated(self):
        dt = self.data['type']
        data = {'type':dt, 'name':self.html_name, 'syntax':'html'}
        if dt == 'inline':  # style attribute
            data['sourceline_address'] = self.data['href']
        elif dt == 'elem':  # <style> tag
            data['sourceline_address'] = self.data['href']
            data['rule_address'] = self.data['rule_address']
        else:  # stylesheet
            data['name'] = self.data['href']
            data['rule_address'] = self.data['rule_address']
            data['syntax'] = 'css'
        self.hyperlink_activated.emit(data)

    def leaveEvent(self, ev):
        self.update_hover(False)
        self.setCursor(Qt.CursorShape.ArrowCursor)
        return QWidget.leaveEvent(self, ev)

    def update_hover(self, hovering):
        cell = self.rows[0][0]
        if (hovering and cell.override_color is None) or (
                not hovering and cell.override_color is not None):
            cell.override_color = QColor(Qt.GlobalColor.red) if hovering else None
            self.update()

    def contextMenuEvent(self, ev):
        self.context_menu_requested.emit(self, ev)


class Box(QWidget):

    hyperlink_activated = pyqtSignal(object)

    def __init__(self, parent=None):
        QWidget.__init__(self, parent)
        self.l = l = QVBoxLayout(self)
        l.setAlignment(Qt.AlignmentFlag.AlignTop)
        self.setLayout(l)
        self.widgets = []

    def show_data(self, data):
        for w in self.widgets:
            self.layout().removeWidget(w)
            for x in ('toggled', 'hyperlink_activated', 'context_menu_requested'):
                if hasattr(w, x):
                    try:
                        getattr(w, x).disconnect()
                    except TypeError:
                        pass
            w.deleteLater()
        self.widgets = []
        for node in data['nodes']:
            node_name = node['name'] + ' @%s' % node['sourceline']
            if node['ancestor_specificity'] != 0:
                title = _('Inherited from %s') % node_name
            else:
                title = _('Matched CSS rules for %s') % node_name
            h = Heading(title, parent=self)
            h.toggled.connect(self.heading_toggled)
            self.widgets.append(h), self.layout().addWidget(h)
            for i, declaration in enumerate(node['css']):
                d = Declaration(data['html_name'], declaration, is_first=i == 0, parent=self)
                d.hyperlink_activated.connect(self.hyperlink_activated)
                self.widgets.append(d), self.layout().addWidget(d)

        h = Heading(_('Computed final style'), parent=self)
        h.toggled.connect(self.heading_toggled)
        self.widgets.append(h), self.layout().addWidget(h)
        ccss = data['computed_css']
        declaration = {'properties':[Property([k, ccss[k][0], '', ccss[k][1]]) for k in sorted(ccss)]}
        d = Declaration(None, declaration, is_first=True, parent=self)
        self.widgets.append(d), self.layout().addWidget(d)
        for w in self.widgets:
            w.context_menu_requested.connect(self.context_menu_requested)

    def heading_toggled(self, heading):
        for i, w in enumerate(self.widgets):
            if w is heading:
                for b in self.widgets[i + 1:]:
                    if isinstance(b, Heading):
                        break
                    b.setVisible(heading.expanded)
                break

    def relayout(self):
        for w in self.widgets:
            w.do_layout()
            w.updateGeometry()

    @property
    def lines_for_copy(self):
        ans = []
        for w in self.widgets:
            ans += w.lines_for_copy
        return ans

    def context_menu_requested(self, widget, ev):
        if isinstance(widget, Heading):
            start = widget
        else:
            found = False
            for w in reversed(self.widgets):
                if w is widget:
                    found = True
                elif found and isinstance(w, Heading):
                    start = w
                    break
            else:
                return
        found = False
        lines = []
        for w in self.widgets:
            if found and isinstance(w, Heading):
                break
            if w is start:
                found = True
            if found:
                lines += w.lines_for_copy
        if not lines:
            return
        block = '\n'.join(lines).replace('\xa0', ' ')
        heading = lines[0]
        m = QMenu(self)
        m.addAction(QIcon(I('edit-copy.png')), _('Copy') + ' ' + heading.replace('\xa0', ' '), lambda : QApplication.instance().clipboard().setText(block))
        all_lines = []
        for w in self.widgets:
            all_lines += w.lines_for_copy
        all_text = '\n'.join(all_lines).replace('\xa0', ' ')
        m.addAction(QIcon(I('edit-copy.png')), _('Copy everything'), lambda : QApplication.instance().clipboard().setText(all_text))
        m.exec(ev.globalPos())


class Property:

    __slots__ = 'name', 'value', 'important', 'color', 'specificity', 'is_overriden'

    def __init__(self, prop, specificity=()):
        self.name, self.value, self.important, self.color = prop
        self.specificity = tuple(specificity)
        self.is_overriden = False

    def __repr__(self):
        return '<Property name={} value={} important={} color={} specificity={} is_overriden={}>'.format(
            self.name, self.value, self.important, self.color, self.specificity, self.is_overriden)


class LiveCSS(QWidget):

    goto_declaration = pyqtSignal(object)

    def __init__(self, preview, parent=None):
        QWidget.__init__(self, parent)
        self.preview = preview
        preview.live_css_data.connect(self.got_live_css_data)
        self.preview_is_refreshing = False
        self.refresh_needed = False
        preview.refresh_starting.connect(self.preview_refresh_starting)
        preview.refreshed.connect(self.preview_refreshed)
        self.apply_theme()
        self.setAutoFillBackground(True)
        self.update_timer = QTimer(self)
        self.update_timer.timeout.connect(self.update_data)
        self.update_timer.setSingleShot(True)
        self.update_timer.setInterval(500)
        self.now_showing = (None, None, None)

        self.stack = s = QStackedLayout(self)
        self.setLayout(s)

        self.clear_label = la = QLabel('<h3>' + _(
            'No style information found') + '</h3><p>' + _(
                'Move the cursor inside a HTML tag to see what styles'
                ' apply to that tag.'))
        la.setWordWrap(True)
        la.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
        s.addWidget(la)

        self.box = box = Box(self)
        box.hyperlink_activated.connect(self.goto_declaration, type=Qt.ConnectionType.QueuedConnection)
        self.scroll = sc = QScrollArea(self)
        sc.setWidget(box)
        sc.setWidgetResizable(True)
        s.addWidget(sc)

    def preview_refresh_starting(self):
        self.preview_is_refreshing = True

    def preview_refreshed(self):
        self.preview_is_refreshing = False
        self.refresh_needed = True
        self.start_update_timer()

    def apply_theme(self):
        f = self.font()
        f.setFamily(tprefs['editor_font_family'] or default_font_family())
        f.setPointSizeF(tprefs['editor_font_size'])
        self.setFont(f)
        theme = get_theme(tprefs['editor_theme'])
        pal = self.palette()
        pal.setColor(QPalette.ColorRole.Window, theme_color(theme, 'Normal', 'bg'))
        pal.setColor(QPalette.ColorRole.WindowText, theme_color(theme, 'Normal', 'fg'))
        pal.setColor(QPalette.ColorRole.AlternateBase, theme_color(theme, 'HighlightRegion', 'bg'))
        pal.setColor(QPalette.ColorRole.Link, theme_color(theme, 'Link', 'fg'))
        pal.setColor(QPalette.ColorRole.LinkVisited, theme_color(theme, 'Keyword', 'fg'))
        self.setPalette(pal)
        if hasattr(self, 'box'):
            self.box.relayout()
        self.update()

    def clear(self):
        self.stack.setCurrentIndex(0)

    def show_data(self, editor_name, sourceline, tags):
        if self.preview_is_refreshing:
            return
        if sourceline is None:
            self.clear()
        else:
            self.preview.request_live_css_data(editor_name, sourceline, tags)

    def got_live_css_data(self, result):
        maximum_specificities = {}
        for node in result['nodes']:
            for rule in node['css']:
                self.process_rule(rule, node['ancestor_specificity'], maximum_specificities)
        for node in result['nodes']:
            for rule in node['css']:
                for prop in rule['properties']:
                    if prop.specificity < maximum_specificities[prop.name]:
                        prop.is_overriden = True
        self.display_received_live_css_data(result)

    def display_received_live_css_data(self, data):
        editor_name = data['editor_name']
        sourceline = data['sourceline']
        tags = data['tags']
        if data is None or len(data['computed_css']) < 1:
            if editor_name == self.current_name and (editor_name, sourceline, tags) == self.now_showing:
                # Try again in a little while in case there was a transient
                # error in the web view
                self.start_update_timer()
                return
            self.clear()
            return
        self.now_showing = (editor_name, sourceline, tags)
        data['html_name'] = editor_name
        self.box.show_data(data)
        self.refresh_needed = False
        self.stack.setCurrentIndex(1)

    def process_rule(self, rule, ancestor_specificity, maximum_specificities):
        selector = rule['selector']
        sheet_index = rule['sheet_index']
        rule_address = rule['rule_address'] or ()
        if selector is not None:
            try:
                specificity = [0] + list(parse(selector)[0].specificity())
            except (AttributeError, TypeError, SelectorError):
                specificity = [0, 0, 0, 0]
        else:  # style attribute
            specificity = [1, 0, 0, 0]
        specificity.extend((sheet_index, tuple(rule_address)))
        properties = []
        for prop in rule['properties']:
            important = 1 if prop[-1] == 'important' else 0
            p = Property(prop, [ancestor_specificity] + [important] + specificity)
            properties.append(p)
            if p.specificity > maximum_specificities.get(p.name, lowest_specificity):
                maximum_specificities[p.name] = p.specificity
        rule['properties'] = properties

        href = rule['href']
        if hasattr(href, 'startswith') and href.startswith(f'{FAKE_PROTOCOL}://{FAKE_HOST}'):
            qurl = QUrl(href)
            name = qurl.path()[1:]
            if name:
                rule['href'] = name

    @property
    def current_name(self):
        return self.preview.current_name

    @property
    def is_visible(self):
        return self.isVisible()

    def showEvent(self, ev):
        self.update_timer.start()
        actions['auto-reload-preview'].setEnabled(True)
        return QWidget.showEvent(self, ev)

    def sync_to_editor(self):
        self.update_data()

    def update_data(self):
        if not self.is_visible or self.preview_is_refreshing:
            return
        editor_name = self.current_name
        ed = editors.get(editor_name, None)
        if self.update_timer.isActive() or (ed is None and editor_name is not None):
            return QTimer.singleShot(100, self.update_data)
        if ed is not None:
            sourceline, tags = ed.current_tag(for_position_sync=False)
            if self.refresh_needed or self.now_showing != (editor_name, sourceline, tags):
                self.show_data(editor_name, sourceline, tags)

    def start_update_timer(self):
        if self.is_visible:
            self.update_timer.start()

    def stop_update_timer(self):
        self.update_timer.stop()

    def navigate_to_declaration(self, data, editor):
        if data['type'] == 'inline':
            sourceline, tags = data['sourceline_address']
            editor.goto_sourceline(sourceline, tags, attribute='style')
        elif data['type'] == 'sheet':
            editor.goto_css_rule(data['rule_address'])
        elif data['type'] == 'elem':
            editor.goto_css_rule(data['rule_address'], sourceline_address=data['sourceline_address'])
