#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library 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
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA  02110-1301 USA.
#
# Cthulhu project: https://git.stormux.org/storm/cthulhu

"""Custom script for Steam Web Helper (Steam client UI)."""

__id__ = "$Id$"
__version__ = "$Revision$"
__date__ = "$Date$"
__copyright__ = "Copyright (c) 2024 Stormux"
__license__ = "LGPL"

import time
import re

from gi.repository import GLib

from cthulhu import debug
from cthulhu import settings
from cthulhu import cthulhu_state
from cthulhu import settings_manager
from cthulhu import structural_navigation
from cthulhu.ax_object import AXObject
from cthulhu.ax_utilities import AXUtilities
from cthulhu.ax_utilities_relation import AXUtilitiesRelation
from cthulhu.ax_utilities_role import AXUtilitiesRole
from cthulhu.scripts.toolkits import Chromium
from .script_utilities import Utilities

settingsManager = settings_manager.getManager()


class SteamStructuralNavigation(structural_navigation.StructuralNavigation):
    def _headingGetter(self, document, arg=None):
        def isUsefulHeading(candidate):
            if not self._script.utilities.inDocumentContent(candidate):
                return False

            text = self._script.utilities.displayedText(candidate) or AXObject.get_name(candidate)
            if not text:
                return False

            return not text.strip().isdigit()

        if arg is None:
            return super()._headingGetter(document, arg)

        headings = super()._headingGetter(document, None)
        headings = [heading for heading in headings if isUsefulHeading(heading)]
        return [heading for heading in headings if self._script.utilities.headingLevel(heading) == arg]


class Script(Chromium.Script):
    """Script for Steam Web Helper with background notification support."""

    def __init__(self, app):
        """Creates a new script for the Steam Web Helper application.

        Arguments:
        - app: the application to create a script for.
        """
        super().__init__(app)
        # CRITICAL: Enable background event processing for notifications
        # This allows Steam notifications to be spoken even when Steam
        # is not the focused application.
        self.presentIfInactive = True
        self._lastSteamNotification = ("", 0.0)
        self._steamPendingNotification = None
        self._steamNotificationDelayMs = 500
        self._steamRelativeTimePattern = re.compile(
            r"^(?:just now|now|\d+\s+(?:second|minute|hour|day|week|month|year)s?\s+ago)$",
            re.IGNORECASE
        )

    def shouldConsumeKeyboardEvent(self, keyboardEvent, handler) -> bool:
        consumes = super().shouldConsumeKeyboardEvent(keyboardEvent, handler)
        if consumes:
            return True

        return self._trySteamButtonActivation(keyboardEvent)

    def onShowingChanged(self, event):
        """Callback for object:state-changed:showing accessibility events."""

        # Check if this is a Steam notification/alert becoming visible
        if event.detail1 and self._isSteamNotification(event.source):
            self._presentSteamNotification(event.source)
            return

        # Fall through to Chromium/web handling
        super().onShowingChanged(event)

    def getUtilities(self):
        return Utilities(self)

    def onChildrenAdded(self, event):
        """Callback for object:children-changed:add accessibility events."""

        # Check if a notification element was added
        if self._isSteamNotification(event.any_data):
            self._presentSteamNotification(event.any_data)
            return

        if self._handleSteamVirtualizedListMutation(event):
            return True

        super().onChildrenAdded(event)

    def onChildrenRemoved(self, event):
        """Callback for object:children-changed:removed accessibility events."""

        if self._handleSteamVirtualizedListMutation(event):
            return True

        return super().onChildrenRemoved(event)

    def getStructuralNavigation(self):
        types = self.getEnabledStructuralNavigationTypes()
        enable = settingsManager.getSetting('structuralNavigationEnabled')
        return SteamStructuralNavigation(self, types, enable)

    def onFocusedChanged(self, event):
        """Callback for object:state-changed:focused accessibility events."""

        if event.detail1 and AXUtilities.is_document_web(event.source):
            prevDocument = self.utilities.getDocumentForObject(cthulhu_state.locusOfFocus)
            if prevDocument and AXUtilities.is_embedded(prevDocument) \
               and self.utilities.inDocumentContent(cthulhu_state.locusOfFocus):
                msg = "STEAM: Ignoring focus event for top-level document while in embedded content"
                debug.printMessage(debug.LEVEL_INFO, msg, True)
                return True

        if event.detail1 \
           and AXUtilitiesRole.is_button(event.source) \
           and cthulhu_state.locusOfFocus in AXUtilitiesRelation.get_is_labelled_by(event.source):
            msg = "STEAM: Ignoring focus event for labelled button"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        return super().onFocusedChanged(event)

    def onTextInserted(self, event):
        """Callback for object:text-changed:insert accessibility events."""

        if self._presentSteamLiveRegionText(event):
            return

        super().onTextInserted(event)

    def onSelectionChanged(self, event):
        """Callback for object:selection-changed accessibility events."""

        self._logSteamNavigationEvent("selection-changed", event)
        return super().onSelectionChanged(event)

    def onActiveDescendantChanged(self, event):
        """Callback for object:active-descendant-changed accessibility events."""

        self._logSteamNavigationEvent("active-descendant-changed", event)
        return super().onActiveDescendantChanged(event)

    def _handleSteamVirtualizedListMutation(self, event):
        if not event or not self.utilities.isSteamVirtualizedList(event.source):
            return False

        focus = cthulhu_state.locusOfFocus
        if not focus or AXObject.is_dead(focus):
            return False

        if not AXObject.find_ancestor(focus, lambda x: x == event.source):
            return False

        AXObject.clear_cache_now("children-changed event.")
        self.utilities.clearSteamVirtualizedListCaches()
        msg = "STEAM: Skipping generic web cache dump for virtualized list churn near focus"
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        return True

    def _trySteamButtonActivation(self, keyboardEvent) -> bool:
        if keyboardEvent.event_string not in ["Return", "KP_Enter"]:
            return False

        if not keyboardEvent.is_pressed_key():
            return False

        if getattr(keyboardEvent, "modifiers", 0):
            return False

        obj = self._getClickableActivationTarget()
        if not obj or not self.utilities.inDocumentContent(obj):
            return False

        if not (AXUtilities.is_button(obj) or AXUtilities.is_push_button(obj)):
            return False

        if not any(
            AXObject.has_action(obj, actionName)
            for actionName in ["press", "click", "click-ancestor", "activate", "open", "jump"]
        ):
            return False

        if self._performClickableAction(obj):
            return True

        from cthulhu import ax_event_synthesizer
        result = ax_event_synthesizer.AXEventSynthesizer.click_object(obj)
        if result:
            self._restoreFocusAfterClick(obj)
            return True

        return False

    def _isSteamNotification(self, obj):
        """Detect if object is a Steam notification.

        Arguments:
        - obj: the accessible object to check

        Returns True if obj appears to be a notification element.
        """
        if obj is None:
            return False

        # Check for NOTIFICATION or ALERT roles
        if AXUtilities.is_notification(obj) or AXUtilities.is_alert(obj):
            msg = "STEAM: Found notification/alert role"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        # Check for live region with assertive/polite politeness
        # (Steam may use ARIA live regions for toasts)
        liveAttr = AXObject.get_attribute(obj, 'live')
        containerLive = AXObject.get_attribute(obj, 'container-live')
        if liveAttr in ['assertive', 'polite'] or containerLive in ['assertive', 'polite']:
            politeness = liveAttr or containerLive
            msg = f"STEAM: Found live region with politeness: {politeness}"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        def isNotificationCandidate(candidate):
            if AXUtilities.is_notification(candidate) or AXUtilities.is_alert(candidate):
                return True
            candidateLive = AXObject.get_attribute(candidate, 'live')
            candidateContainerLive = AXObject.get_attribute(candidate, 'container-live')
            return candidateLive in ['assertive', 'polite'] or candidateContainerLive in ['assertive', 'polite']

        ancestor = AXObject.find_ancestor(obj, isNotificationCandidate)
        if ancestor:
            msg = "STEAM: Found notification ancestor"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return True

        return False

    def _findSteamNotificationRoot(self, obj):
        if obj is None:
            return None

        if AXUtilities.is_notification(obj) or AXUtilities.is_alert(obj):
            return obj

        def isNotificationRole(candidate):
            return AXUtilities.is_notification(candidate) or AXUtilities.is_alert(candidate)

        ancestorNotification = AXObject.find_ancestor(obj, isNotificationRole)
        if ancestorNotification:
            return ancestorNotification

        liveAttr = AXObject.get_attribute(obj, 'live')
        containerLive = AXObject.get_attribute(obj, 'container-live')
        if liveAttr in ['assertive', 'polite'] or containerLive in ['assertive', 'polite']:
            return obj

        def isLiveRegionCandidate(candidate):
            candidateLive = AXObject.get_attribute(candidate, 'live')
            candidateContainerLive = AXObject.get_attribute(candidate, 'container-live')
            return candidateLive in ['assertive', 'polite'] or candidateContainerLive in ['assertive', 'polite']

        return AXObject.find_ancestor(obj, isLiveRegionCandidate)

    def _presentSteamNotification(self, obj):
        """Speak and save the notification.

        Arguments:
        - obj: the notification accessible object
        """
        text = self._getNotificationText(obj)
        if not text:
            msg = "STEAM: Notification had no text content"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return

        self._presentSteamNotificationText(text, obj)

    def _logSteamNavigationEvent(self, label, event):
        if not event:
            return

        sourceInfo = self._describeSteamObject(event.source)
        anyDataInfo = self._describeSteamEventAnyData(event.any_data)
        msg = f"STEAM: {label}: source={sourceInfo}; any_data={anyDataInfo}"
        debug.printMessage(debug.LEVEL_INFO, msg, True)

    def _describeSteamEventAnyData(self, anyData):
        if isinstance(anyData, str):
            text = self._normalizeSteamNotificationText(anyData)
            if not text:
                return "string('')"
            return f"string('{text}')"
        if anyData is None:
            return "None"
        if isinstance(anyData, (bool, int, float)):
            return repr(anyData)
        return self._describeSteamObject(anyData)

    def _describeSteamObject(self, obj):
        if obj is None:
            return "None"

        try:
            name = AXObject.get_name(obj) or ""
            description = AXObject.get_description(obj) or ""
            roleName = AXObject.get_role_name(obj) or ""
            text = self.utilities.displayedText(obj) or ""
            path = AXObject.get_path(obj)

            name = self._normalizeSteamNotificationText(name)
            description = self._normalizeSteamNotificationText(description)
            text = self._normalizeSteamNotificationText(text)

            return (
                f"role='{roleName}' "
                f"name='{name}' "
                f"description='{description}' "
                f"text='{text}' "
                f"path={path}"
            )
        except Exception as error:
            return (
                f"uninspectable(type={type(obj).__name__}, "
                f"value={obj!r}, error={error})"
            )

    def _presentSteamLiveRegionText(self, event):
        if not isinstance(event.any_data, str):
            return False

        text = event.any_data.strip()
        if not text:
            return False

        if not self._isSteamLiveRegion(event.source):
            return False

        notificationRoot = self._findSteamNotificationRoot(event.source)
        sourceObj = notificationRoot or event.source
        if notificationRoot:
            fullText = self._getNotificationText(notificationRoot)
            if fullText:
                text = fullText

        msg = f"STEAM: Live region text inserted: {text}"
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        self._presentSteamNotificationText(text, sourceObj)
        return True

    def _isSteamLiveRegion(self, obj):
        if not obj:
            return False

        liveAttr = AXObject.get_attribute(obj, 'live')
        containerLive = AXObject.get_attribute(obj, 'container-live')
        if liveAttr in ['assertive', 'polite'] or containerLive in ['assertive', 'polite']:
            return True

        def isLiveRegion(candidate):
            candidateLive = AXObject.get_attribute(candidate, 'live')
            candidateContainerLive = AXObject.get_attribute(candidate, 'container-live')
            return candidateLive in ['assertive', 'polite'] \
                or candidateContainerLive in ['assertive', 'polite']

        return AXObject.find_ancestor(obj, isLiveRegion) is not None

    def _presentSteamNotificationText(self, text, obj):
        self._queueSteamNotification(text, obj)

    def _isDuplicateSteamNotification(self, text):
        lastText, lastTime = self._lastSteamNotification
        now = time.monotonic()
        if text == lastText and (now - lastTime) < 1.0:
            return True

        self._lastSteamNotification = (text, now)
        return False

    def _presentSteamNotificationTextNow(self, text, obj):
        if self._isDuplicateSteamNotification(text):
            msg = "STEAM: Suppressing duplicate notification"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return

        notificationMsg = f"Steam: {text}"

        # Speak the notification
        voice = self.speechGenerator.voice(obj=obj, string=notificationMsg)
        self.speakMessage(notificationMsg, voice=voice)

        # Display on braille
        self.displayBrailleMessage(notificationMsg, flashTime=settings.brailleFlashTime)

        # Save to notification history
        self.notificationPresenter.save_notification(notificationMsg)

        msg = f"STEAM: Presented notification: {text}"
        debug.printMessage(debug.LEVEL_INFO, msg, True)

    def _normalizeSteamNotificationText(self, text):
        if not text:
            return ""
        return " ".join(text.split())

    def _isSteamRelativeTimestamp(self, text):
        text = self._normalizeSteamNotificationText(text)
        if not text:
            return False
        return self._steamRelativeTimePattern.match(text) is not None

    def _appendSteamTimestamp(self, baseText, timestampText):
        if not baseText:
            return timestampText
        if baseText[-1] in ".!?":
            return f"{baseText} {timestampText}"
        return f"{baseText}. {timestampText}"

    def _getSteamNotificationIdentity(self, obj):
        if obj is None:
            return None

        try:
            path = AXObject.get_path(obj)
        except Exception:
            path = None

        if path is not None:
            return tuple(path)

        try:
            return hash(obj)
        except TypeError:
            return id(obj)

    def _combineSteamNotificationFragments(self, firstText, secondText):
        combined = f"{firstText} {secondText}"
        return self._normalizeSteamNotificationText(combined)

    def _steamTextContains(self, text, other):
        textNorm = self._normalizeSteamNotificationText(text).lower()
        otherNorm = self._normalizeSteamNotificationText(other).lower()
        if not textNorm or not otherNorm:
            return False
        if len(otherNorm) < 4:
            return textNorm == otherNorm
        return otherNorm in textNorm

    def _queueSteamNotification(self, text, obj):
        text = self._normalizeSteamNotificationText(text)
        if not text:
            return
        sourceKey = self._getSteamNotificationIdentity(obj)

        pending = self._steamPendingNotification
        if self._isSteamRelativeTimestamp(text):
            if pending:
                if pending.get("timestampOnly"):
                    pending["text"] = text
                else:
                    pending["text"] = self._appendSteamTimestamp(pending["text"], text)
                if obj:
                    pending["obj"] = obj
                pending["sourceKey"] = sourceKey
                self._resetSteamPendingTimer()
                return

            self._steamPendingNotification = {
                "text": text,
                "obj": obj,
                "timerId": None,
                "timestampOnly": True,
                "sourceKey": sourceKey
            }
            self._resetSteamPendingTimer()
            return

        if pending:
            pendingText = pending["text"]
            if pending.get("timestampOnly"):
                pending["text"] = self._appendSteamTimestamp(text, pendingText)
                pending["timestampOnly"] = False
                if obj:
                    pending["obj"] = obj
                pending["sourceKey"] = sourceKey
                self._resetSteamPendingTimer()
                return

            if text == pendingText:
                if obj:
                    pending["obj"] = obj
                pending["sourceKey"] = sourceKey
                return

            if self._steamTextContains(text, pendingText):
                pending["text"] = text
                if obj:
                    pending["obj"] = obj
                pending["sourceKey"] = sourceKey
                self._resetSteamPendingTimer()
                return

            if self._steamTextContains(pendingText, text):
                if obj:
                    pending["obj"] = obj
                pending["sourceKey"] = sourceKey
                return

            # Steam often emits multi-line toasts as separate live-region fragments.
            # Keep fragments from the same toast together instead of speaking the first
            # line immediately and the complete toast later.
            if sourceKey is not None and sourceKey == pending.get("sourceKey"):
                pending["text"] = self._combineSteamNotificationFragments(pendingText, text)
                if obj:
                    pending["obj"] = obj
                pending["sourceKey"] = sourceKey
                self._resetSteamPendingTimer()
                return

            self._flushSteamPendingNotification(fromTimer=False)

        self._steamPendingNotification = {
            "text": text,
            "obj": obj,
            "timerId": None,
            "timestampOnly": False,
            "sourceKey": sourceKey
        }
        self._resetSteamPendingTimer()

    def _resetSteamPendingTimer(self):
        pending = self._steamPendingNotification
        if not pending:
            return
        timerId = pending.get("timerId")
        if timerId is not None:
            GLib.source_remove(timerId)
        pending["timerId"] = GLib.timeout_add(
            self._steamNotificationDelayMs,
            self._onSteamPendingTimeout
        )

    def _onSteamPendingTimeout(self):
        self._flushSteamPendingNotification(fromTimer=True)
        return False

    def _flushSteamPendingNotification(self, fromTimer):
        pending = self._steamPendingNotification
        if not pending:
            return
        timerId = pending.get("timerId")
        if not fromTimer and timerId is not None:
            GLib.source_remove(timerId)
        self._steamPendingNotification = None
        if pending.get("timestampOnly"):
            msg = "STEAM: Dropping timestamp-only notification"
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            return
        self._presentSteamNotificationTextNow(pending["text"], pending["obj"])

    def _getNotificationText(self, obj):
        """Extract text from notification element.

        Arguments:
        - obj: the notification accessible object

        Returns the text content of the notification, or empty string if none found.
        """
        def getText(candidate):
            name = AXObject.get_name(candidate)
            if name:
                return name
            return self.utilities.displayedText(candidate)

        # Try getting name directly
        text = getText(obj)
        if text:
            return text

        # Try getting all text from labels/text elements
        texts = []
        for label in AXUtilities.find_all_labels(obj):
            text = getText(label)
            if text:
                texts.append(text)

        if texts:
            return ' '.join(texts)

        def isNamedButton(candidate):
            if not AXUtilitiesRole.is_button(candidate):
                return False
            return bool(getText(candidate))

        descendantButton = AXObject.find_descendant(obj, isNamedButton)
        if descendantButton:
            return getText(descendantButton)

        ancestorButton = AXObject.find_ancestor(obj, isNamedButton)
        if ancestorButton:
            return getText(ancestorButton)

        def hasReadableText(candidate):
            return bool(getText(candidate))

        descendantText = AXObject.find_descendant(obj, hasReadableText)
        if descendantText:
            return getText(descendantText)

        # Fall back to displayed text of the object itself
        return ""
