#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# ================================================== #
# This file is a part of PYGPT package               #
# Website: https://pygpt.net                         #
# GitHub:  https://github.com/szczyglis-dev/py-gpt   #
# MIT License                                        #
# Created By  : Marcin Szczygliński                  #
# Updated Date: 2025.08.19 07:00:00                  #
# ================================================== #

from PySide6.QtCore import Qt, QObject, Signal, Slot, QEvent, QTimer
from PySide6.QtWebChannel import QWebChannel
from PySide6.QtWebEngineCore import QWebEngineSettings, QWebEnginePage, QWebEngineProfile
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtGui import QAction, QIcon
from PySide6.QtWidgets import QMenu

from pygpt_net.core.events import RenderEvent
from pygpt_net.item.ctx import CtxMeta
from pygpt_net.core.text.web_finder import WebFinder
from pygpt_net.ui.widget.tabs.layout import FocusEventFilter
from pygpt_net.utils import trans, mem_clean

SHARED_PROFILE = None

import pygpt_net.icons_rc

class ChatWebOutput(QWebEngineView):
    def __init__(self, window=None):
        """
        HTML output (WebEngine)

        :param window: Window instance
        """
        super(ChatWebOutput, self).__init__(window)
        self.window = window
        self.finder = WebFinder(window, self)
        self.loadFinished.connect(self.on_page_loaded)
        self.customContextMenuRequested.connect(self.on_context_menu)
        self.signals = WebEngineSignals(self)
        self.setContextMenuPolicy(Qt.CustomContextMenu)
        self.filter = FocusEventFilter(self, self.on_focus)
        self.installEventFilter(self)
        self.plain = None
        self.html_content = None
        self.meta = None
        self.tab = None
        self.setProperty('class', 'layout-output-web')

        # OpenGL widgets
        self._glwidget = None
        self._glwidget_filter_installed = False

        # set the page with a shared profile
        self.setUpdatesEnabled(False)  # disable updates until the page is set, re-enable in `on_page_loaded`
        self.setPage(CustomWebEnginePage(self.window, self, profile=self._make_shared_profile()))

    def _make_shared_profile(self) -> QWebEngineProfile:
        """
        Create a shared QWebEngineProfile

        :return: QWebEngineProfile - shared profile instance
        """
        global SHARED_PROFILE
        if not SHARED_PROFILE:
            prof = QWebEngineProfile("app", None)
            # prof.setHttpCacheType(QWebEngineProfile.MemoryHttpCache)
            # prof.setHttpCacheMaximumSize(32 * 1024 * 1024)  # 32MB
            prof.setPersistentCookiesPolicy(QWebEngineProfile.NoPersistentCookies)
            prof.setSpellCheckEnabled(False)
            SHARED_PROFILE = prof
        return SHARED_PROFILE

    def _detach_gl_event_filter(self):
        """Detach OpenGL widget event filter if installed"""
        if self._glwidget and self._glwidget_filter_installed:
            try:
                self._glwidget.removeEventFilter(self)
            except Exception:
                pass
        self._glwidget = None
        self._glwidget_filter_installed = False

    def _release_profile_after_page(self, page, profile):
        """
        Release profile after page is destroyed

        :param page: QWebEnginePage - page to check
        :param profile: QWebEngineProfile - profile to release
        """
        if not profile or profile is QWebEngineProfile.defaultProfile():
            return
        if page:
            try:
                page.destroyed.connect(profile.deleteLater)
                return
            except Exception:
                pass
        try:
            profile.deleteLater()
        except Exception:
            pass

    def _teardown_page(self, page: QWebEnginePage):
        """
        Teardown page to clean up resources

        :param page: QWebEnginePage - page to teardown
        """
        if not page:
            return
        try:
            # detach the channel from the page to break JS<->Python references
            page.cleanup()
            page.setWebChannel(None)
        except Exception as e:
            pass

        # bridge, channel, and signals have parent=page, so deleteLater of the page will clean them up
        page.deleteLater()

    def _create_profile(self, parent=None) -> QWebEngineProfile:
        """
        Create a new QWebEngineProfile with off-the-record settings

        :param parent: QWidget - parent widget
        :return: QWebEngineProfile - new profile instance
        """
        p = QWebEngineProfile(parent or self)
        try:
            p.setHttpCacheMaximumSize(0)
            p.setPersistentCookiesPolicy(QWebEngineProfile.NoPersistentCookies)
            p.setSpellCheckEnabled(False)
        except Exception:
            pass
        return p

    def hard_reset(self, focus_after=True):
        """
        Hard reset of the view, creating a new instance of ChatWebOutput

        :param focus_after: bool - whether to focus the new view after reset
        """
        parent = self.parentWidget()
        if parent is None or parent.layout() is None:
            new_view = ChatWebOutput(self.window)
            new_view.set_tab(self.tab)
            new_view.set_meta(self.meta)
            QTimer.singleShot(0, self.on_delete)
            return new_view

        layout = parent.layout()
        idx = layout.indexOf(self)
        if idx < 0:
            idx = layout.count()

        new_view = ChatWebOutput(self.window)
        new_view.setProperty('class', self.property('class') or 'layout-output-web')
        new_view.set_tab(self.tab)
        new_view.set_meta(self.meta)

        try:
            z = self.get_zoom_value()
            p = new_view.page()
            if p:
                p.setZoomFactor(z)
        except Exception:
            pass

        layout.insertWidget(idx, new_view)
        layout.removeWidget(self)
        self.hide()

        QTimer.singleShot(0, self.on_delete)
        if focus_after:
            try:
                new_view.setFocus()
            except Exception:
                pass
        try:
            mem_clean()
        except Exception:
            pass

        return new_view

    def resetPage(self):
        """Reset current page without creating a new one"""
        self.plain = None
        self.html_content = None
        p = self.page()
        if not p:
            self.setPage(CustomWebEnginePage(self.window, self, profile=SHARED_PROFILE))
            p = self.page()

        p.runJavaScript(
            f"""clean();"""
        )
        try:
            p.history().clear()
        except Exception:
            pass
        mem_clean()

    def on_delete(self):
        """Clean up on delete"""
        self._detach_gl_event_filter()
        if self.finder:
            try:
                self.finder.disconnect()
            except Exception:
                pass
            self.finder = None

        self.tab = None
        self.meta = None

        # remove the page and profile
        page = self.page()
        prof = getattr(self, "_profile", None)
        if page:
            self._teardown_page(page)

        # safely unhook signals (may not have been hooked)
        for sig, slot in (
            (self.loadFinished, self.on_page_loaded),
            (self.customContextMenuRequested, self.on_context_menu),
            (self.signals.save_as, getattr(self.window.controller.chat.render, "handle_save_as", None)),
            (self.signals.audio_read, getattr(self.window.controller.chat.render, "handle_audio_read", None)),
        ):
            if slot:
                try:
                    sig.disconnect(slot)
                except Exception:
                    pass

        self.deleteLater()

    def eventFilter(self, source, event):
        """
        Event filter to handle child added events and mouse button presses

        :param source: QWidget - source of the event
        :param event: QEvent - event to filter
        """
        if event.type() == QEvent.ChildAdded and source is self and event.child().isWidgetType():
            self._detach_gl_event_filter()
            self._glwidget = event.child()
            try:
                self._glwidget.installEventFilter(self)
                self._glwidget_filter_installed = True
            except Exception:
                self._glwidget = None
                self._glwidget_filter_installed = False

        elif event.type() == QEvent.Type.MouseButtonPress:
            try:
                col_idx = self.tab.column_idx
                self.window.controller.ui.tabs.on_column_focus(col_idx)
            except Exception:
                pass

        return super().eventFilter(source, event)

    def on_focus(self, widget):
        """
        On widget clicked

        :param widget: QWidget - widget that received focus
        """
        if self.tab is not None:
            self.window.controller.ui.tabs.on_column_focus(self.tab.column_idx)
        self.setFocus()

    def set_tab(self, tab):
        """
        Set tab for this output

        :param tab: Tab instance
        """
        self.tab = tab

    def set_meta(self, meta: CtxMeta):
        """
        Set meta for this output

        :param meta: CtxMeta instance
        """
        self.meta = meta

    def set_plaintext(self, text: str):
        """
        Set plain text content

        :param text: str - plain text content
        """
        self.plain = text

    def set_html_content(self, html: str):
        """
        Set HTML content

        :param html: str - HTML content
        """
        self.html_content = "<html>" + html + "</html>"

    def on_context_menu(self, position):
        """
        Context menu event

        :param position: QPoint - position of the context menu
        """
        menu = QMenu(self)

        has_selection = self.page().hasSelection()

        if has_selection:
            # copy
            action = QAction(QIcon(":/icons/copy.svg"), trans('action.copy'), self)
            action.triggered.connect(self.copy_selected_text)
            menu.addAction(action)

            # audio read (get text only on click, don't copy immediately)
            action = QAction(QIcon(":/icons/volume.svg"), trans('text.context_menu.audio.read'), self)
            action.triggered.connect(self._read_selected_text)
            menu.addAction(action)

            # copy to
            copy_to_menu = self.window.ui.context_menu.get_copy_to_menu(self, self.get_selected_text())
            menu.addMenu(copy_to_menu)

            # save as (selected) - get selection at the moment of click
            action = QAction(QIcon(":/icons/save.svg"), trans('action.save_selection_as'), self)
            action.triggered.connect(self._save_selected_txt)
            menu.addAction(action)
        else:
            # select all
            action = QAction(QIcon(":/icons/copy.svg"), trans('action.select_all'), self)
            action.triggered.connect(self.select_all_text)
            menu.addAction(action)

            # save as (all) - plain (lazy normalization only on click)
            action = QAction(QIcon(":/icons/save.svg"), trans('action.save_as') + " (text)", self)
            action.triggered.connect(self._save_as_text)
            menu.addAction(action)

            # save as (all) - html
            action = QAction(QIcon(":/icons/save.svg"), trans('action.save_as') + " (html)", self)
            action.triggered.connect(self._save_as_html)
            menu.addAction(action)

        action = QAction(QIcon(":/icons/search.svg"), trans('text.context_menu.find'), self)
        action.triggered.connect(self.find_open)
        menu.addAction(action)

        menu.exec_(self.mapToGlobal(position))

    @Slot()
    def _save_selected_txt(self):
        """Save selected content as text file"""
        self.signals.save_as.emit(self.get_selected_text(), 'txt')

    @Slot()
    def _read_selected_text(self):
        """
        Read selected text using text-to-speech
        """
        selected_text = self.get_selected_text()
        if selected_text:
            self.signals.audio_read.emit(selected_text)

    @Slot()
    def _save_as_text(self):
        """
        Save current content as text file
        """
        # TODO: normalize text (remove extra spaces, newlines, etc.)
        self.page().toPlainText(lambda txt: self.signals.save_as.emit(txt, 'txt'))

    @Slot()
    def _save_as_html(self):
        """
        Save current content as HTML file
        """
        self.page().toHtml(lambda html: self.signals.save_as.emit(html, 'html'))

    def update_zoom(self):
        """Update zoom from config"""
        try:
            if self.window.core.config.has("zoom"):
                p = self.page()
                if p:
                    p.setZoomFactor(self.window.core.config.get("zoom"))
        except Exception:
            pass

    def on_focus_js(self):
        """Focus JavaScript"""
        if self.tab is not None:
            self.window.controller.ui.tabs.on_column_focus(self.tab.column_idx)

    def get_zoom_value(self) -> float:
        """
        Get current zoom value

        :return: zoom factor
        """
        p = self.page()
        return p.zoomFactor() if p else 1.0

    def reset_current_content(self):
        """Reset current content"""
        self.plain = ""
        self.html_content = ""

    def on_page_loaded(self, success):
        """
        Page loaded event handler

        :param success: bool - True if page loaded successfully, False otherwise
        """
        if success:
            event = RenderEvent(RenderEvent.ON_PAGE_LOAD, {
                "meta": self.meta,
                "tab": self.tab,
            })
            self.window.dispatch(event)
            self.setUpdatesEnabled(True)

    def get_selected_text(self) -> str:
        p = self.page()
        return p.selectedText() if p else ""

    def copy_selected_text(self):
        p = self.page()
        if p:
            p.triggerAction(QWebEnginePage.Copy)

    def select_all_text(self):
        p = self.page()
        if p:
            p.triggerAction(QWebEnginePage.SelectAll)

    def unselect_text(self):
        p = self.page()
        if p:
            p.triggerAction(QWebEnginePage.Unselect)

    def find_open(self):
        self.window.controller.finder.open(self.finder)

    def on_update(self):
        if self.finder:
            self.finder.clear()

    def focusInEvent(self, e):
        super(ChatWebOutput, self).focusInEvent(e)
        self.window.controller.finder.focus_in(self.finder)


class CustomWebEnginePage(QWebEnginePage):
    """Custom WebEnginePage to handle web events"""
    def __init__(self, window, view, profile: QWebEngineProfile = None):

        # use the profile if provided, otherwise the default
        if profile is not None:
            super(CustomWebEnginePage, self).__init__(profile, view)
        else:
            super(CustomWebEnginePage, self).__init__(view)

        self.window = window
        self.view = view

        # signals have parent=page (automatic cleanup)
        self.signals = WebEnginePageSignals(self)

        self.findTextFinished.connect(self.on_find_finished)
        self.zoomFactorChanged.connect(self.on_view_changed)
        self.selectionChanged.connect(self.on_selection_changed)

        s = self.settings()
        s.setAttribute(QWebEngineSettings.LocalContentCanAccessFileUrls, True)
        s.setAttribute(QWebEngineSettings.LocalContentCanAccessRemoteUrls, True)
        s.setFontFamily(QWebEngineSettings.StandardFont, 'Lato')
        s.setFontFamily(QWebEngineSettings.FixedFont, 'Monaspace Neon')
        s.setFontFamily(QWebEngineSettings.SerifFont, 'Monaspace Neon')

        if self.window.core.config.has("zoom"):
            self.setZoomFactor(self.window.core.config.get("zoom"))

        # bridge Python <> JavaScript
        # KEY: parent=page, -> disappears along with the page
        self.bridge = Bridge(self.window, parent=self)
        self.channel = QWebChannel(self)
        self.channel.registerObject("bridge", self.bridge)
        self.setWebChannel(self.channel)

    def on_find_finished(self, result):
        current = int(result.activeMatch())
        num = int(result.numberOfMatches())
        self.view.finder.current_match_index = current
        self.view.finder.matches = num
        self.view.finder.on_find_finished()

    def on_view_changed(self):
        zoom = self.zoomFactor()
        self.window.core.config.set("zoom", zoom)
        option = self.window.controller.settings.editor.get_option('zoom')
        option['value'] = zoom
        self.window.controller.config.apply(
            parent_id='config',
            key='zoom',
            option=option,
        )

    def on_selection_changed(self):
        pass

    def acceptNavigationRequest(self, url, _type, isMainFrame):
        if _type == QWebEnginePage.NavigationTypeLinkClicked:
            self.window.core.filesystem.url.handle(url)
            return False
        return super().acceptNavigationRequest(url, _type, isMainFrame)

    def javaScriptConsoleMessage(self, level, message, line_number, source_id):
        # print("[JS CONSOLE] Line", line_number, ":", message)
        self.signals.js_message.emit(line_number, message, source_id)  # handled in debug controller

    def cleanup(self):
        """Cleanup method to release resources"""
        try:
            self.findTextFinished.disconnect()
            self.zoomFactorChanged.disconnect()
            self.selectionChanged.disconnect()
        except Exception:
            pass

        if self.bridge:
            try:
                self.bridge.cleanup()
            except Exception:
                pass
            self.bridge = None

        if self.channel:
            try:
                self.channel.unregisterObject("bridge")
            except Exception:
                pass
            self.channel = None

        if self.signals:
            try:
                self.signals.deleteLater()
            except Exception:
                pass
            self.signals = None

        # delete the page object
        self.deleteLater()


class Bridge(QObject):
    """Bridge between Python and JavaScript"""
    def __init__(self, window, parent=None):
        super(Bridge, self).__init__(parent)
        self.window = window

    chunk = Signal(str, str, str, bool, bool)  # name, buffer, chunk, replace, is_code
    readyChanged = Signal(bool)

    @Slot()
    def js_ready(self):
        self.readyChanged.emit(True)

    @Slot(str)
    def log(self, text: str):
        print(f"JS log: {text}")

    @Slot(str)
    def copy_text(self, text: str):
        self.window.controller.ctx.extra.copy_code_text(text)

    @Slot(str)
    def preview_text(self, text: str):
        self.window.controller.ctx.extra.preview_code_text(text)

    @Slot(str)
    def run_text(self, text: str):
        self.window.controller.ctx.extra.run_code_text(text)

    @Slot(int)
    def update_scroll_position(self, pos: int):
        self.window.controller.chat.render.scroll = pos

    def cleanup(self):
        """Cleanup method to release resources"""
        if self.window:
            try:
                self.window = None
            except Exception:
                pass

        # delete the bridge object
        self.deleteLater()


class WebEngineSignals(QObject):
    save_as = Signal(str, str)
    audio_read = Signal(str)


class WebEnginePageSignals(QObject):
    js_message = Signal(int, str, str)  # on JavaScript message