# SPDX-FileCopyrightText: 2022 Chris Heywood
#
# SPDX-License-Identifier: GPL-3.0-or-later

import gi

gi.require_version("WebKit", "6.0")
from gi.repository import Adw, Gdk, GLib, GObject, WebKit, JavaScriptCore

import json
import logging
from threading import Thread
from typing import Callable, Optional
import webbrowser

from markdown_it.token import Token

from iotas.attachment import Attachment
from iotas.attachment_helpers import (
    get_attachment_disk_states,
    get_attachment_filesystem_uri,
    set_attachment_filesystem_uris_on_tokens,
)
from iotas.markdown_helpers import filter_image_tokens
from iotas.config_manager import ConfigManager
from iotas.html_generator import HtmlGenerator
from iotas.markdown_helpers import parse_to_tokens
from iotas.note import Note


class MarkdownRenderView(WebKit.WebView):
    __gtype_name__ = "MarkdownRenderView"

    __gsignals__ = {
        "checkbox-toggled": (GObject.SignalFlags.RUN_FIRST, None, (int, bool)),
        "loaded": (GObject.SignalFlags.RUN_FIRST, None, ()),
    }

    def __init__(self) -> None:
        super().__init__()
        self.__user_stylesheet = None
        self.__scroll_position = 0.0
        self.__engine_initialised = False
        self.__searching = False
        self.__exporting = False
        self.__loading_attachments: dict[str, Attachment] = {}
        self.__note: Note
        self.__config_manager: Optional[ConfigManager]
        self.__download_attachment: Optional[Callable[[Attachment], bool]] = None

    def setup(
        self,
        html_generator: HtmlGenerator,
        config_manager: Optional[ConfigManager],
        track_timing: bool,
    ) -> None:
        """Setup for new WebKit instance."""
        self.__html_generator = html_generator
        self.__config_manager = config_manager
        self.__track_timing = track_timing

        self.connect("decide-policy", self.on_decide_policy)
        self.connect("load-changed", self.__on_load_changed)
        self.connect("load-failed", lambda _v, _e, _u, _f: self.__clear_exporting())
        self.connect("context-menu", self.on_context_click)
        content_manager = self.get_user_content_manager()
        content_manager.register_script_message_handler("toPython")
        content_manager.connect("script-message-received", self.__handle_js_message)
        self.__content_manager = content_manager

        if config_manager:
            config_manager.connect_changed(ConfigManager.FONT_SIZE, self.update_style)
            config_manager.connect_changed(
                ConfigManager.MARKDOWN_RENDER_MONOSPACE_FONT_RATIO,
                self.update_style,
            )
            config_manager.connect_changed(ConfigManager.LINE_LENGTH, self.update_style)
            config_manager.connect_changed(
                ConfigManager.EDITOR_HEADER_BAR_VISIBILTY,
                self.update_style,
            )
            config_manager.connect_changed(
                ConfigManager.EDITOR_FORMATTING_BAR_VISIBILTY,
                self.update_style,
            )

        style_manager = Adw.StyleManager.get_default()
        style_manager.connect("notify::high-contrast", lambda _o, _v: self.update_style())

        self.connect("web-process-terminated", self.__log_termination)

        # self.get_settings().set_enable_developer_extras(True)

    def render(
        self,
        note: Note,
        export_format: Optional[str],
        attachment_download_func: Optional[Callable[[Attachment], bool]] = None,
        scroll_position: Optional[float] = None,
    ) -> None:
        """Render view with new markdown.

        :param Note note: Note to render
        :param bool export_format: Export format if exporting, optional
        :param Callable[[[Attachment], bool]] attachment_download_func: Function to
            download missing attachment, optional
        :param float scroll_position: Position to scroll to, optional
        """
        self.update_style()
        if export_format is not None:
            self.exporting = True
        parser, tokens = parse_to_tokens(
            note, exporting=self.exporting, tex_support=self.__get_support_tex()
        )

        attachments_states = get_attachment_disk_states(note, tokens)
        if attachments_states.exists:
            set_attachment_filesystem_uris_on_tokens(tokens, attachments_states.exists)
        if attachments_states.missing and attachment_download_func:
            self.__loading_attachments = attachments_states.missing
            hide_loading_images(tokens, self.__loading_attachments)
        else:
            self.__loading_attachments = {}

        render_func = parser.renderer.render
        content = self.__html_generator.generate(
            note,
            tokens,
            render_func,
            parser.options,
            self.__searching,
            export_format,
            scroll_position,
        )
        self.scroll_position = scroll_position if scroll_position else 0

        self.__parser_tokens = tokens
        self.__download_attachment = attachment_download_func
        self.__load_started_at = GLib.get_monotonic_time()
        self.load_html(content, "file://localhost/")

    def render_retaining_scroll(
        self,
        note: Note,
        export_format: Optional[str],
        attachment_download_func: Optional[Callable[[Attachment], bool]] = None,
    ) -> None:
        """Render view with new markdown retaining scroll position.

        :param Note note: Note to render
        :param bool export_format: Export format if exporting or None
        :param Optional[Callable[[Attachment], bool]] attachment_download_func: Function to
            download missing attachments, optional
        """
        self.render(note, export_format, attachment_download_func, self.scroll_position)

    def update_style(self) -> None:
        """Update the configurable style used in the render."""
        stylesheet = self.__html_generator.generate_user_stylesheet(self.__searching)
        if self.__user_stylesheet is not None:
            self.__content_manager.remove_style_sheet(self.__user_stylesheet)

        self.__user_stylesheet = WebKit.UserStyleSheet(
            stylesheet,
            WebKit.UserContentInjectedFrames.ALL_FRAMES,
            WebKit.UserStyleLevel.USER,
            None,
            None,
        )
        self.__content_manager.add_style_sheet(self.__user_stylesheet)

    def scroll_to_heading(self, line_number: int) -> None:
        """Scroll to heading.

        :param int line_number: Line the heading in source markdown
        """
        script = f"""
        element = document.querySelector('[data-map="{line_number}"]');
        if (element)
            element.scrollIntoView();
        """
        self.evaluate_javascript(
            script, length=-1, world_name=None, source_uri=None, cancellable=None, callback=None
        )

    def show_attached_image(self, attachment: Attachment) -> None:
        """Call Javascript to show downloaded image in render.

        :param Attachment attachment: Attachment
        """
        script = f"""
        elements = document.querySelectorAll('img[src="{attachment.path_quoted}"]');
        for (let i = 0; i < elements.length; i++) {{
             elements[i].src = '{get_attachment_filesystem_uri(attachment)}';
             elements[i].className = '';
        }}
        """
        self.evaluate_javascript(
            script, length=-1, world_name=None, source_uri=None, cancellable=None, callback=None
        )

    @staticmethod
    def on_decide_policy(
        _web_view: WebKit.WebView,
        decision: WebKit.PolicyDecision,
        decision_type: WebKit.PolicyDecisionType,
    ) -> bool:
        if (
            decision_type == WebKit.PolicyDecisionType.NAVIGATION_ACTION
            and decision.get_navigation_action().is_user_gesture()
        ):
            uri = decision.get_navigation_action().get_request().get_uri()
            webbrowser.open(uri)
            decision.ignore()
            return True
        return False

    @staticmethod
    def on_context_click(
        _web_view: WebKit.WebView,
        menu: WebKit.ContextMenu,
        _event: Gdk.Event,
    ) -> None:
        for item in menu.get_items():
            if item.get_stock_action() in [
                WebKit.ContextMenuAction.DOWNLOAD_LINK_TO_DISK,
                WebKit.ContextMenuAction.GO_BACK,
                WebKit.ContextMenuAction.GO_FORWARD,
                WebKit.ContextMenuAction.OPEN_LINK,
                WebKit.ContextMenuAction.OPEN_LINK_IN_NEW_WINDOW,
                WebKit.ContextMenuAction.RELOAD,
                WebKit.ContextMenuAction.STOP,
            ]:
                menu.remove(item)

    @GObject.Property(type=bool, default=False)
    def exporting(self) -> bool:
        return self.__exporting

    @exporting.setter
    def set_exporting(self, value: bool) -> None:
        self.__exporting = value

    @GObject.Property(type=float)
    def scroll_position(self) -> float:
        return self.__scroll_position

    @scroll_position.setter
    def set_scroll_position(self, value: float) -> None:
        self.__scroll_position = value

    @GObject.Property(type=bool, default=False)
    def searching(self) -> bool:
        return self.__searching

    @searching.setter
    def set_searching(self, value: bool) -> None:
        self.__searching = value
        self.update_style()

    def __on_load_changed(self, _web_view: WebKit.WebView, load_event: WebKit.LoadEvent) -> None:
        if load_event == WebKit.LoadEvent.FINISHED:
            if self.__track_timing:
                duration = GLib.get_monotonic_time() - self.__load_started_at
                if self.__engine_initialised:
                    logging.debug(f"Render took {duration/1000:.2f}ms")
                else:
                    logging.debug(f"WebKit initialisation and render took {duration/1000:.2f}ms")
                self.__engine_initialised = True
            self.emit("loaded")
            if self.__loading_attachments:
                if self.__download_attachment:
                    logging.debug("Start missing images download")
                    thread = Thread(target=self.__download_attachments)
                    thread.daemon = True
                    thread.start()
                else:
                    logging.warning(f"Missing {len(self.__loading_attachments)} attachments")

    def __download_attachments(self) -> None:
        if not self.__download_attachment:
            return
        for attachment in self.__loading_attachments.values():
            self.__download_attachment(attachment)

    def __clear_exporting(self) -> None:
        self.exporting = False

    def __handle_js_message(
        self, _manager: WebKit.UserContentManager, result: JavaScriptCore.Value
    ) -> None:
        js = result.to_json(2)
        message = json.loads(js)
        if message["type"] == "checkbox":
            self.__handle_checkbox_toggle(message)
        elif message["type"] == "scrollPosition":
            if "position" not in message or message["position"] is None:
                logging.warning("Received scroll position message with no position")
                return
            self.scroll_position = message["position"]

    def __handle_checkbox_toggle(self, message: dict) -> None:
        search_id = message["id"]
        new_value = message["checked"]

        def match(token: Token, search_id: str) -> bool:
            search_str = f'id="{search_id}"'
            return token.type == "html_inline" and search_str in token.content

        def handle_match(line: int, new_value: bool) -> None:
            self.emit("checkbox-toggled", line, new_value)

        line: int | None = None
        for token in self.__parser_tokens:
            if token.map is not None:
                line = token.map[0]
            if match(token, search_id):
                logging.debug(
                    f'Setting checkbox on line {line} with id "{search_id}" to {new_value}'
                )
                assert line is not None
                handle_match(line, new_value)
                return
            if token.children is not None:
                for child in token.children:
                    if match(child, search_id):
                        if token.map is not None:
                            line = token.map[0]
                        logging.debug(
                            f'Setting checkbox on line {line} with id "{search_id}" to {new_value}'
                        )
                        assert line is not None
                        handle_match(line, new_value)
                        return

    def __log_termination(
        self, _web_view: WebKit.WebView, reason: WebKit.WebProcessTerminationReason
    ) -> None:
        if self.__track_timing:
            logging.debug(f"WebKit process terminated for {reason}")
        self.__engine_initialised = False

    def __get_support_tex(self) -> bool:
        if not self.__config_manager:
            return False
        else:
            return self.__config_manager.markdown_tex_support


def hide_loading_images(tokens: list[Token], transferring: dict[str, Attachment]) -> None:
    """Hide server transferring images in parser tokens.

    :param list[Token] tokens: Parser tokens
    :param dict[str, Attachment] transferring: Transferring attachments
    """
    images = filter_image_tokens(tokens)
    for img in images:
        src = img.attrs["src"]
        if src in transferring:
            img.attrs["class"] = "transferring"
