# Copyright © Michal Čihař <michal@weblate.org>
#
# SPDX-License-Identifier: GPL-3.0-or-later

from crispy_forms.layout import Div, Field
from crispy_forms.utils import TEMPLATE_PACK
from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.forms.models import ModelChoiceIterator
from django.template.loader import render_to_string
from django.utils.translation import gettext, gettext_lazy

from weblate.trans.defines import EMAIL_LENGTH, USERNAME_LENGTH
from weblate.trans.filter import FILTERS
from weblate.trans.util import sort_unicode
from weblate.utils.errors import report_error
from weblate.utils.search import parse_query
from weblate.utils.validators import validate_email, validate_username


class QueryField(forms.CharField):
    def __init__(self, parser: str = "unit", **kwargs):
        if "label" not in kwargs:
            kwargs["label"] = gettext_lazy("Query")
        if "required" not in kwargs:
            kwargs["required"] = False
        self.parser = parser
        super().__init__(**kwargs)

    def clean(self, value):
        if not value:
            if self.required:
                raise ValidationError(gettext("Missing query string."))
            return ""
        try:
            parse_query(value, parser=self.parser)
        except ValueError as error:
            raise ValidationError(
                gettext("Could not parse query string: {}").format(error)
            ) from error
        except Exception as error:
            report_error(cause="Error parsing search query")
            raise ValidationError(
                gettext("Could not parse query string: {}").format(error)
            ) from error
        return value


class UsernameField(forms.CharField):
    default_validators = [validate_username]

    def __init__(self, *args, **kwargs):
        params = {
            "max_length": USERNAME_LENGTH,
            "help_text": gettext_lazy(
                "Username may only contain letters, "
                "numbers or the following characters: @ . + - _"
            ),
            "label": gettext_lazy("Username"),
            "required": True,
        }
        params.update(kwargs)
        self.valid = None

        super().__init__(*args, **params)


class UserField(forms.CharField):
    def __init__(
        self,
        queryset=None,
        empty_label="---------",
        to_field_name=None,
        limit_choices_to=None,
        blank=None,
        **kwargs,
    ):
        # This swallows some parameters to mimic ModelChoiceField API
        super().__init__(**kwargs)

    def widget_attrs(self, widget):
        attrs = super().widget_attrs(widget)
        attrs["dir"] = "ltr"
        attrs["class"] = "user-autocomplete"
        attrs["spellcheck"] = "false"
        attrs["autocorrect"] = "off"
        attrs["autocomplete"] = "off"
        attrs["autocapitalize"] = "off"
        return attrs

    def clean(self, value):
        from weblate.auth.models import User

        if not value:
            if self.required:
                raise ValidationError(gettext("Missing username or e-mail."))
            return None
        try:
            return User.objects.get(Q(username=value) | Q(email=value))
        except User.DoesNotExist:
            raise ValidationError(gettext("Could not find any such user."))
        except User.MultipleObjectsReturned:
            raise ValidationError(gettext("More possible users were found."))


class EmailField(forms.EmailField):
    """
    Slightly restricted EmailField.

    We blacklist some additional local parts and customize error messages.
    """

    default_validators = [validate_email]

    def __init__(self, *args, **kwargs):
        kwargs.setdefault("max_length", EMAIL_LENGTH)
        super().__init__(*args, **kwargs)


class SortedSelectMixin:
    """Mixin for Select widgets to sort choices alphabetically."""

    def optgroups(self, name, value, attrs=None):
        groups = super().optgroups(name, value, attrs)
        return sort_unicode(groups, lambda val: str(val[1][0]["label"]))


class ColorWidget(forms.RadioSelect):
    def __init__(self, attrs=None, choices=()):
        attrs = {**(attrs or {}), "class": "color_edit"}
        super().__init__(attrs, choices)


class SortedSelectMultiple(SortedSelectMixin, forms.SelectMultiple):
    """Wrapper class to sort choices alphabetically."""


class SortedSelect(SortedSelectMixin, forms.Select):
    """Wrapper class to sort choices alphabetically."""


class ContextDiv(Div):
    def __init__(self, *fields, **kwargs):
        self.context = kwargs.pop("context", {})
        super().__init__(*fields, **kwargs)

    def render(self, form, context, template_pack=TEMPLATE_PACK, **kwargs):
        template = self.get_template_name(template_pack)
        return render_to_string(template, self.context)


class SearchField(Field):
    def __init__(self, *args, **kwargs):
        kwargs["template"] = "snippets/query-field.html"
        super().__init__(*args, **kwargs)

    def render(self, form, context, template_pack=TEMPLATE_PACK, **kwargs):
        extra_context = {"custom_filter_list": self.get_search_query_choices()}
        return super().render(form, context, template_pack, extra_context, **kwargs)

    def get_search_query_choices(self):
        """Return all filtering choices for query field."""
        filter_keys = [
            "nottranslated",
            "todo",
            "translated",
            "fuzzy",
            "suggestions",
            "variants",
            "screenshots",
            "labels",
            "context",
            "nosuggestions",
            "comments",
            "allchecks",
            "approved",
            "unapproved",
        ]
        return [
            (key, FILTERS.get_filter_name(key), FILTERS.get_filter_query(key))
            for key in filter_keys
        ]


class CachedQueryIterator(ModelChoiceIterator):
    """
    Choice iterator for cached querysets.

    It assumes the queryset is reused and avoids using an iterator or counting queries.
    """

    def __iter__(self):
        if self.field.empty_label is not None:
            yield ("", self.field.empty_label)
        for obj in self.queryset:
            yield self.choice(obj)

    def __len__(self):
        return len(self.queryset) + (1 if self.field.empty_label is not None else 0)

    def __bool__(self):
        return self.field.empty_label is not None or bool(self.queryset)


class NonCopyingSetQuerysetMixin:
    iterator = CachedQueryIterator

    def _get_queryset(self):
        return self._queryset

    def _set_queryset(self, queryset):
        self._queryset = queryset
        self.widget.choices = self.choices

    queryset = property(_get_queryset, _set_queryset)


class CachedModelChoiceField(NonCopyingSetQuerysetMixin, forms.ModelChoiceField):
    pass


class CachedModelMultipleChoiceField(
    NonCopyingSetQuerysetMixin, forms.ModelMultipleChoiceField
):
    pass

weblate/utils/forms.py
# Copyright © Michal Čihař <michal@weblate.org>
#
# SPDX-License-Identifier: GPL-3.0-or-later

from crispy_forms.layout import Div, Field
from crispy_forms.utils import TEMPLATE_PACK
from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.forms.models import ModelChoiceIterator
from django.template.loader import render_to_string
from django.utils.translation import gettext, gettext_lazy

from weblate.trans.defines import EMAIL_LENGTH, USERNAME_LENGTH
from weblate.trans.filter import FILTERS
from weblate.trans.util import sort_unicode
from weblate.utils.errors import report_error
from weblate.utils.search import parse_query
from weblate.utils.validators import validate_email, validate_username


class QueryField(forms.CharField):
    def __init__(self, parser: str = "unit", **kwargs):
        if "label" not in kwargs:
            kwargs["label"] = gettext_lazy("Query")
        if "required" not in kwargs:
            kwargs["required"] = False
        if "widget" not in kwargs:
            kwargs["widget"] = forms.Textarea(attrs={"cols": None, "rows": 1})
        self.parser = parser
        super().__init__(**kwargs)

    def clean(self, value):
        if not value:
            if self.required:
                raise ValidationError(gettext("Missing query string."))
            return ""
        try:
            parse_query(value, parser=self.parser)
        except ValueError as error:
            raise ValidationError(
                gettext("Could not parse query string: {}").format(error)
            ) from error
        except Exception as error:
            report_error(cause="Error parsing search query")
            raise ValidationError(
                gettext("Could not parse query string: {}").format(error)
            ) from error
        return value


class UsernameField(forms.CharField):
    default_validators = [validate_username]

    def __init__(self, *args, **kwargs):
        params = {
            "max_length": USERNAME_LENGTH,
            "help_text": gettext_lazy(
                "Username may only contain letters, "
                "numbers or the following characters: @ . + - _"
            ),
            "label": gettext_lazy("Username"),
            "required": True,
        }
        params.update(kwargs)
        self.valid = None

        super().__init__(*args, **params)


class UserField(forms.CharField):
    def __init__(
        self,
        queryset=None,
        empty_label="---------",
        to_field_name=None,
        limit_choices_to=None,
        blank=None,
        **kwargs,
    ):
        # This swallows some parameters to mimic ModelChoiceField API
        super().__init__(**kwargs)

    def widget_attrs(self, widget):
        attrs = super().widget_attrs(widget)
        attrs["dir"] = "ltr"
        attrs["class"] = "user-autocomplete"
        attrs["spellcheck"] = "false"
        attrs["autocorrect"] = "off"
        attrs["autocomplete"] = "off"
        attrs["autocapitalize"] = "off"
        return attrs

    def clean(self, value):
        from weblate.auth.models import User

        if not value:
            if self.required:
                raise ValidationError(gettext("Missing username or e-mail."))
            return None
        try:
            return User.objects.get(Q(username=value) | Q(email=value))
        except User.DoesNotExist:
            raise ValidationError(gettext("Could not find any such user."))
        except User.MultipleObjectsReturned:
            raise ValidationError(gettext("More possible users were found."))


class EmailField(forms.EmailField):
    """
    Slightly restricted EmailField.

    We blacklist some additional local parts and customize error messages.
    """

    default_validators = [validate_email]

    def __init__(self, *args, **kwargs):
        kwargs.setdefault("max_length", EMAIL_LENGTH)
        super().__init__(*args, **kwargs)


class SortedSelectMixin:
    """Mixin for Select widgets to sort choices alphabetically."""

    def optgroups(self, name, value, attrs=None):
        groups = super().optgroups(name, value, attrs)
        return sort_unicode(groups, lambda val: str(val[1][0]["label"]))


class ColorWidget(forms.RadioSelect):
    def __init__(self, attrs=None, choices=()):
        attrs = {**(attrs or {}), "class": "color_edit"}
        super().__init__(attrs, choices)


class SortedSelectMultiple(SortedSelectMixin, forms.SelectMultiple):
    """Wrapper class to sort choices alphabetically."""


class SortedSelect(SortedSelectMixin, forms.Select):
    """Wrapper class to sort choices alphabetically."""


class ContextDiv(Div):
    def __init__(self, *fields, **kwargs):
        self.context = kwargs.pop("context", {})
        super().__init__(*fields, **kwargs)

    def render(self, form, context, template_pack=TEMPLATE_PACK, **kwargs):
        template = self.get_template_name(template_pack)
        return render_to_string(template, self.context)


class SearchField(Field):
    def __init__(self, *args, **kwargs):
        kwargs["template"] = "snippets/query-field.html"
        super().__init__(*args, **kwargs)

    def render(self, form, context, template_pack=TEMPLATE_PACK, **kwargs):
        extra_context = {"custom_filter_list": self.get_search_query_choices()}
        return super().render(form, context, template_pack, extra_context, **kwargs)

    def get_search_query_choices(self):
        """Return all filtering choices for query field."""
        filter_keys = [
            "nottranslated",
            "todo",
            "translated",
            "fuzzy",
            "suggestions",
            "variants",
            "screenshots",
            "labels",
            "context",
            "nosuggestions",
            "comments",
            "allchecks",
            "approved",
            "unapproved",
        ]
        return [
            (key, FILTERS.get_filter_name(key), FILTERS.get_filter_query(key))
            for key in filter_keys
        ]


class CachedQueryIterator(ModelChoiceIterator):
    """
    Choice iterator for cached querysets.

    It assumes the queryset is reused and avoids using an iterator or counting queries.
    """

    def __iter__(self):
        if self.field.empty_label is not None:
            yield ("", self.field.empty_label)
        for obj in self.queryset:
            yield self.choice(obj)

    def __len__(self):
        return len(self.queryset) + (1 if self.field.empty_label is not None else 0)

    def __bool__(self):
        return self.field.empty_label is not None or bool(self.queryset)


class NonCopyingSetQuerysetMixin:
    iterator = CachedQueryIterator

    def _get_queryset(self):
        return self._queryset

    def _set_queryset(self, queryset):
        self._queryset = queryset
        self.widget.choices = self.choices

    queryset = property(_get_queryset, _set_queryset)


class CachedModelChoiceField(NonCopyingSetQuerysetMixin, forms.ModelChoiceField):
    pass


class CachedModelMultipleChoiceField(
    NonCopyingSetQuerysetMixin, forms.ModelMultipleChoiceField
):
    pass

weblate/utils/forms.py
diff --git a/docs/changes.rst b/docs/changes.rst
index c8728749a1e4..8d36e9ea4e68 100644
--- a/docs/changes.rst
+++ b/docs/changes.rst
@@ -8,6 +8,7 @@ Not yet released.
 * :ref:`check-perl-brace-format` quality check.
 * :doc:`/formats/moko`.
 * :doc:`/formats/formatjs`.
+* Search input is now syntax highlighted, see :doc:`/user/search`.
 
 **Improvements**
 
diff --git a/weblate/static/editor/full.js b/weblate/static/editor/full.js
index e37330c2b499..ae0a6b683e1a 100644
--- a/weblate/static/editor/full.js
+++ b/weblate/static/editor/full.js
@@ -119,7 +119,7 @@
     });
     Mousetrap.bindGlobal("mod+s", function (e) {
       $("#search-dropdown").click();
-      $('input[name="q"]').focus();
+      $('textarea[name="q"]').focus();
       return false;
     });
     Mousetrap.bindGlobal("mod+u", function (e) {
diff --git a/weblate/static/loader-bootstrap.js b/weblate/static/loader-bootstrap.js
index 5c3dddfe05b2..465a2b2bc678 100644
--- a/weblate/static/loader-bootstrap.js
+++ b/weblate/static/loader-bootstrap.js
@@ -356,6 +356,58 @@ function initHighlight(root) {
   if (typeof ResizeObserver === "undefined") {
     return;
   }
+  root.querySelectorAll("textarea[name='q']").forEach((input) => {
+    console.log(input);
+    var parent = input.parentElement;
+    if (parent.classList.contains("editor-wrap")) {
+      return;
+    }
+
+    /* Create wrapper element */
+    var wrapper = document.createElement("div");
+    wrapper.setAttribute("class", "editor-wrap");
+
+    /* Inject wrapper */
+    parent.replaceChild(wrapper, input);
+
+    /* Create highlighter */
+    var highlight = document.createElement("div");
+    highlight.setAttribute("class", "highlighted-output");
+    highlight.setAttribute("role", "status");
+    wrapper.appendChild(highlight);
+
+    /* Add input to wrapper */
+    wrapper.appendChild(input);
+
+    var syncContent = function () {
+      highlight.innerHTML = Prism.highlight(
+        input.value,
+        Prism.languages.weblatesearch,
+        "weblatesearch",
+      );
+    };
+    syncContent();
+    input.addEventListener("input", syncContent);
+
+    /* Handle scrolling */
+    input.addEventListener("scroll", (event) => {
+      highlight.scrollTop = input.scrollTop;
+      highlight.scrollLeft = input.scrollLeft;
+    });
+
+    /* Handle resizing */
+    const resizeObserver = new ResizeObserver((entries) => {
+      for (let entry of entries) {
+        if (entry.target === input) {
+          // match the height and width of the output area to the input area
+          highlight.style.height = input.offsetHeight + "px";
+          highlight.style.width = input.offsetWidth + "px";
+        }
+      }
+    });
+
+    resizeObserver.observe(input);
+  });
   root.querySelectorAll(".highlight-editor").forEach(function (editor) {
     var parent = editor.parentElement;
     var hasFocus = editor == document.activeElement;
@@ -440,6 +492,7 @@ function initHighlight(root) {
 
     /* Handle scrolling */
     editor.addEventListener("scroll", (event) => {
+      console.log(event);
       highlight.scrollTop = editor.scrollTop;
       highlight.scrollLeft = editor.scrollLeft;
     });
@@ -968,7 +1021,7 @@ $(function () {
     }
 
     if ($group.hasClass("query-field")) {
-      $group.find("input[name=q]").val($this.data("field"));
+      $group.find("textarea[name=q]").val($this.data("field"));
       if ($this.closest(".result-page-form").length) {
         var $form = $this.closest("form");
         $form.find("input[name=offset]").prop("disabled", true);
diff --git a/weblate/static/prism-weblatesearch.js b/weblate/static/prism-weblatesearch.js
new file mode 100644
index 000000000000..87bcba818402
--- /dev/null
+++ b/weblate/static/prism-weblatesearch.js
@@ -0,0 +1,16 @@
+// Copyright © Michal Čihař <michal@weblate.org>
+//
+// SPDX-License-Identifier: MIT
+
+Prism.languages.weblatesearch = {
+  string: {
+    pattern: /r?("[^"]*"|'[^']*')/,
+  },
+  important: {
+    pattern: /\b[a-z_]+:=?[<>]?/i,
+    inside: {
+      punctuation: /[:=<>]+$/,
+    },
+  },
+  operator: /\b(and|not|or)\b/i,
+};
diff --git a/weblate/static/style-bootstrap.css b/weblate/static/style-bootstrap.css
index 4c0005ee1ae1..7c9968bcf58a 100644
--- a/weblate/static/style-bootstrap.css
+++ b/weblate/static/style-bootstrap.css
@@ -1650,6 +1650,10 @@ a.language:hover {
   -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
   box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
 }
+.input-group .editor-wrap > textarea,
+.input-group .editor-wrap > .highlighted-output {
+  padding: 6px 12px;
+}
 
 .editor-wrap > textarea.form-control[readonly],
 .editor-wrap > textarea.form-control[disabled],
diff --git a/weblate/templates/base.html b/weblate/templates/base.html
index 75b690174b06..0c447183c757 100644
--- a/weblate/templates/base.html
+++ b/weblate/templates/base.html
@@ -73,6 +73,7 @@
   <script defer data-cfasync="false" src="{% static 'vendor/prism/prism-rest.js' %}{{ cache_param }}"></script>
   <script defer data-cfasync="false" src="{% static 'vendor/prism/prism-markdown.js' %}{{ cache_param }}"></script>
   <script defer data-cfasync="false" src="{% static 'vendor/prism/prism-icu-message-format.js' %}{{ cache_param }}"></script>
+  <script defer data-cfasync="false" src="{% static 'prism-weblatesearch.js' %}{{ cache_param }}"></script>
   <script defer data-cfasync="false" src="{% static 'loader-bootstrap.js' %}{{ cache_param }}"></script>
 {% endcompress %}
 
diff --git a/weblate/utils/forms.py b/weblate/utils/forms.py
index d7cab026e296..a1425e49b462 100644
--- a/weblate/utils/forms.py
+++ b/weblate/utils/forms.py
@@ -25,6 +25,8 @@ def __init__(self, parser: str = "unit", **kwargs):
             kwargs["label"] = gettext_lazy("Query")
         if "required" not in kwargs:
             kwargs["required"] = False
+        if "widget" not in kwargs:
+            kwargs["widget"] = forms.Textarea(attrs={"cols": None, "rows": 1})
         self.parser = parser
         super().__init__(**kwargs)
 

Syntax highlighting of search input
### Describe the issue

1. Go to a screenshot
2. Enter "not found" as the search term
3. A lot of strings appear as search results, most of them not containing anything related to "not found"


If I enter "not" or "found" then fewer results are found compared to "not found".

### I already tried

- [X] I've read and searched [the documentation](https://docs.weblate.org/).
- [X] I've searched for similar issues in this repository.

### Steps to reproduce the behavior

1. Go to a screenshot
2. Enter "not found" as the search term
3. A lot of strings appear as search results, most of them not containing anything related to "not found"

### Expected behavior

Search only lists strings containing "not found"

### Screenshots

![image](https://github.com/WeblateOrg/weblate/assets/69897582/5dcbab51-0de7-4fd8-b454-eb44a08d528e)


### Exception traceback

_No response_

### How do you run Weblate?

weblate.org service

### Weblate versions

_No response_

### Weblate deploy checks

_No response_

### Additional context

_No response_
