# Orca
#
# Copyright 2011-2024 Igalia, S.L.
# Author: Joanmarie Diggs <jdiggs@igalia.com>
#
# 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.

# pylint: disable=too-many-instance-attributes

"""Manages Orca's scripts."""

from __future__ import annotations

import contextlib
import importlib

import gi

gi.require_version("Atspi", "2.0")

from typing import TYPE_CHECKING

from . import command_manager, debug, gsettings_registry, sleep_mode_manager, speech_manager
from .ax_object import AXObject
from .ax_utilities import AXUtilities
from .scripts import apps, default, sleepmode, toolkits

if TYPE_CHECKING:
    from gi.repository import Atspi


class ScriptManager:
    """Manages Orca's scripts."""

    def __init__(self) -> None:
        debug.print_message(debug.LEVEL_INFO, "SCRIPT MANAGER: Initializing", True)
        self.app_scripts: dict = {}
        self.toolkit_scripts: dict = {}
        self.custom_scripts: dict = {}
        self._sleep_mode_scripts: dict = {}
        self._default_script: default.Script | None = None
        self._active_script: default.Script | None = None
        self._active: bool = False
        debug.print_message(debug.LEVEL_INFO, "SCRIPT MANAGER: Initialized", True)

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

        debug.print_message(debug.LEVEL_INFO, "SCRIPT MANAGER: Activating", True, True)
        if self._active:
            debug.print_message(debug.LEVEL_INFO, "SCRIPT MANAGER: Already activated", True)
            return

        self._default_script = self.get_default_script(None)
        self._default_script.register_event_listeners()
        self.set_active_script(self._default_script, "activate")
        self._active = True
        debug.print_message(debug.LEVEL_INFO, "SCRIPT MANAGER: Activated", True)

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

        debug.print_message(debug.LEVEL_INFO, "SCRIPT MANAGER: Deactivating", True, True)
        if not self._active:
            debug.print_message(debug.LEVEL_INFO, "SCRIPT MANAGER: Already deactivated", True)
            return

        if self._default_script is not None:
            self._default_script.deregister_event_listeners()
        self._default_script = None
        self.set_active_script(None, "deactivate")
        self.app_scripts = {}
        self.toolkit_scripts = {}
        self.custom_scripts = {}
        self._active = False
        debug.print_message(debug.LEVEL_INFO, "SCRIPT MANAGER: Deactivated", True)

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

        if app is None:
            msg = "SCRIPT MANAGER: Cannot get module name for null app"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return None

        name = AXObject.get_name(app)
        if not name:
            msg = "SCRIPT MANAGER: Cannot get module name for nameless app"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return None

        app_names = {
            "gtk-window-decorator": "switcher",
            "marco": "switcher",
            "mate-notification-daemon": "notification-daemon",
            "metacity": "switcher",
            "budgie-daemon": "switcher",
            "xfce4-notifyd": "notification-daemon",
        }
        alt_names = list(app_names.keys())
        if name.endswith((".py", ".bin")):
            name = name.split(".")[0]
        elif name.startswith(("org.", "com.")):
            name = name.split(".")[-1]

        names = [n for n in alt_names if n.lower() == name.lower()]
        if names:
            name = app_names.get(names[0], "")
        else:
            for name_list in (apps.__all__, toolkits.__all__):
                names = [n for n in name_list if n.lower() == name.lower()]
                if names:
                    name = names[0]
                    break

        tokens = ["SCRIPT MANAGER: Mapped", app, "to", name]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        return name

    def _toolkit_for_object(self, obj: Atspi.Accessible) -> str | None:
        """Returns the name of the toolkit associated with obj."""

        names = {"GTK": "gtk", "GAIL": "gtk", "WPEWebKit": "WebKitGTK"}
        name = AXObject.get_attribute(obj, "toolkit")
        return names.get(name, name)

    def _script_for_role(self, obj: Atspi.Accessible) -> str:
        """Returns the role-based script for obj."""

        if AXUtilities.is_terminal(obj):
            return "terminal"

        return ""

    def _new_named_script(self, app: Atspi.Accessible, name: str) -> default.Script | None:
        """Returns a script based on this module if it was located and loadable."""

        if not (app and name):
            return None

        packages = ["orca.scripts", "orca.scripts.apps", "orca.scripts.toolkits"]
        script = None
        for package in packages:
            module_name = f"{package}.{name}"
            try:
                module = importlib.import_module(module_name)
            except ImportError:
                continue
            except OSError as error:
                tokens = ["EXCEPTION: Could not import", module_name, ":", error]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True, True)
                continue

            tokens = ["SCRIPT MANAGER: Found", module_name]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            with contextlib.suppress(ImportError):
                module = importlib.import_module(f"{module_name}.script")
            try:
                if hasattr(module, "get_script"):
                    script = module.get_script(app)
                else:
                    script = module.Script(app)
                break
            except (AttributeError, TypeError, ImportError) as error:
                tokens = ["EXCEPTION: Could not load", module_name, ":", error]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True, True)

        return script

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

        module_name = self.get_module_name(app) or ""
        script = self._new_named_script(app, module_name)
        if script:
            return script

        obj_toolkit = self._toolkit_for_object(obj) or ""
        script = self._new_named_script(app, obj_toolkit)
        if script:
            return script

        toolkit_name = AXUtilities.get_application_toolkit_name(app)
        if app and toolkit_name:
            script = self._new_named_script(app, toolkit_name)

        if not script:
            script = self.get_default_script(app)
            tokens = ["SCRIPT MANAGER: Default script created for", app, "(obj: ", obj, ")"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        return script

    def get_default_script(self, app: Atspi.Accessible | None = None) -> default.Script:
        """Returns the default script."""

        if not app and self._default_script:
            return self._default_script

        script = default.Script(app)
        if not app:
            self._default_script = script

        return script

    def get_or_create_sleep_mode_script(self, app: Atspi.Accessible) -> sleepmode.Script:
        """Gets or crates the sleep mode script."""

        script = self._sleep_mode_scripts.get(app)
        if script is not None:
            return script

        script = sleepmode.Script(app)
        self._sleep_mode_scripts[app] = script
        return script

    def get_script(
        self,
        app: Atspi.Accessible | None,
        obj: Atspi.Accessible | None = None,
    ) -> default.Script:
        """Get a script for an app (and make it if necessary)."""

        tokens = ["SCRIPT MANAGER: Getting script for", app, obj]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        custom_script: default.Script | None = None
        app_script: default.Script | None = None
        toolkit_script: default.Script | None = None

        role_name = self._script_for_role(obj)
        if role_name:
            custom_scripts = self.custom_scripts.get(app, {})
            custom_script = custom_scripts.get(role_name)
            if not custom_script:
                custom_script = self._new_named_script(app, role_name)
                custom_scripts[role_name] = custom_script
            self.custom_scripts[app] = custom_scripts

        obj_toolkit = self._toolkit_for_object(obj)
        if obj_toolkit:
            toolkit_scripts = self.toolkit_scripts.get(app, {})
            toolkit_script = toolkit_scripts.get(obj_toolkit)
            if not toolkit_script:
                tokens = ["SCRIPT MANAGER: Creating toolkit script for", app, obj, obj_toolkit]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                toolkit_script = self._create_script(app, obj)
                toolkit_scripts[obj_toolkit] = toolkit_script
            self.toolkit_scripts[app] = toolkit_scripts

        try:
            if not app:
                app_script = self.get_default_script()
            elif app in self.app_scripts:
                app_script = self.app_scripts[app]
            else:
                tokens = ["SCRIPT MANAGER: Creating app script for", app]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                app_script = self._create_script(app, None)
                self.app_scripts[app] = app_script
        except (KeyError, AttributeError, ImportError) as error:
            tokens = ["EXCEPTION: Exception getting app script for", app, ":", error]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            app_script = self.get_default_script()

        assert app_script is not None
        if sleep_mode_manager.get_manager().is_active_for_app(app):
            tokens = ["SCRIPT MANAGER: Sleep-mode toggled on for", app_script, app]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return self.get_or_create_sleep_mode_script(app)  # type: ignore[return-value]

        if custom_script:
            tokens = ["SCRIPT MANAGER: Script is custom script", custom_script]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return custom_script

        # Only defer to the toolkit script for this object if the app script
        # is based on a different toolkit.
        if (
            toolkit_script
            and not (AXUtilities.is_frame(obj) or AXUtilities.is_status_bar(obj))
            and not issubclass(app_script.__class__, toolkit_script.__class__)
        ):
            tokens = ["SCRIPT MANAGER: Script is toolkit script", toolkit_script]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return toolkit_script

        tokens = ["SCRIPT MANAGER: Script is app script", app_script]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        return app_script

    def get_active_script(self) -> default.Script | None:
        """Returns the active script."""

        tokens = ["SCRIPT MANAGER: Active script is:", self._active_script]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        return self._active_script

    def get_active_script_app(self) -> Atspi.Accessible | None:
        """Returns the app associated with the active script."""

        if self._active_script is None:
            return None

        tokens = ["SCRIPT MANAGER: Active script app is:", self._active_script.app]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        return self._active_script.app

    def set_active_script(self, new_script: default.Script | None, reason: str = "") -> None:
        """Set the active script to new_script."""

        if self._active_script == new_script:
            return

        if self._active_script is not None:
            tokens = ["SCRIPT MANAGER: Deactivating", self._active_script, "reason:", reason]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            old_app = self._active_script.app
            self._active_script.deactivate()
            if old_app is not None and (new_script is None or new_script.app != old_app):
                sleep_mode_manager.get_manager().on_app_deactivated(old_app)

        self._active_script = new_script
        if new_script is None:
            gsettings_registry.get_registry().set_active_app(None)
            command_manager.get_manager().check_keyboard_settings()
            return

        gsettings_registry.get_registry().set_active_app(new_script.app_name)

        tokens = ["SCRIPT MANAGER: Setting active script to", new_script, "reason:", reason]
        debug.print_tokens(debug.LEVEL_INFO, tokens, True)
        new_script.activate()

        speech_manager.get_manager().check_speech_setting()
        command_manager.get_manager().check_keyboard_settings()

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

        msg = "SCRIPT MANAGER: Checking and cleaning up scripts."
        debug.print_message(debug.LEVEL_INFO, msg, True)

        app_list = list(self.app_scripts.keys())
        for app in app_list:
            if AXUtilities.is_application_in_desktop(app):
                continue

            try:
                app_script = self.app_scripts.pop(app)
            except KeyError:
                tokens = ["SCRIPT MANAGER:", app, "not found in app_scripts"]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                continue

            tokens = ["SCRIPT MANAGER: Old script for app found:", app_script, app_script.app]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)

            with contextlib.suppress(KeyError):
                self._sleep_mode_scripts.pop(app)

            with contextlib.suppress(KeyError):
                self.toolkit_scripts.pop(app)

            with contextlib.suppress(KeyError):
                self.custom_scripts.pop(app)


_manager: ScriptManager = ScriptManager()


def get_manager() -> ScriptManager:
    """Returns the Script Manager singleton."""
    return _manager
