#!/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.

"""Plugin System Manager for Cthulhu using pluggy."""

import configparser
import hashlib
import importlib.util
import inspect
import logging
import os
import re
import shutil
import subprocess
from enum import IntEnum
from typing import Optional, Dict, List, Any

import pluggy
from . import dbus_service
from . import input_event_manager
from . import keybindings # Added import

# Set to True for more detailed plugin loading debug info
PLUGIN_DEBUG: bool = True

logger: logging.Logger = logging.getLogger(__name__)
if PLUGIN_DEBUG:
    logger.setLevel(logging.DEBUG)

LEGACY_PLUGIN_NAME_ALIASES: Dict[str, str] = {
    "ocrdesktop": "OCR",
}

LEGACY_PLUGIN_DIR_ALIASES: Dict[str, str] = {
    "OCRDesktop": "OCR",
}

_manager: Optional['PluginSystemManager'] = None

def getManager() -> Optional['PluginSystemManager']:
    """Return the shared PluginSystemManager instance."""
    return _manager

class PluginType(IntEnum):
    """Types of plugins we support."""
    SYSTEM = 1
    USER = 2

    def get_root_dir(self) -> str:
        """Returns the directory where this type of plugins can be found."""
        if self.value == PluginType.SYSTEM:
            current_file = inspect.getfile(inspect.currentframe())
            current_dir = os.path.dirname(os.path.realpath(os.path.abspath(current_file)))
            return os.path.join(current_dir, 'plugins')
        elif self.value == PluginType.USER:
            return os.path.expanduser('~/.local/share/cthulhu/plugins')


class PluginInfo:
    """Information about a plugin."""

    def __init__(
        self,
        name: str,
        module_name: str,
        module_dir: str,
        metadata: Optional[Dict[str, Any]] = None,
        canonical_name: Optional[str] = None,
        source_id: Optional[str] = None,
        origin: Optional[str] = None
    ) -> None:
        self.name: str = name
        self.module_name: str = module_name
        self.module_dir: str = module_dir
        self.metadata: Dict[str, Any] = metadata or {}
        self.canonical_name: str = canonical_name or module_name
        self.source_id: str = source_id or "unknown"
        self.origin: str = origin or "unknown"
        self.preferred_alias: bool = False
        self.builtin: bool = False
        self.hidden: bool = False
        self.module: Optional[Any] = None
        self.instance: Optional[Any] = None
        self.loaded: bool = False

    def get_module_name(self) -> str:
        return self.module_name

    def get_canonical_name(self) -> str:
        return self.canonical_name

    def get_name(self) -> str:
        return self.metadata.get('name', self.name)

    def get_version(self) -> str:
        return self.metadata.get('version', '0.0.0')

    def get_description(self) -> str:
        return self.metadata.get('description', '')

    def get_source_id(self) -> str:
        return self.source_id

    def get_origin(self) -> str:
        return self.origin

    def get_source_label(self) -> str:
        if self.origin == "sources":
            return self.source_id
        return self.origin

    def get_module_dir(self) -> str:
        return self.module_dir


class PluginSystemManager:
    """Cthulhu Plugin Manager using pluggy."""

    def __init__(self, app: Any) -> None:  # app is CthulhuApp instance
        global _manager
        self.app: Any = app
        logger.info("Initializing PluginSystemManager")
        _manager = self

        # Initialize plugin manager
        logger.info("Setting up plugin manager")
        self.plugin_manager: pluggy.PluginManager = pluggy.PluginManager("cthulhu")

        # Define hook specifications
        hook_spec = pluggy.HookspecMarker("cthulhu")

        class CthulhuHookSpecs:
            @hook_spec
            def activate(self, plugin: Optional[Any] = None) -> None:
                """Called when the plugin is activated."""
                pass

            @hook_spec
            def deactivate(self, plugin: Optional[Any] = None) -> None:
                """Called when the plugin is deactivated."""
                pass

        logger.info("Adding hook specifications to plugin manager")
        self.plugin_manager.add_hookspecs(CthulhuHookSpecs)

        # Plugin storage
        self._plugins: Dict[str, PluginInfo] = {}  # module_name -> PluginInfo
        self._plugin_name_index: Dict[str, List[str]] = {}  # canonical_name -> [module_name]
        self._active_plugins: List[str] = []
        self._plugin_keybindings: Dict[str, List[Any]] = {} # plugin_name -> [KeyBinding]
        self._global_keybindings: keybindings.KeyBindings = keybindings.KeyBindings()
        self._global_bindings: List[Any] = []  # List[KeyBinding]
        self._last_active_script: Optional[Any] = None  # Optional[Script]

        # Create plugin directories
        self._setup_plugin_dirs()

        # Log available plugins directory paths
        logger.info(f"System plugins directory: {PluginType.SYSTEM.get_root_dir()}")
        logger.info(f"User plugins directory: {PluginType.USER.get_root_dir()}")

        # Connect to active-script-changed signal
        self.app.getSignalManager().connectSignal(
            'active-script-changed', self._on_active_script_changed, 'default')

    def add_keybinding(self, plugin_name: str, binding: Any, global_binding: bool = False) -> None:  # binding: KeyBinding
        """Add a keybinding associated with a specific plugin."""
        if plugin_name not in self._plugin_keybindings:
            self._plugin_keybindings[plugin_name] = []
        self._plugin_keybindings[plugin_name].append(binding)
        logger.debug(f"Added keybinding '{binding.asString()}' for plugin '{plugin_name}'")
        if global_binding:
            if binding not in self._global_bindings:
                self._global_bindings.append(binding)
                self._global_keybindings.add(binding)
                grab_ids = input_event_manager.get_manager().add_grabs_for_keybinding(binding)
                if grab_ids:
                    binding._global_grab_ids = grab_ids
                else:
                    logger.warning(f"Failed to create global key grab for {binding.keysymstring}")

    def activate_keybindings_for_plugin(self, plugin_name: str) -> None:
        """Activates keybindings for a single plugin with the active script."""
        plugin_info = self._plugins.get(plugin_name)
        if not plugin_info or not plugin_info.loaded:
            return
        self._activate_plugin_keybindings(plugin_info)

    def remove_keybinding(self, plugin_name: str, binding: Any) -> None:  # binding: KeyBinding
        """Remove a keybinding associated with a specific plugin."""
        if plugin_name in self._plugin_keybindings:
            if binding in self._plugin_keybindings[plugin_name]:
                self._plugin_keybindings[plugin_name].remove(binding)

        if binding in self._global_bindings:
            if hasattr(binding, '_global_grab_ids'):
                for grab_id in binding._global_grab_ids:
                    input_event_manager.get_manager().remove_grab_by_id(grab_id)
                del binding._global_grab_ids
            if binding in self._global_bindings:
                self._global_bindings.remove(binding)
            self._global_keybindings.remove(binding)
            return

        from . import cthulhu_state
        if cthulhu_state.activeScript:
            active_script = cthulhu_state.activeScript
            if active_script.getKeyBindings().hasKeyBinding(binding, "keysNoMask"):
                active_script.getKeyBindings().remove(binding)
                input_event_manager.get_manager().remove_grabs_for_keybinding(binding)

    def _activate_plugin_keybindings(self, plugin_info: PluginInfo) -> None:
        """Activates all keybindings for a given plugin with the active script."""
        from . import cthulhu_state # Import here to avoid circular dependency
        if not cthulhu_state.activeScript:
            logger.warning(f"No active script to activate keybindings for plugin: {plugin_info.get_module_name()}")
            return

        plugin_name = plugin_info.get_module_name()
        if plugin_name in self._plugin_keybindings:
            active_script = cthulhu_state.activeScript
            input_manager = input_event_manager.get_manager()
            for binding in self._plugin_keybindings[plugin_name]:
                if binding in self._global_bindings:
                    continue
                if not active_script.getKeyBindings().hasKeyBinding(binding, "keysNoMask"):
                    active_script.getKeyBindings().add(binding)
                    grab_ids = input_event_manager.get_manager().add_grabs_for_keybinding(binding)
                    if grab_ids:
                        binding._grab_ids = grab_ids
                    else:
                        logger.warning(f"Failed to create key grab for {binding.keysymstring} for plugin {plugin_name}")
                logger.debug(f"Activated keybinding '{binding.asString()}' for plugin '{plugin_name}'")

    def _deactivate_plugin_keybindings(self, plugin_info: PluginInfo) -> None:
        """Deactivates all keybindings for a given plugin from the active script."""
        from . import cthulhu_state # Import here to avoid circular dependency
        if not cthulhu_state.activeScript:
            logger.warning(f"No active script to deactivate keybindings for plugin: {plugin_info.get_module_name()}")
            return

        plugin_name = plugin_info.get_module_name()
        if plugin_name in self._plugin_keybindings:
            active_script = cthulhu_state.activeScript
            for binding in self._plugin_keybindings[plugin_name]:
                if binding in self._global_bindings:
                    if hasattr(binding, '_global_grab_ids'):
                        for grab_id in binding._global_grab_ids:
                            self.app.removeKeyGrab(grab_id)
                        del binding._global_grab_ids
                    if binding in self._global_bindings:
                        self._global_bindings.remove(binding)
                    self._global_keybindings.remove(binding)
                    continue
                if active_script.getKeyBindings().hasKeyBinding(binding, "keysNoMask"):
                    active_script.getKeyBindings().remove(binding)
                    input_manager.remove_grabs_for_keybinding(binding)
                logger.debug(f"Deactivated keybinding '{binding.asString()}' for plugin '{plugin_name}'")

    def refresh_active_script_keybindings(self) -> None:
        """Public method to refresh keybindings for the currently active script."""
        from . import cthulhu_state
        if cthulhu_state.activeScript:
            self._on_active_script_changed(self.app, cthulhu_state.activeScript)

    def get_global_keybindings(self) -> keybindings.KeyBindings:
        return self._global_keybindings

    def _on_active_script_changed(self, app: Any, new_script: Optional[Any]) -> None:
        """Called when the active script changes. Re-applies keybindings for all active plugins."""
        logger.info(f"Active script changed to {new_script.name if new_script else 'None'}. Re-applying plugin keybindings.")

        # First, remove all existing plugin keybindings from the old script
        # This requires iterating through all plugins, not just active ones, to ensure cleanup
        old_script = self._last_active_script
        if old_script:
            input_manager = input_event_manager.get_manager()
            for plugin_name, bindings in self._plugin_keybindings.items():
                for binding in bindings:
                    if binding in self._global_bindings:
                        continue
                    if old_script.getKeyBindings().hasKeyBinding(binding, "keysNoMask"):
                        old_script.getKeyBindings().remove(binding)
                        input_manager.remove_grabs_for_keybinding(binding)
                        logger.debug(f"Removed keybinding '{binding.asString()}' from old script for plugin '{plugin_name}'")

        # Now, if there's a new active script, apply keybindings for currently active plugins
        self._last_active_script = new_script
        if new_script:
            for plugin_name in self._active_plugins:
                plugin_info = self._plugins.get(plugin_name)
                if plugin_info and plugin_info.loaded:
                    self._activate_plugin_keybindings(plugin_info)

    def _setup_plugin_dirs(self) -> None:
        """Ensure plugin directories exist."""
        os.makedirs(PluginType.SYSTEM.get_root_dir(), exist_ok=True)
        os.makedirs(PluginType.USER.get_root_dir(), exist_ok=True)
        os.makedirs(self._get_plugin_sources_root(), exist_ok=True)

    def _get_plugin_sources_root(self) -> str:
        return os.path.expanduser('~/.local/share/cthulhu/plugin-sources')

    def _get_additional_plugin_dirs(self) -> List[str]:
        return [os.path.expanduser('~/.local/share/plugins')]

    def _path_under_root(self, path: str, root: str) -> bool:
        try:
            return os.path.commonpath([os.path.abspath(path), os.path.abspath(root)]) == os.path.abspath(root)
        except ValueError:
            return False

    def _sanitize_source_id(self, source_id: str) -> str:
        return re.sub(r'[^a-zA-Z0-9._-]+', '-', source_id).strip('-') or "source"

    def _get_origin_info(self, plugin_dir: str) -> tuple[str, str]:
        system_root = PluginType.SYSTEM.get_root_dir()
        user_root = PluginType.USER.get_root_dir()
        local_root = os.path.expanduser('~/.local/share/plugins')
        sources_root = self._get_plugin_sources_root()

        if self._path_under_root(plugin_dir, system_root):
            return ("system", "system")
        if self._path_under_root(plugin_dir, user_root):
            return ("user", "user")
        if self._path_under_root(plugin_dir, local_root):
            return ("local", "local")
        if self._path_under_root(plugin_dir, sources_root):
            rel_path = os.path.relpath(plugin_dir, sources_root)
            source_id = rel_path.split(os.sep, 1)[0]
            return ("sources", self._sanitize_source_id(source_id))

        return ("unknown", "unknown")

    def _make_unique_plugin_id(self, canonical_name, source_id):
        base_id = canonical_name
        if canonical_name in self._plugin_name_index:
            base_id = f"{canonical_name}@{self._sanitize_source_id(source_id)}"

        unique_id = base_id
        suffix = 2
        while unique_id in self._plugins:
            unique_id = f"{base_id}-{suffix}"
            suffix += 1
        return unique_id

    @property
    def plugins(self):
        """Get all available plugins."""
        return list(self._plugins.values())

    def getApp(self):
        return self.app

    def rescanPlugins(self):
        """Scan for plugins in the plugin directories."""
        old_plugins = self._plugins.copy()
        self._plugins = {}
        self._plugin_name_index = {}

        # Scan system and user plugins
        self._scan_plugins_in_directory(PluginType.SYSTEM.get_root_dir())
        self._scan_plugins_in_directory(PluginType.USER.get_root_dir(), max_depth=1)
        for plugin_dir in self._get_additional_plugin_dirs():
            if os.path.isdir(plugin_dir):
                self._scan_plugins_in_directory(plugin_dir, max_depth=1)
        self._scan_plugins_in_directory(self._get_plugin_sources_root(), max_depth=1)

        # Preserve state for already loaded plugins
        for name, old_info in old_plugins.items():
            if name in self._plugins and old_info.loaded:
                self._plugins[name].loaded = True
                self._plugins[name].instance = old_info.instance
                self._plugins[name].module = old_info.module

    def _scan_plugins_in_directory(self, directory, max_depth=0, _depth=0):
        """Scan for plugins in a directory."""
        if not os.path.exists(directory) or not os.path.isdir(directory):
            if _depth == 0:
                logger.warning(f"Plugin directory not found or not a directory: {directory}")
            return

        if _depth == 0:
            logger.info(f"Scanning for plugins in directory: {directory}")

        for item in sorted(os.listdir(directory)):
            if item.startswith('.'):
                continue
            plugin_dir = os.path.join(directory, item)
            if not os.path.isdir(plugin_dir):
                continue

            if self._register_plugin_from_directory(plugin_dir):
                continue

            if _depth < max_depth:
                self._scan_plugins_in_directory(plugin_dir, max_depth=max_depth, _depth=_depth + 1)
            elif max_depth == 0:
                logger.warning(f"No plugin file found in directory: {plugin_dir}")

    def _register_plugin_from_directory(self, plugin_dir):
        item = os.path.basename(plugin_dir)
        plugin_file = os.path.join(plugin_dir, "plugin.py")
        metadata_file = os.path.join(plugin_dir, "plugin.info")

        if not os.path.isfile(plugin_file):
            alternative_plugin_file = os.path.join(plugin_dir, f"{item}.py")
            if os.path.isfile(alternative_plugin_file):
                plugin_file = alternative_plugin_file
                logger.info(f"Using alternative plugin file: {alternative_plugin_file}")

        if not os.path.isfile(plugin_file):
            return False

        canonical_name = os.path.basename(plugin_dir)
        canonical_override = LEGACY_PLUGIN_DIR_ALIASES.get(canonical_name)
        if canonical_override:
            if canonical_override in self._plugin_name_index:
                logger.info(
                    f"Skipping deprecated plugin directory {canonical_name} because "
                    f"{canonical_override} is already registered."
                )
                return True
            logger.info(
                f"Registering deprecated plugin directory {canonical_name} as {canonical_override}."
            )
            canonical_name = canonical_override
        origin, source_id = self._get_origin_info(plugin_dir)
        module_name = self._make_unique_plugin_id(canonical_name, source_id)
        if canonical_name != module_name:
            logger.warning(
                "Duplicate plugin name detected: "
                f"{canonical_name} (origin={origin}, source={source_id}). "
                f"Registering as {module_name}."
            )
        logger.info(f"Found plugin: {canonical_name} in {plugin_dir}")
        metadata = self._load_plugin_metadata(metadata_file)

        plugin_info = PluginInfo(
            metadata.get('name', canonical_name),
            module_name,
            plugin_dir,
            metadata,
            canonical_name=canonical_name,
            source_id=source_id,
            origin=origin,
        )

        plugin_info.builtin = metadata.get('builtin', 'false').lower() == 'true'
        plugin_info.hidden = metadata.get('hidden', 'false').lower() == 'true'

        if canonical_name not in self._plugin_name_index:
            plugin_info.preferred_alias = True
            self._plugin_name_index[canonical_name] = []
        self._plugin_name_index[canonical_name].append(module_name)

        logger.info(f"Adding plugin to registry: {module_name}")
        self._plugins[module_name] = plugin_info
        return True

    def _plugin_source_dir(self, source_url):
        base_name = source_url.rstrip('/').split('/')[-1] or 'plugin-source'
        if base_name.endswith('.git'):
            base_name = base_name[:-4]
        base_name = re.sub(r'[^a-zA-Z0-9._-]+', '-', base_name).strip('-') or 'plugin-source'
        digest = hashlib.sha1(source_url.encode('utf-8')).hexdigest()[:8]
        return os.path.join(self._get_plugin_sources_root(), f"{base_name}-{digest}")

    def syncPluginSources(self, sources, progress_callback=None):
        """Clone or update plugin source repositories."""
        if not sources:
            return

        git_path = shutil.which("git")
        if not git_path:
            logger.error("Git not available; cannot sync plugin sources.")
            return

        os.makedirs(self._get_plugin_sources_root(), exist_ok=True)
        total = len(sources)
        for index, source in enumerate(sources, start=1):
            source = source.strip()
            if not source:
                continue
            dest_dir = self._plugin_source_dir(source)
            if os.path.isdir(dest_dir):
                try:
                    subprocess.run(
                        [git_path, "-C", dest_dir, "pull", "--ff-only"],
                        check=True,
                        stdout=subprocess.PIPE,
                        stderr=subprocess.PIPE,
                    )
                    logger.info(f"Updated plugin source: {source}")
                    if progress_callback:
                        progress_callback(index, total, f"Updated {source}")
                except subprocess.CalledProcessError as exc:
                    logger.error(f"Failed to update plugin source {source}: {exc.stderr.decode('utf-8', 'ignore')}")
                    if progress_callback:
                        progress_callback(index, total, f"Failed to update {source}")
            else:
                try:
                    subprocess.run(
                        [git_path, "clone", source, dest_dir],
                        check=True,
                        stdout=subprocess.PIPE,
                        stderr=subprocess.PIPE,
                    )
                    logger.info(f"Installed plugin source: {source}")
                    if progress_callback:
                        progress_callback(index, total, f"Installed {source}")
                except subprocess.CalledProcessError as exc:
                    logger.error(f"Failed to clone plugin source {source}: {exc.stderr.decode('utf-8', 'ignore')}")
                    if progress_callback:
                        progress_callback(index, total, f"Failed to clone {source}")

    def removePluginSources(self, sources):
        """Remove plugin source repositories."""
        if not sources:
            return

        sources_root = self._get_plugin_sources_root()
        for source in sources:
            source = source.strip()
            if not source:
                continue
            dest_dir = self._plugin_source_dir(source)
            if not os.path.isdir(dest_dir):
                continue
            if os.path.commonpath([dest_dir, sources_root]) != sources_root:
                logger.warning(f"Skipping removal outside plugin sources root: {dest_dir}")
                continue
            try:
                shutil.rmtree(dest_dir)
                logger.info(f"Removed plugin source: {source}")
            except Exception as exc:
                logger.error(f"Failed to remove plugin source {source}: {exc}")

    def _load_plugin_metadata(self, metadata_file):
        """Load plugin metadata from a file."""
        metadata = {}

        if os.path.isfile(metadata_file):
            try:
                with open(metadata_file, 'r', encoding='utf-8') as f:
                    contents = f.read()

                has_section_header = False
                for line in contents.splitlines():
                    stripped = line.strip()
                    if not stripped or stripped.startswith('#') or stripped.startswith(';'):
                        continue
                    if stripped.startswith('[') and stripped.endswith(']'):
                        has_section_header = True
                        break

                if has_section_header:
                    parser = configparser.ConfigParser()
                    try:
                        parser.read_string(contents)
                        if parser.sections():
                            for section in parser.sections():
                                for key, value in parser[section].items():
                                    metadata[key.strip().lower()] = value.strip()
                            return metadata
                    except configparser.Error as e:
                        logger.warning(f"Plugin metadata INI parse failed for {metadata_file}: {e}")

                for line in contents.splitlines():
                    line = line.strip()
                    if not line or line.startswith('#') or line.startswith(';') or line.startswith('['):
                        continue

                    if '=' in line:
                        key, value = line.split('=', 1)
                        metadata[key.strip().lower()] = value.strip()
            except Exception as e:
                logger.error(f"Error loading plugin metadata: {e}")

        return metadata

    def getActivePlugins(self):
        """Get the list of active plugin names."""
        return self._active_plugins

    def get_active_plugins(self):
        """Get the list of active plugin instances."""
        active_instances = []
        for plugin_name in self._active_plugins:
            if plugin_name in self._plugins:
                plugin_info = self._plugins[plugin_name]
                if plugin_info.loaded and plugin_info.instance:
                    active_instances.append(plugin_info.instance)
        return active_instances

    def _resolve_plugin_info(self, plugin_name):
        if not plugin_name:
            return None

        if not self._plugins:
            self.rescanPlugins()

        plugin_name_lower = plugin_name.lower()
        candidates = []

        for info in self.plugins:
            module_name = info.get_module_name()
            canonical_name = info.get_canonical_name()
            if plugin_name == module_name:
                return info
            if plugin_name_lower == module_name.lower():
                candidates.append(info)
            elif plugin_name_lower == canonical_name.lower():
                candidates.append(info)

        if not candidates:
            return None

        for info in candidates:
            if info.builtin:
                return info
        for info in candidates:
            if info.preferred_alias:
                return info
        return candidates[0]

    def _get_plugin_dbus_module_name(self, plugin_info, plugin_instance):
        if not plugin_info or not plugin_instance:
            return ''
        if hasattr(plugin_instance, "get_dbus_module_name"):
            module_name = plugin_instance.get_dbus_module_name()
        else:
            module_name = f"Plugin_{plugin_info.get_module_name()}"
        return module_name or ''

    def _register_plugin_dbus_module(self, plugin_info):
        if not plugin_info or not plugin_info.instance:
            return

        module_name = self._get_plugin_dbus_module_name(plugin_info, plugin_info.instance)
        if not module_name:
            return

        try:
            controller = dbus_service.get_remote_controller()
            controller.register_decorated_module(module_name, plugin_info.instance)
            logger.info(f"Registered D-Bus module for plugin {plugin_info.get_module_name()}: {module_name}")
        except Exception as error:
            logger.error(f"Failed to register D-Bus module for plugin {plugin_info.get_module_name()}: {error}")

    def _deregister_plugin_dbus_module(self, plugin_info):
        if not plugin_info or not plugin_info.instance:
            return

        module_name = self._get_plugin_dbus_module_name(plugin_info, plugin_info.instance)
        if not module_name:
            return

        try:
            controller = dbus_service.get_remote_controller()
            controller.deregister_module_commands(module_name)
            logger.info(f"Deregistered D-Bus module for plugin {plugin_info.get_module_name()}: {module_name}")
        except Exception as error:
            logger.error(f"Failed to deregister D-Bus module for plugin {plugin_info.get_module_name()}: {error}")

    @dbus_service.command
    def list_plugins(self) -> list[str]:
        """Returns a list of available plugin module names."""
        if not self._plugins:
            self.rescanPlugins()
        return [info.get_module_name() for info in self.plugins]

    @dbus_service.command
    def list_active_plugins(self) -> list[str]:
        """Returns a list of currently active plugin module names."""
        return [info.get_module_name() for info in self.plugins if info.loaded]

    @dbus_service.parameterized_command
    def is_plugin_active(self, plugin_name: str) -> bool:
        """Returns True if the specified plugin is active."""
        plugin_info = self._resolve_plugin_info(plugin_name)
        if not plugin_info:
            return False
        return self.isPluginActive(plugin_info)

    @dbus_service.parameterized_command
    def set_plugin_active(self, plugin_name: str, active: bool) -> bool:
        """Enable or disable a plugin for this session only."""
        plugin_info = self._resolve_plugin_info(plugin_name)
        if not plugin_info:
            return False

        if plugin_info.builtin and not active:
            logger.warning(f"Plugin {plugin_info.get_module_name()} is builtin and cannot be disabled")
            return False

        self.setPluginActive(plugin_info, active)
        if active:
            return plugin_info.loaded
        return not plugin_info.loaded

    @dbus_service.command
    def rescan_plugins(self):
        """Rescans plugin directories and refreshes the plugin list."""
        self.rescanPlugins()
        return True

    def setActivePlugins(self, activePlugins):
        """Set active plugins and sync their state."""
        logger.info(f"PLUGIN SYSTEM: setActivePlugins called with: {activePlugins}")
        logger.info(f"Setting active plugins: {activePlugins}")

        original_active = list(activePlugins or [])
        normalized_requested = []
        for name in original_active:
            if not name:
                continue
            name_lower = name.lower()
            alias = LEGACY_PLUGIN_NAME_ALIASES.get(name_lower)
            if alias:
                logger.info(f"Mapping legacy plugin name {name} to {alias}")
                name = alias
            normalized_requested.append(name)
        activePlugins = normalized_requested
        if activePlugins != original_active:
            try:
                from . import settings as settings_module
                settings_module.activePlugins = list(activePlugins)
            except Exception:
                logger.debug("Unable to normalize settings.activePlugins for legacy plugin aliases.")

        # Make sure we have scanned for plugins first
        if not self._plugins:
            logger.info("No plugins found, rescanning...")
            self.rescanPlugins()

        plugin_by_id = {info.get_module_name(): info for info in self.plugins}
        plugin_by_id_lower = {name.lower(): info for name, info in plugin_by_id.items()}
        preferred_by_canonical = {
            info.get_canonical_name().lower(): info.get_module_name()
            for info in self.plugins if info.preferred_alias
        }
        canonical_with_builtin = {
            info.get_canonical_name().lower()
            for info in self.plugins if info.builtin
        }

        normalized_active = []
        seen_canonicals = set()
        unknown_active = []

        for name in activePlugins:
            if not name:
                continue
            name_lower = name.lower()
            plugin_info = plugin_by_id.get(name) or plugin_by_id_lower.get(name_lower)
            if plugin_info:
                canonical_lower = plugin_info.get_canonical_name().lower()
                if canonical_lower in canonical_with_builtin and not plugin_info.builtin:
                    logger.warning(
                        f"Skipping plugin {plugin_info.get_module_name()} because builtin plugin "
                        f"uses name {plugin_info.get_canonical_name()}"
                    )
                    continue
                if canonical_lower in seen_canonicals:
                    logger.warning(
                        f"Skipping plugin {plugin_info.get_module_name()} because another plugin "
                        f"with name {plugin_info.get_canonical_name()} is already active"
                    )
                    continue
                normalized_active.append(plugin_info.get_module_name())
                seen_canonicals.add(canonical_lower)
                continue

            preferred_id = preferred_by_canonical.get(name_lower)
            if preferred_id:
                preferred_info = plugin_by_id.get(preferred_id)
                canonical_lower = name_lower
                if preferred_info and canonical_lower in canonical_with_builtin and not preferred_info.builtin:
                    logger.warning(
                        f"Skipping plugin {preferred_id} because builtin plugin uses name {preferred_info.get_canonical_name()}"
                    )
                    continue
                if canonical_lower in seen_canonicals:
                    logger.warning(
                        f"Skipping plugin {preferred_id} because another plugin with name {name} is already active"
                    )
                    continue
                normalized_active.append(preferred_id)
                seen_canonicals.add(canonical_lower)
                continue

            unknown_active.append(name)

        self._active_plugins = normalized_active + unknown_active

        # Log active vs available plugins
        available_plugins = [p.get_module_name() for p in self.plugins]
        available_aliases = [p.get_canonical_name() for p in self.plugins if p.preferred_alias]
        available_plugins_lower = {p.lower() for p in available_plugins}
        available_aliases_lower = {p.lower() for p in available_aliases}
        logger.info(f"Available plugins: {available_plugins}")
        logger.info(f"Active plugins: {self._active_plugins}")
        
        # Check specifically for DisplayVersion
        if 'DisplayVersion' in self._active_plugins:
            logger.info("DisplayVersion is in active plugins list!")
        else:
            logger.warning("DisplayVersion is NOT in active plugins list!")

        # Find missing plugins
        missing_plugins = [
            p for p in self._active_plugins
            if p not in available_plugins
            and p not in available_aliases
            and p.lower() not in available_plugins_lower
            and p.lower() not in available_aliases_lower
        ]
        if missing_plugins:
            logger.warning(f"Active plugins not found: {missing_plugins}")

        self.syncAllPluginsActive()
        logger.info("=== PluginSystemManager.setActivePlugins completed ===")

    def setPluginActive(self, pluginInfo, active):
        """Set the active state of a plugin."""
        if pluginInfo.builtin:
            active = True

        pluginName = pluginInfo.get_module_name()

        if active:
            if pluginName not in self.getActivePlugins():
                if self.loadPlugin(pluginInfo):
                    self._active_plugins.append(pluginName)
        else:
            if pluginName in self.getActivePlugins():
                if self.unloadPlugin(pluginInfo):
                    self._active_plugins.remove(pluginName)

    def isPluginActive(self, pluginInfo):
        """Check if a plugin is active."""
        module_name = pluginInfo.get_module_name()
        canonical_name = pluginInfo.get_canonical_name()

        # Builtin plugins are always active
        if pluginInfo.builtin:
            logger.debug(f"Plugin {module_name} is builtin, active by default")
            return True

        # If a plugin is already loaded, it's active
        if pluginInfo.loaded:
            logger.debug(f"Plugin {module_name} is already loaded, considered active")
            return True

        # Check case-insensitive match in active plugins list
        active_plugins = self.getActivePlugins()

        # Try exact match first
        if module_name in active_plugins:
            logger.debug(f"Plugin {module_name} found in active plugins list")
            return True

        if pluginInfo.preferred_alias and canonical_name in active_plugins:
            logger.debug(f"Plugin {module_name} matched canonical name {canonical_name}")
            return True

        # Try case-insensitive match
        module_name_lower = module_name.lower()
        canonical_lower = canonical_name.lower()
        is_active = any(plugin.lower() == module_name_lower for plugin in active_plugins)
        if not is_active and pluginInfo.preferred_alias:
            is_active = any(plugin.lower() == canonical_lower for plugin in active_plugins)

        if is_active:
            logger.debug(f"Plugin {module_name} found in active plugins list (case-insensitive match)")
        else:
            logger.debug(f"Plugin {module_name} not found in active plugins list")

        return is_active

    def syncAllPluginsActive(self):
        """Sync the active state of all plugins."""
        logger.info("Syncing active state of all plugins")

        # Log plugin status before syncing
        if PLUGIN_DEBUG:
            for pluginInfo in self.plugins:
                is_active = self.isPluginActive(pluginInfo)
                is_loaded = pluginInfo.loaded
                logger.debug(f"Plugin {pluginInfo.get_module_name()}: active={is_active}, loaded={is_loaded}")

        # First unload inactive plugins
        for pluginInfo in self.plugins:
            if not self.isPluginActive(pluginInfo) and pluginInfo.loaded:
                logger.info(f"Unloading inactive plugin: {pluginInfo.get_module_name()}")
                self.unloadPlugin(pluginInfo)

        # Then load active plugins
        for pluginInfo in self.plugins:
            if self.isPluginActive(pluginInfo) and not pluginInfo.loaded:
                logger.info(f"Loading active plugin: {pluginInfo.get_module_name()}")
                result = self.loadPlugin(pluginInfo)
                logger.info(f"Plugin {pluginInfo.get_module_name()} load result: {result}")

        # Log final plugin status
        active_plugins = [p.get_module_name() for p in self.plugins if p.loaded]
        logger.info(f"Active plugins after sync: {active_plugins}")
        inactive_plugins = [p.get_module_name() for p in self.plugins if not p.loaded]
        logger.info(f"Inactive plugins after sync: {inactive_plugins}")

    @staticmethod
    def _lifecycle_accepts_plugin_instance(method: Any) -> bool:
        """Return True if the bound lifecycle method expects a plugin argument."""

        try:
            parameters = list(inspect.signature(method).parameters.values())
        except (TypeError, ValueError):
            return True

        if any(parameter.kind == inspect.Parameter.VAR_POSITIONAL for parameter in parameters):
            return True

        positional_parameters = [
            parameter for parameter in parameters
            if parameter.kind in (
                inspect.Parameter.POSITIONAL_ONLY,
                inspect.Parameter.POSITIONAL_OR_KEYWORD,
            )
        ]
        return bool(positional_parameters)

    def _callPluginLifecycle(self, pluginInstance: Any, methodName: str) -> None:
        """Call a plugin lifecycle method without replaying it through pluggy."""

        method = getattr(pluginInstance, methodName)
        if self._lifecycle_accepts_plugin_instance(method):
            method(pluginInstance)
            return

        method()

    def loadPlugin(self, pluginInfo):
        """Load a plugin."""
        module_name = pluginInfo.get_module_name()
        logger.info(f"=== PluginSystemManager.loadPlugin starting for: {module_name} ===")

        try:
            # Already loaded?
            if pluginInfo.loaded:
                logger.info(f"Plugin {module_name} already loaded, skipping")
                return True

            # Try to find the plugin file
            module_name = pluginInfo.get_module_name()
            plugin_dir = pluginInfo.get_module_dir()

            # Check for plugin.py first (standard format)
            plugin_file = os.path.join(plugin_dir, "plugin.py")

            # Fall back to [PluginName].py if plugin.py doesn't exist
            if not os.path.exists(plugin_file):
                canonical_name = pluginInfo.get_canonical_name()
                alternative_plugin_file = os.path.join(plugin_dir, f"{canonical_name}.py")
                if os.path.exists(alternative_plugin_file):
                    plugin_file = alternative_plugin_file
                    logger.info(f"Using alternative plugin file: {alternative_plugin_file}")

            if not os.path.exists(plugin_file):
                logger.error(f"Plugin file not found: {plugin_file}")
                return False

            logger.info(f"Loading plugin from: {plugin_file}")
            spec = importlib.util.spec_from_file_location(module_name, plugin_file)
            if spec is None:
                logger.error(f"Failed to create spec for plugin: {module_name}")
                return False

            module = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(module)
            pluginInfo.module = module

            # Find Plugin class
            plugin_class = None
            for attr_name in dir(module):
                attr = getattr(module, attr_name)
                if (inspect.isclass(attr) and 
                    attr.__module__ == module.__name__ and
                    hasattr(attr, 'activate')):
                    plugin_class = attr
                    logger.info(f"Found plugin class: {attr.__name__} in {module_name}")
                    break

            if not plugin_class:
                logger.error(f"No plugin class found in {module_name}")
                return False

            # Create and initialize plugin instance
            logger.info(f"Creating instance of plugin class: {plugin_class.__name__}")
            plugin_instance = plugin_class()
            pluginInfo.instance = plugin_instance

            # Ensure plugins have a reference to the app
            plugin_instance.app = self.getApp()
            logger.info(f"Set app reference for plugin: {module_name}")

            if hasattr(plugin_instance, 'set_app'):
                plugin_instance.set_app(self.getApp())
                logger.info(f"Called set_app() for plugin: {module_name}")

            if hasattr(plugin_instance, 'set_plugin_info'):
                plugin_instance.set_plugin_info(pluginInfo)
                logger.info(f"Called set_plugin_info() for plugin: {module_name}")

            # Register with pluggy and activate
            if self.plugin_manager is None:
                logger.error(f"Plugin manager is None when loading {module_name}")
                return False

            logger.info(f"Registering plugin with pluggy: {module_name}")
            self.plugin_manager.register(plugin_instance)

            try:
                logger.info(f"Activating plugin: {module_name}")
                # Lifecycle is per-plugin. Broadcasting through pluggy replays
                # activate() on every previously-registered plugin.
                self._callPluginLifecycle(plugin_instance, "activate")
            except Exception as e:
                logger.error(f"Error activating plugin {module_name}: {e}")
                import traceback
                logger.error(traceback.format_exc())
                return False

            pluginInfo.loaded = True
            logger.info(f"Successfully loaded plugin: {module_name}")

            self._register_plugin_dbus_module(pluginInfo)

            # Activate plugin-specific keybindings
            self._activate_plugin_keybindings(pluginInfo)

            return True

        except Exception as e:
            logger.error(f"Failed to load plugin {module_name}: {e}")
            import traceback
            logger.error(traceback.format_exc())
            return False

    def unloadPlugin(self, pluginInfo):
        """Unload a plugin."""
        if pluginInfo.builtin:
            return False

        module_name = pluginInfo.get_module_name()

        try:
            # Not loaded?
            if not pluginInfo.loaded:
                return True

            # Deactivate plugin
            plugin_instance = pluginInfo.instance
            if plugin_instance:
                try:
                    # Mirror targeted activation and only deactivate the plugin
                    # instance being unloaded.
                    self._callPluginLifecycle(plugin_instance, "deactivate")
                except Exception as e:
                    logger.error(f"Error deactivating plugin {module_name}: {e}")

                self._deregister_plugin_dbus_module(pluginInfo)

                # Unregister from pluggy
                self.plugin_manager.unregister(plugin_instance)

            # Clean up
            pluginInfo.instance = None
            pluginInfo.loaded = False

            logger.info(f"Unloaded plugin: {module_name}")
            return True

        except Exception as e:
            logger.error(f"Failed to unload plugin {module_name}: {e}")
            return False

    def unloadAllPlugins(self, ForceAllPlugins=False):
        """Unload all plugins."""
        for pluginInfo in self.plugins:
            if ForceAllPlugins or pluginInfo.loaded:
                self.unloadPlugin(pluginInfo)
