# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Part of the high-level python bindings for Kopano

Copyright 2018 - 2019 Kopano and its licensors (see LICENSE file)
"""

import datetime
import re

import dateutil.parser

"""This module converts Microsoft KQL (Keyword Query Language)
queries into MAPI restrictions, as used when for example searching
for specific mails or users.

To achieve this, it uses a hand-written parser, using the building
blocks in parse.py.
"""

from MAPI import (
    FL_SUBSTRING, FL_IGNORECASE, RELOP_GT, RELOP_EQ, PT_BOOLEAN, PT_UNICODE,
    BMR_EQZ, BMR_NEZ, PT_SYSTIME, RELOP_GE, RELOP_LT, RELOP_LE, RELOP_NE,
    PT_SHORT, PT_LONG, PT_LONGLONG, PT_FLOAT, PT_DOUBLE, MNID_ID, MNID_STRING,
    PT_MV_UNICODE, MAPI_TO, MAPI_CC, MAPI_BCC
)

from MAPI.Tags import (
    PR_SUBJECT_W, PR_BODY_W, PR_MESSAGE_DELIVERY_TIME, PR_HASATTACH,
    PR_MESSAGE_SIZE, PR_MESSAGE_FLAGS, MSGFLAG_READ, PR_SENDER_NAME_W,
    PR_DISPLAY_NAME_W, PR_SENT_REPRESENTING_NAME_W, PR_SMTP_ADDRESS_W,
    PR_MESSAGE_ATTACHMENTS, PR_ATTACH_LONG_FILENAME_W, PR_MESSAGE_RECIPIENTS,
    PR_RECIPIENT_TYPE, PR_SENDER_EMAIL_ADDRESS_W, PR_EMAIL_ADDRESS_W,
    PR_GIVEN_NAME_W, PR_COMPANY_NAME_W, PR_SURNAME_W, PR_OFFICE_LOCATION_W,
    PR_MOBILE_TELEPHONE_NUMBER_W, PR_BUSINESS_TELEPHONE_NUMBER_W,
    PR_POSTAL_CODE_W, PR_BIRTHDAY, PR_PREFERRED_BY_NAME_W,
    PR_RESPONSIBILITY, PR_USER_NAME, PR_FOLDER_CHILD_COUNT,
    PR_CONTENT_COUNT, PR_PARENT_ENTRYID, PR_CONTENT_UNREAD
)
from MAPI.Struct import (
    SOrRestriction, SAndRestriction, SNotRestriction, SContentRestriction,
    SPropValue, SPropertyRestriction, SBitMaskRestriction, SSubRestriction,
)

from MAPI.Defs import PROP_TYPE

from .errors import ArgumentError
from .restriction import Restriction
from .defs import PSETID_Address, PS_PUBLIC_STRINGS
# Backwards compatible import for older python-mapi versions without datetime_to_filetime available in MAPI.Time
from . import utils as _utils

from .parse import (
    ParserInput, Parser, Char, CharSet, ZeroOrMore, OneOrMore, Sequence,
    Choice, Optional, Wrapper, NoMatch
)

# TODO such grouping: 'subject:(fresh exciting)'
# TODO Regex: avoid substr
# TODO OneOrMore(regex) not needed?
# TODO operator associativity/precedence (e.g. 'NOT a AND b'), check MSG
# TODO escaping double quotes
# TODO asterisk not implicit for phrases
# TODO relative dates rel. to timezone (e.g. "received:today")
# TODO graph does not support 'size>"10 KB" and such? we now roll our own
# TODO email matching on to/cc/bcc (PR_SEARCH_KEY/PR_EMAIL_ADDRESS?)
# TODO sender:user2@domain.com OR category:blue doesn't work, even if they
# separately work?

EMAIL1_NAME = (PSETID_Address, MNID_ID, 0x8083, PT_UNICODE) # TODO merge
CATEGORY_NAME = (PS_PUBLIC_STRINGS, MNID_STRING, 'Keywords', PT_MV_UNICODE)

RECIP_PROPS = [PR_DISPLAY_NAME_W, PR_EMAIL_ADDRESS_W, PR_SMTP_ADDRESS_W]

MESSAGE_KEYWORD_PROP = {
    'subject': PR_SUBJECT_W,
    'body': PR_BODY_W,
    'content': PR_BODY_W, # TODO what does content mean
    'received': PR_MESSAGE_DELIVERY_TIME,
    'hasAttachment': PR_HASATTACH,
    'hasAttachments': PR_HASATTACH,
    'size': PR_MESSAGE_SIZE,
    'read': (PR_MESSAGE_FLAGS, MSGFLAG_READ),
    'from': PR_SENT_REPRESENTING_NAME_W, # TODO email address
    # TODO why does 'from:user1@domain.com' work with just PR_SENDER_NAME_W!?
    'sender': [PR_SENDER_NAME_W, PR_SENDER_EMAIL_ADDRESS_W],
    'attachment': (PR_MESSAGE_ATTACHMENTS, PR_ATTACH_LONG_FILENAME_W),
    'category': CATEGORY_NAME,
    'to': (PR_MESSAGE_RECIPIENTS, RECIP_PROPS, MAPI_TO),
    'cc': (PR_MESSAGE_RECIPIENTS, RECIP_PROPS, MAPI_CC),
    'bcc': (PR_MESSAGE_RECIPIENTS, RECIP_PROPS, MAPI_BCC),
    'participants': (PR_MESSAGE_RECIPIENTS, RECIP_PROPS, None),
}

CONTACT_KEYWORD_PROP = {
    'name': PR_DISPLAY_NAME_W,
    'email': EMAIL1_NAME,
}

USER_KEYWORD_PROP = {
    'name': PR_DISPLAY_NAME_W,
    'givenName': PR_GIVEN_NAME_W,
    'preferredName': PR_PREFERRED_BY_NAME_W,
    'surname': PR_SURNAME_W,
    'userPrincipalName': PR_USER_NAME,
    'mail': PR_SMTP_ADDRESS_W,
    'mobilePhone': PR_MOBILE_TELEPHONE_NUMBER_W,
    'postalCode': PR_POSTAL_CODE_W,
    'birthday': PR_BIRTHDAY,
    'companyName': PR_COMPANY_NAME_W,
    'officeLocation': PR_OFFICE_LOCATION_W,
    'businessPhones': PR_BUSINESS_TELEPHONE_NUMBER_W,
    'responsibilities': PR_RESPONSIBILITY,
}

FOLDER_KEYWORD_PROP = {
    'displayName': PR_DISPLAY_NAME_W,
    'childFolderCount': PR_FOLDER_CHILD_COUNT,
    'parentFolderId': PR_PARENT_ENTRYID,
    'totalItemCount': PR_CONTENT_COUNT,
    'unreadItemCount': PR_CONTENT_UNREAD,
}

TYPE_KEYWORD_PROPMAP = {
    'message': MESSAGE_KEYWORD_PROP,
    'contact': CONTACT_KEYWORD_PROP,
    'user': USER_KEYWORD_PROP,
    'folder': FOLDER_KEYWORD_PROP,
}

DEFAULT_PROPTAGS = {
    'message': [PR_SUBJECT_W, PR_BODY_W, PR_SENT_REPRESENTING_NAME_W],
    'contact': [PR_DISPLAY_NAME_W, EMAIL1_NAME],
    'user': [PR_DISPLAY_NAME_W, PR_SMTP_ADDRESS_W],
    'folder': [PR_DISPLAY_NAME_W],
}

OP_RELOP = {
    '<': RELOP_LT,
    '>': RELOP_GT,
    '>=': RELOP_GE,
    '<=': RELOP_LE,
    '<>': RELOP_NE,
}


def _interval_restriction(proptag, start, end):
    start = _utils.datetime_to_filetime(start)
    end = _utils.datetime_to_filetime(end)

    return SAndRestriction([
        SPropertyRestriction(RELOP_GE, proptag, SPropValue(proptag, start)),
        SPropertyRestriction(RELOP_LT, proptag, SPropValue(proptag, end))
    ])

# AST node
class Term(object):
    def __init__(self, sign=None, field=None, op=None, value=None, hoepa=None):
        self.sign = sign
        self.field = field
        self.op = op
        self.value = value

    def restriction(self, type_, store):
        if self.field:
            # determine proptag for term, eg 'subject'
            proptag = TYPE_KEYWORD_PROPMAP[type_][self.field]
            flag = None
            subobj = None
            recipient_type = None

            # property in sub-object (attachments/recipient): sub-restriction
            if isinstance(proptag, tuple):
                if(proptag[0]) == PR_MESSAGE_ATTACHMENTS:
                    subobj, proptag = proptag
                elif(proptag[0]) == PR_MESSAGE_RECIPIENTS:
                    subobj, proptag, recipient_type = proptag
                elif len(proptag) == 2:
                    proptag, flag = proptag

                # named property: resolve local proptag
                elif len(proptag) == 4:
                    proptag = store._name_id(proptag[:3]) | proptag[3]

            # make restriction on proptag(s)
            if isinstance(proptag, list):
                restr = SOrRestriction([
                    self.prop_restriction(proptag, flag) for proptag in proptag
                ])
            else:
                restr = self.prop_restriction(proptag, flag)

            # turn restriction into sub-restriction
            if subobj:
                if recipient_type is not None:
                    restr = SAndRestriction([
                        restr,
                        SPropertyRestriction(
                                RELOP_EQ,
                                PR_RECIPIENT_TYPE,
                                SPropValue(PR_RECIPIENT_TYPE, recipient_type)
                        )
                    ])
                restr = SSubRestriction(subobj, restr)

        else:
            defaults = [(store._name_id(proptag[:3]) | proptag[3])
                           if isinstance(proptag, tuple) else proptag
                               for proptag in DEFAULT_PROPTAGS[type_]]

            restr = SOrRestriction([
                       SContentRestriction(
                           FL_SUBSTRING | FL_IGNORECASE,
                           p,
                           SPropValue(p, self.value)
                       ) for p in defaults
                   ])

        if self.sign == '-':
            restr = SNotRestriction(restr)

        return restr

    def prop_restriction(self, proptag, flag):
        # comparison operator
        if self.op in ('<', '>', '>=', '<=', '<>'):
            if PROP_TYPE(proptag) == PT_SYSTIME:
                d = dateutil.parser.parse(self.value)
                d = _utils.datetime_to_filetime(d)
                restr = SPropertyRestriction(
                            OP_RELOP[self.op],
                            proptag,
                            SPropValue(proptag, d)
                        )
            else:
                value = self.value
                unit = ''
                if [x for x in ('KB', 'MB', 'GB') if value.endswith(x)]:
                    value, unit = value[:-2], value[-2:]

                value = int(value)

                if unit == 'KB':
                    value *= 1024
                elif unit == 'MB':
                    value *= 1024**2
                elif unit == 'GB':
                    value *= 1024**3

                restr = SPropertyRestriction(
                            OP_RELOP[self.op],
                            proptag,
                            SPropValue(proptag, value)
                        )

        # contains/equals operator
        elif self.op in (':', '='):
            if PROP_TYPE(proptag) == PT_UNICODE:
                restr = SContentRestriction(
                            FL_SUBSTRING | FL_IGNORECASE,
                            proptag,
                            SPropValue(proptag, self.value)
                        )

            elif flag or PROP_TYPE(proptag) == PT_BOOLEAN:
                if flag:
                    restr = SBitMaskRestriction(
                        BMR_NEZ if self.value in ('yes', 'true') else BMR_EQZ,
                                proptag,
                                flag
                    )
                else:
                    restr = SPropertyRestriction(
                        RELOP_EQ,
                        proptag,
                        SPropValue(proptag, self.value in ('yes', 'true'))
                    )

            elif PROP_TYPE(proptag) == PT_MV_UNICODE:
                proptag2 = (proptag ^ PT_MV_UNICODE) | PT_UNICODE # funky!
                restr = SContentRestriction(
                            FL_SUBSTRING | FL_IGNORECASE,
                            proptag,
                            SPropValue(proptag2, self.value)
                        )

            elif (PROP_TYPE(proptag) in \
                  (PT_SHORT, PT_LONG, PT_LONGLONG, PT_FLOAT, PT_DOUBLE)):
                conv = float if PROP_TYPE(proptag) in (PT_FLOAT, PT_DOUBLE) \
                    else int
                if '..' in self.value:
                    val1, val2 = self.value.split('..')
                    restr = SAndRestriction([
                        SPropertyRestriction(
                            RELOP_GE,
                            proptag,
                            SPropValue(proptag, conv(val1))
                        ),
                        SPropertyRestriction(
                            RELOP_LT,
                            proptag,
                            SPropValue(proptag, conv(val2))
                        )
                    ])
                else:
                    restr = SPropertyRestriction(
                                RELOP_EQ,
                                proptag,
                                SPropValue(proptag, conv(self.value))
                            )

            elif PROP_TYPE(proptag) == PT_SYSTIME:
                if self.value == 'today':
                    d = datetime.datetime.now().date()
                    d2 = d + datetime.timedelta(days=1)
                    restr = _interval_restriction(proptag, d, d2)

                elif self.value == 'yesterday':
                    d2 = datetime.datetime.now().date()
                    d = d2 - datetime.timedelta(days=1)
                    restr = _interval_restriction(proptag, d, d2)

                elif self.value == 'this week':
                    d2 = datetime.datetime.now()
                    d = d2.date() - datetime.timedelta(days=d2.weekday())
                    restr = _interval_restriction(proptag, d, d2)

                elif self.value == 'this month':
                    d2 = datetime.datetime.now()
                    d = d2.date() - datetime.timedelta(days=d2.day-1)
                    restr = _interval_restriction(proptag, d, d2)

                elif self.value == 'last month':
                    now = datetime.datetime.now()
                    d2 = now.date() - datetime.timedelta(days=now.day-1)
                    d = (d2 - datetime.timedelta(days=1)).replace(day=1)
                    restr = _interval_restriction(proptag, d, d2)

                elif self.value == 'this year':
                    d2 = datetime.datetime.now()
                    d = datetime.datetime(d2.year, 1, 1)
                    restr = _interval_restriction(proptag, d, d2)

                elif self.value == 'last year':
                    now = datetime.datetime.now()
                    d2 = datetime.datetime(now.year, 1, 1)
                    d = datetime.datetime(d2.year-1, 1, 1)
                    restr = _interval_restriction(proptag, d, d2)

                elif '..' in self.value:
                    date1, date2 = self.value.split('..') # TODO hours etc
                    d = dateutil.parser.parse(date1)
                    d2 = dateutil.parser.parse(date2)
                    restr = _interval_restriction(proptag, d, d2)

                else:
                    d = dateutil.parser.parse(self.value) # TODO hours etc
                    d2 = d + datetime.timedelta(days=1)
                    restr = _interval_restriction(proptag, d, d2)

        return restr

class Operation(object):
    def __init__(self, op=None, args=None):
        self.op = op
        self.args = args

    def restriction(self, type_, store):
        if self.op == 'AND':
            return SAndRestriction(
                [arg.restriction(type_, store) for arg in self.args]
            )
        elif self.op == 'OR':
            return SOrRestriction(
                [arg.restriction(type_, store) for arg in self.args]
            )
        elif self.op == 'NOT':
            return SNotRestriction(
                self.args[0].restriction(type_, store)
            )

# build parser

class Regex(Parser):
    def __init__(self, regex):
        self._re = re.compile(regex)

    def parse(self, parser_input) :
        if parser_input.remaining() == 0:
            return NoMatch()
        rest = parser_input._data[parser_input._position:] # TODO slow
        match = self._re.match(rest)
        if not match:
            return NoMatch()
        else:
            n = match.end()
            parser_input.read(n)
            parser_input.inc_position(n)
            return self.match(rest[:n])

def _build_parser():
    whitespace = CharSet(' ')

    alphaspace = Regex(r'[\w \-+*@.]+')
    alphaplus = Regex(r'[\w\-+:<>=@.]+')

    word = OneOrMore(Regex(r'[\w+\-\*@\./]'))
    word.modifier = lambda t: ''.join(t)

    text = OneOrMore(alphaspace)
    text.modifier = lambda t: ''.join(t)

    quoted = Sequence(Char('"'), text, Char('"'))
    quoted.modifier = lambda t: ''.join(t)[1:-1]

    value = Choice(word, quoted)

    def op(s):
        operator = Sequence(*(Char(c) for c in s))
        operator.modifier = lambda t: ''.join(t)
        return operator

    operator = Choice(op(':'), op('='), op('<='), op('>='), op('<>'),
                      op('<'), op('>'))

    sign = CharSet('+-')

    term = Sequence(Optional(sign), Optional(Sequence(word, operator)), value)
    term.modifier = lambda t: Term(
        sign=t[0] if t[0] else None,
        field=t[1][0] if t[1] else None,
        op=t[1][1] if t[1] else None,
        value=t[2],
        hoepa=t
    )

    termplus = OneOrMore(alphaplus)
    termplus.modifier = lambda t: ''.join(t)

    term_fallback = Sequence(ZeroOrMore(whitespace), Choice(term, termplus))
    term_fallback.modifier = lambda t: \
        (Term(value=t[1]) if not isinstance(t[1], Term) else t[1])

    lpar = Sequence(ZeroOrMore(whitespace), op('('))
    rpar = Sequence(ZeroOrMore(whitespace), op(')'))

    expr = Wrapper()

    bracketed = Sequence(lpar, expr, rpar)
    bracketed.modifier = lambda t: t[1]

    unit = Choice(bracketed, term_fallback)

    and_ = Sequence(ZeroOrMore(whitespace), op('AND'))
    and_.modifier = lambda t: 'AND'
    or_ = Sequence(ZeroOrMore(whitespace), op('OR'))
    or_.modifier = lambda t: 'OR'
    not_ = Sequence(ZeroOrMore(whitespace), op('NOT'))
    not_.modifier = lambda t: 'NOT'

    wsexpr = Sequence(ZeroOrMore(whitespace), expr)
    wsexpr.modifier = lambda t: t[1]

    wsexpr2 = Sequence(OneOrMore(whitespace), expr)
    wsexpr2.modifier = lambda t: t[1]

    andor = Sequence(unit,
        Optional(Sequence(Optional(Choice(and_, or_)),
            Choice(bracketed, wsexpr))))
    def modifier(t):
        if t[1] is None:
            return t[0]
        else:
            return Operation(op=t[1][0] or 'AND', args=[t[0], t[1][1]])
    andor.modifier = modifier

    nott = Sequence(not_, Choice(bracketed, wsexpr2))
    nott.modifier = lambda t: Operation(op=t[0], args=[t[1]])

    expr.parser = Choice(nott, andor)

    return expr

_PARSER = _build_parser()

def _query_to_restriction(query, type_, store):
    query = str(query)
    try:
        ast = _PARSER.parse(ParserInput(query)).value
        return Restriction(ast.restriction(type_, store))
    except Exception:
        raise ArgumentError("could not process query")
