#!/usr/bin/env python3
#
# Copyright (c) 2024 Stormux
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA  02110-1301 USA.
#
# Cthulhu project: https://git.stormux.org/storm/cthulhu

"""Sound Theme Manager for Cthulhu screen reader.

Handles discovery and playback of sound theme files from:
- System: /usr/share/cthulhu/sounds/{theme_name}/
- Local: ~/.local/share/cthulhu/sounds/{theme_name}/
- User themes: Same as local, user can add custom folders

Themes are simply folders containing sound files. The folder name is the theme name.
"""

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

import os

from gi.repository import GLib
from gi.repository import Atspi

from . import debug
from . import role_keys
from . import sound
from .sound_generator import Icon

# Sound event constants - add new events here for easy extensibility
SOUND_FOCUS_MODE = "focus_mode"
SOUND_BROWSE_MODE = "browse_mode"
SOUND_BUTTON = "button"
SOUND_START = "start"
SOUND_STOP = "stop"

ROLE_SOUND_ALIASES = {
    Atspi.Role.PUSH_BUTTON: ["button", "push_button"],
    Atspi.Role.TOGGLE_BUTTON: ["toggle_button", "button"],
    Atspi.Role.RADIO_BUTTON: ["radio_button", "radio"],
    Atspi.Role.CHECK_BOX: ["checkbox", "check_box"],
    Atspi.Role.COMBO_BOX: ["combobox", "combo_box"],
}

ROLE_STATE_SOUND_BASES = {
    Atspi.Role.CHECK_BOX: ["checkbox", "check_box"],
    Atspi.Role.CHECK_MENU_ITEM: ["checkbox", "check_box"],
    Atspi.Role.RADIO_BUTTON: ["radiobutton", "radio_button"],
    Atspi.Role.RADIO_MENU_ITEM: ["radiobutton", "radio_button"],
    Atspi.Role.TOGGLE_BUTTON: ["togglebutton", "toggle_button"],
    Atspi.Role.SWITCH: ["switch"],
}

STATE_SOUND_SUFFIXES = {
    "checked": ["checked", "on", "pressed", "selected"],
    "unchecked": ["unchecked", "off", "not_pressed", "unselected"],
    "mixed": ["mixed", "partially_checked", "indeterminate"],
}

# Special theme name for no sounds
THEME_NONE = "none"


class SoundThemeManager:
    """Manages sound themes for Cthulhu."""

    def __init__(self, app):
        self.app = app
        self._systemSoundsDir = None
        self._userSoundsDir = None

    def _deriveDatadirFromModulePath(self):
        """Derive the datadir from where this module is actually installed.

        If module is at /usr/local/lib/python3.x/site-packages/cthulhu/,
        then datadir should be /usr/local/share.
        """
        try:
            modulePath = os.path.dirname(os.path.abspath(__file__))
            # Walk up looking for lib/python* pattern
            currentPath = modulePath
            for _ in range(10):  # Limit iterations to avoid infinite loop
                parent = os.path.dirname(currentPath)
                if parent == currentPath:
                    break
                dirName = os.path.basename(currentPath)
                # Check if we're in a lib directory
                if dirName == 'lib' or dirName.startswith('lib64'):
                    # Parent of lib is the prefix
                    prefix = parent
                    datadir = os.path.join(prefix, 'share')
                    if os.path.isdir(datadir):
                        return datadir
                currentPath = parent
        except Exception:
            pass
        return None

    def getSystemSoundsDir(self):
        """Get system sounds directory from platform settings.

        First tries cthulhu_platform.datadir, then derives from module location.
        """
        if self._systemSoundsDir is None:
            datadir = None

            # First try the configured datadir from cthulhu_platform
            try:
                from . import cthulhu_platform
                datadir = getattr(cthulhu_platform, 'datadir', None)
            except ImportError:
                pass

            # If datadir points to user home, it's likely wrong - try to derive it
            if datadir and (datadir.startswith(os.path.expanduser('~')) or
                            '/home/' in datadir):
                derivedDatadir = self._deriveDatadirFromModulePath()
                if derivedDatadir:
                    datadir = derivedDatadir

            # Fallback to /usr/share if nothing else works
            if not datadir:
                datadir = '/usr/share'

            self._systemSoundsDir = os.path.join(datadir, 'cthulhu', 'sounds')
        return self._systemSoundsDir

    def getUserSoundsDir(self):
        """Get user sounds directory (XDG_DATA_HOME/cthulhu/sounds)."""
        if self._userSoundsDir is None:
            self._userSoundsDir = os.path.join(
                GLib.get_user_data_dir(), 'cthulhu', 'sounds'
            )
        return self._userSoundsDir

    def getAvailableThemes(self):
        """Discover all available sound themes.

        Returns list of theme names (folder names) from both system and user dirs.
        User themes with same name as system themes will override system themes.
        Also checks standard fallback paths.
        """
        themes = set()

        # Check configured paths plus standard fallback locations
        # /usr/share is standard, /usr/local/share is non-standard
        searchDirs = [
            self.getUserSoundsDir(),
            self.getSystemSoundsDir(),
            '/usr/share/cthulhu/sounds',
            '/usr/local/share/cthulhu/sounds',
        ]

        for baseDir in searchDirs:
            if os.path.isdir(baseDir):
                try:
                    for entry in os.listdir(baseDir):
                        entryPath = os.path.join(baseDir, entry)
                        if os.path.isdir(entryPath):
                            themes.add(entry)
                except OSError as e:
                    tokens = ["SOUND THEME: Error listing directory:", baseDir, str(e)]
                    debug.printTokens(debug.LEVEL_INFO, tokens, True)

        return sorted(list(themes))

    def getThemePath(self, themeName):
        """Get the path to a theme directory.

        User themes take precedence over system themes.
        Checks multiple standard locations as fallbacks.
        """
        # Check user directory first
        userPath = os.path.join(self.getUserSoundsDir(), themeName)
        if os.path.isdir(userPath):
            return userPath

        # Check the configured system directory
        systemPath = os.path.join(self.getSystemSoundsDir(), themeName)
        if os.path.isdir(systemPath):
            return systemPath

        # Fallback: check standard system paths in case cthulhu_platform.datadir
        # doesn't match where sounds are actually installed
        # Check /usr/share first (standard), then /usr/local/share (non-standard)
        fallbackPaths = [
            '/usr/share/cthulhu/sounds',
            '/usr/local/share/cthulhu/sounds',
        ]
        for fallbackDir in fallbackPaths:
            fallbackPath = os.path.join(fallbackDir, themeName)
            if os.path.isdir(fallbackPath):
                return fallbackPath

        return None

    def getSoundPath(self, themeName, soundName):
        """Get path to a specific sound file.

        Checks for common audio file extensions: .wav, .ogg, .mp3, .flac.
        User themes are checked first, then system themes as fallback so
        partial user themes can inherit missing sounds.
        """
        themePaths = []

        userPath = os.path.join(self.getUserSoundsDir(), themeName)
        if os.path.isdir(userPath):
            themePaths.append(userPath)

        systemPath = os.path.join(self.getSystemSoundsDir(), themeName)
        if os.path.isdir(systemPath):
            themePaths.append(systemPath)

        fallbackDirs = [
            '/usr/share/cthulhu/sounds',
            '/usr/local/share/cthulhu/sounds',
        ]
        for fallbackDir in fallbackDirs:
            fallbackPath = os.path.join(fallbackDir, themeName)
            if os.path.isdir(fallbackPath) and fallbackPath not in themePaths:
                themePaths.append(fallbackPath)

        if not themePaths:
            return None

        for themePath in themePaths:
            for ext in ['.wav', '.ogg', '.mp3', '.flac']:
                soundPath = os.path.join(themePath, soundName + ext)
                if os.path.isfile(soundPath):
                    return soundPath

        return None

    def _getRoleSoundCandidates(self, role):
        """Return candidate sound names for a given role."""
        role = self._normalizeRole(role)
        if role is None:
            return []
        candidates = ROLE_SOUND_ALIASES.get(role, [])
        if candidates:
            return candidates

        roleName = Atspi.role_get_name(role)
        if roleName:
            return [roleName.replace(' ', '_')]

        return []

    def _normalizeRole(self, role):
        if isinstance(role, str):
            if role == role_keys.ROLE_SWITCH:
                return Atspi.Role.SWITCH
            return None
        return role

    def _getRoleStateSoundCandidates(self, role, stateKey):
        """Return candidate sound names for a given role/state pair."""
        role = self._normalizeRole(role)
        if role is None:
            return []
        bases = ROLE_STATE_SOUND_BASES.get(role, [])
        if not bases:
            return []

        suffixes = STATE_SOUND_SUFFIXES.get(stateKey, [])
        if not suffixes:
            return []

        candidates = []
        for base in bases:
            for suffix in suffixes:
                candidates.append(f"{base}_{suffix}")
        return candidates

    def getRoleSoundIcon(self, role, themeName=None):
        """Return an Icon for the role sound from the current theme, if any."""
        themeName = themeName or self.app.getSettingsManager().getSetting('soundTheme') or 'default'
        if themeName == THEME_NONE:
            return None

        for candidate in self._getRoleSoundCandidates(role):
            soundPath = self.getSoundPath(themeName, candidate)
            if soundPath:
                return Icon(os.path.dirname(soundPath), os.path.basename(soundPath))

        return None

    def getLinkSoundIcon(self, visited=False, themeName=None):
        """Return an Icon for a plain or visited link sound from the current theme."""

        themeName = themeName or self.app.getSettingsManager().getSetting('soundTheme') or 'default'
        if themeName == THEME_NONE:
            return None

        candidates = ["link"]
        if visited:
            candidates.insert(0, "link_visited")

        for candidate in candidates:
            soundPath = self.getSoundPath(themeName, candidate)
            if soundPath:
                return Icon(os.path.dirname(soundPath), os.path.basename(soundPath))

        return None

    def getRoleStateSoundIcon(self, role, stateKey, themeName=None):
        """Return an Icon for the role/state sound from the current theme, if any."""
        themeName = themeName or self.app.getSettingsManager().getSetting('soundTheme') or 'default'
        if themeName == THEME_NONE:
            return None

        for candidate in self._getRoleStateSoundCandidates(role, stateKey):
            soundPath = self.getSoundPath(themeName, candidate)
            if soundPath:
                return Icon(os.path.dirname(soundPath), os.path.basename(soundPath))

        return None

    def _playThemeSound(self, soundName, interrupt=True, wait=False,
                        requireSoundSetting=False, timeoutSeconds=10):
        """Play a themed sound with optional gating and blocking.

        Args:
            soundName: The name of the sound file (without extension)
            interrupt: Whether to interrupt currently playing sounds
            wait: Whether to block until the sound finishes playing
            requireSoundSetting: Honor enableSound setting

        Returns:
            True if sound was played, False otherwise
        """
        if requireSoundSetting and not self.app.getSettingsManager().getSetting('enableSound'):
            return False

        themeName = self.app.getSettingsManager().getSetting('soundTheme')
        if not themeName:
            themeName = 'default'

        # "none" theme means no sounds
        if themeName == THEME_NONE:
            return False

        soundPath = self.getSoundPath(themeName, soundName)
        if not soundPath:
            tokens = ["SOUND THEME: Sound not found:", soundName, "in theme:", themeName]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)
            return False

        try:
            icon = Icon(os.path.dirname(soundPath), os.path.basename(soundPath))
            if icon.isValid():
                if wait:
                    success, reason = sound.playIconSafely(icon, timeoutSeconds=timeoutSeconds)
                    if not success:
                        failureReason = reason or f"Failed to play sound '{soundName}'"
                        msg = (
                            "SOUND THEME: Failed to play helper-isolated sound "
                            f"'{soundName}'. Continuing with sound enabled. Reason: {failureReason}"
                        )
                        debug.printMessage(debug.LEVEL_INFO, msg, True)
                    return success
                player = sound.getPlayer()
                player.play(icon, interrupt=interrupt)
                return True
        except Exception as e:
            tokens = ["SOUND THEME: Error playing sound:", str(e)]
            debug.printTokens(debug.LEVEL_INFO, tokens, True)

        return False

    def playSound(self, soundName, interrupt=True, wait=False, timeoutSeconds=10):
        """Play a sound from the current theme if enabled.

        Args:
            soundName: The name of the sound file (without extension)
            interrupt: Whether to interrupt currently playing sounds
            wait: Whether to block until the sound finishes playing

        Returns:
            True if sound was played, False otherwise
        """
        return self._playThemeSound(
            soundName,
            interrupt=interrupt,
            wait=wait,
            requireSoundSetting=True,
            timeoutSeconds=timeoutSeconds
        )

    def playFocusModeSound(self):
        """Play sound for entering focus mode."""
        return self.playSound(SOUND_FOCUS_MODE)

    def playBrowseModeSound(self):
        """Play sound for entering browse mode."""
        return self.playSound(SOUND_BROWSE_MODE)

    def playButtonSound(self):
        """Play sound for button focus (future use)."""
        return self.playSound(SOUND_BUTTON)

    def playStartSound(self, wait=False, timeoutSeconds=10):
        """Play sound for application startup."""
        return self._playThemeSound(
            SOUND_START,
            interrupt=True,
            wait=wait,
            requireSoundSetting=True,
            timeoutSeconds=timeoutSeconds
        )

    def playStopSound(self, wait=False, timeoutSeconds=10):
        """Play sound for application shutdown."""
        return self._playThemeSound(
            SOUND_STOP,
            interrupt=True,
            wait=wait,
            requireSoundSetting=True,
            timeoutSeconds=timeoutSeconds
        )

_manager = None
def getManager():
    """Returns the Sound Theme Manager"""

    global _manager
    if _manager is None:
        from . import cthulhu
        _manager = SoundThemeManager(cthulhu.cthulhuApp)
    return _manager
