import gi
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi

import bisect
import copy
import time
from gi.repository import GLib

from . import cmdnames
from . import debug
from . import focus_manager
from . import keybindings
from . import messages
from . import input_event
from . import settings_manager
from .ax_collection import AXCollection
from .ax_object import AXObject
from .ax_text import AXText

# define 'live' property types
LIVE_OFF       = -1
LIVE_NONE      = 0
LIVE_POLITE    = 1
LIVE_ASSERTIVE = 2
LIVE_RUDE      = 3

# Seconds a message is held in the queue before it is discarded
MSG_KEEPALIVE_TIME = 45  # in seconds

# The number of messages that are cached and can later be reviewed via
# LiveRegionManager.reviewLiveAnnouncement.
CACHE_SIZE = 9  # corresponds to one of nine key bindings

class PriorityQueue:
    """ This class represents a thread **UNSAFE** priority queue where priority
    is determined by the given integer priority.  The entries are also
    maintained in chronological order.

    TODO: experiment with Queue.Queue to make thread safe
    """
    def __init__(self):
        self.queue = []

    def enqueue(self, data, priority, obj):
        """ Add a new element to the queue according to 1) priority and
        2) timestamp. """
        bisect.insort_left(self.queue, (priority, time.time(), data, obj))

    def dequeue(self):
        """get the highest priority element from the queue.  """
        return self.queue.pop(0)

    def clear(self):
        """ Clear the queue """
        self.queue = []

    def purgeByKeepAlive(self):
        """ Purge items from the queue that are older than the keepalive time """
        currenttime = time.time()

        def myfilter(item):
            return item and item[1] + MSG_KEEPALIVE_TIME > currenttime

        self.queue = list(filter(myfilter, self.queue))

    def purgeByPriority(self, priority):
        """ Purge items from the queue that have a lower than or equal priority
        than the given argument """

        def myfilter(item):
            return item and item[0] > priority

        self.queue = list(filter(myfilter, self.queue))

    def __len__(self):
        """ Return the length of the queue """
        return len(self.queue)


class LiveRegionManager:
    def __init__(self, script):
        self._script = script
        # message priority queue
        self.msg_queue = PriorityQueue()

        # To make it possible for focus mode to suspend commands without changing
        # the user's preferred setting.
        self._suspended = False

        self._handlers = self.get_handlers(True)
        self._bindings = keybindings.KeyBindings()

        # This is temporary.
        self.functions = [self.advancePoliteness,
                          self.setLivePolitenessOff,
                          self.toggleMonitoring,
                          self.reviewLiveAnnouncement]

        # Message cache.  Used to store up to 9 previous messages so user can
        # review if desired.
        self.msg_cache = []

        # User overrides for politeness settings.
        self._politenessOverrides = None
        self._restoreOverrides = None

        # last live obj to be announced
        self.lastliveobj = None

        # Used to track whether a user wants to monitor all live regions
        # Not to be confused with the global Gecko.liveRegionsOn which
        # completely turns off live region support.  This one is based on
        # a user control by changing politeness levels to LIVE_OFF or back
        # to the bookmark or markup politeness value.
        self.monitoring = True

        # Set up politeness level overrides and subscribe to bookmarks
        # for load and save user events.
        # We are initialized after bookmarks so call the load handler once
        # to get initialized.
        #
        self.bookmarkLoadHandler()
        script.bookmarks.addSaveObserver(self.bookmarkSaveHandler)
        script.bookmarks.addLoadObserver(self.bookmarkLoadHandler)

    def get_bindings(self, refresh=False, is_desktop=True):
        """Returns the live-region-manager keybindings."""

        if refresh:
            msg = "LIVE REGION MANAGER: Refreshing bindings."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            self._setup_bindings()
        elif self._bindings.isEmpty():
            self._setup_bindings()

        return self._bindings

    def get_handlers(self, refresh=False):
        """Returns the live-region-manager handlers."""

        if refresh:
            msg = "LIVE REGION MANAGER: Refreshing handlers."
            debug.printMessage(debug.LEVEL_INFO, msg, True)
            self._setup_handlers()

        return self._handlers

    def _setup_handlers(self):
        """Sets up the live-region-manager input event handlers."""

        self._handlers = {}

        self._handlers["advanceLivePoliteness"] = \
            input_event.InputEventHandler(
                self.advancePoliteness,
                cmdnames.LIVE_REGIONS_ADVANCE_POLITENESS,
                enabled = not self._suspended)

        self._handlers["setLivePolitenessOff"] = \
            input_event.InputEventHandler(
                self.setLivePolitenessOff,
                cmdnames.LIVE_REGIONS_SET_POLITENESS_OFF,
                enabled = not self._suspended)

        self._handlers["monitorLiveRegions"] = \
            input_event.InputEventHandler(
                self.toggleMonitoring,
                cmdnames.LIVE_REGIONS_MONITOR,
                enabled = not self._suspended)

        self._handlers["reviewLiveAnnouncement"] = \
            input_event.InputEventHandler(
                self.reviewLiveAnnouncement,
                cmdnames.LIVE_REGIONS_REVIEW,
                enabled = not self._suspended)

        msg = f"LIVE REGION MANAGER: Handlers set up. Suspended: {self._suspended}"
        debug.printMessage(debug.LEVEL_INFO, msg, True)

    def _setup_bindings(self):
        """Sets up the live-region-manager key bindings."""

        self._bindings = keybindings.KeyBindings()

        self._bindings.add(
            keybindings.KeyBinding(
                "backslash",
                keybindings.defaultModifierMask,
                keybindings.NO_MODIFIER_MASK,
                self._handlers.get("advanceLivePoliteness"),
                1,
                not self._suspended))

        self._bindings.add(
            keybindings.KeyBinding(
                "backslash",
                keybindings.defaultModifierMask,
                keybindings.SHIFT_MODIFIER_MASK,
                self._handlers.get("setLivePolitenessOff"),
                1,
                not self._suspended))

        self._bindings.add(
            keybindings.KeyBinding(
                "backslash",
                keybindings.defaultModifierMask,
                keybindings.ORCA_SHIFT_MODIFIER_MASK,
                self._handlers.get("monitorLiveRegions"),
                1,
                not self._suspended))

        for key in ["F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9"]:
            self._bindings.add(
                keybindings.KeyBinding(
                    key,
                    keybindings.defaultModifierMask,
                    keybindings.ORCA_MODIFIER_MASK,
                    self._handlers.get("reviewLiveAnnouncement"),
                    1,
                    not self._suspended))

        # This pulls in the user's overrides to alternative keys.
        self._bindings = settings_manager.getManager().overrideKeyBindings(
            self._handlers, self._bindings, False)

        msg = f"LIVE REGION MANAGER: Bindings set up. Suspended: {self._suspended}"
        debug.printMessage(debug.LEVEL_INFO, msg, True)

        tokens = [self._bindings]
        debug.printTokens(debug.LEVEL_INFO, tokens, True)

    def refresh_bindings_and_grabs(self, script, reason=""):
        """Refreshes live region bindings and grabs for script."""

        msg = "LIVE REGION MANAGER: Refreshing bindings and grabs"
        if reason:
            msg += f": {reason}"
        debug.printMessage(debug.LEVEL_INFO, msg, True)

        for binding in self._bindings.keyBindings:
            script.keyBindings.remove(binding, includeGrabs=True)

        self._handlers = self.get_handlers(True)
        self._bindings = self.get_bindings(True)

        for binding in self._bindings.keyBindings:
            script.keyBindings.add(binding, includeGrabs=not self._suspended)

    def suspend_commands(self, script, suspended, reason=""):
        """Suspends live region commands independent of the enabled setting."""

        if suspended == self._suspended:
            return

        msg = f"LIVE REGION MANAGER: Commands suspended: {suspended}"
        if reason:
            msg += f": {reason}"
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        self._suspended = suspended
        self.refresh_bindings_and_grabs(script, f"Suspended changed to {suspended}")

    def reset(self):
        # First we will purge our politeness override dictionary of LIVE_NONE
        # objects that are not registered for this page
        newpoliteness = {}
        currenturi = self._script.bookmarks.getURIKey()
        for key, value in self._politenessOverrides.items():
            if key[0] == currenturi or value != LIVE_NONE:
                newpoliteness[key] = value
        self._politenessOverrides = newpoliteness

    def bookmarkSaveHandler(self):
        """Bookmark save callback"""
        self._script.bookmarks.saveBookmarksToDisk(self._politenessOverrides,
                                                    filename='politeness')

    def bookmarkLoadHandler(self):
        """Bookmark load callback"""
        # readBookmarksFromDisk() returns None on error. Just initialize to an
        # empty dictionary if this is the case.
        self._politenessOverrides = \
        self._script.bookmarks.readBookmarksFromDisk(filename='politeness') \
        or {}

    def handleEvent(self, event):
        """Main live region event handler"""
        politeness = self._getLiveType(event.source)
        if politeness == LIVE_OFF:
            return
        if politeness == LIVE_NONE:
            # All the 'registered' LIVE_NONE objects will be set to off
            # if not monitoring.  We will ignore LIVE_NONE objects that
            # arrive after the user switches off monitoring.
            if not self.monitoring:
                return
        elif politeness == LIVE_POLITE:
            # Nothing to do for now
            pass
        elif politeness ==  LIVE_ASSERTIVE:
            self.msg_queue.purgeByPriority(LIVE_POLITE)
        elif politeness == LIVE_RUDE:
            self.msg_queue.purgeByPriority(LIVE_ASSERTIVE)

        message = self._getMessage(event)
        if message:
            if len(self.msg_queue) == 0:
                GLib.timeout_add(100, self.pumpMessages)
            self.msg_queue.enqueue(message, politeness, event.source)

    def pumpMessages(self):
        """ Main gobject callback for live region support.  Handles both
        purging the message queue and outputting any queued messages that
        were queued up in the handleEvent() method.
        """

        if len(self.msg_queue) > 0:
            debug.printMessage(debug.eventDebugLevel, "\nvvvvv PRESENT LIVE REGION MESSAGE vvvvv")
            self.msg_queue.purgeByKeepAlive()
            politeness, timestamp, message, obj = self.msg_queue.dequeue()
            # Form output message.  No need to repeat labels and content.
            # TODO: really needs to be tested in real life cases.  Perhaps
            # a verbosity setting?
            if message['labels'] == message['content']:
                utts = message['content']
            else:
                utts = message['labels'] + message['content']

            if self.monitoring:
                self._script.presentMessage(utts)
            else:
                msg = "INFO: Not presenting message because monitoring is off"
                debug.printMessage(debug.LEVEL_INFO, msg, True)

            # set the last live obj to be announced
            self.lastliveobj = obj

            # cache our message
            self._cacheMessage(utts)

        # We still want to maintain our queue if we are not monitoring
        if not self.monitoring:
            self.msg_queue.purgeByKeepAlive()

        msg = f'LIVE REGIONS: messages in queue: {len(self.msg_queue)}'
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        debug.printMessage(debug.eventDebugLevel, "^^^^^ PRESENT LIVE REGION MESSAGE ^^^^^\n")

        # See you again soon, stay in event loop if we still have messages.
        return len(self.msg_queue) > 0

    def getLiveNoneObjects(self):
        """Return the live objects that are registered and have a politeness
        of LIVE_NONE. """
        retval = []
        currenturi = self._script.bookmarks.getURIKey()
        for uri, objectid in self._politenessOverrides:
            if uri == currenturi and isinstance(objectid, tuple):
                retval.append(self._script.bookmarks.pathToObj(objectid))
        return retval

    def advancePoliteness(self, script, inputEvent):
        """Advance the politeness level of the given object"""

        if not settings_manager.getManager().getSetting('inferLiveRegions'):
            self._script.presentMessage(messages.LIVE_REGIONS_OFF)
            return

        obj = focus_manager.getManager().get_locus_of_focus()
        objectid = self._getObjectId(obj)
        uri = self._script.bookmarks.getURIKey()

        try:
            # The current priority is either a previous override or the
            # live property.  If an exception is thrown, an override for
            # this object has never occurred and the object does not have
            # live markup.  In either case, set the override to LIVE_NONE.
            cur_priority = self._politenessOverrides[(uri, objectid)]
        except KeyError:
            cur_priority = self._liveStringToType(obj)

        if cur_priority == LIVE_OFF or cur_priority == LIVE_NONE:
            self._politenessOverrides[(uri, objectid)] = LIVE_POLITE
            self._script.presentMessage(messages.LIVE_REGIONS_LEVEL_POLITE)
        elif cur_priority == LIVE_POLITE:
            self._politenessOverrides[(uri, objectid)] = LIVE_ASSERTIVE
            self._script.presentMessage(messages.LIVE_REGIONS_LEVEL_ASSERTIVE)
        elif cur_priority == LIVE_ASSERTIVE:
            self._politenessOverrides[(uri, objectid)] = LIVE_RUDE
            self._script.presentMessage(messages.LIVE_REGIONS_LEVEL_RUDE)
        elif cur_priority == LIVE_RUDE:
            self._politenessOverrides[(uri, objectid)] = LIVE_OFF
            self._script.presentMessage(messages.LIVE_REGIONS_LEVEL_OFF)


    def goLastLiveRegion(self):
        """Move the caret to the last announced live region and speak the
        contents of that object"""
        if self.lastliveobj:
            self._script.utilities.setCaretPosition(self.lastliveobj, 0)
            self._script.speakContents(self._script.utilities.getObjectContentsAtOffset(
                                       self.lastliveobj, 0))

    def reviewLiveAnnouncement(self, script, inputEvent):
        """Speak the given number cached message"""

        msgnum = int(inputEvent.event_string[1:])
        if not settings_manager.getManager().getSetting('inferLiveRegions'):
            self._script.presentMessage(messages.LIVE_REGIONS_OFF)
            return

        if msgnum > len(self.msg_cache):
            self._script.presentMessage(messages.LIVE_REGIONS_NO_MESSAGE)
        else:
            self._script.presentMessage(self.msg_cache[-msgnum])

    def setLivePolitenessOff(self, script, inputEvent):
        """User toggle to set all live regions to LIVE_OFF or back to their
        original politeness."""

        if not settings_manager.getManager().getSetting('inferLiveRegions'):
            self._script.presentMessage(messages.LIVE_REGIONS_OFF)
            return

        # start at the document frame
        docframe = self._script.utilities.documentFrame()
        # get the URI of the page.  It is used as a partial key.
        uri = self._script.bookmarks.getURIKey()

        # The user is currently monitoring live regions but now wants to
        # change all live region politeness on page to LIVE_OFF
        if self.monitoring:
            self._script.presentMessage(messages.LIVE_REGIONS_ALL_OFF)
            self.msg_queue.clear()

            # First we'll save off a copy for quick restoration
            self._restoreOverrides = copy.copy(self._politenessOverrides)

            # Set all politeness overrides to LIVE_OFF.
            for override in self._politenessOverrides.keys():
                self._politenessOverrides[override] = LIVE_OFF

            # look through all the objects on the page and set/add to
            # politeness overrides.  This only adds live regions with good
            # markup.
            matches = self.getAllLiveRegions(docframe)
            for match in matches:
                objectid = self._getObjectId(match)
                self._politenessOverrides[(uri, objectid)] = LIVE_OFF

            # Toggle our flag
            self.monitoring = False

        # The user wants to restore politeness levels
        else:
            for key, value in self._restoreOverrides.items():
                self._politenessOverrides[key] = value
            self._script.presentMessage(messages.LIVE_REGIONS_ALL_RESTORED)
            # Toggle our flag
            self.monitoring = True

    def getAllLiveRegions(self, document):
        attrs = []
        levels = ["off", "polite", "assertive"]
        for level in levels:
            attrs.append('container-live:' + level)

        rule = AXCollection.create_match_rule(attributes=attrs)
        result = AXCollection.get_all_matches(document, rule)

        msg = f'LIVE REGIONS: {len(result)} regions found'
        debug.printMessage(debug.LEVEL_INFO, msg, True)
        return result

    def generateLiveRegionDescription(self, obj, **args):
        """Used in conjunction with whereAmI to output description and
        politeness of the given live region object"""
        objectid = self._getObjectId(obj)
        uri = self._script.bookmarks.getURIKey()

        results = []

        # get the description if there is one.
        relation = AXObject.get_relation(obj, Atspi.RelationType.DESCRIBED_BY)
        if relation:
            targetobj = relation.get_target(0)
            # We will add on descriptions if they don't duplicate
            # what's already in the object's description.
            # See http://bugzilla.gnome.org/show_bug.cgi?id=568467
            # for more information.
            description = AXText.get_all_text(targetobj)
            if description.strip() != AXObject.get_description(obj).strip():
                results.append(description)

        # get the politeness level as a string
        try:
            livepriority = self._politenessOverrides[(uri, objectid)]
            liveprioritystr = self._liveTypeToString(livepriority)
        except KeyError:
            liveprioritystr = 'none'

        # We will only output useful information
        #
        if results or liveprioritystr != 'none':
            results.append(messages.LIVE_REGIONS_LEVEL % liveprioritystr)

        return results

    def _findContainer(self, obj):
        def isContainer(x):
            return self._getAttrDictionary(x).get('atomic')

        if isContainer(obj):
            return obj

        return AXObject.find_ancestor(obj, isContainer)

    def _getMessage(self, event):
        """Gets the message associated with a given live event."""
        attrs = self._getAttrDictionary(event.source)
        content = ""
        labels = ""

        # A message is divided into two parts: labels and content.  We
        # will first try to get the content.  If there is None,
        # assume it is an invalid message and return None
        if event.type.startswith('object:children-changed:add'):
            if attrs.get('container-atomic') == 'true':
                content = self._script.utilities.expandEOCs(event.source)
            else:
                content = self._script.utilities.expandEOCs(event.any_data)

        elif event.type.startswith('object:text-changed:insert'):
            if attrs.get('container-atomic') != 'true':
                if "\ufffc" not in event.any_data:
                    content = event.any_data
                else:
                    content = self._script.utilities.expandEOCs(
                        event.source, event.detail1, event.detail1 + event.detail2)
            else:
                container = self._findContainer(event.source)
                content = self._script.utilities.expandEOCs(container)

        if not content:
            return None

        content = content.strip()

        # Proper live regions typically come with proper aria labels. These
        # labels are typically exposed as names. Failing that, descriptions.
        # Looking for actual labels seems a non-performant waste of time.
        name = (AXObject.get_name(event.source) or AXObject.get_description(event.source)).strip()
        if name and name != content:
            labels = name

        # instantly send out notify messages
        if attrs.get('channel') == 'notify':
            utts = labels + content
            self._script.presentationInterrupt()
            self._script.presentMessage(utts)
            return None

        return {'content':[content], 'labels':[labels]}

    def flushMessages(self):
        self.msg_queue.clear()

    def _cacheMessage(self, utts):
        """Cache a message in our cache list of length CACHE_SIZE"""
        self.msg_cache.append(utts)
        if len(self.msg_cache) > CACHE_SIZE:
            self.msg_cache.pop(0)

    def _getLiveType(self, obj):
        """Returns the live politeness setting for a given object. Also,
        registers LIVE_NONE objects in politeness overrides when monitoring."""
        objectid = self._getObjectId(obj)
        uri = self._script.bookmarks.getURIKey()
        if (uri, objectid) in self._politenessOverrides:
            # look to see if there is a user politeness override
            return self._politenessOverrides[(uri, objectid)]
        else:
            livetype = self._liveStringToType(obj)
            # We'll save off a reference to LIVE_NONE if we are monitoring
            # to give the user a chance to change the politeness level.  It
            # is done here for performance sake (objectid, uri are expensive)
            if livetype == LIVE_NONE and self.monitoring:
                self._politenessOverrides[(uri, objectid)] = livetype
            return livetype

    def _getObjectId(self, obj):
        """Returns the HTML 'id' or a path to the object is an HTML id is
        unavailable"""
        attrs = self._getAttrDictionary(obj)
        if attrs is None:
            return self._getPath(obj)
        try:
            return attrs['id']
        except KeyError:
            return self._getPath(obj)

    def _liveStringToType(self, obj, attributes=None):
        """Returns the politeness enum for a given object"""
        attrs = attributes or self._getAttrDictionary(obj)
        try:
            if attrs['container-live'] == 'off':
                return LIVE_OFF
            elif attrs['container-live'] == 'polite':
                return LIVE_POLITE
            elif attrs['container-live'] == 'assertive':
                return LIVE_ASSERTIVE
            elif attrs['container-live'] == 'rude':
                return LIVE_RUDE
            else:
                return LIVE_NONE
        except KeyError:
            return LIVE_NONE

    def _liveTypeToString(self, politeness):
        """Returns the politeness level as a string given a politeness enum"""
        if politeness == LIVE_OFF:
            return 'off'
        elif politeness == LIVE_POLITE:
            return 'polite'
        elif politeness == LIVE_ASSERTIVE:
            return 'assertive'
        elif politeness == LIVE_RUDE:
            return 'rude'
        elif politeness == LIVE_NONE:
            return 'none'
        else:
            return 'unknown'

    def _getAttrDictionary(self, obj):
        return AXObject.get_attributes_dict(obj)

    def _getPath(self, obj):
        """ Returns, as a tuple of integers, the path from the given object
        to the document frame."""
        docframe = self._script.utilities.documentFrame()
        path = []
        while True:
            if obj == docframe or AXObject.get_parent(obj) is None:
                path.reverse()
                return tuple(path)
            path.append(AXObject.get_index_in_parent(obj))
            obj = AXObject.get_parent(obj)

    def toggleMonitoring(self, script, inputEvent):
        if not settings_manager.getManager().getSetting('inferLiveRegions'):
            settings_manager.getManager().setSetting('inferLiveRegions', True)
            self._script.presentMessage(messages.LIVE_REGIONS_MONITORING_ON)
        else:
            settings_manager.getManager().setSetting('inferLiveRegions', False)
            self.flushMessages()
            self._script.presentMessage(messages.LIVE_REGIONS_MONITORING_OFF)
