"""This submodule offers functions to work with stencils in expression an offset-list form."""
from collections import defaultdict
from typing import Sequence

import numpy as np
import sympy as sp


def inverse_direction(direction):
    """Returns inverse i.e. negative of given direction tuple

    Example:
        >>> inverse_direction((1, -1, 0))
        (-1, 1, 0)
    """
    return tuple([-i for i in direction])


def inverse_direction_string(direction):
    """Returns inverse of given direction string"""
    return offset_to_direction_string(inverse_direction(direction_string_to_offset(direction)))


def is_valid(stencil, max_neighborhood=None):
    """
    Tests if a nested sequence is a valid stencil i.e. all the inner sequences have the same length.
    If max_neighborhood is specified, it is also verified that the stencil does not contain any direction components
    with absolute value greater than the maximal neighborhood.

    Examples:
        >>> is_valid([(1, 0), (1, 0, 0)])  # stencil entries have different length
        False
        >>> is_valid([(2, 0), (1, 0)])
        True
        >>> is_valid([(2, 0), (1, 0)], max_neighborhood=1)
        False
        >>> is_valid([(2, 0), (1, 0)], max_neighborhood=2)
        True
    """
    expected_dim = len(stencil[0])
    for d in stencil:
        if len(d) != expected_dim:
            return False
        if max_neighborhood is not None:
            for d_i in d:
                if abs(d_i) > max_neighborhood:
                    return False
    return True


def is_symmetric(stencil):
    """Tests for every direction d, that -d is also in the stencil

    Examples:
        >>> is_symmetric([(1, 0), (0, 1)])
        False
        >>> is_symmetric([(1, 0), (-1, 0)])
        True
    """
    for d in stencil:
        if inverse_direction(d) not in stencil:
            return False
    return True


def have_same_entries(s1, s2):
    """Checks if two stencils are the same

    Examples:
        >>> stencil1 = [(1, 0), (-1, 0), (0, 1), (0, -1)]
        >>> stencil2 = [(-1, 0), (0, -1), (1, 0), (0, 1)]
        >>> stencil3 = [(-1, 0), (0, -1), (1, 0)]
        >>> have_same_entries(stencil1, stencil2)
        True
        >>> have_same_entries(stencil1, stencil3)
        False
    """
    if len(s1) != len(s2):
        return False
    return len(set(s1) - set(s2)) == 0


# -------------------------------------Expression - Coefficient Form Conversion ----------------------------------------


def coefficient_dict(expr):
    """Extracts coefficients in front of field accesses in a expression.

    Expression may only access a single field at a single index.

    Returns:
        center, coefficient dict, nonlinear part
        where center is the single field that is accessed in expression accessed at center
        and coefficient dict maps offsets to coefficients. The nonlinear part is everything that is not in the form of
        coefficient times field access.

    Examples:
        >>> import pystencils as ps
        >>> f = ps.fields("f(3) : double[2D]")
        >>> field, coeffs, nonlinear_part = coefficient_dict(2 * f[0, 1](1) + 3 * f[-1, 0](1) + 123)
        >>> assert nonlinear_part == 123 and field == f(1)
        >>> sorted(coeffs.items())
        [((-1, 0), 3), ((0, 1), 2)]
    """
    from pystencils.field import Field
    expr = expr.expand()
    field_accesses = expr.atoms(Field.Access)
    fields = set(fa.field for fa in field_accesses)
    accessed_indices = set(fa.index for fa in field_accesses)

    if len(fields) != 1:
        raise ValueError("Could not extract stencil coefficients. "
                         "Expression has to be a linear function of exactly one field.")
    if len(accessed_indices) != 1:
        raise ValueError("Could not extract stencil coefficients. Field is accessed at multiple indices")

    field = fields.pop()
    idx = accessed_indices.pop()

    coeffs = defaultdict(lambda: 0)
    coeffs.update({fa.offsets: expr.coeff(fa) for fa in field_accesses})

    linear_part = sum(c * field[off](*idx) for off, c in coeffs.items())
    nonlinear_part = expr - linear_part
    return field(*idx), coeffs, nonlinear_part


def coefficients(expr):
    """Returns two lists - one with accessed offsets and one with their coefficients.

    Same restrictions as `coefficient_dict` apply. Expression must not have any nonlinear part

    >>> import pystencils as ps
    >>> f = ps.fields("f(3) : double[2D]")
    >>> coff = coefficients(2 * f[0, 1](1) + 3 * f[-1, 0](1))
    """
    field_center, coeffs, nonlinear_part = coefficient_dict(expr)
    assert nonlinear_part == 0
    stencil = list(coeffs.keys())
    entries = [coeffs[c] for c in stencil]
    return stencil, entries


def coefficient_list(expr, matrix_form=False):
    """Returns stencil coefficients in the form of nested lists

    Same restrictions as `coefficient_dict` apply. Expression must not have any nonlinear part

    Examples:
        >>> import pystencils as ps
        >>> f = ps.fields("f: double[2D]")
        >>> coefficient_list(2 * f[0, 1] + 3 * f[-1, 0])
        [[0, 0, 0], [3, 0, 0], [0, 2, 0]]
        >>> coefficient_list(2 * f[0, 1] + 3 * f[-1, 0], matrix_form=True)
        Matrix([
        [0, 2, 0],
        [3, 0, 0],
        [0, 0, 0]])
    """
    field_center, coeffs, nonlinear_part = coefficient_dict(expr)
    assert nonlinear_part == 0
    field = field_center.field

    dim = field.spatial_dimensions
    max_offsets = defaultdict(lambda: 0)
    for offset in coeffs.keys():
        for d, off in enumerate(offset):
            max_offsets[d] = max(max_offsets[d], abs(off))

    if dim == 1:
        result = [coeffs[(i,)] for i in range(-max_offsets[0], max_offsets[0] + 1)]
        return sp.Matrix(result) if matrix_form else result
    else:
        y_range = list(range(-max_offsets[1], max_offsets[1] + 1))
        if matrix_form:
            y_range.reverse()
        if dim == 2:
            result = [[coeffs[(i, j)]
                       for i in range(-max_offsets[0], max_offsets[0] + 1)]
                      for j in y_range]
            return sp.Matrix(result) if matrix_form else result
        elif dim == 3:
            result = [[[coeffs[(i, j, k)]
                        for i in range(-max_offsets[0], max_offsets[0] + 1)]
                       for j in y_range]
                      for k in range(-max_offsets[2], max_offsets[2] + 1)]
            return [sp.Matrix(l) for l in result] if matrix_form else result
        else:
            raise ValueError("Can only handle fields with 1,2 or 3 spatial dimensions")


# ------------------------------------- Point-on-compass notation ------------------------------------------------------


def offset_component_to_direction_string(coordinate_id: int, value: int) -> str:
    """Translates numerical offset to string notation.

    x offsets are labeled with east 'E' and 'W',
    y offsets with north 'N' and 'S' and
    z offsets with top 'T' and bottom 'B'
    If the absolute value of the offset is bigger than 1, this number is prefixed.

    Args:
        coordinate_id: integer 0, 1 or 2 standing for x,y and z
        value: integer offset

    Examples:
        >>> offset_component_to_direction_string(0, 1)
        'E'
        >>> offset_component_to_direction_string(1, 2)
        '2N'
    """
    assert 0 <= coordinate_id < 3, "Works only for at most 3D arrays"
    name_components = (('W', 'E'),  # west, east
                       ('S', 'N'),  # south, north
                       ('B', 'T'))  # bottom, top
    if value == 0:
        result = ""
    elif value < 0:
        result = name_components[coordinate_id][0]
    else:
        result = name_components[coordinate_id][1]
    if abs(value) > 1:
        result = "%d%s" % (abs(value), result)
    return result


def offset_to_direction_string(offsets: Sequence[int]) -> str:
    """
    Translates numerical offset to string notation.
    For details see :func:`offset_component_to_direction_string`
    Args:
        offsets: 3-tuple with x,y,z offset

    Examples:
        >>> offset_to_direction_string([1, -1, 0])
        'SE'
        >>> offset_to_direction_string(([-3, 0, -2]))
        '2B3W'
    """
    if len(offsets) > 3:
        return str(offsets)
    names = ["", "", ""]
    for i in range(len(offsets)):
        names[i] = offset_component_to_direction_string(i, offsets[i])
    name = "".join(reversed(names))
    if name == "":
        name = "C"
    return name


def direction_string_to_offset(direction: str, dim: int = 3):
    """
    Reverse mapping of :func:`offset_to_direction_string`

    Args:
        direction: string representation of offset
        dim: dimension of offset, i.e the length of the returned list

    Examples:
        >>> direction_string_to_offset('NW', dim=3)
        array([-1,  1,  0])
        >>> direction_string_to_offset('NW', dim=2)
        array([-1,  1])
        >>> direction_string_to_offset(offset_to_direction_string((3,-2,1)))
        array([ 3, -2,  1])
    """
    offset_dict = {
        'C': np.array([0, 0, 0]),

        'W': np.array([-1, 0, 0]),
        'E': np.array([1, 0, 0]),

        'S': np.array([0, -1, 0]),
        'N': np.array([0, 1, 0]),

        'B': np.array([0, 0, -1]),
        'T': np.array([0, 0, 1]),
    }
    offset = np.array([0, 0, 0])

    while len(direction) > 0:
        factor = 1
        first_non_digit = 0
        while direction[first_non_digit].isdigit():
            first_non_digit += 1
        if first_non_digit > 0:
            factor = int(direction[:first_non_digit])
            direction = direction[first_non_digit:]
        cur_offset = offset_dict[direction[0]]
        offset += factor * cur_offset
        direction = direction[1:]
    return offset[:dim]


# -------------------------------------- Visualization -----------------------------------------------------------------


def plot(stencil, **kwargs):
    dim = len(stencil[0])
    if dim == 2:
        plot_2d(stencil, **kwargs)
    else:
        slicing = False
        if 'slice' in kwargs:
            slicing = kwargs['slice']
            del kwargs['slice']

        if slicing:
            plot_3d_slicing(stencil, **kwargs)
        else:
            plot_3d(stencil, **kwargs)


def plot_2d(stencil, axes=None, figure=None, data=None, textsize='12', **kwargs):
    """
    Creates a matplotlib 2D plot of the stencil

    Args:
        stencil: sequence of directions
        axes: optional matplotlib axes
        figure: optional matplotlib figure
        data: data to annotate the directions with, if none given, the indices are used
        textsize: size of annotation text
    """
    from matplotlib.patches import BoxStyle
    import matplotlib.pyplot as plt

    if axes is None:
        if figure is None:
            figure = plt.gcf()
        axes = figure.gca()

    text_box_style = BoxStyle("Round", pad=0.3)
    head_length = 0.1
    max_offsets = [max(abs(int(d[c])) for d in stencil) for c in (0, 1)]

    if data is None:
        data = list(range(len(stencil)))

    for direction, annotation in zip(stencil, data):
        assert len(direction) == 2, "Works only for 2D stencils"
        direction = tuple(int(i) for i in direction)
        if not(direction[0] == 0 and direction[1] == 0):
            axes.arrow(0, 0, direction[0], direction[1], head_width=0.08, head_length=head_length, color='k')

        if isinstance(annotation, sp.Basic):
            annotation = "$" + sp.latex(annotation) + "$"
        else:
            annotation = str(annotation)

        def position_correction(d, magnitude=0.18):
            if d < 0:
                return -magnitude
            elif d > 0:
                return +magnitude
            else:
                return 0
        text_position = [direction[c] + position_correction(direction[c]) for c in (0, 1)]
        axes.text(x=text_position[0], y=text_position[1], s=annotation, verticalalignment='center',
                  zorder=30, horizontalalignment='center', size=textsize,
                  bbox=dict(boxstyle=text_box_style, facecolor='#00b6eb', alpha=0.85, linewidth=0))

    axes.set_axis_off()
    axes.set_aspect('equal')
    max_offsets = [m if m > 0 else 0.1 for m in max_offsets]
    border = 0.1
    axes.set_xlim([-border - max_offsets[0], border + max_offsets[0]])
    axes.set_ylim([-border - max_offsets[1], border + max_offsets[1]])


def plot_3d_slicing(stencil, slice_axis=2, figure=None, data=None, **kwargs):
    """Visualizes a 3D, first-neighborhood stencil by plotting 3 slices along a given axis.

    Args:
        stencil: stencil as sequence of directions
        slice_axis: 0, 1, or 2 indicating the axis to slice through
        figure: optional matplotlib figure
        data: optional data to print as text besides the arrows
    """
    import matplotlib.pyplot as plt

    for d in stencil:
        for element in d:
            assert element == -1 or element == 0 or element == 1, "This function can only first neighborhood stencils"

    if figure is None:
        figure = plt.gcf()

    axes = [figure.add_subplot(1, 3, i + 1) for i in range(3)]
    splitted_directions = [[], [], []]
    splitted_data = [[], [], []]
    axes_names = ['x', 'y', 'z']

    for i, d in enumerate(stencil):
        split_idx = d[slice_axis] + 1
        reduced_dir = tuple([element for j, element in enumerate(d) if j != slice_axis])
        splitted_directions[split_idx].append(reduced_dir)
        splitted_data[split_idx].append(i if data is None else data[i])

    for i in range(3):
        plot_2d(splitted_directions[i], axes=axes[i], data=splitted_data[i], **kwargs)
    for i in [-1, 0, 1]:
        axes[i + 1].set_title("Cut at %s=%d" % (axes_names[slice_axis], i), y=1.08)


def plot_3d(stencil, figure=None, axes=None, data=None, textsize='8'):
    """
    Draws 3D stencil into a 3D coordinate system, parameters are similar to :func:`visualize_stencil_2d`
    If data is None, no labels are drawn. To draw the labels as in the 2D case, use ``data=list(range(len(stencil)))``
    """
    from matplotlib.patches import FancyArrowPatch
    from mpl_toolkits.mplot3d import proj3d
    import matplotlib.pyplot as plt
    from matplotlib.patches import BoxStyle
    from itertools import product, combinations
    import numpy as np

    class Arrow3D(FancyArrowPatch):
        def __init__(self, xs, ys, zs, *args, **kwargs):
            FancyArrowPatch.__init__(self, (0, 0), (0, 0), *args, **kwargs)
            self._verts3d = xs, ys, zs

        def draw(self, renderer):
            xs3d, ys3d, zs3d = self._verts3d
            xs, ys, zs = proj3d.proj_transform(xs3d, ys3d, zs3d, renderer.M)
            self.set_positions((xs[0], ys[0]), (xs[1], ys[1]))
            FancyArrowPatch.draw(self, renderer)

    if axes is None:
        if figure is None:
            figure = plt.figure()
        axes = figure.gca(projection='3d')
        try:
            axes.set_aspect("equal")
        except NotImplementedError:
            pass

    if data is None:
        data = [None] * len(stencil)

    text_offset = 1.25
    text_box_style = BoxStyle("Round", pad=0.3)

    # Draw cell (cube)
    r = [-1, 1]
    for s, e in combinations(np.array(list(product(r, r, r))), 2):
        if np.sum(np.abs(s - e)) == r[1] - r[0]:
            axes.plot3D(*zip(s, e), color="k", alpha=0.5)

    for d, annotation in zip(stencil, data):
        assert len(d) == 3, "Works only for 3D stencils"
        d = tuple(int(i) for i in d)
        if not (d[0] == 0 and d[1] == 0 and d[2] == 0):
            if d[0] == 0:
                color = '#348abd'
            elif d[1] == 0:
                color = '#fac364'
            elif sum([abs(d) for d in d]) == 2:
                color = '#95bd50'
            else:
                color = '#808080'

            a = Arrow3D([0, d[0]], [0, d[1]], [0, d[2]], mutation_scale=20, lw=2, arrowstyle="-|>", color=color)
            axes.add_artist(a)

        if annotation:
            if isinstance(annotation, sp.Basic):
                annotation = "$" + sp.latex(annotation) + "$"
            else:
                annotation = str(annotation)

            axes.text(x=d[0] * text_offset, y=d[1] * text_offset, z=d[2] * text_offset,
                      s=annotation, verticalalignment='center', zorder=30,
                      size=textsize, bbox=dict(boxstyle=text_box_style, facecolor='#777777', alpha=0.6, linewidth=0))

    axes.set_xlim([-text_offset * 1.1, text_offset * 1.1])
    axes.set_ylim([-text_offset * 1.1, text_offset * 1.1])
    axes.set_zlim([-text_offset * 1.1, text_offset * 1.1])
    axes.set_axis_off()


def plot_expression(expr, **kwargs):
    """Displays coefficients of a linear update expression of a single field as matplotlib arrow drawing."""
    stencil, coeffs = coefficients(expr)
    dim = len(stencil[0])
    assert 0 < dim <= 3
    if dim == 1:
        return coefficient_list(expr, matrix_form=True)
    elif dim == 2:
        return plot_2d(stencil, data=coeffs, **kwargs)
    elif dim == 3:
        return plot_3d_slicing(stencil, data=coeffs, **kwargs)
