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

import re
from datetime import datetime
from functools import lru_cache, reduce
from itertools import chain
from typing import Dict

from dateutil.parser import ParserError, parse
from django.db import transaction
from django.db.models import Q, Value
from django.db.utils import DataError
from django.utils import timezone
from django.utils.translation import gettext as _
from pyparsing import (
    CaselessKeyword,
    OpAssoc,
    Optional,
    Regex,
    Word,
    infix_notation,
    one_of,
)
from rapidfuzz.distance import DamerauLevenshtein

from weblate.checks.parser import RawQuotedString
from weblate.trans.util import PLURAL_SEPARATOR
from weblate.utils.db import re_escape, using_postgresql
from weblate.utils.state import (
    STATE_APPROVED,
    STATE_FUZZY,
    STATE_NAMES,
    STATE_READONLY,
    STATE_TRANSLATED,
)


class Comparer:
    """String comparer abstraction.

    The reason is to be able to change implementation.
    """

    def similarity(self, first, second):
        """Returns string similarity in range 0 - 100%."""
        return int(100 * DamerauLevenshtein.normalized_similarity(first, second))


# Field type definitions
PLAIN_FIELDS = ("source", "target", "context", "note", "location")
NONTEXT_FIELDS = {
    "priority": "priority",
    "id": "id",
    "state": "state",
    "pending": "pending",
    "changed": "change__timestamp",
    "change_time": "change__timestamp",
    "added": "timestamp",
    "change_action": "change__action",
}
STRING_FIELD_MAP = {
    "suggestion": "suggestion__target",
    "comment": "comment__comment",
    "resolved_comment": "comment__comment",
    "key": "context",
    "explanation": "source_unit__explanation",
}
EXACT_FIELD_MAP = {
    "check": "check__name",
    "dismissed_check": "check__name",
    "language": "translation__language__code",
    "component": "translation__component__slug",
    "project": "translation__component__project__slug",
    "changed_by": "change__author__username",
    "suggestion_author": "suggestion__user__username",
    "comment_author": "comment__user__username",
    "label": "source_unit__labels__name",
    "screenshot": "source_unit__screenshots__name",
}
OPERATOR_MAP = {
    ":": "substring",
    ":=": "exact",
    ":<": "lt",
    ":<=": "lte",
    ":>": "gt",
    ":>=": "gte",
}

# Parsing grammar

AND = CaselessKeyword("AND")
OR = Optional(CaselessKeyword("OR"))
NOT = CaselessKeyword("NOT")

# Search operator
OPERATOR = one_of(OPERATOR_MAP.keys())

# Field name, explicitly exclude URL like patterns
FIELD = Regex(r"""(?!http|ftp|https|mailto)[a-zA-Z_]+""")

# Match token
WORD = Regex(r"""[^ \(\)]([^ '"]*[^ '"\)])?""")
DATE = Word("0123456789:.-T")

# Date range
RANGE = "[" + DATE + "to" + DATE + "]"

# Match value
REGEX_STRING = "r" + RawQuotedString('"')
STRING = REGEX_STRING | RawQuotedString("'") | RawQuotedString('"') | WORD

# Single term, either field specific or not
TERM = (FIELD + OPERATOR + (RANGE | STRING)) | STRING

# Multi term with or without operator
QUERY = Optional(
    infix_notation(
        TERM,
        [
            (
                NOT,
                1,
                OpAssoc.RIGHT,
            ),
            (
                AND,
                2,
                OpAssoc.LEFT,
            ),
            (
                OR,
                2,
                OpAssoc.LEFT,
            ),
        ],
    )
)

# Helper parsing objects


class RegexExpr:
    def __init__(self, tokens):
        self.expr = tokens[1]


REGEX_STRING.add_parse_action(RegexExpr)


class RangeExpr:
    def __init__(self, tokens):
        self.start = tokens[1]
        self.end = tokens[3]


RANGE.add_parse_action(RangeExpr)


class TermExpr:
    def __init__(self, tokens):
        if len(tokens) == 1:
            self.field = None
            self.operator = ":"
            self.match = tokens[0]
        else:
            self.field, self.operator, self.match = tokens
            self.fixup()

    def __repr__(self):
        return f"<TermExpr: {self.field!r}, {self.operator!r}, {self.match!r}>"

    def fixup(self):
        # Avoid unwanted lt/gt searches on plain text fields
        if self.field in PLAIN_FIELDS and self.operator not in (":", ":="):
            self.match = self.operator[1:] + self.match
            self.operator = ":"

    def is_field(self, text, context: Dict):
        if text in ("read-only", "readonly"):
            return Q(state=STATE_READONLY)
        if text == "approved":
            return Q(state=STATE_APPROVED)
        if text in ("fuzzy", "needs-editing"):
            return Q(state=STATE_FUZZY)
        if text == "translated":
            return Q(state__gte=STATE_TRANSLATED)
        if text == "untranslated":
            return Q(state__lt=STATE_TRANSLATED)
        if text == "pending":
            return Q(pending=True)

        raise ValueError(f"Unsupported is lookup: {text}")

    def has_field(self, text, context: Dict):  # noqa: C901
        if text == "plural":
            return Q(source__search=PLURAL_SEPARATOR)
        if text == "suggestion":
            return Q(suggestion__isnull=False)
        if text == "explanation":
            return ~Q(source_unit__explanation="")
        if text == "note":
            return ~Q(note="")
        if text == "comment":
            return Q(comment__resolved=False)
        if text in ("resolved-comment", "resolved_comment"):
            return Q(comment__resolved=True)
        if text in ("check", "failing-check", "failing_check"):
            return Q(check__dismissed=False)
        if text in (
            "dismissed-check",
            "dismissed_check",
            "ignored-check",
            "ignored_check",
        ):
            return Q(check__dismissed=True)
        if text == "translation":
            return Q(state__gte=STATE_TRANSLATED)
        if text in ("variant", "shaping"):
            return Q(variant__isnull=False)
        if text == "label":
            return Q(source_unit__labels__isnull=False) | Q(labels__isnull=False)
        if text == "context":
            return ~Q(context="")
        if text == "screenshot":
            return Q(screenshots__isnull=False) | Q(
                source_unit__screenshots__isnull=False
            )
        if text == "flags":
            return ~Q(source_unit__extra_flags="")
        if text == "glossary":
            project = context.get("project")
            if not project:
                return Q(source__isnull=True)
            terms = set(
                chain.from_iterable(
                    glossary.glossary_sources for glossary in project.glossaries
                )
            )
            if not terms:
                return Q(source__isnull=True)
            if using_postgresql():
                template = r"[[:<:]]({})[[:>:]]"
            else:
                template = r"(^|[ \t\n\r\f\v])({})($|[ \t\n\r\f\v])"
            return Q(
                source__iregex=template.format(
                    "|".join(re_escape(term) for term in terms)
                )
            )

        raise ValueError(f"Unsupported has lookup: {text}")

    def field_extra(self, field, query, match):
        from weblate.trans.models import Change

        if field in {"changed", "changed_by"}:
            return query & Q(change__action__in=Change.ACTIONS_CONTENT)
        if field == "check":
            return query & Q(check__dismissed=False)
        if field == "dismissed_check":
            return query & Q(check__dismissed=True)
        if field == "component":
            return query | Q(translation__component__name__icontains=match)
        if field == "label":
            return query | Q(labels__name__iexact=match)
        if field == "screenshot":
            return query | Q(screenshots__name__iexact=match)
        if field == "comment":
            return query & Q(comment__resolved=False)
        if field == "resolved_comment":
            return query & Q(comment__resolved=True)

        return query

    def convert_state(self, text):
        if text is None:
            return None
        if text.isdigit():
            return int(text)
        try:
            return STATE_NAMES[text]
        except KeyError:
            raise ValueError(_("Unsupported state: {}").format(text))

    def convert_bool(self, text):
        ltext = text.lower()
        if ltext in ("yes", "true", "on", "1"):
            return True
        if ltext in ("no", "false", "off", "0"):
            return False
        raise ValueError(f"Invalid boolean value: {text}")

    def convert_pending(self, text):
        return self.convert_bool(text)

    def convert_int(self, text):
        return int(text)

    def convert_priority(self, text):
        return self.convert_int(text)

    def convert_id(self, text):
        if "," in text:
            return {self.convert_int(part) for part in text.split(",")}
        return self.convert_int(text)

    def convert_datetime(self, text, hour=5, minute=55, second=55, microsecond=0):
        if isinstance(text, RangeExpr):
            return (
                self.convert_datetime(
                    text.start, hour=0, minute=0, second=0, microsecond=0
                ),
                self.convert_datetime(
                    text.end, hour=23, minute=59, second=59, microsecond=999999
                ),
            )
        if text.isdigit() and len(text) == 4:
            year = int(text)
            tzinfo = timezone.get_current_timezone()
            return (
                datetime(
                    year=year,
                    month=1,
                    day=1,
                    hour=0,
                    minute=0,
                    second=0,
                    microsecond=0,
                    tzinfo=tzinfo,
                ),
                datetime(
                    year=year,
                    month=12,
                    day=31,
                    hour=23,
                    minute=59,
                    second=59,
                    microsecond=999999,
                    tzinfo=tzinfo,
                ),
            )
        try:
            # Here we inject 5:55:55 time and if that was not changed
            # during parsing, we assume it was not specified while
            # generating the query
            result = parse(
                text,
                default=timezone.now().replace(
                    hour=hour, minute=minute, second=second, microsecond=microsecond
                ),
            )
        except ParserError as error:
            raise ValueError(_("Invalid timestamp: {}").format(error))
        if result.hour == 5 and result.minute == 55 and result.second == 55:
            return (
                result.replace(hour=0, minute=0, second=0, microsecond=0),
                result.replace(hour=23, minute=59, second=59, microsecond=999999),
            )
        return result

    def convert_change_action(self, text):
        from weblate.trans.models import Change

        try:
            return Change.ACTION_NAMES[text]
        except KeyError:
            return Change.ACTION_STRINGS[text]

    def convert_change_time(self, text):
        return self.convert_datetime(text)

    def convert_changed(self, text):
        return self.convert_datetime(text)

    def convert_added(self, text):
        return self.convert_datetime(text)

    def field_name(self, field, suffix=None):
        if suffix is None:
            suffix = OPERATOR_MAP[self.operator]

        if field in PLAIN_FIELDS:
            return f"{field}__{suffix}"
        if field in STRING_FIELD_MAP:
            return f"{STRING_FIELD_MAP[field]}__{suffix}"
        if field in EXACT_FIELD_MAP:
            # Change contains to exact, do not change other (for example regex)
            if suffix == "substring":
                suffix = "iexact"
            return f"{EXACT_FIELD_MAP[field]}__{suffix}"
        if field in NONTEXT_FIELDS:
            if suffix not in ("substring", "iexact"):
                return f"{NONTEXT_FIELDS[field]}__{suffix}"
            return NONTEXT_FIELDS[field]
        raise ValueError(f"Unsupported field: {field}")

    def as_sql(self, context: Dict):
        field = self.field
        match = self.match
        # Simple term based search
        if not field:
            return (
                Q(source__substring=self.match)
                | Q(target__substring=self.match)
                | Q(context__substring=self.match)
            )

        # Field specific code
        field_method = getattr(self, f"{field}_field", None)
        if field_method is not None:
            return field_method(match, context)

        # Field conversion
        convert_method = getattr(self, f"convert_{field}", None)
        if convert_method is not None:
            match = convert_method(match)

        if isinstance(match, RegexExpr):
            # Regullar expression
            try:
                re.compile(match.expr)
            except re.error as error:
                raise ValueError(_("Invalid regular expression: {}").format(error))
            from weblate.trans.models import Unit

            with transaction.atomic():
                try:
                    Unit.objects.annotate(test=Value("")).filter(
                        test__trgm_regex=match.expr
                    ).exists()
                except DataError as error:
                    raise ValueError(str(error))
            return Q(**{self.field_name(field, "trgm_regex"): match.expr})

        if isinstance(match, tuple):
            start, end = match
            # Ranges
            if self.operator in (":", ":="):
                query = Q(
                    **{
                        self.field_name(field, "gte"): start,
                        self.field_name(field, "lte"): end,
                    }
                )
            elif self.operator in (":>", ":>="):
                query = Q(**{self.field_name(field, "gte"): start})
            else:
                query = Q(**{self.field_name(field, "lte"): end})

        elif isinstance(match, set):
            query = Q(**{self.field_name(field, "in"): match})
        else:
            # Generic query
            query = Q(**{self.field_name(field): match})

        return self.field_extra(field, query, match)


TERM.add_parse_action(TermExpr)


def parser_to_query(obj, context: Dict):
    # Simple lookups
    if isinstance(obj, TermExpr):
        return obj.as_sql(context)

    # Operators
    operator = "AND"
    expressions = []
    for item in obj:
        if isinstance(item, str) and item.upper() in ("OR", "AND", "NOT"):
            operator = item.upper()
            continue
        expressions.append(parser_to_query(item, context))

    if not expressions:
        return Q()

    if operator == "NOT":
        return ~expressions[0]
    if operator == "AND":
        return reduce(lambda x, y: x & y, expressions)
    return reduce(lambda x, y: x | y, expressions)


@lru_cache(maxsize=512)
def parse_string(text):
    if "\x00" in text:
        raise ValueError("Invalid query string.")
    return QUERY.parse_string(text, parse_all=True)


def parse_query(text, **context):
    parsed = parse_string(text)
    return parser_to_query(parsed, context)
