# Orca
#
# Copyright 2025 Valve Corporation
# 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-locals
# pylint: disable=too-many-statements
# pylint: disable=too-many-return-statements
# pylint: disable=too-many-lines
# pylint: disable=too-many-branches

"""Integration tests for Orca's D-Bus service."""

from __future__ import annotations

import contextlib
import os
from typing import TYPE_CHECKING, Any

import pytest
from dasbus.error import DBusError
from gi.repository import GLib

from .dbus_fixtures import (
    command_signature,
    get_property,
    list_module_names,
    module_interface_xml,
    parameterized_command_names,
    property_names,
    property_signature,
    set_property,
    simple_command_names,
)

if TYPE_CHECKING:
    from dasbus.connection import SessionMessageBus

# Get configurable timeout values from environment variables
DEFAULT_MODULE_TIMEOUT = 15
FLAT_REVIEW_TIMEOUT = int(os.environ.get("ORCA_FLAT_REVIEW_TIMEOUT", str(DEFAULT_MODULE_TIMEOUT)))
STRUCTURAL_NAVIGATOR_TIMEOUT = int(
    os.environ.get("ORCA_STRUCTURAL_NAVIGATOR_TIMEOUT", str(DEFAULT_MODULE_TIMEOUT)),
)
SPEECH_MANAGER_TIMEOUT = int(os.environ.get("ORCA_SPEECH_MANAGER_TIMEOUT", "30"))
SPEECH_PRESENTER_TIMEOUT = int(os.environ.get("ORCA_SPEECH_PRESENTER_TIMEOUT", "30"))

MODULE_TIMEOUTS = {
    "FlatReviewPresenter": FLAT_REVIEW_TIMEOUT,
    "StructuralNavigator": STRUCTURAL_NAVIGATOR_TIMEOUT,
    "SpeechManager": SPEECH_MANAGER_TIMEOUT,
    "SpeechPresenter": SPEECH_PRESENTER_TIMEOUT,
}

# Modules that may not be present in all environments (e.g., X11 vs Wayland)
OPTIONAL_MODULES = {"MousePresenter"}

MODULE_CONFIG = {
    "ActionPresenter": {
        "commands": ["ShowActionsList"],
        "parameterized_commands": [],
        "getters": [],
        "setters": [],
        "ui_commands": ["ShowActionsList"],
        "toggle_commands": [],
        "skip": [],
    },
    "ClipboardPresenter": {
        "commands": ["PresentClipboardContents"],
        "parameterized_commands": [],
        "getters": [],
        "setters": [],
        "ui_commands": [],
        "toggle_commands": [],
        "skip": [],
    },
    "CommandManager": {
        "commands": ["ToggleKeyboardLayout"],
        "parameterized_commands": [],
        "getters": ["DesktopModifierKeys", "KeyboardLayoutIsDesktop", "LaptopModifierKeys"],
        "setters": ["DesktopModifierKeys", "KeyboardLayoutIsDesktop", "LaptopModifierKeys"],
        "ui_commands": [],
        "toggle_commands": ["ToggleKeyboardLayout"],
        "skip": [],
    },
    "DocumentPresenter": {
        "commands": [
            "EnableStickyBrowseMode",
            "EnableStickyFocusMode",
            "TogglePresentationMode",
        ],
        "parameterized_commands": [],
        "getters": [
            "AutoStickyFocusModeForWebApps",
            "BrowseModeIsSticky",
            "FindResultsMinimumLength",
            "FocusModeIsSticky",
            "InFocusMode",
            "NativeNavTriggersFocusMode",
            "OnlySpeakChangedLines",
            "PageSummaryOnLoad",
            "SayAllOnLoad",
            "SpeakFindResults",
        ],
        "setters": [
            "AutoStickyFocusModeForWebApps",
            "FindResultsMinimumLength",
            "NativeNavTriggersFocusMode",
            "OnlySpeakChangedLines",
            "PageSummaryOnLoad",
            "SayAllOnLoad",
            "SpeakFindResults",
        ],
        "ui_commands": [],
        "toggle_commands": ["TogglePresentationMode"],
        "skip": [],
    },
    "MousePresenter": {
        "commands": [
            "LeftClickItem",
            "RightClickItem",
            "RoutePointerToItem",
            "ToggleMouseReview",
        ],
        "parameterized_commands": [],
        "getters": ["IsEnabled", "PresentTooltips"],
        "setters": ["IsEnabled", "PresentTooltips"],
        "ui_commands": [],
        "toggle_commands": ["ToggleMouseReview"],
        "skip": [],
    },
    "FlatReviewPresenter": {
        "commands": [
            "AppendToClipboard",
            "CopyToClipboard",
            "GoAbove",
            "GoBelow",
            "GoBottomLeft",
            "GoEnd",
            "GoEndOfLine",
            "GoHome",
            "GoNextCharacter",
            "GoNextItem",
            "GoNextLine",
            "GoPreviousCharacter",
            "GoPreviousItem",
            "GoPreviousLine",
            "GoStartOfLine",
            "LeftClickOnObject",
            "MoveFocusToReview",
            "MoveReviewToFocus",
            "PhoneticItem",
            "PhoneticLine",
            "PresentCharacter",
            "PresentItem",
            "PresentLine",
            "PresentObject",
            "RightClickOnObject",
            "RoutePointerToObject",
            "SayAll",
            "ShowContents",
            "SpellCharacter",
            "SpellItem",
            "SpellLine",
            "ToggleFlatReviewMode",
            "ToggleRestrict",
            "UnicodeCurrentCharacter",
        ],
        "parameterized_commands": [],
        "getters": ["DisplaysUpdates", "FocusTracking", "IsRestricted", "SpeaksUpdates"],
        "setters": ["DisplaysUpdates", "FocusTracking", "IsRestricted", "SpeaksUpdates"],
        "ui_commands": [
            "ShowContents",
            "LeftClickOnObject",
            "RightClickOnObject",
            "RoutePointerToObject",
        ],
        "toggle_commands": ["ToggleFlatReviewMode", "ToggleRestrict"],
        "skip": [],
    },
    "ObjectNavigator": {
        "commands": [
            "MoveToFirstChild",
            "MoveToNextSibling",
            "MoveToParent",
            "MoveToPreviousSibling",
            "PerformAction",
            "ToggleSimplify",
        ],
        "parameterized_commands": [],
        "getters": [],
        "setters": [],
        "ui_commands": ["PerformAction"],
        "toggle_commands": ["ToggleSimplify"],
        "skip": [],
    },
    "ProfileManager": {
        "commands": [
            "CycleSettingsProfile",
            "PresentCurrentProfile",
        ],
        "parameterized_commands": [],
        "getters": [
            "ActiveProfile",
            "AvailableProfiles",
            "StartingProfile",
        ],
        "setters": [
            "ActiveProfile",
            "StartingProfile",
        ],
        "ui_commands": [],
        "toggle_commands": [],
        "skip": [],
    },
    "MathPresenter": {
        "commands": [],
        "parameterized_commands": [],
        "getters": [
            "AutoZoomOut",
            "BrailleCode",
            "BrailleNavHighlight",
            "CopyFormat",
            "Language",
            "NavMode",
            "SpeechStyle",
            "Verbosity",
        ],
        "setters": [
            "AutoZoomOut",
            "BrailleCode",
            "BrailleNavHighlight",
            "CopyFormat",
            "Language",
            "NavMode",
            "SpeechStyle",
            "Verbosity",
        ],
        "ui_commands": [],
        "toggle_commands": [],
        "skip": [],
    },
    "NotificationPresenter": {
        "commands": [
            "PresentLastNotification",
            "PresentNextNotification",
            "PresentPreviousNotification",
            "ShowNotificationList",
        ],
        "parameterized_commands": [],
        "getters": [],
        "setters": [],
        "ui_commands": ["ShowNotificationList"],
        "toggle_commands": [],
        "skip": [],
    },
    "SleepModeManager": {
        "commands": ["ToggleSleepMode"],
        "parameterized_commands": [],
        "getters": ["SleepModeApps"],
        "setters": ["SleepModeApps"],
        "ui_commands": [],
        "toggle_commands": ["ToggleSleepMode"],
        "skip": [],
    },
    "SoundPresenter": {
        "commands": [],
        "parameterized_commands": [],
        "getters": [
            "BeepProgressBarUpdates",
            "ProgressBarBeepInterval",
            "ProgressBarBeepVerbosity",
            "SoundIsEnabled",
            "SoundVolume",
        ],
        "setters": [
            "BeepProgressBarUpdates",
            "ProgressBarBeepInterval",
            "ProgressBarBeepVerbosity",
            "SoundIsEnabled",
            "SoundVolume",
        ],
        "ui_commands": [],
        "toggle_commands": [],
        "skip": [],
    },
    "SpeechManager": {
        "commands": [
            "CycleCapitalizationStyle",
            "CyclePunctuationLevel",
            "CycleSynthesizer",
            "CycleVoiceSet",
            "DecreasePitch",
            "DecreasePitchRange",
            "DecreaseRate",
            "DecreaseVolume",
            "IncreasePitch",
            "IncreasePitchRange",
            "IncreaseRate",
            "IncreaseVolume",
            "InterruptSpeech",
            "RefreshSpeech",
            "ShutdownSpeech",
            "StartSpeech",
            "ToggleSpeech",
        ],
        "parameterized_commands": ["ActivateVoiceSet", "GetVoicesForLanguage"],
        "getters": [
            "ActiveVoiceSet",
            "AutoLanguageSwitching",
            "AutoLanguageSwitchingUi",
            "OnlySwitchConfiguredLanguages",
            "AvailableServers",
            "AvailableSynthesizers",
            "AvailableVoiceSets",
            "AvailableVoices",
            "CapitalizationStyle",
            "CurrentServer",
            "CurrentSynthesizer",
            "CurrentVoice",
            "InsertPausesBetweenUtterances",
            "Pitch",
            "PitchRange",
            "PunctuationLevel",
            "Rate",
            "SpeakNumbersAsDigits",
            "SpeechIsEnabled",
            "SpeechIsMuted",
            "UseColorNames",
            "UsePronunciationDictionary",
            "Volume",
        ],
        "setters": [
            "ActiveVoiceSet",
            "AutoLanguageSwitching",
            "AutoLanguageSwitchingUi",
            "OnlySwitchConfiguredLanguages",
            "CapitalizationStyle",
            "CurrentServer",
            "CurrentSynthesizer",
            "CurrentVoice",
            "InsertPausesBetweenUtterances",
            "Pitch",
            "PitchRange",
            "PunctuationLevel",
            "Rate",
            "SpeakNumbersAsDigits",
            "SpeechIsEnabled",
            "SpeechIsMuted",
            "UseColorNames",
            "UsePronunciationDictionary",
            "Volume",
        ],
        "ui_commands": [],
        "toggle_commands": ["ToggleSpeech"],
        "skip": [],
    },
    "SpeechPresenter": {
        "commands": [
            "ChangeNumberStyle",
            "CycleTextAttributeChangeMode",
            "ToggleIndentation",
            "ToggleMonitor",
            "ToggleTableCellReadingMode",
            "ToggleVerbosity",
        ],
        "parameterized_commands": [],
        "getters": [
            "AlwaysAnnounceSelectedRangeInSpreadsheet",
            "AnnounceArticle",
            "AnnounceBlockquote",
            "AnnounceCellCoordinates",
            "AnnounceCellHeaders",
            "AnnounceCellSpan",
            "AnnounceCodeBlock",
            "AnnounceDocument",
            "AnnounceForm",
            "AnnounceGrouping",
            "AnnounceLandmark",
            "AnnounceList",
            "AnnounceSpreadsheetCellCoordinates",
            "AnnounceTable",
            "AnnounceTrackedChanges",
            "MessagesAreDetailed",
            "OnlySpeakDisplayedText",
            "ProgressBarSpeechInterval",
            "ProgressBarSpeechVerbosity",
            "RepeatedCharacterLimit",
            "SpeakBlankLines",
            "SpeakDescription",
            "SpeakIndentation",
            "SpeakIndentationOnlyIfChanged",
            "SpeakMisspelledIndicator",
            "SpeakPositionInSet",
            "SpeakProgressBarUpdates",
            "SpeakRowInDocumentTable",
            "SpeakRowInGuiTable",
            "SpeakRowInSpreadsheet",
            "SpeakTextAttributeChanges",
            "SpeakTutorialMessages",
            "SpeakWidgetMnemonic",
            "MonitorIsEnabled",
            "MonitorFontSize",
            "MonitorForeground",
            "MonitorBackground",
            "VerbosityLevel",
        ],
        "setters": [
            "AlwaysAnnounceSelectedRangeInSpreadsheet",
            "AnnounceArticle",
            "AnnounceBlockquote",
            "AnnounceCellCoordinates",
            "AnnounceCellHeaders",
            "AnnounceCellSpan",
            "AnnounceCodeBlock",
            "AnnounceDocument",
            "AnnounceForm",
            "AnnounceGrouping",
            "AnnounceLandmark",
            "AnnounceList",
            "AnnounceSpreadsheetCellCoordinates",
            "AnnounceTable",
            "AnnounceTrackedChanges",
            "MessagesAreDetailed",
            "OnlySpeakDisplayedText",
            "ProgressBarSpeechInterval",
            "ProgressBarSpeechVerbosity",
            "RepeatedCharacterLimit",
            "SpeakBlankLines",
            "SpeakDescription",
            "SpeakIndentation",
            "SpeakIndentationOnlyIfChanged",
            "SpeakMisspelledIndicator",
            "SpeakPositionInSet",
            "SpeakProgressBarUpdates",
            "SpeakRowInDocumentTable",
            "SpeakRowInGuiTable",
            "SpeakRowInSpreadsheet",
            "SpeakTextAttributeChanges",
            "SpeakTutorialMessages",
            "SpeakWidgetMnemonic",
            "MonitorIsEnabled",
            "MonitorFontSize",
            "MonitorForeground",
            "MonitorBackground",
            "VerbosityLevel",
        ],
        "ui_commands": [],
        "toggle_commands": [
            "ToggleIndentation",
            "ToggleTableCellReadingMode",
            "ToggleVerbosity",
        ],
        "skip": [],
    },
    "SpellCheckPresenter": {
        "commands": [],
        "parameterized_commands": [],
        "getters": ["PresentContext", "SpellError", "SpellSuggestion"],
        "setters": ["PresentContext", "SpellError", "SpellSuggestion"],
        "ui_commands": [],
        "toggle_commands": [],
        "skip": [],
    },
    "StructuralNavigator": {
        "commands": [
            "ContainerEnd",
            "ContainerStart",
            "CycleMode",
            "ListAnnotations",
            "ListBlockquotes",
            "ListButtons",
            "ListCheckboxes",
            "ListClickables",
            "ListComboboxes",
            "ListEntries",
            "ListFormFields",
            "ListHeadings",
            "ListHeadingsLevel1",
            "ListHeadingsLevel2",
            "ListHeadingsLevel3",
            "ListHeadingsLevel4",
            "ListHeadingsLevel5",
            "ListHeadingsLevel6",
            "ListIframes",
            "ListImages",
            "ListLandmarks",
            "ListLargeObjects",
            "ListLinks",
            "ListListItems",
            "ListLists",
            "ListMath",
            "ListParagraphs",
            "ListRadioButtons",
            "ListTables",
            "ListUnvisitedLinks",
            "ListVisitedLinks",
            "NextAnnotation",
            "NextBlockquote",
            "NextButton",
            "NextCheckbox",
            "NextClickable",
            "NextCombobox",
            "NextEntry",
            "NextFormField",
            "NextHeading",
            "NextHeadingLevel1",
            "NextHeadingLevel2",
            "NextHeadingLevel3",
            "NextHeadingLevel4",
            "NextHeadingLevel5",
            "NextHeadingLevel6",
            "NextIframe",
            "NextImage",
            "NextLandmark",
            "NextLargeObject",
            "NextLink",
            "NextList",
            "NextListItem",
            "NextLiveRegion",
            "NextMath",
            "NextParagraph",
            "NextRadioButton",
            "NextSeparator",
            "NextTable",
            "NextUnvisitedLink",
            "NextVisitedLink",
            "PreviousAnnotation",
            "PreviousBlockquote",
            "PreviousButton",
            "PreviousCheckbox",
            "PreviousClickable",
            "PreviousCombobox",
            "PreviousEntry",
            "PreviousFormField",
            "PreviousHeading",
            "PreviousHeadingLevel1",
            "PreviousHeadingLevel2",
            "PreviousHeadingLevel3",
            "PreviousHeadingLevel4",
            "PreviousHeadingLevel5",
            "PreviousHeadingLevel6",
            "PreviousIframe",
            "PreviousImage",
            "PreviousLandmark",
            "PreviousLargeObject",
            "PreviousLink",
            "PreviousList",
            "PreviousListItem",
            "PreviousLiveRegion",
            "PreviousMath",
            "PreviousParagraph",
            "PreviousRadioButton",
            "PreviousSeparator",
            "PreviousTable",
            "PreviousUnvisitedLink",
            "PreviousVisitedLink",
        ],
        "parameterized_commands": [],
        "getters": [
            "IsEnabled",
            "LargeObjectTextLength",
            "NavigationWraps",
            "SkipUnlabeledImages",
            "TriggersFocusMode",
        ],
        "setters": [
            "IsEnabled",
            "LargeObjectTextLength",
            "NavigationWraps",
            "SkipUnlabeledImages",
            "TriggersFocusMode",
        ],
        "ui_commands": [
            "ListAnnotations",
            "ListBlockquotes",
            "ListButtons",
            "ListCheckboxes",
            "ListClickables",
            "ListComboboxes",
            "ListEntries",
            "ListFormFields",
            "ListHeadings",
            "ListHeadingsLevel1",
            "ListHeadingsLevel2",
            "ListHeadingsLevel3",
            "ListHeadingsLevel4",
            "ListHeadingsLevel5",
            "ListHeadingsLevel6",
            "ListIframes",
            "ListImages",
            "ListLandmarks",
            "ListLargeObjects",
            "ListLinks",
            "ListListItems",
            "ListLists",
            "ListMath",
            "ListParagraphs",
            "ListRadioButtons",
            "ListTables",
            "ListUnvisitedLinks",
            "ListVisitedLinks",
        ],
        "toggle_commands": [],
        "skip": ["CycleMode"],  # Test flakiness depending on app and focus.
    },
    "SystemInformationPresenter": {
        "commands": [
            "PresentBatteryStatus",
            "PresentCpuAndMemoryUsage",
            "PresentDate",
            "PresentModifierKeysState",
            "PresentTime",
        ],
        "parameterized_commands": [],
        "getters": [
            "DateFormat",
            "TimeFormat",
            "AvailableDateFormats",
            "AvailableTimeFormats",
        ],
        "setters": [
            "DateFormat",
            "TimeFormat",
        ],
        "ui_commands": [],
        "toggle_commands": [],
        "skip": ["PresentBatteryStatus"],  # Can timeout on systems without battery
    },
    "TextAttributeManager": {
        "commands": [],
        "parameterized_commands": [],
        "getters": ["AttributesToBraille", "AttributesToSpeak"],
        "setters": ["AttributesToBraille", "AttributesToSpeak"],
        "ui_commands": [],
        "toggle_commands": [],
        "skip": [],
    },
    "TableNavigator": {
        "commands": [
            "ClearDynamicColumnHeadersRow",
            "ClearDynamicRowHeadersColumn",
            "MoveDown",
            "MoveLeft",
            "MoveRight",
            "MoveToBeginningOfRow",
            "MoveToBottomOfColumn",
            "MoveToEndOfRow",
            "MoveToFirstCell",
            "MoveToLastCell",
            "MoveToTopOfColumn",
            "MoveUp",
            "SetDynamicColumnHeadersRow",
            "SetDynamicRowHeadersColumn",
            "ToggleEnabled",
        ],
        "parameterized_commands": [],
        "getters": ["IsEnabled", "SkipBlankCells"],
        "setters": ["IsEnabled", "SkipBlankCells"],
        "ui_commands": [],
        "toggle_commands": ["ToggleEnabled"],
        "skip": [],
    },
    "TypingEchoPresenter": {
        "commands": ["CycleKeyEcho"],
        "parameterized_commands": [],
        "getters": [
            "KeyEchoEnabled",
            "CharacterEchoEnabled",
            "WordEchoEnabled",
            "SentenceEchoEnabled",
            "AlphabeticKeysEnabled",
            "NumericKeysEnabled",
            "PunctuationKeysEnabled",
            "SpaceEnabled",
            "ModifierKeysEnabled",
            "FunctionKeysEnabled",
            "ActionKeysEnabled",
            "NavigationKeysEnabled",
            "DiacriticalKeysEnabled",
            "LockingKeysPresented",
        ],
        "setters": [
            "KeyEchoEnabled",
            "CharacterEchoEnabled",
            "WordEchoEnabled",
            "SentenceEchoEnabled",
            "AlphabeticKeysEnabled",
            "NumericKeysEnabled",
            "PunctuationKeysEnabled",
            "SpaceEnabled",
            "ModifierKeysEnabled",
            "FunctionKeysEnabled",
            "ActionKeysEnabled",
            "NavigationKeysEnabled",
            "DiacriticalKeysEnabled",
            "LockingKeysPresented",
        ],
        "ui_commands": [],
        "toggle_commands": [],
        "skip": [],
    },
    "MathNavigator": {
        "commands": ["CopyToClipboard", "EnterMathModeCommand", "ExitMathMode"],
        "parameterized_commands": ["ExecuteMathcatCommand"],
        "getters": ["IsActive", "SupportedCommands"],
        "setters": [],
        "ui_commands": [],
        "toggle_commands": [],
        "skip": [],
    },
    "CaretNavigator": {
        "commands": [
            "ToggleEnabled",
            "ToggleLayoutMode",
            "NextCharacter",
            "PreviousCharacter",
            "NextWord",
            "PreviousWord",
            "NextLine",
            "PreviousLine",
            "StartOfLine",
            "EndOfLine",
            "StartOfFile",
            "EndOfFile",
        ],
        "parameterized_commands": [],
        "getters": ["IsEnabled", "LayoutMode", "TriggersFocusMode"],
        "setters": ["IsEnabled", "LayoutMode", "TriggersFocusMode"],
        "ui_commands": [],
        "toggle_commands": ["ToggleEnabled", "ToggleLayoutMode"],
        "skip": [],
    },
    "ChatPresenter": {
        "commands": [
            "PresentNextMessage",
            "PresentPreviousMessage",
            "ToggleBuddyTyping",
            "ToggleMessageHistories",
            "TogglePrefix",
        ],
        "parameterized_commands": [],
        "getters": [
            "AnnounceBuddyTyping",
            "MessageVerbosity",
            "RoomHistories",
            "SpeakRoomName",
            "SpeakRoomNameLast",
        ],
        "setters": [
            "AnnounceBuddyTyping",
            "MessageVerbosity",
            "RoomHistories",
            "SpeakRoomName",
            "SpeakRoomNameLast",
        ],
        "ui_commands": [],
        "toggle_commands": [],
        "skip": [],
    },
    "SayAllPresenter": {
        "commands": ["SayAll", "Rewind", "FastForward"],
        "parameterized_commands": [],
        "getters": [
            "AnnounceArticle",
            "AnnounceBlockquote",
            "AnnounceCodeBlock",
            "AnnounceDocument",
            "AnnounceForm",
            "AnnounceGrouping",
            "AnnounceLandmark",
            "AnnounceList",
            "AnnounceTable",
            "AnnounceTrackedChanges",
            "OnlySpeakDisplayedText",
            "Style",
            "StructuralNavigationEnabled",
            "RewindAndFastForwardEnabled",
            "TextAttributeChangeModeAsString",
        ],
        "setters": [
            "AnnounceArticle",
            "AnnounceBlockquote",
            "AnnounceCodeBlock",
            "AnnounceDocument",
            "AnnounceForm",
            "AnnounceGrouping",
            "AnnounceLandmark",
            "AnnounceList",
            "AnnounceTable",
            "AnnounceTrackedChanges",
            "OnlySpeakDisplayedText",
            "Style",
            "StructuralNavigationEnabled",
            "RewindAndFastForwardEnabled",
            "TextAttributeChangeModeAsString",
        ],
        "ui_commands": [],
        "toggle_commands": [],
        "skip": [],
    },
    "WhereAmIPresenter": {
        "commands": [
            "PresentCellFormula",
            "PresentCharacterAttributes",
            "ShowCharacterAttributes",
            "PresentDefaultButton",
            "PresentLink",
            "PresentSelectedText",
            "PresentSelection",
            "PresentSizeAndPosition",
            "PresentStatusBar",
            "PresentTitle",
            "WhereAmIBasic",
            "WhereAmIDetailed",
        ],
        "parameterized_commands": [],
        "getters": [],
        "setters": [],
        "ui_commands": [],
        "toggle_commands": [],
        "skip": [],
    },
    "BraillePresenter": {
        "commands": ["ToggleMonitor"],
        "parameterized_commands": [],
        "getters": [
            "AvailableContractionTables",
            "BrailleIsEnabled",
            "MonitorIsEnabled",
            "MonitorCellCount",
            "MonitorShowDots",
            "MonitorForeground",
            "MonitorBackground",
            "BrailleProgressBarUpdates",
            "ComputerBrailleAtCursorIsEnabled",
            "ContractedBrailleIsEnabled",
            "ContractionTable",
            "DisplayAncestors",
            "EndOfLineIndicatorIsEnabled",
            "FlashMessageDuration",
            "FlashMessagesAreDetailed",
            "FlashMessagesAreEnabled",
            "FlashMessagesArePersistent",
            "LinkIndicator",
            "PresentMnemonics",
            "ProgressBarBrailleInterval",
            "ProgressBarBrailleVerbosity",
            "RolenameStyle",
            "SelectorIndicator",
            "TextAttributesIndicator",
            "VerbosityLevel",
            "WordWrapIsEnabled",
        ],
        "setters": [
            "BrailleIsEnabled",
            "MonitorIsEnabled",
            "MonitorCellCount",
            "MonitorShowDots",
            "MonitorForeground",
            "MonitorBackground",
            "BrailleProgressBarUpdates",
            "ComputerBrailleAtCursorIsEnabled",
            "ContractedBrailleIsEnabled",
            "ContractionTable",
            "DisplayAncestors",
            "EndOfLineIndicatorIsEnabled",
            "FlashMessageDuration",
            "FlashMessagesAreDetailed",
            "FlashMessagesAreEnabled",
            "FlashMessagesArePersistent",
            "LinkIndicator",
            "PresentMnemonics",
            "ProgressBarBrailleInterval",
            "ProgressBarBrailleVerbosity",
            "RolenameStyle",
            "SelectorIndicator",
            "TextAttributesIndicator",
            "VerbosityLevel",
            "WordWrapIsEnabled",
        ],
        "ui_commands": [],
        "toggle_commands": [],
        "skip": [],
    },
}

PARAMETERIZED_TEST_PARAMS = {
    "ActivateVoiceSet": {"set_id": "primary"},
    "GetVoicesForLanguage": {"language": "en", "variant": ""},
    "ExecuteMathcatCommand": {"mathcat_command": "ReadCurrent"},
}


def is_context_error(error_str: str) -> bool:
    """Check if error indicates missing context (script/runtime).

    This function checks for common error patterns that indicate the Orca
    service is running but lacks the proper context (e.g., no active script
    or application context) to execute the requested operation.
    """

    context_error_patterns = [
        # NoneType attribute errors indicate missing objects/context
        ("NoneType" in error_str and "attribute" in error_str),
        # Script-related None errors indicate missing script context
        ("script" in error_str and "None" in error_str),
        # AT-SPI context missing
        ("context" in error_str.lower() and "none" in error_str.lower()),
        # Missing application object
        ("application" in error_str.lower() and "none" in error_str.lower()),
    ]

    return any(pattern for pattern in context_error_patterns)


def is_timeout_error(error_str: str) -> bool:
    """Check if error indicates a timeout condition."""

    timeout_patterns = [
        "timed out" in error_str.lower(),
        "timeout" in error_str.lower(),
        "deadline exceeded" in error_str.lower(),
        "connection timeout" in error_str.lower(),
    ]

    return any(pattern for pattern in timeout_patterns)


def get_alternative_value(
    bus: SessionMessageBus,
    prop_name: str,
    current_value: str | float | bool,
) -> str | float | bool:
    """Get an alternative value for testing setters, avoiding defaults when possible."""

    try:
        if prop_name == "CurrentServer":
            servers = get_property(bus, "SpeechManager", "AvailableServers")
            assert isinstance(current_value, str)
            non_default = [s for s in servers if s != current_value and "default" not in s.lower()]
            if non_default:
                return non_default[0]
            return next((s for s in servers if s != current_value), current_value)
        if prop_name == "CurrentSynthesizer":
            synths = get_property(bus, "SpeechManager", "AvailableSynthesizers")
            assert isinstance(current_value, str)
            non_default = [s for s in synths if s != current_value and "default" not in s.lower()]
            if non_default:
                return non_default[0]
            return next((s for s in synths if s != current_value), current_value)
        if prop_name == "CurrentVoice":
            voices = get_property(bus, "SpeechManager", "AvailableVoices")
            if voices and len(voices) > 1:
                assert isinstance(current_value, str)
                current_voice_name = current_value.split()[0] if current_value else ""
                non_default = [
                    v[0]
                    for v in voices
                    if v[0] != current_voice_name and "default" not in v[0].lower()
                ]
                if non_default:
                    return non_default[0]
                return next((v[0] for v in voices if v[0] != current_voice_name), current_value)
            return current_value
        return current_value
    except (DBusError, AttributeError, TypeError, ValueError):
        return current_value


def get_test_value(
    bus: SessionMessageBus,
    prop_name: str,
    current_value: str | float | bool,
) -> str | float | bool:
    """Generate an appropriate test value for a property."""

    if isinstance(current_value, bool):
        return not current_value
    if isinstance(current_value, (int, float)):
        return current_value + 1 if current_value < 100 else current_value - 1
    if isinstance(current_value, str):
        return get_alternative_value(bus, prop_name, current_value)
    if isinstance(current_value, list):
        return list(reversed(current_value)) if len(current_value) > 1 else current_value
    return current_value


def to_variant(value: str | bool | float | list, signature: str | None = None) -> Any:
    """Convert a Python value to GLib.Variant. Uses signature if given, else infers from type."""

    if signature is not None:
        return GLib.Variant(signature, value)
    if isinstance(value, bool):
        return GLib.Variant("b", value)
    if isinstance(value, str):
        return GLib.Variant("s", value)
    if isinstance(value, int):
        return GLib.Variant("i", value)
    if isinstance(value, float):
        return GLib.Variant("d", value)
    if isinstance(value, list):
        return GLib.Variant("as", value)
    return GLib.Variant("s", str(value))


def _module_is_testing_only(bus, module_name: str) -> bool:
    """Returns True if module_name exposes only gated *ForTesting members."""

    iface = module_interface_xml(bus, module_name)
    if iface is None:
        return False
    methods = [name for m in iface.findall("method") if (name := m.get("name"))]
    return (
        bool(methods)
        and not iface.findall("property")
        and all(name.endswith("ForTesting") for name in methods)
    )


@pytest.mark.dbus
class TestOrcaDBusIntegration:
    """Integration tests for Orca D-Bus service using pytest features."""

    @pytest.mark.dbus
    def test_orca_dbus_service_running(self, dbus_service_proxy):
        """Test that Orca D-Bus service is running and accessible."""

        version = dbus_service_proxy.GetVersion()
        assert version is not None
        assert len(str(version)) > 0

    @pytest.mark.dbus
    def test_list_modules(self, bus):
        """Test listing available modules via introspection of the service root."""

        modules = list_module_names(bus)
        assert isinstance(modules, list)

        expected_modules = set(MODULE_CONFIG.keys()) - OPTIONAL_MODULES
        actual_modules = set(modules)
        missing_modules = expected_modules - actual_modules

        assert not missing_modules, f"Missing expected modules: {missing_modules}"

    @pytest.mark.dbus
    def test_present_message(self, dbus_service_proxy):
        """Test the PresentMessage service command."""

        dbus_service_proxy.PresentMessage("Integration test message")

        # TODO - JD: What we really want to do is verify that the message was presented.
        # That will require we have a way to capture the speech and braille. Until then,
        # verify service is still responsive after presentation.
        version = dbus_service_proxy.GetVersion()
        assert version is not None

    @pytest.mark.dbus
    def test_error_handling(self, dbus_service_proxy):
        """Test error handling for invalid D-Bus calls."""

        with pytest.raises((DBusError, AttributeError, TypeError)):
            dbus_service_proxy.NonExistentMethod()

    @pytest.mark.parametrize(
        "module_name,config",
        MODULE_CONFIG.items(),
        ids=list(MODULE_CONFIG.keys()),
    )
    @pytest.mark.dbus
    def test_module_capabilities(self, bus, run_with_timeout, module_name, config):
        """Test that each module's introspection XML matches the expected capabilities."""

        print(f"\n  Testing {module_name} capabilities:")
        for cap_type in ["commands", "parameterized_commands", "getters", "setters"]:
            items = config.get(cap_type, [])
            if items:
                print(f"    • {cap_type}: {len(items)} items")
                for item in sorted(items)[:5]:  # Show first 5 items
                    print(f"      - {item}")
                if len(items) > 5:
                    print(f"      - ... and {len(items) - 5} more")

        def get_capabilities():
            iface = module_interface_xml(bus, module_name)
            return {
                "commands": simple_command_names(iface),
                "parameterized_commands": parameterized_command_names(iface),
                "getters": property_names(iface, "read", "readwrite"),
                "setters": property_names(iface, "write", "readwrite"),
            }

        timeout = MODULE_TIMEOUTS.get(module_name)
        result = run_with_timeout(get_capabilities, timeout)
        assert result["success"], f"Failed to get {module_name} capabilities: {result['error']}"

        for cap_type in ["commands", "parameterized_commands", "getters", "setters"]:
            expected = set(config.get(cap_type, []))
            actual = set(result["result"].get(cap_type, []))
            # Test-only commands (gated by ORCA_TEST_RPC_SECRET) are not part of the API surface.
            actual = {name for name in actual if not name.endswith("ForTesting")}
            missing = expected - actual
            unexpected = actual - expected

            assert not missing, f"{module_name} missing {cap_type}: {sorted(missing)}"
            assert not unexpected, f"{module_name} unexpected {cap_type}: {sorted(unexpected)}"

    @pytest.mark.parametrize(
        "module_name,config",
        [(name, config) for name, config in MODULE_CONFIG.items() if config.get("commands")],
        ids=[name for name, config in MODULE_CONFIG.items() if config.get("commands")],
    )
    @pytest.mark.dbus
    def test_module_commands(self, module_proxy_factory, run_with_timeout, module_name, config):
        """Test that module commands execute without errors."""

        commands = config["commands"]
        ui_commands = config.get("ui_commands", [])
        toggle_commands = config.get("toggle_commands", [])
        skip_commands = config.get("skip", [])
        print(f"\n  Testing {module_name} commands ({len(commands)} total):")
        for cmd in sorted(commands):
            status_parts = []
            if cmd in ui_commands:
                status_parts.append("UI - skipped")
            if cmd in skip_commands:
                status_parts.append("skipped")
            if cmd in toggle_commands:
                status_parts.append("toggle - restore state")
            status = f"({', '.join(status_parts)})" if status_parts else ""
            print(f"    • {cmd} {status}")

        def test_single_command(proxy, cmd_name, ui_commands, toggle_commands, skip_commands):
            if cmd_name in ui_commands or cmd_name in skip_commands:
                return {"success": True, "skipped": True}
            try:
                getattr(proxy, cmd_name)(False)
                if cmd_name in toggle_commands:
                    print(f"      → Restoring {cmd_name} to original state")
                    getattr(proxy, cmd_name)(False)
                return {"success": True}
            except (DBusError, AttributeError, TypeError, ValueError) as error:
                error_str = str(error)
                if is_context_error(error_str) or is_timeout_error(error_str):
                    return {"success": True, "context_required": True}
                return {"success": False, "error": error_str}

        def test_commands():
            proxy = module_proxy_factory(module_name)
            ui_commands = config.get("ui_commands", [])
            toggle_commands = config.get("toggle_commands", [])
            skip_commands = config.get("skip", [])
            return {
                cmd: test_single_command(proxy, cmd, ui_commands, toggle_commands, skip_commands)
                for cmd in config["commands"]
            }

        timeout = MODULE_TIMEOUTS.get(module_name)
        result = run_with_timeout(test_commands, timeout)
        assert result["success"], f"Failed to test {module_name} commands: {result['error']}"

        failed = [
            f"{cmd}: {res['error']}"
            for cmd, res in result["result"].items()
            if not res["success"] and not res.get("skipped")
        ]
        assert not failed, f"{module_name} command failures: {failed}"

    @pytest.mark.parametrize(
        "module_name,config",
        [
            (name, config)
            for name, config in MODULE_CONFIG.items()
            if config.get("parameterized_commands")
        ],
        ids=[
            name for name, config in MODULE_CONFIG.items() if config.get("parameterized_commands")
        ],
    )
    @pytest.mark.dbus
    def test_module_parameterized_commands(
        self,
        bus,
        module_proxy_factory,
        run_with_timeout,
        module_name,
        config,
    ):
        """Test that module parameterized commands execute with proper parameters."""
        param_commands = config["parameterized_commands"]
        print(f"\n  Testing {module_name} parameterized commands ({len(param_commands)} total):")
        for cmd in sorted(param_commands):
            params = PARAMETERIZED_TEST_PARAMS.get(cmd, {})
            param_str = ", ".join(f"{k}={v}" for k, v in params.items())
            print(f"    • {cmd}({param_str})")

        iface = module_interface_xml(bus, module_name)

        def test_single_param_command(proxy, cmd_name):
            params = PARAMETERIZED_TEST_PARAMS.get(cmd_name, {})
            if not params:
                return {"success": False, "error": "No test parameters"}

            try:
                # Order positional args by the introspected parameter order so we call
                # the native method with the right argument positions.
                ordered_args = [
                    params.get(arg_name, False if arg_name == "notify_user" else None)
                    for arg_name, _ in command_signature(iface, cmd_name)
                ]
                result = getattr(proxy, cmd_name)(*ordered_args)
                return {"success": True, "result": result}
            except (DBusError, AttributeError, TypeError, ValueError) as error:
                if is_context_error(str(error)):
                    return {"success": True, "context_required": True}
                return {"success": False, "error": str(error)}

        def test_parameterized_commands():
            if GLib is None:
                pytest.skip("GLib not available")
            proxy = module_proxy_factory(module_name)
            return {
                cmd: test_single_param_command(proxy, cmd)
                for cmd in config["parameterized_commands"]
            }

        timeout = MODULE_TIMEOUTS.get(module_name)
        result = run_with_timeout(test_parameterized_commands, timeout)
        error_msg = f"Failed to test {module_name} parameterized commands: {result['error']}"
        assert result["success"], error_msg

        failed = [
            f"{cmd}: {res['error']}"
            for cmd, res in result["result"].items()
            if not res["success"] and not res.get("context_required")
        ]
        assert not failed, f"{module_name} parameterized command failures: {failed}"

    @pytest.mark.parametrize(
        "module_name,config",
        [
            (name, config)
            for name, config in MODULE_CONFIG.items()
            if config.get("getters") or config.get("setters")
        ],
        ids=[
            name
            for name, config in MODULE_CONFIG.items()
            if config.get("getters") or config.get("setters")
        ],
    )
    @pytest.mark.dbus
    def test_module_getters_setters(
        self,
        bus,
        run_with_timeout,
        module_name,
        config,
    ):
        """Test that module getter/setter pairs work correctly."""

        all_props = sorted(set(config.get("getters", []) + config.get("setters", [])))
        print(f"\n  Testing {module_name} properties ({len(all_props)} total):")
        for prop in all_props:
            has_getter = prop in config.get("getters", [])
            has_setter = prop in config.get("setters", [])
            status_parts = []
            if has_getter:
                status_parts.append("get")
            if has_setter:
                status_parts.append("set")
            status = f"({'/'.join(status_parts)})" if status_parts else ""
            print(f"    • {prop} {status}")

        iface_xml = module_interface_xml(bus, module_name)

        def test_single_property(prop_name, is_setter=False):
            try:
                current_value = get_property(bus, module_name, prop_name)
                if not is_setter:
                    return {"success": True, "value": current_value}
                test_value = get_test_value(bus, prop_name, current_value)
                sig = property_signature(iface_xml, prop_name)
                try:
                    set_property(bus, module_name, prop_name, to_variant(test_value, sig))
                except DBusError as error:
                    # A setter that rejects a value (validation) is acceptable, not a failure.
                    return {
                        "success": True,
                        "rejected": True,
                        "test_value": test_value,
                        "error": str(error),
                    }
                new_value = get_property(bus, module_name, prop_name)
                # Restoring the original is best-effort: a value that was readable isn't guaranteed
                # to be settable (e.g. a synthesizer the active server doesn't offer).
                with contextlib.suppress(DBusError):
                    set_property(bus, module_name, prop_name, to_variant(current_value, sig))
                return {
                    "success": True,
                    "original_value": current_value,
                    "test_value": test_value,
                    "actual_new_value": new_value,
                }
            except (DBusError, AttributeError, TypeError, ValueError) as error:
                return {"success": False, "error": str(error)}

        def test_getters_setters():
            results = {}
            getter_props = config.get("getters", [])
            setter_props = config.get("setters", [])

            for prop in getter_props:
                results[f"get_{prop}"] = test_single_property(prop, is_setter=False)

            # Setter round-trips need a getter for read-back. Write-only properties
            # (in setters but not getters) can't be round-tripped via D-Bus Properties.
            for prop in setter_props:
                if prop not in getter_props:
                    continue
                results[f"set_{prop}"] = test_single_property(prop, is_setter=True)

            return results

        timeout = MODULE_TIMEOUTS.get(module_name)
        result = run_with_timeout(test_getters_setters, timeout)
        assert result["success"], f"Failed to test {module_name} getters/setters: {result['error']}"

        if result["result"]:
            getter_results = {k: v for k, v in result["result"].items() if k.startswith("get_")}
            setter_results = {k: v for k, v in result["result"].items() if k.startswith("set_")}

            if getter_results:
                print("    Getter values:")
                for prop_key, res in getter_results.items():
                    prop = prop_key[4:]
                    if res["success"]:
                        print(f"      - {prop}: {res['value']}")
                    else:
                        print(f"      - {prop}: ERROR - {res['error']}")

            if setter_results:
                print("    Setter tests:")
                for prop_key, res in setter_results.items():
                    prop = prop_key[4:]
                    if res.get("rejected"):
                        print(
                            f"      - {prop}: rejected test value {res['test_value']!r} (validated)"
                        )
                    elif res["success"]:
                        original = res["original_value"]
                        test_val = res["test_value"]
                        actual = res["actual_new_value"]
                        print(f"      - {prop}: {original} → {test_val} (got: {actual})")
                    else:
                        print(f"      - {prop}: ERROR - {res['error']}")

        failed = [
            f"{prop}: {res['error']}"
            for prop, res in result["result"].items()
            if not res["success"]
        ]
        assert not failed, f"{module_name} getter/setter failures: {failed}"

    @pytest.mark.dbus
    def test_parameterized_command_signatures(self, bus, run_with_timeout):
        """Test that parameterized commands have correct parameter signatures."""

        def get_signatures():
            iface = module_interface_xml(bus, "SpeechManager")
            return {
                name: command_signature(iface, name) for name in parameterized_command_names(iface)
            }

        result = run_with_timeout(get_signatures)
        error_msg = f"Could not get parameterized command signatures: {result['error']}"
        assert result["success"], error_msg

        signatures = result["result"]
        if "GetVoicesForLanguage" in signatures:
            # Native D-Bus interface lists each input arg with its D-Bus type signature.
            expected = [("language", "s"), ("variant", "s")]
            actual = signatures["GetVoicesForLanguage"]
            error_msg = (
                f"GetVoicesForLanguage signature mismatch: expected {expected}, got {actual}"
            )
            assert actual == expected, error_msg

    @pytest.mark.dbus
    def test_no_unexpected_modules(self, bus):
        """Test that no unexpected modules exist - ensures test coverage for all modules."""

        actual_modules = set(list_module_names(bus))
        expected_modules = set(MODULE_CONFIG.keys())
        candidates = actual_modules - expected_modules - OPTIONAL_MODULES
        unexpected_modules = {m for m in candidates if not _module_is_testing_only(bus, m)}

        if unexpected_modules:
            module_list = sorted(unexpected_modules)
            error_msg = (
                f"Found {len(unexpected_modules)} unexpected modules that lack test coverage: "
                f"{module_list}\n"
                f"Please add configuration for these modules to MODULE_CONFIG in the test file."
            )
            raise AssertionError(error_msg)
