#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
# Copyright (c) 2010-2012 The Orca Team
# Copyright (c) 2012 Igalia, S.L.
# Copyright (c) 2005-2010 Sun Microsystems Inc.
#
# 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.
#
# Forked from Orca screen reader.
# Cthulhu project: https://git.stormux.org/storm/cthulhu

from __future__ import annotations

__id__        = "$Id$"
__version__   = "$Revision$"
__date__      = "$Date$"
__copyright__ = "Copyright (c) 2011. Cthulhu Team."
__license__   = "LGPL"

import importlib
from typing import TYPE_CHECKING, Optional, Dict, Any, List, Union

from . import debug
from . import cthulhu_state
from .ax_object import AXObject
from .scripts import apps, toolkits

if TYPE_CHECKING:
    from gi.repository import Atspi
    from .cthulhu import Cthulhu
    from .script import Script
    from .input_event import InputEvent

# Forward references to avoid circular imports
# Script is defined in script.py
# Atspi.Accessible comes from AT-SPI

def _get_ax_utilities() -> Any:
    # Avoid circular import with ax_utilities -> ax_utilities_event -> focus_manager -> braille -> settings_manager -> script_manager.
    from .ax_utilities import AXUtilities
    return AXUtilities

def _log(message: str, reason: Optional[str] = None, timestamp: bool = True, stack: bool = False) -> None:
    debug.print_log(debug.LEVEL_INFO, "SCRIPT MANAGER", message, reason, timestamp, stack)

def _log_tokens(tokens: list[Any], reason: Optional[str] = None, timestamp: bool = True, stack: bool = False) -> None:
    debug.print_log_tokens(debug.LEVEL_INFO, "SCRIPT MANAGER", tokens, reason, timestamp, stack)

class ScriptManager:

    def __init__(self, app: Cthulhu) -> None:
        _log("Initializing")
        self.app: Cthulhu = app  # Store app instance
        self.appScripts: Dict[Atspi.Accessible, Script] = {}
        self.toolkitScripts: Dict[Atspi.Accessible, Dict[str, Script]] = {}
        self.customScripts: Dict[Atspi.Accessible, Dict[str, Script]] = {}
        self._sleepModeScripts: Dict[Atspi.Accessible, Script] = {}
        self._appModules: List[str] = apps.__all__
        self._toolkitModules: List[str] = toolkits.__all__
        self._defaultScript: Optional[Script] = None
        self._scriptPackages: List[str] = \
            ["cthulhu-scripts",
             "cthulhu.scripts",
             "cthulhu.scripts.apps",
             "cthulhu.scripts.toolkits"]
        self._appNames: Dict[str, str] = \
            {'Icedove': 'Thunderbird',
             'Nereid': 'Banshee',
             'gnome-calculator': 'gcalctool',
             'Steam': 'steamwebhelper',
             'Steam Web Helper': 'steamwebhelper',
             'gtk-window-decorator': 'switcher',
             'marco': 'switcher',
             'xfce4-notifyd': 'notification-daemon',
             'mate-notification-daemon': 'notification-daemon',
             'metacity': 'switcher',
             'pluma': 'gedit',
            }
        self._toolkitNames: Dict[str, str] = \
            {'WebKitGTK': 'WebKitGtk', 'GTK': 'gtk'}

        self.set_active_script(None, "lifecycle: init")
        self._active: bool = False
        _log("Initialized")

    def activate(self) -> None:
        """Called when this script manager is activated."""

        _log("Activating")
        self._defaultScript = self.get_script(None)
        if self._defaultScript:
            self._defaultScript.registerEventListeners()
        self.set_active_script(self._defaultScript, "lifecycle: activate")
        self._active = True
        _log("Activated")

    def deactivate(self) -> None:
        """Called when this script manager is deactivated."""

        _log("Deactivating")
        if self._defaultScript:
            self._defaultScript.deregisterEventListeners()
        self._defaultScript = None
        self.set_active_script(None, "lifecycle: deactivate")
        self.appScripts = {}
        self.toolkitScripts = {}
        self.customScripts = {}
        self._active = False
        _log("Deactivated")

    def get_module_name(self, app: Optional[Atspi.Accessible]) -> Optional[str]:
        """Returns the module name of the script to use for application app."""

        if app is None:
            _log("Cannot get module name for null app", "null-app")
            return None

        name = AXObject.get_name(app)
        if not name:
            _log("Cannot get module name for nameless app", "nameless-app")
            return None

        pid = AXObject.get_process_id(app)
        if pid != -1:
            cmdline = debug.getCmdline(pid)
            if "steamwebhelper" in cmdline:
                return "steamwebhelper"

        altNames = list(self._appNames.keys())
        if name.endswith(".py") or name.endswith(".bin"):
            name = name.split('.')[0]
        elif name.startswith("org.") or name.startswith("com."):
            name = name.split('.')[-1]

        names = [n for n in altNames if n.lower() == name.lower()]
        if names:
            name = self._appNames.get(names[0])
        else:
            for nameList in (self._appModules, self._toolkitModules):
                names = [n for n in nameList if n.lower() == name.lower()]
                if names:
                    name = names[0]
                    break

        _log_tokens(["Mapped", app, "to", name])
        return name

    def _toolkit_for_object(self, obj: Optional[Atspi.Accessible]) -> Optional[str]:
        """Returns the name of the toolkit associated with obj."""
        if obj is None:
            return None
        name = AXObject.get_attribute(obj, 'toolkit')
        return self._toolkitNames.get(name, name)

    def _script_for_role(self, obj: Optional[Atspi.Accessible]) -> str:
        if _get_ax_utilities().is_terminal(obj):
            return 'terminal'

        return ''

    def _new_named_script(self, app: Optional[Atspi.Accessible], name: Optional[str]) -> Optional[Script]:
        """Attempts to locate and load the named module. If successful, returns
        a script based on this module."""

        if not (app and name):
            return None

        script = None
        for package in self._scriptPackages:
            moduleName = '.'.join((package, name))
            try:
                module = importlib.import_module(moduleName)
            except ImportError:
                continue
            except OSError:
                debug.examineProcesses()

            _log_tokens(["Found", moduleName])
            try:
                if hasattr(module, 'get_script'):
                    script = module.get_script(app)
                else:
                    script = module.Script(app)
                break
            except Exception as error:
                _log_tokens(["Could not load", moduleName, ":", error], "load-failed")

        return script

    def _create_script(self, app: Optional[Atspi.Accessible], obj: Optional[Atspi.Accessible] = None) -> Script:
        """For the given application, create a new script instance."""

        moduleName = self.get_module_name(app)
        script = self._new_named_script(app, moduleName)
        if script:
            return script

        objToolkit = self._toolkit_for_object(obj)
        script = self._new_named_script(app, objToolkit)
        if script:
            return script

        toolkitName = _get_ax_utilities().get_application_toolkit_name(app)
        if app and toolkitName:
            script = self._new_named_script(app, toolkitName)

        if not script:
            script = self.get_default_script(app)
            _log_tokens(["Default script created for", app, "(obj:", obj, ")"])

        return script

    def get_default_script(self, app: Optional[Atspi.Accessible] = None) -> Script:
        if not app and self._defaultScript:
            return self._defaultScript

        from .scripts import default
        script = default.Script(app)

        if not app:
            self._defaultScript = script

        return script

    def sanity_check_script(self, script: Script) -> Script:
        if not self._active:
            return script

        if _get_ax_utilities().is_application_in_desktop(script.app):
            return script

        newScript = self._get_script_for_app_replicant(script.app)
        if newScript:
            return newScript

        _log_tokens(["Failed to get a replacement script for", script.app], "replacement-missing")
        return script

    def get_script_for_mouse_button_event(self, event: Any) -> Script:
        # Note: event type unspecified in original code, likely InputEvent or similar
        isActive = _get_ax_utilities().is_active(cthulhu_state.activeWindow)
        _log_tokens([cthulhu_state.activeWindow, "is active:", isActive])

        if isActive and cthulhu_state.activeScript:
            return cthulhu_state.activeScript

        script = self.get_default_script()
        activeWindow = script.utilities.activeWindow()
        if not activeWindow:
            return script

        focusedObject = _get_ax_utilities().get_focused_object(activeWindow)
        if focusedObject:
            return self.get_script(AXObject.get_application(focusedObject), focusedObject)

        return self.get_script(AXObject.get_application(activeWindow), activeWindow)

    def get_active_script(self) -> Optional[Script]:
        return cthulhu_state.activeScript

    def get_script(self, app: Optional[Atspi.Accessible], obj: Optional[Atspi.Accessible] = None, sanity_check: bool = False) -> Script:
        """Get a script for an app (and make it if necessary).  This is used
        instead of a simple calls to Script's constructor.

        Arguments:
        - app: the Python app

        Returns an instance of a Script.
        """

        if app:
            try:
                from . import sleep_mode_manager
                sleepModeManager = sleep_mode_manager.getManager()
                sleepModeManager.refreshAutoSleepConfig()
                if sleepModeManager and sleepModeManager.isActiveForApp(app):
                    return self.get_or_create_sleep_mode_script(app)
            except Exception as error:
                _log_tokens(["Could not check sleep mode for", app, ":", error], "sleep-mode-check-failed")

        customScript = None
        appScript = None
        toolkitScript = None

        roleName = self._script_for_role(obj)
        if roleName:
            customScripts = self.customScripts.get(app, {}) # type: ignore
            customScript = customScripts.get(roleName)
            if not customScript:
                customScript = self._new_named_script(app, roleName)
                if customScript:
                    customScripts[roleName] = customScript
            if app:
                self.customScripts[app] = customScripts

        objToolkit = self._toolkit_for_object(obj)
        if objToolkit:
            toolkitScripts = self.toolkitScripts.get(app, {}) # type: ignore
            toolkitScript = toolkitScripts.get(objToolkit)
            if not toolkitScript:
                toolkitScript = self._create_script(app, obj)
                toolkitScripts[objToolkit] = toolkitScript
            if app:
                self.toolkitScripts[app] = toolkitScripts

        try:
            if not app:
                appScript = self.get_default_script()
            elif app in self.appScripts:
                appScript = self.appScripts[app]
            else:
                appScript = self._create_script(app, None)
                self.appScripts[app] = appScript
        except Exception as error:
            _log_tokens(["Exception getting app script for", app, ":", error], "app-script-error")
            appScript = self.get_default_script()

        if customScript:
            return customScript

        # Only defer to the toolkit script for this object if the app script
        # is based on a different toolkit.
        if toolkitScript and not (_get_ax_utilities().is_frame(obj) or _get_ax_utilities().is_status_bar(obj)) \
           and not issubclass(appScript.__class__, toolkitScript.__class__):
            return toolkitScript

        if app and sanity_check:
            appScript = self.sanity_check_script(appScript)

        return appScript

    def get_or_create_sleep_mode_script(self, app: Atspi.Accessible) -> Script:
        """Gets or creates the sleep mode script."""
        script = self._sleepModeScripts.get(app)
        if script is not None:
            return script

        # Import sleepmode dynamically to avoid circular imports
        from .scripts import sleepmode
        script = sleepmode.Script(app)
        self._sleepModeScripts[app] = script
        return script

    def set_active_script(self, newScript: Optional[Script], reason: Optional[str] = None) -> None:
        """Set the new active script.

        Arguments:
        - newScript: the new script to be made active.
        """

        if cthulhu_state.activeScript == newScript:
            return

        if cthulhu_state.activeScript:
            cthulhu_state.activeScript.deactivate()

        cthulhu_state.activeScript = newScript
        if not newScript:
            self._log_active_state(reason)
            return

        newScript.activate()

        # Emit signal that active script has changed, so PluginSystemManager can update keybindings
        from . import cthulhu
        if cthulhu.cthulhuApp:
            cthulhu.cthulhuApp.getSignalManager().emitSignal('active-script-changed', newScript)

        _log_tokens(["Setting active script to", newScript], reason)
        self._log_active_state(reason)

    def activate_script_for_context(self, app: Optional[Atspi.Accessible], obj: Optional[Atspi.Accessible], reason: Optional[str] = None) -> Script:
        script = self.get_script(app, obj)
        self.set_active_script(script, reason)
        return script

    def _log_active_state(self, reason: Optional[str] = None) -> None:
        _log_tokens(
            ["Active state:", "window", cthulhu_state.activeWindow,
             "focus", cthulhu_state.locusOfFocus,
             "script", cthulhu_state.activeScript],
            reason
        )

    def _get_script_for_app_replicant(self, app: Atspi.Accessible) -> Optional[Script]:
        if not self._active:
            return None

        pid = AXObject.get_process_id(app)
        if pid == -1:
            return None

        items = self.appScripts.items()
        for a, script in items:
            if AXObject.get_process_id(a) != pid:
                continue
            if a != app and _get_ax_utilities().is_application_in_desktop(a):
                if script.app is None:
                    script.app = a
                _log_tokens(["Script for app replicant:", script, script.app])
                return script

        return None

    def reclaim_scripts(self) -> None:
        """Compares the list of known scripts to the list of known apps,
        deleting any scripts as necessary.
        """

        _log("Checking and cleaning up scripts.")

        appList = list(self.appScripts.keys())
        for app in appList:
            if _get_ax_utilities().is_application_in_desktop(app):
                continue

            try:
                appScript = self.appScripts.pop(app)
            except KeyError:
                _log_tokens([app, "not found in appScripts"])
                continue

            _log_tokens(["Old script for app found:", appScript, appScript.app])

            newScript = self._get_script_for_app_replicant(app)
            if newScript:
                _log_tokens(["Transferring attributes:", newScript, newScript.app])
                attrs = appScript.getTransferableAttributes()
                for attr, value in attrs.items():
                    _log_tokens(["Setting", attr, "to", value])
                    setattr(newScript, attr, value)

            del appScript

            try:
                toolkitScripts = self.toolkitScripts.pop(app)
            except KeyError:
                pass
            else:
                for toolkitScript in toolkitScripts.values():
                    del toolkitScript

            try:
                customScripts = self.customScripts.pop(app)
            except KeyError:
                pass
            else:
                for customScript in customScripts.values():
                    del customScript

            del app

_manager: Optional[ScriptManager] = None

def get_manager() -> Optional[ScriptManager]:
    """Returns the Script Manager"""

    global _manager
    if _manager is None:
        from . import cthulhu
        if cthulhu.cthulhuApp:
            _manager = cthulhu.cthulhuApp.scriptManager
    return _manager
