from functools import singledispatch
from hashlib import sha256
from textwrap import dedent, indent

import numpy as np
from numba.core.extending import overload
from numpy.lib.array_utils import normalize_axis_index, normalize_axis_tuple
from numpy.lib.stride_tricks import as_strided

from pytensor import config
from pytensor.graph.op import Op
from pytensor.link.numba.cache import (
    compile_numba_function_src,
)
from pytensor.link.numba.dispatch import basic as numba_basic
from pytensor.link.numba.dispatch.basic import (
    create_tuple_string,
    numba_funcify_and_cache_key,
    register_funcify_and_cache_key,
    register_funcify_default_op_cache_key,
)
from pytensor.link.numba.dispatch.vectorize_codegen import (
    _vectorized,
    encode_literals,
    store_core_outputs,
)
from pytensor.scalar.basic import (
    AND,
    OR,
    XOR,
    Add,
    IntDiv,
    Maximum,
    Minimum,
    Mul,
    Sub,
    TrueDiv,
    get_scalar_type,
    maximum,
)
from pytensor.scalar.basic import add as add_as
from pytensor.tensor.blas import BatchedDot
from pytensor.tensor.elemwise import CAReduce, DimShuffle, Elemwise
from pytensor.tensor.math import Argmax, Dot, MulWithoutZeros, Sum
from pytensor.tensor.special import LogSoftmax, Softmax, SoftmaxGrad


@singledispatch
def scalar_in_place_fn(op: Op, idx: str, res: str, arr: str):
    """Return code for an in-place update on an array using a binary scalar :class:`Op`.

    Parameters
    ----------
    op
        The scalar :class:`Op`
    idx
        The index of `res` that needs to be updated.
    res
        The symbol name for the first input and results/output.
    arr
        The symbol name for the second input.
    """
    raise NotImplementedError(f"No scalar_in_place_fn implemented for {op}")


@scalar_in_place_fn.register(Add)
def scalar_in_place_fn_Add(op, idx, res, arr):
    return f"{res}[{idx}] += {arr}"


@scalar_in_place_fn.register(Sub)
def scalar_in_place_fn_Sub(op, idx, res, arr):
    return f"{res}[{idx}] -= {arr}"


@scalar_in_place_fn.register(Mul)
def scalar_in_place_fn_Mul(op, idx, res, arr):
    return f"{res}[{idx}] *= {arr}"


@scalar_in_place_fn.register(MulWithoutZeros)
def scalar_in_place_fn_MulWithoutZeros(op, idx, res, arr):
    return f"{res}[{idx}] = {arr} if {res}[{idx}] == 0 else ({res}[{idx}] if {arr} == 0 else {res}[{idx}] * {arr})"


@scalar_in_place_fn.register(AND)
def scalar_in_place_fn_AND(op, idx, res, arr):
    return f"{res}[{idx}] &= {arr}"


@scalar_in_place_fn.register(OR)
def scalar_in_place_fn_OR(op, idx, res, arr):
    return f"{res}[{idx}] |= {arr}"


@scalar_in_place_fn.register(XOR)
def scalar_in_place_fn_XOR(op, idx, res, arr):
    return f"{res}[{idx}] ^= {arr}"


@scalar_in_place_fn.register(TrueDiv)
def scalar_in_place_fn_TrueDiv(op, idx, res, arr):
    return f"{res}[{idx}] /= {arr}"


@scalar_in_place_fn.register(IntDiv)
def scalar_in_place_fn_IntDiv(op, idx, res, arr):
    return f"{res}[{idx}] //= {arr}"


@scalar_in_place_fn.register(Maximum)
def scalar_in_place_fn_Maximum(op, idx, res, arr):
    return f"""
if {res}[{idx}] < {arr}:
    {res}[{idx}] = {arr}
"""


@scalar_in_place_fn.register(Minimum)
def scalar_in_place_fn_Minimum(op, idx, res, arr):
    return f"""
if {res}[{idx}] > {arr}:
    {res}[{idx}] = {arr}
"""


def create_multiaxis_reducer(
    scalar_op,
    *,
    identity,
    axes,
    ndim,
    acc_dtype=None,
    out_dtype,
    keepdims: bool = False,
):
    r"""Construct a function that reduces multiple axes.

    The functions generated by this function take the following form:

    .. code-block:: python

        def careduce_add(x):
            x_shape = x.shape
            res_shape = (x_shape[0], x_shape[1])
            # identity = 0.0
            res = np.full(res_shape, identity, dtype=np.float64)
            for i0 in range(x_shape[0]):
                for i1 in range(x_shape[1]):
                    for i2 in range(x_shape[2]):
                        res[i0, i1] += x[i0, i1, i2]
            return res

    If accumulation dtype differs from output_dtype

    .. code-block:: python

        def careduce_add(x):
            x_shape = x.shape
            res_shape = (x_shape[0], x_shape[1])
            # identity = 0.0
            res = np.full(res_shape, identity, dtype=np.float64)
            for i0 in range(x_shape[0]):
                for i1 in range(x_shape[1]):
                    for i2 in range(x_shape[2]):
                        res[i0, i1] += x[i0, i1, i2]
            return res.astype(np.int32)

    Full reductions accumulate on scalars

    .. code-block:: python

        def careduce_mul(x):
            x_shape = x.shape
            res_shape = ()
            # identity = 1.0
            res = identity
            for i0 in range(x_shape[0]):
                for i1 in range(x_shape[1]):
                    for i2 in range(x_shape[2]):
                        res *= x[i0, i1, i2]
            return np.array(res, dtype=np.int32)


    Parameters
    ==========
    scalar_op:
        The scalar :class:`Op` that performs the desired reduction.
    identity:
        The identity value for the reduction.
    axes:
        The axes to reduce.
    ndim:
        The number of dimensions of the input variable.
    acc_dtype: dtype, optional
        The data type used during accumulation. Defaults to out_dtype if not provided
    out_dtype:
        The data type of the result.
    keepdims: boolean, default False
        Whether to keep the reduced dimensions.
    Returns
    =======
    A Python function that can be JITed.

    """
    # if len(axes) == 1:
    #     return create_axis_reducer(scalar_op, identity, axes[0], ndim, dtype)

    axes = normalize_axis_tuple(axes, ndim)
    if keepdims and len(axes) > 1:
        raise NotImplementedError(
            "Cannot keep multiple dimensions when reducing multiple axes"
        )

    out_dtype = np.dtype(out_dtype)
    acc_dtype = out_dtype if acc_dtype is None else np.dtype(acc_dtype)
    # Numba doesn't allow converting complex to real with a simple `astype`
    complex_to_real = acc_dtype.kind == "c" and out_dtype.kind != "c"
    out_dtype_str = f"np.{out_dtype.name}"
    acc_dtype_str = f"np.{acc_dtype.name}"
    careduce_fn_name = f"careduce_{scalar_op}"

    if acc_dtype.kind in "ui" and not np.isfinite(identity):
        if np.isposinf(identity):
            identity = np.iinfo(acc_dtype).max
        else:
            identity = np.iinfo(acc_dtype).min

    # Make sure it has the correct dtype
    identity = getattr(np, acc_dtype.name)(identity)

    complete_reduction = len(axes) == ndim
    kept_axis = tuple(i for i in range(ndim) if i not in axes)

    res_indices = []
    arr_indices = []
    for i in range(ndim):
        index_label = f"i{i}"
        arr_indices.append(index_label)
        if i not in axes:
            res_indices.append(index_label)
    res_indices = ", ".join(res_indices) if res_indices else ()
    arr_indices = ", ".join(arr_indices) if arr_indices else ()

    inplace_update_stmt = scalar_in_place_fn(
        scalar_op, res_indices, "res", f"x[{arr_indices}]"
    )

    res_shape = create_tuple_string([f"x_shape[{i}]" for i in kept_axis])
    if complete_reduction and ndim > 0:
        # We accumulate on a scalar, not an array
        res_creator = "identity"
        inplace_update_stmt = inplace_update_stmt.replace("res[()]", "res")
        if complex_to_real:
            return_obj = f"np.array(res).real.astype({out_dtype_str})"
        else:
            return_obj = f"np.array(res, dtype={out_dtype_str})"
    else:
        res_creator = f"np.full(res_shape, identity, dtype={acc_dtype_str})"
        if complex_to_real:
            return_obj = f"res.real.astype({out_dtype_str})"
        else:
            return_obj = (
                "res" if out_dtype == acc_dtype else f"res.astype({out_dtype_str})"
            )

    if keepdims:
        [axis] = axes
        return_obj = f"np.expand_dims({return_obj}, {axis})"

    careduce_def_src = dedent(
        f"""
        def {careduce_fn_name}(x):
            x_shape = x.shape
            res_shape = {res_shape}
            # identity = {identity}
            res = {res_creator}
        """
    )
    for axis in range(ndim):
        careduce_def_src += indent(
            f"for i{axis} in range(x_shape[{axis}]):\n",
            " " * (4 + 4 * axis),
        )
    careduce_def_src += indent(inplace_update_stmt, " " * (4 + 4 * ndim))
    careduce_def_src += "\n"
    careduce_def_src += indent(f"return {return_obj}", " " * 4)

    careduce_fn = compile_numba_function_src(
        careduce_def_src, careduce_fn_name, globals() | {"np": np, "identity": identity}
    )
    return careduce_fn


def create_axis_apply_fn(fn, axis, ndim, dtype):
    axis = normalize_axis_index(axis, ndim)

    reaxis_first = (*(i for i in range(ndim) if i != axis), axis)

    @numba_basic.numba_njit(boundscheck=False)
    def axis_apply_fn(x):
        x_reaxis = x.transpose(reaxis_first)

        res = np.zeros(x_reaxis.shape[:-1], dtype=dtype)
        for m in np.ndindex(res.shape):
            v = fn(x_reaxis[m])
            res[m] = v
        return res

    return axis_apply_fn


@register_funcify_and_cache_key(Elemwise)
def numba_funcify_Elemwise(op, node, **kwargs):
    scalar_inputs = [get_scalar_type(dtype=input.dtype)() for input in node.inputs]
    scalar_node = op.scalar_op.make_node(*scalar_inputs)
    scalar_op_fn, scalar_cache_key = numba_funcify_and_cache_key(
        op.scalar_op,
        node=scalar_node,
        **kwargs,
    )

    nin = len(node.inputs)
    nout = len(node.outputs)
    core_op_fn = store_core_outputs(scalar_op_fn, nin=nin, nout=nout)

    input_bc_patterns = tuple(inp.type.broadcastable for inp in node.inputs)
    output_bc_patterns = tuple(out.type.broadcastable for out in node.outputs)
    output_dtypes = tuple(out.type.dtype for out in node.outputs)
    inplace_pattern = tuple(op.inplace_pattern.items())
    core_output_shapes = tuple(() for _ in range(nout))

    # numba doesn't support nested literals right now...
    input_bc_patterns_enc = encode_literals(input_bc_patterns)
    output_bc_patterns_enc = encode_literals(output_bc_patterns)
    output_dtypes_enc = encode_literals(output_dtypes)
    inplace_pattern_enc = encode_literals(inplace_pattern)

    # Pure python implementation, that will be used in tests
    def elemwise(*inputs):
        Elemwise._check_runtime_broadcast(node, inputs)
        inputs_bc = np.broadcast_arrays(*inputs)
        shape = inputs_bc[0].shape

        if len(output_dtypes) == 1:
            output = np.empty(shape, dtype=output_dtypes[0])
            for idx in np.ndindex(shape):
                output[idx] = scalar_op_fn(*(inp[idx] for inp in inputs_bc))
            return output

        else:
            outputs = [np.empty(shape, dtype=dtype) for dtype in output_dtypes]
            for idx in np.ndindex(shape):
                outs_vals = scalar_op_fn(*(inp[idx] for inp in inputs_bc))
                for out, out_val in zip(outputs, outs_vals):
                    out[idx] = out_val
            return outputs

    @overload(elemwise)
    def ov_elemwise(*inputs):
        def impl(*inputs):
            return _vectorized(
                core_op_fn,
                input_bc_patterns_enc,
                output_bc_patterns_enc,
                output_dtypes_enc,
                inplace_pattern_enc,
                (),  # constant_inputs
                inputs,
                core_output_shapes,  # core_shapes
                None,  # size
            )

        return impl

    if scalar_cache_key is None:
        # If the scalar op cannot be cached, the Elemwise wrapper cannot be cached either
        elemwise_key = None
    else:
        elemwise_key = str(
            (
                type(op),
                tuple(op.inplace_pattern.items()),
                input_bc_patterns,
                scalar_cache_key,
            )
        )
        elemwise_key = sha256(elemwise_key.encode()).hexdigest()
    return elemwise, elemwise_key


@register_funcify_and_cache_key(CAReduce)
def numba_funcify_CAReduce(op, node, **kwargs):
    axes = op.axis
    if axes is None:
        axes = list(range(node.inputs[0].ndim))

    if hasattr(op, "acc_dtype") and op.acc_dtype is not None:
        acc_dtype = op.acc_dtype
    else:
        acc_dtype = node.outputs[0].type.dtype

    out_dtype = np.dtype(node.outputs[0].type.dtype)

    if (
        isinstance(op, Sum)
        and node.inputs[0].ndim == len(axes)
        and out_dtype == acc_dtype
    ):
        # Slightly faster for this case
        @numba_basic.numba_njit
        def impl_sum(array):
            return np.array(array.sum())

        careduce_fn = impl_sum  # Some tests look for this name

    else:
        ndim = node.inputs[0].ndim
        careduce_py_fn = create_multiaxis_reducer(
            op.scalar_op,
            identity=op.scalar_op.identity,
            axes=axes,
            ndim=ndim,
            acc_dtype=acc_dtype,
            out_dtype=out_dtype,
        )
        careduce_fn = numba_basic.numba_njit(careduce_py_fn, boundscheck=False)

    cache_version = 1
    careduce_key = sha256(
        str(
            (
                type(op),
                type(op.scalar_op),
                axes,
                out_dtype,
                acc_dtype,
                op.scalar_op.identity,
                cache_version,
            )
        ).encode()
    ).hexdigest()
    return careduce_fn, careduce_key


@register_funcify_default_op_cache_key(DimShuffle)
def numba_funcify_DimShuffle(op: DimShuffle, node, **kwargs):
    # We use `as_strided` to achieve the DimShuffle behavior of transposing and expanding/squezing dimensions in one call
    # Numba doesn't currently support multiple expand/squeeze, and reshape is limited to contiguous arrays.
    new_order = tuple(op._new_order)
    drop = tuple(op.drop)
    shape_template = (1,) * node.outputs[0].ndim
    strides_template = (0,) * node.outputs[0].ndim

    if new_order == ():
        # Special case needed because of https://github.com/numba/numba/issues/9933

        @numba_basic.numba_njit
        def squeeze_to_0d(x):
            if not x.size == 1:
                raise ValueError(
                    "DimShuffle: Attempting to squeeze axes with size not equal to one"
                )
            assert x.size == 1
            return as_strided(x, shape=(), strides=())

        return squeeze_to_0d

    else:

        @numba_basic.numba_njit
        def dimshuffle(x):
            old_shape = x.shape
            old_strides = x.strides

            new_shape = shape_template
            new_strides = strides_template
            for i, o in enumerate(new_order):
                if o != -1:
                    new_shape = numba_basic.tuple_setitem(new_shape, i, old_shape[o])
                    new_strides = numba_basic.tuple_setitem(
                        new_strides, i, old_strides[o]
                    )
            if drop:
                for dropped_dim in drop:
                    if old_shape[dropped_dim] != 1:
                        raise ValueError(
                            "DimShuffle: Attempting to squeeze axes with size not equal to one"
                        )

            return as_strided(x, shape=new_shape, strides=new_strides)

    cache_version = 1
    return dimshuffle, cache_version


@register_funcify_default_op_cache_key(Softmax)
def numba_funcify_Softmax(op, node, **kwargs):
    ndim = node.inputs[0].type.ndim
    inp_dtype = node.inputs[0].type.numpy_dtype
    axis = op.axis

    if ndim > 1 and axis is not None:
        reduce_max_py = create_multiaxis_reducer(
            maximum,
            identity=-np.inf,
            axes=(axis,),
            ndim=ndim,
            out_dtype=inp_dtype,
            keepdims=True,
        )
        reduce_sum_py = create_multiaxis_reducer(
            add_as,
            identity=0.0,
            axes=(axis,),
            ndim=ndim,
            out_dtype=inp_dtype,
            keepdims=True,
        )

        jit_fn = numba_basic.numba_njit(boundscheck=False)
        reduce_max = jit_fn(reduce_max_py)
        reduce_sum = jit_fn(reduce_sum_py)
    else:
        reduce_max = np.max
        reduce_sum = np.sum

    @numba_basic.numba_njit(boundscheck=False)
    def softmax(x):
        z = reduce_max(x)
        e_x = np.exp(x - z)
        w = reduce_sum(e_x)
        sm = e_x / w
        return sm

    cache_version = 1
    return softmax, cache_version


@register_funcify_default_op_cache_key(SoftmaxGrad)
def numba_funcify_SoftmaxGrad(op, node, **kwargs):
    ndim = node.inputs[0].type.ndim
    inp_dtype = node.inputs[0].type.numpy_dtype

    axis = op.axis
    if ndim > 1 and axis is not None:
        reduce_sum_py = create_multiaxis_reducer(
            add_as,
            identity=0.0,
            axes=(axis,),
            ndim=ndim,
            out_dtype=inp_dtype,
            keepdims=True,
        )

        jit_fn = numba_basic.numba_njit(boundscheck=False)
        reduce_sum = jit_fn(reduce_sum_py)
    else:
        reduce_sum = np.sum

    @numba_basic.numba_njit(boundscheck=False)
    def softmax_grad(dy, sm):
        dy_times_sm = dy * sm
        sum_dy_times_sm = reduce_sum(dy_times_sm)
        dx = dy_times_sm - sum_dy_times_sm * sm
        return dx

    cache_version = 1
    return softmax_grad, cache_version


@register_funcify_default_op_cache_key(LogSoftmax)
def numba_funcify_LogSoftmax(op, node, **kwargs):
    ndim = node.inputs[0].type.ndim
    inp_dtype = node.inputs[0].type.numpy_dtype
    axis = op.axis

    if ndim > 1 and axis is not None:
        reduce_max_py = create_multiaxis_reducer(
            maximum,
            identity=-np.inf,
            axes=(axis,),
            ndim=ndim,
            out_dtype=inp_dtype,
            keepdims=True,
        )
        reduce_sum_py = create_multiaxis_reducer(
            add_as,
            identity=0.0,
            axes=(axis,),
            ndim=ndim,
            out_dtype=inp_dtype,
            keepdims=True,
        )

        jit_fn = numba_basic.numba_njit(boundscheck=False)
        reduce_max = jit_fn(reduce_max_py)
        reduce_sum = jit_fn(reduce_sum_py)
    else:
        reduce_max = np.max
        reduce_sum = np.sum

    @numba_basic.numba_njit(boundscheck=False)
    def log_softmax(x):
        xdev = x - reduce_max(x)
        lsm = xdev - np.log(reduce_sum(np.exp(xdev)))
        return lsm

    cache_version = 1
    return log_softmax, cache_version


@register_funcify_default_op_cache_key(Argmax)
def numba_funcify_Argmax(op, node, **kwargs):
    axis = op.axis
    x_pt = node.inputs[0]
    x_ndim = x_pt.ndim

    if x_ndim == 0:

        @numba_basic.numba_njit
        def argmax(x):
            return np.array(0, dtype="int64")

    else:
        if axis is None:
            axes = tuple(range(x_ndim))
        else:
            axes = tuple(int(ax) for ax in axis)

        # NumPy does not support multiple axes for argmax; this is a
        # work-around
        keep_axes = tuple(i for i in range(x_ndim) if i not in axes)

        reduced_x_ndim = x_ndim - len(axes) + 1
        argmax_axis = create_axis_apply_fn(
            np.argmax, reduced_x_ndim - 1, reduced_x_ndim, np.int64
        )

        reaxis_order = keep_axes + axes
        sl1 = slice(None, len(keep_axes))
        sl2 = slice(len(keep_axes), None)

        @numba_basic.numba_njit
        def argmax(x):
            # Not-reduced axes in front
            transposed_x = np.ascontiguousarray(np.transpose(x, reaxis_order))
            kept_shape = transposed_x.shape[sl1]
            reduced_shape = transposed_x.shape[sl2]
            reduced_size = 1
            for s in reduced_shape:
                reduced_size *= s

            # Numpy.prod returns 1.0 when arg is empty, so we cast it to int64
            # Otherwise reshape would complain citing float arg
            new_shape = (*kept_shape, reduced_size)
            reshaped_x = transposed_x.reshape(new_shape)

            max_idx_res = argmax_axis(reshaped_x)

            return max_idx_res

    cache_version = 1
    return argmax, cache_version


@register_funcify_default_op_cache_key(Dot)
def numba_funcify_Dot(op, node, **kwargs):
    # Numba's `np.dot` does not support integer dtypes, so we need to cast to float.
    x, y = node.inputs
    [out] = node.outputs

    x_dtype = x.type.numpy_dtype
    y_dtype = y.type.numpy_dtype

    numba_dot_dtype = out_dtype = out.type.numpy_dtype
    if out_dtype.kind not in "fc":
        # Numba alawys returns non-integral outputs, we need to cast to float
        numba_dot_dtype = np.dtype(
            f"float{max((32, out.type.numpy_dtype.itemsize * 8))}"
        )

    if config.compiler_verbose and not (
        x_dtype == y_dtype == out_dtype == numba_dot_dtype
    ):
        print(  # noqa: T201
            "Numba Dot requires a type casting of inputs and/or output: "
            f"{x_dtype=}, {y_dtype=}, {out_dtype=}, {numba_dot_dtype=}"
        )

    if x_dtype == numba_dot_dtype and y_dtype == numba_dot_dtype:

        @numba_basic.numba_njit
        def dot(x, y):
            return np.asarray(np.dot(x, y))

    elif x_dtype == numba_dot_dtype and y_dtype != numba_dot_dtype:

        @numba_basic.numba_njit
        def dot(x, y):
            return np.asarray(np.dot(x, y.astype(numba_dot_dtype)))

    elif x_dtype != numba_dot_dtype and y_dtype == numba_dot_dtype:

        @numba_basic.numba_njit
        def dot(x, y):
            return np.asarray(np.dot(x.astype(numba_dot_dtype), y))

    else:

        @numba_basic.numba_njit
        def dot(x, y):
            return np.asarray(
                np.dot(x.astype(numba_dot_dtype), y.astype(numba_dot_dtype))
            )

    cache_version = 1

    if out_dtype == numba_dot_dtype:
        return dot, cache_version

    else:

        @numba_basic.numba_njit
        def dot_with_cast(x, y):
            return dot(x, y).astype(out_dtype)

        return dot_with_cast, cache_version


@register_funcify_default_op_cache_key(BatchedDot)
def numba_funcify_BatchedDot(op, node, **kwargs):
    dtype = node.outputs[0].type.numpy_dtype

    @numba_basic.numba_njit
    def batched_dot(x, y):
        # Numba does not support 3D matmul
        # https://github.com/numba/numba/issues/3804
        shape = x.shape[:-1] + y.shape[2:]
        z0 = np.empty(shape, dtype=dtype)
        for i in range(z0.shape[0]):
            z0[i] = np.dot(x[i], y[i])

        return z0

    return batched_dot
