# (c) cavaliba.com - data - search.py

import re
import shlex

from django.db.models import FloatField, Q
from django.db.models.functions import Cast

from .models import DataEAV, DataInstance

# Direct columns on DataEAV (not stored as fieldname/value pairs in EAV rows)
_META_FIELDS = {"is_enabled", "keyname", "displayname"}

_BOOL_OPS = {"AND", "OR"}

_COMPARE_OPS = [
    (">=", "gte"),
    ("<=", "lte"),
    (">", "gt"),
    ("<", "lt"),
]


def get_query_from_request(request=None, classname=None, update_session=False):

    if not request:
        return None

    if not classname:
        return None

    query = None

    # GET request => URL param (priority) or session or blank
    if request.method == "GET":
        get_query = request.GET.get("query", None)
        if get_query is not None:
            if update_session:
                if "query" not in request.session:
                    request.session["query"] = {classname: get_query}
                else:
                    request.session["query"][classname] = get_query
                    request.session.modified = True
            return get_query
        try:
            return request.session["query"][classname]
        except Exception:
            return None

    # POST request : store in session if valid
    elif request.method == "POST":
        query = request.POST.get("query", None)
        m = re.compile(r'^[a-zA-Z0-9()_\/\.\s:\'"*><=-]*$')
        if not m.match(query):
            query = ""

        if update_session:
            if "query" not in request.session:
                request.session["query"] = {classname: query}
            else:
                request.session["query"][classname] = query
                request.session.modified = True

    return query


def get_instance_from_query(
    query=None, classname=None, offset=None, limit=None, page=None, size=None
):

    if not query:
        query = ""

    # special: RELATED:REF_CLASSNAME:REF_KEYNAME (case-insensitive for backward compat)
    if query.lower().startswith("related:"):
        parts = query.split(":", 2)
        if len(parts) == 3:
            iids = get_instance_ids_from_related(parts[1], parts[2], classname)
            return DataInstance.objects.filter(id__in=iids)

    if page and size:
        offset = (page - 1) * size
        limit = offset + size
    if not offset:
        offset = 0
    if limit:
        try:
            int(limit)
            if limit <= 0:
                return
        except Exception:
            return

    if len(query) > 0:
        q = Q()
        for item in query.split():
            q |= Q(keyname__icontains=item)
            q |= Q(displayname__icontains=item)
            q |= Q(data_json__icontains=item)

        if classname:
            qc = Q(classname=classname)
            q2 = qc & q
        else:
            q2 = q

        if limit:
            instances = DataInstance.objects.filter(q2)[offset:limit]
        else:
            instances = DataInstance.objects.filter(q2)[offset:]

    else:
        if limit:
            instances = DataInstance.objects.filter(classname=classname)[offset:limit]
        else:
            instances = DataInstance.objects.filter(classname=classname)[offset:]

    return instances


def get_instance_ids_from_related(ref_classname, ref_keyname, classname):
    return (
        DataEAV.objects.filter(
            classname=classname,
            format="schema:" + ref_classname,
            value=ref_keyname,
        )
        .values_list("iid", flat=True)
        .distinct()
    )


# ---------------------------------------------------------------------------
# Advanced search — EAV-based
# ---------------------------------------------------------------------------


def tokenize_query(query_str):
    """Split query string into tokens, preserving quoted strings."""
    if not query_str:
        return []
    try:
        return shlex.split(query_str)
    except ValueError:
        return query_str.split()


def _parse_value_expr(expr):
    """Parse a value expression into (compare_op, value_str)."""
    for prefix, op in _COMPARE_OPS:
        if expr.startswith(prefix):
            return op, expr[len(prefix) :]

    starts_star = expr.startswith("*")
    ends_star = expr.endswith("*")
    if starts_star and ends_star:
        return "icontains", expr[1:-1]
    if starts_star:
        return "iendswith", expr[1:]
    if ends_star:
        return "istartswith", expr[:-1]

    return "icontains", expr


def parse_atom(token):
    """
    Parse a single token into a term dict.
    Keys: type, field, compare_op, value, is_meta, ref_classname, ref_keyname
    """
    token_upper = token.upper()

    # related:CLASSNAME:KEYNAME — split on first two colons
    if token_upper.startswith("RELATED:"):
        parts = token.split(":", 2)
        if len(parts) == 3:
            return {
                "type": "related",
                "ref_classname": parts[1],  # preserve case — must match EAV format field
                "ref_keyname": parts[2],
                "field": None,
                "compare_op": None,
                "value": None,
                "is_meta": False,
            }

    # EXISTS:fieldname
    if token_upper.startswith("EXISTS:"):
        return {
            "type": "exists",
            "field": token[7:],
            "compare_op": None,
            "value": None,
            "is_meta": False,
            "ref_classname": None,
            "ref_keyname": None,
        }

    # EMPTY:fieldname
    if token_upper.startswith("EMPTY:"):
        return {
            "type": "empty",
            "field": token[6:],
            "compare_op": None,
            "value": None,
            "is_meta": False,
            "ref_classname": None,
            "ref_keyname": None,
        }

    # field:value_expr — split on first colon only
    if ":" in token:
        colon_pos = token.index(":")
        field = token[:colon_pos]
        expr = token[colon_pos + 1 :]
        compare_op, value = _parse_value_expr(expr)
        return {
            "type": "field",
            "field": field,
            "compare_op": compare_op,
            "value": value,
            "is_meta": field in _META_FIELDS,
            "ref_classname": None,
            "ref_keyname": None,
        }

    # Plain word — fulltext search across all EAV values
    return {
        "type": "fulltext",
        "field": None,
        "compare_op": "icontains",
        "value": token,
        "is_meta": False,
        "ref_classname": None,
        "ref_keyname": None,
    }


def parse_query(tokens):
    """
    Build list of (connector, negate, term_dict) from token list.
    Default connector between terms is AND. NOT modifies the next atom only.
    """
    result = []
    connector = "AND"
    negate = False

    for token in tokens:
        if token in _BOOL_OPS:
            connector = token
        elif token == "NOT":
            negate = True
        else:
            result.append((connector, negate, parse_atom(token)))
            connector = "AND"
            negate = False

    return result


def _coerce_bool(value):
    """Convert string representation to Python bool."""
    if isinstance(value, bool):
        return value
    return str(value).lower() in ("true", "1", "yes")


def _get_pred_for_term(term, negate, classname):
    """
    Return a Q predicate suitable for DataInstance.objects.filter().
    All DataEAV subqueries stay as SQL — no Python-side materialisation.
    """
    t = term["type"]

    if t == "fulltext":
        v = term["value"]
        eav_q = Q(value__icontains=v) | Q(keyname__icontains=v) | Q(displayname__icontains=v)
        if classname:
            eav_q = Q(classname=classname) & eav_q
        iid_qs = DataEAV.objects.filter(eav_q).values_list("iid", flat=True).distinct()
        pred = Q(id__in=iid_qs)
        return ~pred if negate else pred

    if t == "exists":
        eav_q = Q(fieldname__iexact=term["field"])
        if classname:
            eav_q = Q(classname=classname) & eav_q
        iid_qs = DataEAV.objects.filter(eav_q).values_list("iid", flat=True).distinct()
        pred = Q(id__in=iid_qs)
        return ~pred if negate else pred

    if t == "empty":
        # EAV never stores empty/zero-length values (skipped in update_eav).
        # "EMPTY:field" = no EAV row exists for that fieldname.
        eav_q = Q(fieldname__iexact=term["field"])
        if classname:
            eav_q = Q(classname=classname) & eav_q
        has_value_iids = DataEAV.objects.filter(eav_q).values_list("iid", flat=True).distinct()
        if negate:
            # NOT EMPTY: instances that DO have a non-empty EAV row
            return Q(id__in=has_value_iids)
        else:
            # EMPTY: instances that do NOT have a non-empty EAV row
            return ~Q(id__in=has_value_iids)

    if t == "related":
        iid_qs = get_instance_ids_from_related(
            term["ref_classname"], term["ref_keyname"], classname
        )
        pred = Q(id__in=iid_qs)
        return ~pred if negate else pred

    # type == "field"
    field = term["field"]
    compare_op = term["compare_op"]
    value = term["value"]

    # Meta fields map to direct DataInstance columns — no DataEAV subquery needed
    if term["is_meta"]:
        if field == "is_enabled":
            pred = Q(is_enabled=_coerce_bool(value))
        else:
            # keyname, displayname
            pred = Q(**{f"{field}__{compare_op}": value})
        return ~pred if negate else pred

    # Regular EAV field: filter by fieldname/value in DataEAV
    eav_filter = {"fieldname__iexact": field}
    if classname:
        eav_filter["classname"] = classname

    if compare_op in ("gt", "gte", "lt", "lte"):
        try:
            num = float(value)
            iid_qs = (
                DataEAV.objects.filter(**eav_filter)
                .annotate(_num=Cast("value", output_field=FloatField()))
                .filter(**{f"_num__{compare_op}": num})
                .values_list("iid", flat=True)
                .distinct()
            )
        except (ValueError, TypeError):
            # Non-numeric value for a numeric operator — fall back to string comparison
            iid_qs = (
                DataEAV.objects.filter(**eav_filter, **{f"value__{compare_op}": value})
                .values_list("iid", flat=True)
                .distinct()
            )
    else:
        iid_qs = (
            DataEAV.objects.filter(**eav_filter, **{f"value__{compare_op}": value})
            .values_list("iid", flat=True)
            .distinct()
        )

    pred = Q(id__in=iid_qs)
    return ~pred if negate else pred


def get_instance_from_advanced_query(
    query=None, classname=None, offset=None, limit=None, page=None, size=None
):
    """
    Advanced instance search using DataEAV.
    Supports AND/OR/NOT, field:value, wildcards, numeric comparisons, EXISTS:, EMPTY:.
    All filtering is performed in the DB via Q objects — no Python-side instance loading.
    Returns a DataInstance queryset.
    """
    if not query:
        query = ""

    if page and size:
        offset = (page - 1) * size
        limit = offset + size
    offset = offset or 0

    base_qs = (
        DataInstance.objects.filter(classname=classname)
        if classname
        else DataInstance.objects.all()
    )

    if not query:
        return base_qs[offset:limit] if limit else base_qs[offset:]

    tokens = tokenize_query(query)
    parsed = parse_query(tokens)

    if not parsed:
        return base_qs[offset:limit] if limit else base_qs[offset:]

    overall_q = None
    for connector, negate, term in parsed:
        pred = _get_pred_for_term(term, negate, classname)
        if overall_q is None:
            overall_q = pred
        elif connector == "OR":
            overall_q = overall_q | pred
        else:
            overall_q = overall_q & pred

    qs = base_qs.filter(overall_q)
    return qs[offset:limit] if limit else qs[offset:]
