# Cherrypy-Foundation
# Copyright (C) 2026 IKUS Software
#
# 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 <https://www.gnu.org/licenses/>.

import logging
import re
from typing import Any

import cherrypy
from sqlalchemy import String, cast, or_
from sqlalchemy.orm import Query

CONTEXT = 'DATATABLETOOL'


class DataTableQuery:
    """
    Reusable SQLAlchemy-DataTables server-side helper.

    Column resolution uses the DataTables name/data protocol:
      - Sorting  : order[0][name]  (then order[0][column] → columns[N][name/data] as fallback)
      - Per-col  : columns[N][name] or columns[N][data]

    The name/data value must match the SQLAlchemy column's ``key``
    (i.e. the attribute name on the mapped class, e.g. ``"username"``).

    Usage:
        result = DataTableQuery(
            query=db.session.query(Message).join(UserObject).with_entities(
                Message.body, Message.created_at, UserObject.username
            ),
            search_columns=[Message.body, UserObject.username],
            params=request.params,
        ).paginate(draw=draw, start=start, length=length)
    """

    DEFAULT_DIR = "desc"
    DEFAULT_COL = 0
    MAX_LENGTH = 100

    def __init__(
        self,
        query: Query,
        params: dict[str, Any],
        search_columns: list | None = None,
        max_length: int = MAX_LENGTH,
        default_dir: str = DEFAULT_DIR,
        default_col: int = DEFAULT_COL,
    ) -> None:
        self._query = query
        self._params = params
        self._search_columns = search_columns
        self._max_length = max_length
        self._default_dir = default_dir
        self._default_col = default_col

        # Count total records before any filtering
        self._total_records = query.count()

        self._apply_sorting()
        self._apply_global_search()
        self._apply_column_search()

        # Count filtered records after search/filter applied
        self._filtered_records = self._query.count()

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def paginate(self, draw: int, start: int, length: int) -> dict:
        """
        Apply pagination and return the DataTables-compatible response dict.
        """
        length = min(length, self._max_length)
        rows = self._query.offset(start).limit(length).all()

        return {
            "draw": draw,
            "recordsTotal": self._total_records,
            "recordsFiltered": self._filtered_records,
            "data": [self._serialize(row) for row in rows],
        }

    # ------------------------------------------------------------------
    # Sorting
    # ------------------------------------------------------------------

    def _apply_sorting(self) -> None:
        raw_col = self._params.get("order[0][column]", self._default_col)
        raw_dir = self._params.get("order[0][dir]", self._default_dir)

        # Validate direction
        direction = raw_dir if raw_dir in ("asc", "desc") else None
        if direction is None:
            cherrypy.log(
                "Invalid sort direction %r. Falling back to %r." % (raw_dir, self._default_dir),
                context=CONTEXT,
                severity=logging.WARNING,
            )
            direction = self._default_dir

        # Resolve the column name
        col_name = self._params.get(f"columns[{raw_col}][data]", "").strip()
        if not col_name:
            cherrypy.log(
                "Cannot resolve sort column name from order[0][column]. Sorting skipped.",
                context=CONTEXT,
                severity=logging.WARNING,
            )
            return

        column = self._get_query_column(col_name)
        if column is None:
            return

        self._query = self._query.order_by(column.asc() if direction == "asc" else column.desc())

    # ------------------------------------------------------------------
    # Global search
    # ------------------------------------------------------------------

    def _apply_global_search(self) -> None:
        search_value = self._params.get("search[value]", "").strip()
        if not search_value:
            return

        if not self._search_columns:
            cherrypy.log(
                "Global search requested but search_columns is not defined. Skipping.",
                context=CONTEXT,
                severity=logging.WARNING,
            )
            return

        conditions = [col.ilike(f"%{search_value}%") for col in self._search_columns]
        self._query = self._query.filter(or_(*conditions))

    # ------------------------------------------------------------------
    # Per-column search
    # ------------------------------------------------------------------

    def _apply_column_search(self) -> None:
        col_idx = 0

        while True:
            search_key = f"columns[{col_idx}][search][value]"
            if search_key not in self._params:
                break

            value = self._params.get(search_key, "").strip()
            is_regex = self._params.get(f"columns[{col_idx}][search][regex]", "false").lower() == "true"

            # Resolve column name: prefer columns[N][name], fall back to columns[N][data]
            col_name = (
                self._params.get(f"columns[{col_idx}][name]", "").strip()
                or self._params.get(f"columns[{col_idx}][data]", "").strip()
            )

            if value and col_name:
                column = self._get_query_column(col_name)
                if column is not None:
                    if is_regex and self._is_valid_regex(value, col_name):
                        self._query = self._query.filter(cast(column, String).regexp_match(value))
                    else:
                        self._query = self._query.filter(cast(column, String).ilike(f"%{value}%"))

            col_idx += 1

    # ------------------------------------------------------------------
    # Column resolution
    # ------------------------------------------------------------------

    def _get_query_column(self, col_name: str):
        """
        Resolve a SQLAlchemy column expression by name from the query's
        ``column_descriptions``.

        Resolution order:
          1. ``column_descriptions[N]["name"]`` — the attribute name SQLAlchemy
             assigns to each selected expression (e.g. ``"username"``).
          2. ``column_descriptions[N]["expr"].key`` — explicit label or mapped
             attribute key on the expression itself.

        Returns the matched expression, or ``None`` if no match is found.

        Requirements
        ------------
        The query **must** use explicit column selection, e.g.::

            session.query(User.username, User.email)
            # or
            session.query(Message).join(User).with_entities(
                Message.body, User.username
            )

        Queries that select a full mapped entity (``session.query(User)``) are
        not supported — the resolver will log a warning and return ``None`` for
        any name lookup against such an entity slot.
        """
        for desc in self._query.column_descriptions:
            expr = desc.get("expr")

            # Skip full model entity slots
            if isinstance(expr, type):
                continue

            # Match against the descriptor name SQLAlchemy derived from the expression
            if desc.get("name") == col_name:
                return expr

            # Match against the expression's own key (covers labeled columns)
            if hasattr(expr, "key") and expr.key == col_name:
                return expr

        cherrypy.log(
            "Column name %r not found in query column_descriptions. "
            "Available names: %s. Skipping."
            % (
                col_name,
                [d.get("name") for d in self._query.column_descriptions],
            ),
            context=CONTEXT,
            severity=logging.WARNING,
        )
        return None

    # ------------------------------------------------------------------
    # Regex validation
    # ------------------------------------------------------------------

    @staticmethod
    def _is_valid_regex(pattern: str, col_name: str) -> bool:
        try:
            re.compile(pattern)
            return True
        except re.error:
            cherrypy.log(
                "Invalid regex pattern %r for column %r. Skipping column filter." % (pattern, col_name),
                context=CONTEXT,
                severity=logging.WARNING,
            )
            return False

    # ------------------------------------------------------------------
    # Serialization
    # ------------------------------------------------------------------

    @staticmethod
    def _serialize(row: Any) -> dict:
        """
        Serialize a SQLAlchemy result row to a dict.
        Supports:
          - Named tuples (multi-column / with_entities queries)
          - Single model instances
        """
        if hasattr(row, "_fields"):
            return {field: DataTableQuery._serialize_value(value) for field, value in zip(row._fields, row)}

        if hasattr(row, "__table__"):
            return {col.name: DataTableQuery._serialize_value(getattr(row, col.name)) for col in row.__table__.columns}

        raise TypeError(f"Cannot serialize row of type {type(row)!r}")

    @staticmethod
    def _serialize_value(value: Any) -> Any:
        """Convert non-JSON-serializable types to safe primitives."""
        from datetime import date, datetime
        from decimal import Decimal

        match value:
            case datetime():
                return value.isoformat()
            case date():
                return value.isoformat()
            case Decimal():
                return float(value)
            case _:
                return value


# ----------------------------------------------------------------------
# CherryPy Tool — handler replacement pattern (mirrors json_out)
# ----------------------------------------------------------------------


def datatable_handler(*args, **kwargs):
    """
    Replacement handler. Calls the original handler, expects a Query back,
    runs DataTableQuery, and returns the DataTables response dict.
    The outer json_out tool (or similar) is responsible for final JSON encoding.
    """
    request = cherrypy.serving.request
    params = request.params

    # Call the original handler to get the Query
    query = request._datatables_inner_handler(*args, **kwargs)

    if not isinstance(query, Query):
        cherrypy.log(
            "Handler must return a SQLAlchemy Query instance, got %r instead." % type(query),
            context=CONTEXT,
            severity=logging.WARNING,
        )
        return None

    try:
        draw = int(params.get("draw", 0))
        start = int(params.get("start", 0))
        length = int(params.get("length", DataTableQuery.MAX_LENGTH))
    except (TypeError, ValueError):
        cherrypy.log(
            "Invalid draw/start/length params, using defaults.",
            context=CONTEXT,
            severity=logging.WARNING,
        )
        draw, start, length = 0, 0, DataTableQuery.MAX_LENGTH

    return DataTableQuery(
        query=query,
        params=params,
        search_columns=request._datatables_search_columns,
        max_length=request._datatables_max_length,
        default_dir=request._datatables_default_dir,
        default_col=request._datatables_default_col,
    ).paginate(draw=draw, start=start, length=length)


def datatables_out(
    search_columns: list | None = None,
    max_length: int = DataTableQuery.MAX_LENGTH,
    default_dir: str = DataTableQuery.DEFAULT_DIR,
    default_col: int = DataTableQuery.DEFAULT_COL,
    handler=datatable_handler,
    debug: bool = False,
):
    """
    Wrap request.handler to process its Query return value through DataTableQuery
    and return a DataTables-compatible dict. Pair with json_out for full JSON response.

    Usage:
        @cherrypy.expose
        @cherrypy.tools.json_out()
        @cherrypy.tools.datatables_out(search_columns=[Message.body, UserObject.username])
        def data_json(self, **kwargs):
            return db.session.query(Message).join(UserObject).with_entities(
                    Message.body, Message.created_at, UserObject.username
                )
    """
    request = cherrypy.serving.request

    if request.handler is None:
        return

    if debug:
        cherrypy.log(
            f"Replacing {request.handler} with datatable handler",
            "TOOLS.DATATABLES",
        )

    # Stash config on the request for datatable_handler to pick up
    request._datatables_inner_handler = request.handler
    request._datatables_search_columns = search_columns
    request._datatables_max_length = max_length
    request._datatables_default_dir = default_dir
    request._datatables_default_col = default_col
    request.handler = handler


cherrypy.tools.datatables_out = cherrypy.Tool("before_handler", datatables_out, priority=29)
