# Orca
#
# Copyright 2023-2026 Igalia, S.L.
# Author: Joanmarie Diggs <jdiggs@igalia.com>
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library; if not, write to the
# Free Software Foundation, Inc., Franklin Street, Fifth Floor,
# Boston MA  02110-1301 USA.

"""Utilities for obtaining selection-related information about accessible objects."""

from __future__ import annotations

from typing import TYPE_CHECKING

import gi

gi.require_version("Atspi", "2.0")
from gi.repository import Atspi

from . import debug
from .ax_object import AXObject
from .ax_selection import AXSelection
from .ax_table import AXTable
from .ax_utilities_collection import AXUtilitiesCollection
from .ax_utilities_object import AXUtilitiesObject
from .ax_utilities_role import AXUtilitiesRole
from .ax_utilities_state import AXUtilitiesState
from .ax_utilities_table import AXUtilitiesTable

if TYPE_CHECKING:
    from collections.abc import Callable
    from typing import ClassVar


class AXUtilitiesSelection:
    """Utilities for obtaining selection-related information about accessible objects."""

    _all_items_selected: ClassVar[dict[int, bool]] = {}

    @staticmethod
    def clear_cache_now(reason: str = "") -> None:
        """Clears all cached selection state."""

        msg = "AXUtilitiesSelection: Clearing cache."
        if reason:
            msg += f" Reason: {reason}"
        debug.print_message(debug.LEVEL_INFO, msg, True)
        AXUtilitiesSelection._all_items_selected.clear()

    @staticmethod
    def get_all_items_selected_state(obj: Atspi.Accessible) -> bool:
        """Returns the cached all-items-selected state for obj."""

        return AXUtilitiesSelection._all_items_selected.get(hash(obj), False)

    @staticmethod
    def set_all_items_selected_state(obj: Atspi.Accessible, selected: bool) -> None:
        """Sets the cached all-items-selected state for obj."""

        AXUtilitiesSelection._all_items_selected[hash(obj)] = selected

    @staticmethod
    def get_selected_children(obj: Atspi.Accessible) -> list[Atspi.Accessible]:
        """Returns a list of all the selected children of obj."""

        if obj is None:
            return []

        count = AXSelection.get_selected_child_count(obj)
        if not count and AXUtilitiesRole.is_combo_box(obj):
            if AXObject.supports_collection(obj):
                container = AXUtilitiesCollection.find_first_with_role(
                    obj, [Atspi.Role.MENU, Atspi.Role.LIST_BOX]
                )
            else:
                container = AXUtilitiesObject.find_descendant(
                    obj,
                    lambda x: AXUtilitiesRole.is_menu(x) or AXUtilitiesRole.is_list_box(x),
                )
            return AXUtilitiesSelection.get_selected_children(container)

        children = set()
        for i in range(count):
            child = AXSelection.get_selected_child(obj, i)
            if child is not None:
                children.add(child)

        if obj in children:
            tokens = ["AXUtilitiesSelection:", obj, "claims to be its own selected child"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            children.remove(obj)

        result = list(children)
        if len(result) != count:
            tokens = ["AXUtilitiesSelection: Selected child count of", obj, f"is {count}"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)

        return result

    @staticmethod
    def selected_children(obj: Atspi.Accessible) -> list[Atspi.Accessible]:
        """Returns a list of selected children in obj."""

        if AXUtilitiesTable.is_spreadsheet_table(obj):
            return []

        return AXUtilitiesSelection.get_selected_children(obj)

    @staticmethod
    def get_selection_container(obj: Atspi.Accessible) -> Atspi.Accessible | None:
        """Returns the selection container for obj."""

        if not obj:
            return None

        if AXUtilitiesRole.is_paragraph(obj) or AXUtilitiesState.is_editable(obj):
            return None

        if AXObject.supports_selection(obj):
            return obj

        rolemap = {
            Atspi.Role.CANVAS: [Atspi.Role.LAYERED_PANE],
            Atspi.Role.ICON: [Atspi.Role.LAYERED_PANE],
            Atspi.Role.LIST_ITEM: [Atspi.Role.LIST_BOX],
            Atspi.Role.TREE_ITEM: [Atspi.Role.TREE, Atspi.Role.TREE_TABLE],
            Atspi.Role.TABLE_CELL: [Atspi.Role.TABLE, Atspi.Role.TREE_TABLE],
            Atspi.Role.TABLE_ROW: [Atspi.Role.TABLE, Atspi.Role.TREE_TABLE],
        }

        matching_roles = rolemap.get(AXObject.get_role(obj))

        def is_match(x: Atspi.Accessible) -> bool:
            if matching_roles and AXObject.get_role(x) not in matching_roles:
                return False
            return AXObject.supports_selection(x)

        return AXUtilitiesObject.find_ancestor(obj, is_match)

    @staticmethod
    def selectable_child_count(obj: Atspi.Accessible) -> int:
        """Returns the number of selectable children in obj."""

        if not AXObject.supports_selection(obj):
            return 0

        if AXObject.supports_table(obj):
            rows = AXTable.get_row_count(obj)
            return max(0, rows)

        rolemap = {
            Atspi.Role.LIST_BOX: [Atspi.Role.LIST_ITEM],
            Atspi.Role.TREE: [Atspi.Role.TREE_ITEM],
        }

        role = AXObject.get_role(obj)
        if role not in rolemap:
            return AXObject.get_child_count(obj)

        child_roles = rolemap[role]
        if AXObject.supports_collection(obj):
            return len(AXUtilitiesCollection.find_all_with_role(obj, child_roles))

        def is_match(x: Atspi.Accessible) -> bool:
            return AXObject.get_role(x) in child_roles

        return len(AXUtilitiesObject.find_all_descendants(obj, is_match))

    @staticmethod
    def selected_child_count(obj: Atspi.Accessible) -> int:
        """Returns the number of selected children in obj."""

        if AXObject.supports_table(obj):
            return AXTable.get_selected_row_count(obj)
        return AXSelection.get_selected_child_count(obj)

    @staticmethod
    def all_items_selected(obj: Atspi.Accessible) -> bool:
        """Returns True if all items in obj are selected."""

        if not AXObject.supports_selection(obj):
            return False

        if AXUtilitiesState.is_expandable(obj) and not AXUtilitiesState.is_expanded(obj):
            return False

        if AXUtilitiesRole.is_combo_box(obj) or AXUtilitiesRole.is_menu(obj):
            return False

        child_count = AXObject.get_child_count(obj)
        if child_count == AXSelection.get_selected_child_count(obj):
            child = AXSelection.get_selected_child(obj, 0)
            if AXObject.get_parent(child) != obj:
                return False

            tokens = ["AXUtilitiesSelection: All", child_count, "children believed to be selected"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return True

        return AXUtilitiesTable.all_cells_are_selected(obj)

    @staticmethod
    def get_selected_child_for_focus(
        obj: Atspi.Accessible,
        focus: Atspi.Accessible | None,
        should_skip: Callable[[Atspi.Accessible], bool] | None = None,
    ) -> Atspi.Accessible | None:
        """Returns the selected child of obj that should become the locus of focus, or None."""

        selected = AXUtilitiesSelection.selected_children(obj)
        if focus in selected:
            tokens = ["AXUtilitiesSelection: Focus", focus, "is already in selected children"]
            debug.print_tokens(debug.LEVEL_INFO, tokens, True)
            return None

        for child in selected:
            if should_skip is not None and should_skip(child):
                continue

            if AXUtilitiesObject.is_ancestor(focus, child):
                tokens = ["AXUtilitiesSelection: Child", child, "is ancestor of focus"]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return None

            if (
                AXUtilitiesRole.is_page_tab(child)
                and focus is not None
                and AXObject.get_name(child) == AXObject.get_name(focus)
                and not AXUtilitiesState.is_focused(obj)
            ):
                tokens = ["AXUtilitiesSelection: Selected", child, "matches focus", focus]
                debug.print_tokens(debug.LEVEL_INFO, tokens, True)
                return None

            return child

        return None
