"""Core pagination engine: ``paginate`` (async).

Accepts a SQLAlchemy ``Select`` statement together with one or more
``order_by`` column expressions and Relay-style cursor arguments, then
returns a typed ``Connection[T]`` containing edges, ``PageInfo``, and an
optional ``totalCount``.

Argument validation is handled by constructing a ``PaginationArgs`` model
at the entry point — invalid combinations raise ``InvalidArgumentsError``
before any database query is issued.

Single-column cursors are plain ``str(value)`` (no encoding).
Compound cursors are base64url-encoded JSON by default, with a pluggable
codec via ``PaginationConfig.cursor_codec``.

Query optimisations driven by ``RequestedFields``
-------------------------------------------------
* If **no** field from the page query is requested (no edges, no cursors, no
  ``hasNextPage``/``hasPreviousPage``), the page query is skipped entirely.
* ``totalCount`` triggers a separate ``COUNT(*)`` subquery only when
  explicitly requested.
* The extra-row fetch (``LIMIT n+1``) that detects whether a next/previous
  page exists is only issued when ``hasNextPage`` or ``hasPreviousPage`` is
  actually in the selection.
"""

from typing import Any, TypeAlias, TypeVar

from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.selectable import Select

from sqlalchemy_relay_pagination.codec import CursorCodec
from sqlalchemy_relay_pagination.config import PaginationConfig, get_config
from sqlalchemy_relay_pagination.cursor import decode_cursor, encode_cursor
from sqlalchemy_relay_pagination.keyset import (
    ColWithDir,
    build_compound_keyset_filter,
    build_keyset_filter,
    normalise_order_by,
    reverse_order_by_list,
)
from sqlalchemy_relay_pagination.types import Connection, Edge, PageInfo, PaginationArgs, RequestedFields

# Accepted ORDER BY expression: a bare column attribute, .asc()/.desc(), or a list thereof.
OrderByExpr: TypeAlias = Any

T = TypeVar("T")


async def paginate(
    session: AsyncSession,
    query: Select[tuple[T]],
    order_by: OrderByExpr,
    first: int | None = None,
    last: int | None = None,
    after: str | None = None,
    before: str | None = None,
    requested_fields: RequestedFields | None = None,
    config: PaginationConfig | None = None,
) -> Connection[T]:
    """Paginate a SQLAlchemy async query using Relay-style keyset cursors.

    Args:
        session: An open ``AsyncSession``.
        query: A ``select(Model)`` statement.  Must **not** have an
            ``ORDER BY`` or ``LIMIT`` clause — those are applied internally.
        order_by: A column expression or list of column expressions (plain
            attributes or ``.asc()``/``.desc()`` wrappers).
        first: Return the first *n* edges (forward pagination).
        last: Return the last *n* edges (backward pagination).
        after: Opaque cursor — return edges after this position.
        before: Opaque cursor — return edges before this position.
        requested_fields: Declare which fields are actually needed to allow
            skipping unnecessary queries.  Defaults to requesting all fields
            except ``totalCount``.
        config: Per-call config override.  Falls back to the global config.

    Returns:
        A ``Connection[T]`` with the requested fields populated.
    """
    cfg = config or get_config()
    args = PaginationArgs(first=first, last=last, after=after, before=before, config=config)  # type: ignore[arg-type]
    fields = requested_fields or RequestedFields()
    cols, raw_order_exprs = normalise_order_by(order_by)

    fetch_extra = (args.is_forward and fields.has_next_page) or (not args.is_forward and fields.has_previous_page)
    fetch_limit = args.effective_limit + 1 if fetch_extra else args.effective_limit

    async def _run_page_query() -> list[T]:
        q = query
        if args.cursor:
            cursor_values = decode_cursor(args.cursor, cols, cfg.cursor_codec)
            if len(cols) == 1:
                q = q.filter(build_keyset_filter(cols[0][0], cursor_values[0], cols[0][1], args.is_forward))
            else:
                q = q.filter(build_compound_keyset_filter(cols, cursor_values, args.is_forward))
        if args.is_forward:  # noqa: SIM108
            q = q.order_by(*raw_order_exprs)
        else:
            q = q.order_by(*reverse_order_by_list(cols))
        q = q.limit(fetch_limit)
        result = await session.execute(q)
        return list(result.scalars().all())

    async def _run_count_query() -> int:
        count_q = select(func.count()).select_from(query.subquery())
        result = await session.execute(count_q)
        return result.scalar_one()

    # TODO: parallelize page and count queries with asyncio.gather — blocked by
    # AsyncSession enforcing a single active connection per session
    # (raises "concurrent operations are not permitted").  Would require
    # accepting an AsyncEngine alongside the session so the count query can
    # open its own connection independently.
    rows: list[T] | None = await _run_page_query() if fields.needs_page_query else None
    total: int | None = await _run_count_query() if fields.total_count else None

    return _assemble_connection(
        rows, total, cols, cfg.cursor_codec, args.effective_limit, args.is_forward, fetch_extra, fields
    )


def _assemble_connection(
    rows: list[T] | None,
    total: int | None,
    cols: list[ColWithDir],
    codec: CursorCodec,
    limit: int,
    is_forward: bool,
    fetch_extra: bool,
    fields: RequestedFields,
) -> Connection[T]:
    """Build a ``Connection`` from raw query results."""
    if rows is None:
        rows = []

    has_extra = fetch_extra and len(rows) == limit + 1
    if has_extra:
        rows = rows[:limit]

    if not is_forward:
        rows = list(reversed(rows))

    has_next_page = has_extra if is_forward else False
    has_previous_page = has_extra if not is_forward else False

    edges: list[Edge[T]] = []
    for row in rows:
        cursor = encode_cursor(cols, row, codec)
        edges.append(Edge(node=row, cursor=cursor))

    start_cursor = edges[0].cursor if edges and fields.start_cursor else None
    end_cursor = edges[-1].cursor if edges and fields.end_cursor else None

    page_info = PageInfo(
        has_next_page=has_next_page if fields.has_next_page else False,
        has_previous_page=has_previous_page if fields.has_previous_page else False,
        start_cursor=start_cursor,
        end_cursor=end_cursor,
        total_count=total,
    )

    return Connection(
        edges=edges if fields.edges else [],
        page_info=page_info,
    )
