# Copyright (C) 2017-2023 J.F.Dockes
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

"""Translate an UPnP search string into a Recoll one and run the search. Note that the translation 
is not exact, we are just making a best effort."""

import sys
import re

from recoll import recoll

from upmplgutils import uplog
from conftree import stringToStrings
import uprclutils
import uprclinit


def _getchar(s, i):
    if i < len(s):
        return i + 1, s[i]
    else:
        return i, None


def _readword(s, i):
    """Extract word from whole string s starting at offset i. Return final position and word.
    The final position will point to whitespace or EOS"""
    # uplog(f"_readword: input: <{s[i:]}>")
    w = ""
    for j in range(i, len(s)):
        if s[j].isspace():
            break
        w += s[j]
    # If we broke at the end of string, increment to indicate eos
    if j == len(s) - 1:
        j += 1
    # uplog(f"_readword returning index {j} word <{w}>")
    return j, w


def _parsestring(s, i):
    """Parse double-quoted string looking like ["hello \"one phrase\" world"]
    Called with the first quote already read.
    Upnp search term strings are double quoted, but we should not take them as recoll phrases.
    We separate parts which are internally (backslash-escaped) quoted, and become phrases, and
    lists of words which we interpret as an AND search (comma-separated).
    Note that we don't handle possible quotes inside an escape-quoted phrase string."""

    # uplog(f"parseString: input: <{s[i:]}>")
    str = ""
    escape = False
    instring = False
    for j in range(i, len(s)):
        if instring:
            if escape:
                if s[j] == '"':
                    str += '"'
                    instring = False
                else:
                    str += "\\" + s[j]
                escape = False
            else:
                if s[j] == "\\":
                    escape = True
                else:
                    str += s[j]
        else:  # Not inside internal string
            if escape:
                str += s[j]
                escape = False
                if s[j] == '"':
                    instring = True
            else:
                if s[j] == "\\":
                    escape = True
                elif s[j] == '"':
                    j += 1
                    break
                else:
                    str += s[j]

    tokens = stringToStrings(str)
    # uplog(f"parseString: return: j {j} tokens {tokens}")
    return j, tokens


def _separatePhrasesAndWords(v):
    """Built separate lists for phrases and words from parseString output"""
    swords = ""
    phrases = []
    for w in v:
        if len(w.split()) == 1:
            if swords:
                swords += ","
            swords += w
        else:
            phrases.append(w)
    return (swords, phrases)


def _searchClauses(out, neg, field, oper, words, phrases):
    """Build recoll search clauses out of list of words and phrases and given negation, field name
    and operator, e.g. [title:word, title:"some phrase"]"""
    if words:
        if neg:
            out.append("-")
        out.append(field)
        out.append(oper)
        out.append(words)
    for ph in phrases:
        if neg:
            out.append("-")
        out.append(field)
        out.append(oper)
        out.append('"' + ph + '"')
    return out


def _makeSearchExp(out, v, fields, oper, neg):
    """Process data from an UPnP relExp triplet.
    Build a recoll search expression, given:
    v: terms or phrases out of _parseString.
    fields: one or several fields to be ORed (e.g. title -> title or filename)
    oper: :, = etc. "I" for ignore.
    neg: exclusion"""

    # uplog(f"_makeSearchExp: v <{v}> field <{field}> oper <{oper}> neg <{neg}>")

    if oper == "I":
        return

    # Test coming from, e.g. <upnp:class derivedfrom/= object.container.album>
    if (oper == ":" or oper == "=") and len(v) == 1:
        if v[0].startswith("object.container"):
            v = [
                "inode/directory",
            ]
        elif v[0].startswith("object.item"):
            neg = True
            v = [
                "inode/directory",
            ]

    swords, phrases = _separatePhrasesAndWords(v)

    # Special-case 'title' because we want to also match the file name. Possibly only for
    # directories, but there is no way to do inside a single query (see comment further).
    if isinstance(fields, str):
        fields = (fields,)

    if len(fields) > 1:
        out.append(" (")

    for i in range(len(fields)):
        field = fields[i]
        out.append(" (")
        _searchClauses(out, neg, field, oper, swords, phrases)
        # We'd like to do the following to avoid matching regular file names (maybe: what of the
        # untagged files?) but recoll takes all mime: clause as global filters, so can't work
        # anyway:
        # if i == 1: out.append(" AND mime:inode/directory")
        out.append(")")
        if len(fields) > 1 and i < len(fields) -1:
            if neg:
                out.append(" AND ")
            else:
                out.append(" OR ")

    if len(fields) > 1:
        out.append(") ")


# Some fields are found in searches but not in the upnp2rclfield dict, which would lead to a search
# failure
_fieldaliases = {
    "dc:creator": "upnp:artist",
}
# Some upnp fields need to be expanded to multiple recoll ones and result in an OR search
# Note that upnp has no notion at all of "composer" or "conductor", only upnp:artist or dc:creator.
# The "role" value is free text as far as I can see.
_fieldexpansions = {
    "upnp:artist": ("artist", "composer", "conductor", "author"),
    "dc:title": ("title", "filename"),
}

# Upnp searches are made of relExps which are always (selector, operator, value), there are no unary
# operators. The relExp-s can be joined with and/or and grouped with parentheses, but they are
# always triplets, which make things reasonably easy to translate into a recoll search, just
# translating the relExps into field clauses and forwarding the booleans and parentheses.
#
# Also the set of unquoted keywords or operators is unambiguous and all un-reserved values are
# quoted, so that we don't even use a state machine, but rely of comparing token values for
# determining our syntactic state.
#
# This is all quite approximative though, but simpler than a formal parser, and mostly works in
# practise because we rely on recoll to deal with logical operators and parentheses.
# UPnP search string grammar as from the standard:
#    searchCrit ::= searchExp | asterisk
#    searchExp  ::= relExp |
#                   searchExp wChar+ logOp wChar+ searchExp |
#                   '(' wChar* searchExp wChar* ')'
#    logOp      ::= 'and' | 'or'
#    relExp     ::= property wChar+ binOp wChar+ quotedVal |
#                   property wChar+ existsOp wChar+ boolVal
#    binOp      ::= relOp |stringOp
#    relOp      ::= '=' | '!=' | '<' | '<=' | '>' | '>='
#    stringOp   ::= 'contains' | 'doesNotContain' | 'derivedFrom'
#    existsOp   ::= 'exists'
#    boolVal    ::= 'true' | 'false'
#    wChar      ::= space | hTab | lineFeed | vTab | formFeed | return
#    property   ::= (* property name as defined in section 2.4 *)
#    escapedQuote ::= (* double-quote escaped string as defined in section 2.3.1 *)
def _upnpsearchtorecoll(s):
    uplog(f"_upnpsearchtorecoll:in: <{s}>")

    # Simplify: turn all whitespace sequences into single spaces
    s = re.sub(r"[\t\n\r\f ]+", " ", s)

    out = []
    field = ""
    oper = ""
    neg = False
    i = 0
    while True:
        i, c = _getchar(s, i)
        if not c:
            break
        # uplog(f"_upnpsearchtorecoll: nextchar: <{c}>")

        if c.isspace():
            continue

        if c == "*":
            if (len(out) > 1 or (len(out) == 1 and not out[-1].isspace())) or (
                len(s[i:]) and not s[i:].isspace()
            ):
                raise Exception("If * is used it must be the only input")
            out = ["mime:*"]
            break

        if c == "(" or c == ")":
            # uplog(f"Appending <{c}>")
            out.append(c)
        elif c == ">" or c == "<" or c == "=":
            oper += c
        else:
            if c == '"':
                # Quoted strings are always values (3rd triplet elements) and values are always
                # quoted. Read it, then generate and append one or several recoll search clauses,
                # according to the already read 1st and 2nd triplet elements.

                # We must have a field and an operator at this point.
                if not field or not oper:
                    uplog(f"Search: syntax error: [{s}]")
                    return None

                i, v = _parsestring(s, i)

                # If the operator is "=" for a string field, we transform the search into an
                # anchored phrase search.
                if field != "mime" and oper == "=" :
                    fs = "^" + " ".join(v) + "$"
                    v = [fs]
                    oper = ":"

                _makeSearchExp(out, v, field, oper, neg)

                # Reset everything and go on
                field = ""
                oper = ""
                neg = False
                continue

            # Unquoted word.
            i -= 1
            i, w = _readword(s, i)

            w = w.lower()
            if w == "contains":
                oper = ":"
            elif w == "doesnotcontain":
                neg = True
                oper = ":"
            elif w == "derivedfrom":
                oper = ":"
            elif w == "exists":
                # somefield exists true
                # can't use this, will be ignored
                oper = "I"
            elif w == "true" or w == "false":
                # Don't know what to do with this. Just ignore it,
                # by not calling makeSearchExp.
                pass
            elif w == "and":
                # Recoll has implied AND, but see next
                pass
            elif w == "or":
                # Does not work because OR/AND priorities are reversed between recoll and upnp. This
                # would be very difficult to correct, let's hope that the callers use parentheses
                out.append("OR")
            else:
                if w == "upnp:class":
                    field = "mime"
                else:
                    try:
                        if w in _fieldaliases:
                            w = _fieldaliases[w]
                        if w in _fieldexpansions:
                            field = _fieldexpansions[w]
                        else:
                            field = uprclutils.upnp2rclfields[w]
                    except Exception as ex:
                        uplog(f"Field translation error for <{w}>: {ex}. Ignoring search.")
                        # Recoll would just ignore an unknown field spec and search the main
                        # text. We don't want to do this, it yields confusing results (happens for
                        # example with someting like: upnp:artist[@role="composer"] from kazoo
                        return None

    return " ".join(out)


def search(foldersobj, rclconfdir, inobjid, upnps, idprefix, httphp, pathprefix):
    """Run UPnP search operation. inobjid is for the container this search is run from."""

    tags = uprclinit.getTree("tags")

    # Translate UPnP search string to recoll one
    rcls = _upnpsearchtorecoll(upnps)

    if not rcls:
        uplog(f"Upnp search string parse failed or ignored for [{upnps}]. Recoll search is empty")
        return []
    uplog(f"Search: recoll search: <{rcls}>")

    filterdir = foldersobj.dirpath(inobjid)
    if filterdir and filterdir != "/":
        # uplog(f"filterdir: <{filterdir}>")
        rcls += ' dir:"' + filterdir + '"'

    rcldb = recoll.connect(confdir=rclconfdir)
    try:
        rclq = rcldb.query()
        rclq.execute(rcls)
    except Exception as e:
        uplog("Search: recoll query raised: %s" % e)
        return []

    uplog("Estimated query results: %d" % (rclq.rowcount))
    if rclq.rowcount == 0:
        return []

    entries = []
    maxcnt = 0
    while True:
        docs = rclq.fetchmany()
        for doc in docs:
            # The doc is either an actual recollindex product from the
            # FS or a synthetic one from uprcltags creating album
            # entries. Different processing for either
            e = None
            if doc["rcludi"].find("albid") == 0:
                albid = doc["rcludi"][5:]
                # uplog(f"Search: album: {doc['rcludi']} albid {albid}")
                e = tags.direntryforalbid(albid)
            elif doc["rcludi"].find("artid") == 0:
                artid = doc["rcludi"][5:]
                # uplog(f"Search: artist: {doc['rcludi']} albid {albid}")
                e = tags.direntryforartid(artid)
            else:
                # Objidfordoc uses the path from the url to walk the _dirvec and determine the right
                # entry if doc is a container. If doc is an item, the returned id is not usable but
                # still unique (based on the xdocid). We used to return
                # (0$uprcl$folders$seeyoulater), but this ennoys bubble to no end.  This still
                # breaks the recommendation for the objids to be consistent and unchanging
                id = foldersobj.objidfordoc(doc)
                # uplog("Search: id [%s] for doc udi [%s]\n" % (id, doc["rcludi"]))
                e = uprclutils.rcldoctoentry(id, inobjid, httphp, pathprefix, doc)
            # uplog(f"Search: entry: {e}")
            if e:
                entries.append(e)
        if (maxcnt > 0 and len(entries) >= maxcnt) or len(docs) != rclq.arraysize:
            break
    uplog("Search retrieved %d docs" % (len(entries),))

    entries.sort(key=uprclutils.cmpentries)
    return entries


if __name__ == "__main__":
    s = '(upnp:artist derivedFrom  "abc\\"def\\g") or (dc:title:xxx) '
    s = 'upnp:class derivedfrom "object.container.album" and dc:title contains "n"'
    if len(sys.argv) > 1:
        s = sys.argv[1]
    print("INPUT: %s" % s)
    o = _upnpsearchtorecoll(s)
    print("OUTPUT: %s" % o)
