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

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

"""Provides support for a flat review find."""

import copy
import re
import time
from collections.abc import Callable

import gi

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

from . import (
    cmdnames,
    command_manager,
    debug,
    flat_review_presenter,
    focus_manager,
    guilabels,
    keybindings,
    messages,
    presentation_manager,
)
from .flat_review import Context


class _SearchQueryMatch:
    """Represents a SearchQuery match."""

    def __init__(self, context: Context, pattern: re.Pattern) -> None:
        self._line, self._zone, self._word, self._char = context.get_current_location()
        self._line_string: str = context.get_current_line_string()
        self._pattern: re.Pattern = pattern

    def __str__(self) -> str:
        return (
            f"SEARCH QUERY MATCH: '{self._line_string}' "
            f"(line {self._line}, zone {self._zone}, word {self._word}, char {self._char}) "
            f"for '{self._pattern}'"
        )

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, _SearchQueryMatch):
            return False

        return (
            self._line_string == other._line_string
            and self._line == other._line
            and self._zone == other._zone
            and self._word == other._word
            and self._char == other._char
        )

    def __hash__(self) -> int:
        return hash((self._line_string, self._line, self._zone, self._word, self._char))


class SearchQuery:
    """Represents a search that the user wants to perform."""

    def __init__(self) -> None:
        self.search_string: str = ""
        self.search_backwards: bool = False
        self.case_sensitive: bool = False
        self.match_entire_word: bool = False
        self.window_wrap: bool = False
        self.start_at_top: bool = False

    def __str__(self) -> str:
        string = f"'{self.search_string}'."
        options = []
        if self.search_backwards:
            options.append("search backwards")
        if self.case_sensitive:
            options.append("case sensitive")
        if self.match_entire_word:
            options.append("match entire word")
        if self.window_wrap:
            options.append("wrap")
        if self.start_at_top:
            options.append("start at top")
        if options:
            string += f" Options: {', '.join(options)}"
        return string


class FlatReviewFinder:
    """Provides tools to search the current window's flat-review contents."""

    def __init__(self) -> None:
        self._gui: FlatReviewFinderGUI | None = None
        self._last_query: SearchQuery | None = None
        self._location: tuple[int, int, int, int] = 0, 0, 0, 0
        self._wrapped: bool = False
        self._match: _SearchQueryMatch | None = None
        self._focus: Atspi.Accessible | None = None
        self._initialized: bool = False

    def set_up_commands(self) -> None:
        """Sets up commands with CommandManager."""

        if self._initialized:
            return
        self._initialized = True

        manager = command_manager.get_manager()
        group_label = guilabels.KB_GROUP_FIND

        # (name, function, description, desktop_binding, laptop_binding)
        commands_data = [
            (
                "findHandler",
                self.show_dialog,
                cmdnames.SHOW_FIND_GUI,
                keybindings.KeyBinding("KP_Delete", keybindings.NO_MODIFIER_MASK),
                keybindings.KeyBinding("bracketleft", keybindings.ORCA_MODIFIER_MASK),
            ),
            (
                "findNextHandler",
                self.find_next,
                cmdnames.FIND_NEXT,
                keybindings.KeyBinding("KP_Delete", keybindings.ORCA_MODIFIER_MASK),
                keybindings.KeyBinding("bracketright", keybindings.ORCA_MODIFIER_MASK),
            ),
            (
                "findPreviousHandler",
                self.find_previous,
                cmdnames.FIND_PREVIOUS,
                keybindings.KeyBinding("KP_Delete", keybindings.ORCA_SHIFT_MODIFIER_MASK),
                keybindings.KeyBinding("bracketright", keybindings.ORCA_CTRL_MODIFIER_MASK),
            ),
        ]

        for name, function, description, desktop_kb, laptop_kb in commands_data:
            manager.add_command(
                command_manager.KeyboardCommand(
                    name,
                    function,
                    group_label,
                    description,
                    desktop_keybinding=desktop_kb,
                    laptop_keybinding=laptop_kb,
                ),
            )

        msg = "FLAT REVIEW FINDER: Commands set up."
        debug.print_message(debug.LEVEL_INFO, msg, True)

    def _on_query(self, query: SearchQuery) -> None:
        """Handler after a query has been made via the Find dialog."""

        self._last_query = query

    def show_dialog(self, script, _event=None) -> bool:
        """Shows the Find dialog."""

        self._focus = focus_manager.get_manager().get_locus_of_focus()
        self._gui = FlatReviewFinderGUI(script, self._on_query)
        self._gui.show_gui(self._focus)
        return True

    def find_next(self, script, event=None) -> bool:
        """Searches forward for the next instance of the string."""

        if self._last_query is None:
            self.show_dialog(script, event)
            return True

        self._last_query.search_backwards = False
        self._last_query.start_at_top = False
        self.find(script, self._last_query)
        return True

    def find_previous(self, script, event=None) -> bool:
        """Searches backwards for the previous instance of the string."""

        if self._last_query is None:
            self.show_dialog(script, event)
            return True

        self._last_query.search_backwards = True
        self._last_query.start_at_top = False
        self.find(script, self._last_query)
        return True

    def find(self, script, query: SearchQuery | None = None) -> None:
        """Searches for the specified query, or the most recent one."""

        query = query or self._last_query
        if query is None:
            return

        presenter = flat_review_presenter.get_presenter()
        context = presenter.get_or_create_context(script)
        location = self._do_find(query, context)
        if not location:
            presentation_manager.get_manager().present_message(messages.STRING_NOT_FOUND)
        else:
            context.set_current_location(location.get_current_location())
            presenter.present_item(script)

    def _move(self, query: SearchQuery, context: Context, context_type: int) -> bool:
        """Moves within the flat review context while looking for a match."""

        if context_type == Context.WORD:
            return context.go_previous_word() if query.search_backwards else context.go_next_word()

        if context_type == Context.ZONE:
            if query.search_backwards:
                moved = context.go_previous_zone()
                context.go_to_end_of(Context.ZONE)
            else:
                moved = context.go_next_zone()
            return moved

        if context_type != Context.LINE:
            return False

        if query.search_backwards:
            moved = context.go_previous_line()
            context.go_to_end_of(Context.LINE)
        else:
            moved = context.go_next_line()
        if moved or not query.window_wrap or self._wrapped:
            return moved
        self._wrapped = True
        if query.search_backwards:
            presentation_manager.get_manager().present_message(messages.WRAPPING_TO_BOTTOM)
            return context.go_previous_line(True)
        presentation_manager.get_manager().present_message(messages.WRAPPING_TO_TOP)
        return context.go_next_line(True)

    def _find_match_in(
        self,
        query: SearchQuery,
        context: Context,
        pattern: re.Pattern,
        context_type: int,
    ) -> bool:
        """Searches for a match of pattern in context for the given type."""

        def matches(context, pattern, context_type):
            string = ""
            if context_type == Context.LINE:
                type_string = "LINE"
                string = context.get_current_line_string()
            elif context_type == Context.ZONE:
                type_string = "ZONE"
                string = context.get_current_zone_string()
            elif context_type == Context.WORD:
                type_string = "WORD"
                string = context.get_current_word_string()
            else:
                return False

            match = re.search(pattern, string)
            debug_string = string.replace("\n", "\\n")
            msg = f"FLAT REVIEW FINDER: Looking in {type_string}='{debug_string}'. Match: {match}"
            debug.print_message(debug.LEVEL_INFO, msg, True)
            return bool(match)

        found = matches(context, pattern, context_type)
        while not found:
            if not self._move(query, context, context_type):
                break
            found = matches(context, pattern, context_type)

        return found

    def _find_match(self, query: SearchQuery, context: Context, pattern: re.Pattern) -> bool:
        """Searches for a match of pattern in context."""

        for context_type in (Context.LINE, Context.ZONE, Context.WORD):
            if not self._find_match_in(query, context, pattern, context_type):
                return False

        if self._match != _SearchQueryMatch(context, pattern):
            return True

        for context_type in (Context.WORD, Context.ZONE):
            if self._move(query, context, context_type) and self._find_match_in(
                query,
                context,
                pattern,
                context_type,
            ):
                return True

        if self._move(query, context, Context.LINE):
            return self._find_match(query, context, pattern)

        return False

    def _do_find(self, query: SearchQuery, context: Context) -> Context | None:
        """Performs the actual search."""

        msg = f"FLAT REVIEW FINDER: Searching for {query!s}"
        if self._match:
            msg += f". Last match: {self._match}"
        debug.print_message(debug.LEVEL_INFO, msg, True)

        flags = re.UNICODE
        if not query.case_sensitive:
            flags = flags | re.IGNORECASE
        if query.match_entire_word:
            regexp = "\\b" + query.search_string + "\\b"
        else:
            regexp = query.search_string
        pattern = re.compile(regexp, flags)

        self._save_location(context)
        if query.start_at_top:
            context.go_to_start_of(Context.WINDOW)

        location = None
        if self._find_match(query, context, pattern):
            self._save_location(context)
            self._match = _SearchQueryMatch(context, pattern)
            self._wrapped = False
            location = copy.copy(context)
        else:
            self._restore_location(context)
        self._last_query = copy.copy(query)
        return location

    def _save_location(self, context: Context) -> None:
        """Saves the context location."""

        self._location = context.get_current_location()

    def _restore_location(self, context: Context) -> None:
        """Restores the context location."""

        context.set_current_location(self._location)
        self._location = 0, 0, 0, 0


class FlatReviewFinderGUI:
    """The dialog containing the find options."""

    def __init__(self, script, query_handler: Callable[[SearchQuery], None]) -> None:
        self._script = script
        self._entry: Gtk.Entry | None = None
        self._gui: Gtk.Dialog = self._create_dialog()
        self._query: SearchQuery = SearchQuery()
        self.on_apply: Callable[[SearchQuery], None] = query_handler
        self._focus: Atspi.Accessible | None = None

    def _create_dialog(self) -> Gtk.Dialog:
        """Creates the Find dialog."""

        def _frame_with_grid(label, widgets):
            frame = Gtk.Frame()
            frame.set_shadow_type(Gtk.ShadowType.NONE)  # pylint: disable=no-member
            label = Gtk.Label(f"<b>{label}</b>")
            label.set_use_markup(True)
            frame.set_label_widget(label)
            grid = Gtk.Grid()
            for i, widget in enumerate(widgets):
                grid.attach(widget, 0, i, 1, 1)
            frame.add(grid)  # pylint: disable=no-member
            return frame

        dialog = Gtk.Dialog(
            guilabels.FIND_DIALOG_TITLE,
            None,
            Gtk.DialogFlags.MODAL,
            (Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE, Gtk.STOCK_FIND, Gtk.ResponseType.APPLY),
        )
        dialog.set_default_response(Gtk.ResponseType.APPLY)
        grid = Gtk.Grid()
        grid.set_row_spacing(20)
        grid.set_column_spacing(20)
        grid.set_border_width(12)  # pylint: disable=no-member
        dialog.get_content_area().add(grid)

        entry_grid = Gtk.Grid()
        entry_grid.set_hexpand(True)
        entry_grid.set_column_spacing(20)
        self._entry = Gtk.Entry()
        self._entry.set_hexpand(True)
        self._entry.set_activates_default(True)
        self._entry.connect("changed", self.on_entry_changed)
        label = Gtk.Label(guilabels.FIND_SEARCH_FOR)
        label.set_use_markup(True)
        label.set_use_underline(True)
        label.set_mnemonic_widget(self._entry)
        entry_grid.attach(label, 0, 0, 1, 1)
        entry_grid.attach(self._entry, 1, 0, 2, 1)
        grid.attach(entry_grid, 0, 0, 3, 1)

        rb1 = Gtk.RadioButton.new_with_mnemonic_from_widget(
            None,
            guilabels.FIND_START_AT_CURRENT_LOCATION,
        )
        rb1.connect("toggled", self.on_current_location_toggled)
        rb2 = Gtk.RadioButton.new_with_mnemonic_from_widget(
            rb1,
            guilabels.FIND_START_AT_TOP_OF_WINDOW,
        )
        grid.attach(_frame_with_grid(guilabels.FIND_START_FROM, (rb1, rb2)), 0, 1, 1, 1)

        cb1 = Gtk.CheckButton.new_with_mnemonic(guilabels.FIND_SEARCH_BACKWARDS)
        cb1.connect("toggled", self.on_search_backwards_toggled)
        cb2 = Gtk.CheckButton.new_with_mnemonic(guilabels.FIND_WRAP_AROUND)
        cb2.connect("toggled", self.on_wrap_around_toggled)
        grid.attach(_frame_with_grid(guilabels.FIND_SEARCH_DIRECTION, (cb1, cb2)), 1, 1, 1, 1)

        cb3 = Gtk.CheckButton.new_with_mnemonic(guilabels.FIND_MATCH_CASE)
        cb3.connect("toggled", self.on_match_case_toggled)
        cb4 = Gtk.CheckButton.new_with_mnemonic(guilabels.FIND_MATCH_ENTIRE_WORD)
        cb4.connect("toggled", self.on_match_entire_word_toggled)
        grid.attach(_frame_with_grid(guilabels.FIND_MATCH_OPTIONS, (cb3, cb4)), 2, 1, 1, 1)

        dialog.connect("response", self.on_response)
        return dialog

    def on_current_location_toggled(self, widget: Gtk.Widget) -> None:
        """The handler for the 'toggled' signal on the current-location radio button."""

        self._query.start_at_top = not widget.get_active()

    def on_entry_changed(self, widget: Gtk.Entry) -> None:
        """The handler for the 'changed' signal on the search entry."""

        self._query.search_string = widget.get_text()

    def on_match_case_toggled(self, widget: Gtk.CheckButton) -> None:
        """The handler for the 'toggled' signal on the match-case checkbox."""

        self._query.case_sensitive = widget.get_active()

    def on_match_entire_word_toggled(self, widget: Gtk.CheckButton) -> None:
        """The handler for the 'toggled' signal on the match-entire-word checkbox."""

        self._query.match_entire_word = widget.get_active()

    def on_search_backwards_toggled(self, widget: Gtk.CheckButton) -> None:
        """The handler for the 'toggled' signal on the search-backwards checkbox."""

        self._query.search_backwards = widget.get_active()

    def on_wrap_around_toggled(self, widget: Gtk.CheckButton) -> None:
        """The handler for the 'toggled' signal on the wrap-around checkbox."""

        self._query.window_wrap = widget.get_active()

    def on_response(self, dialog: Gtk.Dialog, response: int) -> None:
        """The handler for the 'response' signal."""

        if response == Gtk.ResponseType.CLOSE:
            self._gui.hide()
            return

        if response == Gtk.ResponseType.APPLY:
            self.on_apply(copy.copy(self._query))
            dialog.destroy()
            self._script.run_find_command_on = self._focus

    def show_gui(self, focus: Atspi.Accessible) -> None:
        """Shows the find dialog."""

        self._focus = focus
        self._gui.show_all()  # pylint: disable=no-member
        self._gui.present_with_time(time.time())


_finder: FlatReviewFinder = FlatReviewFinder()


def get_finder() -> FlatReviewFinder:
    """Returns the Flat Review Finder"""

    return _finder
