"""
psycopg row factories
"""

# Copyright (C) 2021 The Psycopg Team

from __future__ import annotations

import functools
from typing import TYPE_CHECKING, Any, NamedTuple, NoReturn, Protocol, TypeAlias
from collections import namedtuple
from collections.abc import Callable, Sequence

from . import errors as e
from . import pq
from ._compat import TypeVar
from ._encodings import _as_python_identifier

if TYPE_CHECKING:
    from .cursor import Cursor
    from .pq.abc import PGresult
    from ._cursor_base import BaseCursor
    from .cursor_async import AsyncCursor

COMMAND_OK = pq.ExecStatus.COMMAND_OK
TUPLES_OK = pq.ExecStatus.TUPLES_OK
SINGLE_TUPLE = pq.ExecStatus.SINGLE_TUPLE
TUPLES_CHUNK = pq.ExecStatus.TUPLES_CHUNK

T = TypeVar("T", covariant=True)

# Row factories

Row = TypeVar("Row", covariant=True, default="TupleRow")


class RowMaker(Protocol[Row]):
    """
    Callable protocol taking a sequence of value and returning an object.

    The sequence of value is what is returned from a database query, already
    adapted to the right Python types. The return value is the object that your
    program would like to receive: by default (`tuple_row()`) it is a simple
    tuple, but it may be any type of object.

    Typically, `!RowMaker` functions are returned by `RowFactory`.
    """

    def __call__(self, __values: Sequence[Any]) -> Row: ...


class RowFactory(Protocol[Row]):
    """
    Callable protocol taking a `~psycopg.Cursor` and returning a `RowMaker`.

    A `!RowFactory` is typically called when a `!Cursor` receives a result.
    This way it can inspect the cursor state (for instance the
    `~psycopg.Cursor.description` attribute) and help a `!RowMaker` to create
    a complete object.

    For instance the `dict_row()` `!RowFactory` uses the names of the column to
    define the dictionary key and returns a `!RowMaker` function which would
    use the values to create a dictionary for each record.
    """

    def __call__(self, __cursor: Cursor[Any]) -> RowMaker[Row]: ...


class AsyncRowFactory(Protocol[Row]):
    """
    Like `RowFactory`, taking an async cursor as argument.
    """

    def __call__(self, __cursor: AsyncCursor[Any]) -> RowMaker[Row]: ...


class BaseRowFactory(Protocol[Row]):
    """
    Like `RowFactory`, taking either type of cursor as argument.
    """

    def __call__(self, __cursor: BaseCursor[Any, Any]) -> RowMaker[Row]: ...


TupleRow: TypeAlias = tuple[Any, ...]
"""
An alias for the type returned by `tuple_row()` (i.e. a tuple of any content).
"""


DictRow: TypeAlias = dict[str, Any]
"""
An alias for the type returned by `dict_row()`

A `!DictRow` is a dictionary with keys as string and any value returned by the
database.
"""


def tuple_row(cursor: BaseCursor[Any, Any]) -> RowMaker[TupleRow]:
    r"""Row factory to represent rows as simple tuples.

    This is the default factory, used when `~psycopg.Connection.connect()` or
    `~psycopg.Connection.cursor()` are called without a `!row_factory`
    parameter.

    """
    # Implementation detail: make sure this is the tuple type itself, not an
    # equivalent function, because the C code fast-paths on it.
    return tuple


def dict_row(cursor: BaseCursor[Any, Any]) -> RowMaker[DictRow]:
    """Row factory to represent rows as dictionaries.

    The dictionary keys are taken from the column names of the returned columns.
    """
    if (names := _get_names(cursor)) is not None:

        def dict_row_(values: Sequence[Any]) -> dict[str, Any]:
            return dict(zip(names, values))

        return dict_row_

    else:
        return no_result


def namedtuple_row(cursor: BaseCursor[Any, Any]) -> RowMaker[NamedTuple]:
    """Row factory to represent rows as `~collections.namedtuple`.

    The field names are taken from the column names of the returned columns,
    with some mangling to deal with invalid names.
    """
    if (res := cursor.pgresult) and (nfields := _get_nfields(res)) is not None:
        nt = _make_nt(cursor._encoding, *(res.fname(i) for i in range(nfields)))
        return nt._make
    else:
        return no_result


@functools.lru_cache(512)
def _make_nt(enc: str, *names: bytes) -> type[NamedTuple]:
    snames = tuple(_as_python_identifier(n.decode(enc)) for n in names)
    return namedtuple("Row", snames)  # type: ignore[return-value]


def class_row(cls: type[T]) -> BaseRowFactory[T]:
    r"""Generate a row factory to represent rows as instances of the class `!cls`.

    The class must support every output column name as a keyword parameter.

    :param cls: The class to return for each row. It must support the fields
        returned by the query as keyword arguments.
    :rtype: `!Callable[[Cursor],` `RowMaker`\[~T]]
    """

    def class_row_(cursor: BaseCursor[Any, Any]) -> RowMaker[T]:
        if (names := _get_names(cursor)) is not None:

            def class_row__(values: Sequence[Any]) -> T:
                return cls(**dict(zip(names, values)))

            return class_row__

        else:
            return no_result

    return class_row_


def args_row(func: Callable[..., T]) -> BaseRowFactory[T]:
    """Generate a row factory calling `!func` with positional parameters for every row.

    :param func: The function to call for each row. It must support the fields
        returned by the query as positional arguments.
    """

    def args_row_(cur: BaseCursor[Any, T]) -> RowMaker[T]:
        def args_row__(values: Sequence[Any]) -> T:
            return func(*values)

        return args_row__

    return args_row_


def kwargs_row(func: Callable[..., T]) -> BaseRowFactory[T]:
    """Generate a row factory calling `!func` with keyword parameters for every row.

    :param func: The function to call for each row. It must support the fields
        returned by the query as keyword arguments.
    """

    def kwargs_row_(cursor: BaseCursor[Any, T]) -> RowMaker[T]:
        if (names := _get_names(cursor)) is not None:

            def kwargs_row__(values: Sequence[Any]) -> T:
                return func(**dict(zip(names, values)))

            return kwargs_row__

        else:
            return no_result

    return kwargs_row_


def scalar_row(cursor: BaseCursor[Any, Any]) -> RowMaker[Any]:
    """
    Generate a row factory returning the first column
    as a scalar value.
    """
    if (res := cursor.pgresult) and (nfields := _get_nfields(res)) is not None:
        if nfields < 1:
            raise e.ProgrammingError("at least one column expected")

        def scalar_row_(values: Sequence[Any]) -> Any:
            return values[0]

        return scalar_row_

    else:
        return no_result


def no_result(values: Sequence[Any]) -> NoReturn:
    """A `RowMaker` that always fail.

    It can be used as return value for a `RowFactory` called with no result.
    Note that the `!RowFactory` *will* be called with no result, but the
    resulting `!RowMaker` never should.
    """
    raise e.InterfaceError("the cursor doesn't have a result")


def _get_names(cursor: BaseCursor[Any, Any]) -> list[str] | None:
    if (res := cursor.pgresult) and (nfields := _get_nfields(res)) is not None:
        enc = cursor._encoding
        return [
            res.fname(i).decode(enc) for i in range(nfields)  # type: ignore[union-attr]
        ]
    else:
        return None


def _get_nfields(res: PGresult) -> int | None:
    """
    Return the number of columns in a result, if it returns tuples else None

    Take into account the special case of results with zero columns.
    """
    nfields = res.nfields

    if (
        res.status == TUPLES_OK
        or res.status == SINGLE_TUPLE
        or res.status == TUPLES_CHUNK
        # "describe" in named cursors
        or (res.status == COMMAND_OK and nfields)
    ):
        return nfields
    else:
        return None
