import numpy as np
import time
import sklearn.datasets
import skimage.transform
import scipy.stats

from . import python_utils
from . import image_utils

# if python_utils.module_exists("matplotlib.pyplot"):
#     import matplotlib.pyplot as plt

CV2 = False
if python_utils.module_exists("cv2"):
    import cv2

    CV2 = True


# import multiprocessing
#
# import python_utils
#
# if python_utils.module_exists("joblib"):
#     from joblib import Parallel, delayed
#     JOBLIB = True
# else:
#     JOBLIB = False


# def plot_field_map(field_map):
#     from mpl_toolkits.mplot3d import Axes3D
#
#     row = np.linspace(0, 1, field_map.shape[0])
#     col = np.linspace(0, 1, field_map.shape[1])
#     rr, cc = np.meshgrid(row, col, indexing='ij')
#
#     fig = plt.figure(figsize=(18, 9))
#     ax = fig.add_subplot(121, projection='3d')
#     ax.plot_surface(rr, cc, field_map[:, :, 0], rstride=3, cstride=3, linewidth=1, antialiased=True)
#
#     ax = fig.add_subplot(122, projection='3d')
#     ax.plot_surface(rr, cc, field_map[:, :, 1], rstride=3, cstride=3, linewidth=1, antialiased=True)
#
#     plt.show()

# --- Classes --- #

class AverageMeter(object):
    """Computes and stores the average and current value"""

    def __init__(self, name="", init_val=0, fmt=':f'):
        self.name = name
        self.init_val = init_val
        self.fmt = fmt
        self.val = self.avg = self.init_val
        self.sum = self.count = 0

    def reset(self):
        self.val = self.avg = self.init_val
        self.sum = self.count = 0

    def update(self, val, n=1):
        self.val = val
        self.sum += val * n
        self.count += n
        self.avg = self.sum / self.count

    def get_avg(self):
        return self.avg

    def __str__(self):
        fmtstr = '{name} {val' + self.fmt + '} ({avg' + self.fmt + '})'
        return fmtstr.format(**self.__dict__)


class RunningDecayingAverage(object):
    """
    Updates average with val*(1 - decay) + avg*decay
    """

    def __init__(self, decay, init_val=0):
        assert 0 < decay < 1
        self.decay = decay
        self.init_val = init_val
        self.val = self.avg = self.init_val

    def reset(self):
        self.val = self.avg = self.init_val

    def update(self, val):
        self.val = val
        self.avg = (1 - self.decay) * val + self.decay * self.avg

    def get_avg(self):
        return self.avg


class DispFieldMapsPatchCreator:
    def __init__(self, global_shape, patch_res, map_count,
                 modes, gauss_mu_range, gauss_sig_scaling):
        self.global_shape = global_shape
        self.patch_res = patch_res
        self.map_count = map_count
        self.modes = modes
        self.gauss_mu_range = gauss_mu_range
        self.gauss_sig_scaling = gauss_sig_scaling

        self.current_patch_index = -1
        self.patch_boundingboxes = image_utils.compute_patch_boundingboxes(self.global_shape, stride=self.patch_res,
                                                                           patch_res=self.patch_res)
        self.disp_maps = None
        self.create_new_disp_maps()

    def create_new_disp_maps(self):
        print("DispFieldMapsPatchCreator.create_new_disp_maps()")
        self.disp_maps = create_displacement_field_maps(self.global_shape, self.map_count, self.modes,
                                                        self.gauss_mu_range, self.gauss_sig_scaling)

    def get_patch(self):
        self.current_patch_index += 1

        if len(self.patch_boundingboxes) <= self.current_patch_index:
            self.current_patch_index = 0
            self.create_new_disp_maps()

        patch_boundingbox = self.patch_boundingboxes[self.current_patch_index]
        patch_disp_maps = self.disp_maps[:, patch_boundingbox[0]:patch_boundingbox[2],
                                         patch_boundingbox[1]:patch_boundingbox[3], :]
        return patch_disp_maps


# --- --- #

def compute_crossfield_c0c2(u, v):
    c0 = np.power(u, 2) * np.power(v, 2)
    c2 = - (np.power(u, 2) + np.power(v, 2))
    crossfield = np.stack([c0.real, c0.imag, c2.real, c2.imag], axis=-1)
    return crossfield


def compute_crossfield_uv(c0c2):
    c0 = c0c2[..., 0] + 1j * c0c2[..., 1]
    c2 = c0c2[..., 2] + 1j * c0c2[..., 3]
    sqrt_c2_squared_minus_4c0 = np.sqrt(np.power(c2, 2) - 4 * c0)
    u_squared = (c2 + sqrt_c2_squared_minus_4c0) / 2
    v_squared = (c2 - sqrt_c2_squared_minus_4c0) / 2
    u = np.sqrt(u_squared)
    v = np.sqrt(v_squared)
    return u, v


def to_homogeneous(array):
    new_array = np.ones(
        (array.shape[0],
         array.shape[1] + 1),
        dtype=array.dtype)
    new_array[..., :-1] = array
    return new_array


def to_euclidian(array_homogeneous):
    array = array_homogeneous[:, 0:2] / array_homogeneous[:, 2:3]
    return array


def stretch(array):
    mini = np.min(array)
    maxi = np.max(array)
    if maxi - mini:
        array -= mini
        array *= 2 / (maxi - mini)
        array -= 1
    return array


def crop_center(array, out_shape):
    assert len(out_shape) == 2, "out_shape should be of length 2"
    in_shape = np.array(array.shape[:2])
    start = in_shape // 2 - (out_shape // 2)
    out_array = array[start[0]:start[0] + out_shape[0],
                      start[1]:start[1] + out_shape[1], ...]
    return out_array


def multivariate_gaussian(pos, mu, sigma):
    """Return the multivariate Gaussian distribution on array pos.

    pos is an array constructed by packing the meshed arrays of variables
    x_1, x_2, x_3, ..., x_k into its _last_ dimension.

    """

    n = mu.shape[0]
    sigma_det = np.linalg.det(sigma)
    sigma_inv = np.linalg.inv(sigma)
    N = np.sqrt((2 * np.pi) ** n * sigma_det)
    # This einsum call calculates (x-mu)T.sigma-1.(x-mu) in a vectorized
    # way across all the input variables.

    # print("\tStarting to create multivariate Gaussian")
    # start = time.time()

    # print((pos - mu).shape)
    # print(sigma_inv.shape)
    try:
        fac = np.einsum(
            '...k,kl,...l->...',
            pos - mu,
            sigma_inv,
            pos - mu,
            optimize=True)
    except BaseException:
        fac = np.einsum('...k,kl,...l->...', pos - mu, sigma_inv, pos - mu)
    # print(fac.shape)

    # end = time.time()
    # print("\tFinished Gaussian in {}s".format(end - start))

    return np.exp(-fac / 2) / N


def create_multivariate_gaussian_mixture_map(
        shape, mode_count, mu_range, sig_scaling):
    shape = np.array(shape)
    # print("Starting to create multivariate Gaussian mixture")
    # main_start = time.time()

    dim_count = 2
    downsample_factor = 4
    dtype = np.float32

    mu_scale = mu_range[1] - mu_range[0]
    row = np.linspace(
        mu_range[0],
        mu_range[1],
        mu_scale *
        shape[0] /
        downsample_factor,
        dtype=dtype)
    col = np.linspace(
        mu_range[0],
        mu_range[1],
        mu_scale *
        shape[1] /
        downsample_factor,
        dtype=dtype)
    rr, cc = np.meshgrid(row, col, indexing='ij')
    grid = np.stack([rr, cc], axis=2)

    mus = np.random.uniform(
        mu_range[0],
        mu_range[1],
        (mode_count,
         dim_count,
         2)).astype(dtype)
    # gams = np.random.rand(mode_count, dim_count, 2, 2).astype(dtype)
    signs = np.random.choice([1, -1], size=(mode_count, dim_count))

    # print("\tAdding gaussian mixtures one by one")
    # start = time.time()

    # if JOBLIB:
    #     # Parallel computing of multivariate gaussians
    #     inputs = range(8)
    #
    #     def processInput(i):
    #         size = 10 * i + 2000
    #         a = np.random.random_sample((size, size))
    #         b = np.random.random_sample((size, size))
    #         n = np.dot(a, b)
    #         return n
    #
    #     num_cores = multiprocessing.cpu_count()
    #     print("num_cores: {}".format(num_cores))
    #     # num_cores = 1
    #
    #     results = Parallel(n_jobs=num_cores)(delayed(processInput)(i) for i in inputs)
    #     for result in results:
    #         print(result.shape)
    #
    #     gaussian_mixture = np.zeros_like(grid)
    # else:
    gaussian_mixture = np.zeros_like(grid)
    for mode_index in range(mode_count):
        for dim in range(dim_count):
            sig = (sig_scaling[1] - sig_scaling[0]) * \
                sklearn.datasets.make_spd_matrix(2) + sig_scaling[0]
            # sig = (sig_scaling[1] - sig_scaling[0]) * np.dot(gams[mode_index, dim], np.transpose(gams[mode_index, dim])) + sig_scaling[0]
            sig = sig.astype(dtype)
            multivariate_gaussian_grid = signs[mode_index, dim] * \
                multivariate_gaussian(grid, mus[mode_index, dim], sig)
            gaussian_mixture[:, :, dim] += multivariate_gaussian_grid

    # end = time.time()
    # print("\tFinished adding gaussian mixtures in {}s".format(end - start))

    # squared_gaussian_mixture = np.square(gaussian_mixture)
    # magnitude_disp_field_map = np.sqrt(squared_gaussian_mixture[:, :, 0] + squared_gaussian_mixture[:, :, 1])
    # max_magnitude = magnitude_disp_field_map.max()

    gaussian_mixture[:, :, 0] = stretch(gaussian_mixture[:, :, 0])
    gaussian_mixture[:, :, 1] = stretch(gaussian_mixture[:, :, 1])

    # Crop
    gaussian_mixture = crop_center(
        gaussian_mixture,
        shape // downsample_factor)

    # plot_field_map(gaussian_mixture)

    # Upsample mixture
    # gaussian_mixture = skimage.transform.rescale(gaussian_mixture, downsample_factor)
    gaussian_mixture = skimage.transform.resize(gaussian_mixture, shape)

    main_end = time.time()  # NOQA
    # print("Finished multivariate Gaussian mixture in {}s".format(main_end - main_start))

    return gaussian_mixture


def create_displacement_field_maps(
        shape, map_count, modes, gauss_mu_range, gauss_sig_scaling, seed=None):
    if seed is not None:
        np.random.seed(seed)
    disp_field_maps_list = []
    for disp_field_map_index in range(map_count):
        disp_field_map_normed = create_multivariate_gaussian_mixture_map(shape,
                                                                         modes,
                                                                         gauss_mu_range,
                                                                         gauss_sig_scaling)
        disp_field_maps_list.append(disp_field_map_normed)
    disp_field_maps = np.stack(disp_field_maps_list, axis=0)

    return disp_field_maps


def get_h_mat(t, theta, scale_offset, shear, p):
    """
    Computes the homography matrix given the parameters
    See https://medium.com/uruvideo/dataset-augmentation-with-random-homographies-a8f4b44830d4
    (fixed mistake in H_a)

    :param t: 2D translation vector
    :param theta: Scalar angle
    :param scale_offset: 2D scaling vector
    :param shear: 2D shearing vector
    :param p: 2D projection vector
    :return: h_mat: shape (3, 3)
    """
    cos_theta = np.cos(theta)
    sin_theta = np.sin(theta)
    h_e = np.array([
        [cos_theta, -sin_theta, t[0]],
        [sin_theta, cos_theta, t[1]],
        [0, 0, 1],
    ])
    h_a = np.array([
        [1 + scale_offset[0], shear[1], 0],
        [shear[0], 1 + scale_offset[1], 0],
        [0, 0, 1],
    ])
    h_p = np.array([
        [1, 0, 0],
        [0, 1, 0],
        [p[0], p[1], 1],
    ])
    h_mat = h_e @ h_a @ h_p
    return h_mat


if CV2:
    def find_homography_4pt(src, dst):
        """
        Estimates the homography that transforms src points into dst points.
        Then converts the matrix representation into the 4 points representation.

        :param src:
        :param dst:
        :return:
        """
        h_mat, _ = cv2.findHomography(src, dst)
        h_4pt = convert_h_mat_to_4pt(h_mat)
        return h_4pt

    def convert_h_mat_to_4pt(h_mat):
        src_4pt = np.array([[
            [-1, -1],
            [1, -1],
            [1, 1],
            [-1, 1],
        ]], dtype=np.float64)
        h_4pt = cv2.perspectiveTransform(src_4pt, h_mat)
        return h_4pt

    def convert_h_4pt_to_mat(h_4pt):
        src_4pt = np.array([
            [-1, -1],
            [1, -1],
            [1, 1],
            [-1, 1],
        ], dtype=np.float32)
        h_4pt = h_4pt.astype(np.float32)
        h_mat = cv2.getPerspectiveTransform(src_4pt, h_4pt)
        return h_mat

    def field_map_to_image(field_map):
        mag, ang = cv2.cartToPolar(field_map[..., 0], field_map[..., 1])
        hsv = np.zeros((field_map.shape[0], field_map.shape[1], 3))
        hsv[..., 0] = ang * 180 / np.pi / 2
        hsv[..., 1] = 255
        hsv[..., 2] = cv2.normalize(mag, None, 0, 255, cv2.NORM_MINMAX)
        hsv = hsv.astype(np.uint8)
        rgb = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
        return rgb
else:
    def find_homography_4pt(src, dst):
        print(
            "cv2 is not available, the find_homography_4pt(src, dst) function cannot work!")

    def convert_h_mat_to_4pt(h_mat):
        print("cv2 is not available, the convert_h_mat_to_4pt(h_mat) function cannot work!")

    def convert_h_4pt_to_mat(h_4pt):
        print("cv2 is not available, the convert_h_4pt_to_mat(h_4pt) function cannot work!")

    def field_map_to_image(field_map):
        print(
            "cv2 is not available, the field_map_to_image(field_map) function cannot work!")


def circular_diff(a1, a2, range_max):
    """
    Compute difference between a1 and a2 belonging to the circular interval [0, range_max).
    For example to compute angle difference, use range_max=2*PI.
    a1 and a2 must be between range_min and range_max!
    Thus difference between 0 and range_max is 0.
    :param a1: numpy array
    :param a2: numpy array
    :param range_max:
    :return:
    """
    d = range_max / 2 - np.abs(np.abs(a1 - a2) - range_max / 2)
    return d


def invert_permutation(p):
    '''The argument p is assumed to be some permutation of 0, 1, ..., len(p)-1.
    Returns an array s, where s[i] gives the index of i in p.
    '''
    s = np.empty(p.size, p.dtype)
    s[p] = np.arange(p.size)
    return s


def region_growing_1d(array, max_range, max_skew):
    """
    :param array:
    :param max_var:
    :param max_mean_median_diff:
    :return:
    """

    def verify_predicate(region):
        """
        Region is sorted
        :param region:
        :return:
        """
        skew = scipy.stats.skew(region)
        return region[-1] - region[0] < max_range and abs(skew) < max_skew

    assert len(
        array.shape) == 1, "array should be 1d, not {}".format(
        array.shape)
    p = np.argsort(array)
    sorted_array = array[p]

    labels = np.zeros(len(sorted_array), dtype=np.long)
    region_start = 0
    region_label = 1
    labels[region_start] = region_label
    centers = []
    for i in range(1, len(sorted_array)):
        region = sorted_array[region_start:i + 1]
        if not verify_predicate(region):
            # End current region
            median = region[len(region) // 2]  # region is sorted
            centers.append(median)
            # Begin a new region
            region_start = i
            region_label += 1
        labels[i] = region_label
    centers.append(median)

    return labels[invert_permutation(p)], centers


def bilinear_interpolate(im, pos):
    # From
    # https://gist.github.com/peteflorence/a1da2c759ca1ac2b74af9a83f69ce20e
    x = pos[..., 1]
    y = pos[..., 0]

    x0 = np.floor(x).astype(int)
    x1 = x0 + 1
    y0 = np.floor(y).astype(int)
    y1 = y0 + 1

    x0_clipped = np.clip(x0, 0, im.shape[1] - 1)
    x1_clipped = np.clip(x1, 0, im.shape[1] - 1)
    y0_clipped = np.clip(y0, 0, im.shape[0] - 1)
    y1_clipped = np.clip(y1, 0, im.shape[0] - 1)

    Ia = im[y0_clipped, x0_clipped]
    Ib = im[y1_clipped, x0_clipped]
    Ic = im[y0_clipped, x1_clipped]
    Id = im[y1_clipped, x1_clipped]

    wa = (x1 - x) * (y1 - y)
    wb = (x1 - x) * (y - y0)
    wc = (x - x0) * (y1 - y)
    wd = (x - x0) * (y - y0)

    value = (Ia.T * wa).T + (Ib.T * wb).T + (Ic.T * wc).T + (Id.T * wd).T

    return value


def main():
    import matplotlib.pyplot as plt
    # shape = (220, 220)
    # mode_count = 30
    # mu_range = [0, 1]
    # sig_scaling = [0.0, 0.002]
    # create_multivariate_gaussian_mixture_map(shape, mode_count, mu_range, sig_scaling)

    # a1 = np.array([0.0])
    # a2 = np.array([3*np.pi/4])
    # range_max = np.pi
    # d = circular_diff(a1, a2, range_max)
    # print(d)

    array = np.concatenate(
        [np.arange(1, 1.01, 0.001), np.arange(0, np.pi / 2, np.pi / 100)])
    print(array)
    labels = region_growing_1d(array, max_range=np.pi / 10, max_skew=1)
    print(labels)

    plt.plot(array, labels, ".")
    plt.show()


if __name__ == "__main__":
    main()
