"""
Tools

A module for converting from mpl language to plotly language.

"""
import math
import warnings

import matplotlib.dates
import pytz


def check_bar_match(old_bar, new_bar):
    """Check if two bars belong in the same collection (bar chart).

    Positional arguments:
    old_bar -- a previously sorted bar dictionary.
    new_bar -- a new bar dictionary that needs to be sorted.

    """
    tests = []
    tests += new_bar['orientation'] == old_bar['orientation'],
    tests += new_bar['facecolor'] == old_bar['facecolor'],
    if new_bar['orientation'] == 'v':
        new_width = new_bar['x1'] - new_bar['x0']
        old_width = old_bar['x1'] - old_bar['x0']
        tests += new_width - old_width < 0.000001,
        tests += new_bar['y0'] == old_bar['y0'],
    elif new_bar['orientation'] == 'h':
        new_height = new_bar['y1'] - new_bar['y0']
        old_height = old_bar['y1'] - old_bar['y0']
        tests += new_height - old_height < 0.000001,
        tests += new_bar['x0'] == old_bar['x0'],
    if all(tests):
        return True
    else:
        return False


def check_corners(inner_obj, outer_obj):
    inner_corners = inner_obj.get_window_extent().corners()
    outer_corners = outer_obj.get_window_extent().corners()
    if inner_corners[0][0] < outer_corners[0][0]:
        return False
    elif inner_corners[0][1] < outer_corners[0][1]:
        return False
    elif inner_corners[3][0] > outer_corners[3][0]:
        return False
    elif inner_corners[3][1] > outer_corners[3][1]:
        return False
    else:
        return True


def convert_dash(mpl_dash):
    """Convert mpl line symbol to plotly line symbol and return symbol."""
    if mpl_dash in DASH_MAP:
        return DASH_MAP[mpl_dash]
    else:
        dash_array = mpl_dash.split(',')

        if (len(dash_array) < 2):
            return 'solid'

        # Catch the exception where the off length is zero, in case
        # matplotlib 'solid' changes from '10,0' to 'N,0'
        if (math.isclose(float(dash_array[1]), 0.)):
            return 'solid'

        # If we can't find the dash pattern in the map, convert it
        # into custom values in px, e.g. '7,5' -> '7px,5px'
        dashpx = ','.join([x + 'px' for x in dash_array])

        # TODO: rewrite the convert_dash code
        # only strings 'solid', 'dashed', etc allowed
        if dashpx == '7.4px,3.2px':
            dashpx = 'dashed'
        elif dashpx == '12.8px,3.2px,2.0px,3.2px':
            dashpx = 'dashdot'
        elif dashpx == '2.0px,3.3px':
            dashpx = 'dotted'
        return dashpx


def convert_path(path):
    verts = path[0]  # may use this later
    code = tuple(path[1])
    if code in PATH_MAP:
        return PATH_MAP[code]
    else:
        return None


def convert_symbol(mpl_symbol):
    """Convert mpl marker symbol to plotly symbol and return symbol."""
    if isinstance(mpl_symbol, list):
        symbol = list()
        for s in mpl_symbol:
            symbol += [convert_symbol(s)]
        return symbol
    elif mpl_symbol in SYMBOL_MAP:
        return SYMBOL_MAP[mpl_symbol]
    else:
        return 'circle'  # default


def hex_to_rgb(value):
    """
    Change a hex color to an rgb tuple

    :param (str|unicode) value: The hex string we want to convert.
    :return: (int, int, int) The red, green, blue int-tuple.

    Example:

        '#FFFFFF' --> (255, 255, 255)

    """
    value = value.lstrip('#')
    lv = len(value)
    return tuple(int(value[i:i + lv // 3], 16) for i in range(0, lv, lv // 3))


def merge_color_and_opacity(color, opacity):
    """
    Merge hex color with an alpha (opacity) to get an rgba tuple.

    :param (str|unicode) color: A hex color string.
    :param (float|int) opacity: A value [0, 1] for the 'a' in 'rgba'.
    :return: (int, int, int, float) The rgba color and alpha tuple.

    """
    if color is None:  # None can be used as a placeholder, just bail.
        return None

    rgb_tup = hex_to_rgb(color)
    if opacity is None:
        return 'rgb {}'.format(rgb_tup)

    rgba_tup = rgb_tup + (opacity,)
    return 'rgba {}'.format(rgba_tup)


def convert_va(mpl_va):
    """Convert mpl vertical alignment word to equivalent HTML word.

    Text alignment specifiers from mpl differ very slightly from those used
    in HTML. See the VA_MAP for more details.

    Positional arguments:
    mpl_va -- vertical mpl text alignment spec.

    """
    if mpl_va in VA_MAP:
        return VA_MAP[mpl_va]
    else:
        return None  # let plotly figure it out!


def convert_x_domain(mpl_plot_bounds, mpl_max_x_bounds):
    """Map x dimension of current plot to plotly's domain space.

    The bbox used to locate an axes object in mpl differs from the
    method used to locate axes in plotly. The mpl version locates each
    axes in the figure so that axes in a single-plot figure might have
    the bounds, [0.125, 0.125, 0.775, 0.775] (x0, y0, width, height),
    in mpl's figure coordinates. However, the axes all share one space in
    plotly such that the domain will always be [0, 0, 1, 1]
    (x0, y0, x1, y1). To convert between the two, the mpl figure bounds
    need to be mapped to a [0, 1] domain for x and y. The margins set
    upon opening a new figure will appropriately match the mpl margins.

    Optionally, setting margins=0 and simply copying the domains from
    mpl to plotly would place axes appropriately. However,
    this would throw off axis and title labeling.

    Positional arguments:
    mpl_plot_bounds -- the (x0, y0, width, height) params for current ax **
    mpl_max_x_bounds -- overall (x0, x1) bounds for all axes **

    ** these are all specified in mpl figure coordinates

    """
    mpl_x_dom = [mpl_plot_bounds[0], mpl_plot_bounds[0]+mpl_plot_bounds[2]]
    plotting_width = (mpl_max_x_bounds[1]-mpl_max_x_bounds[0])
    x0 = (mpl_x_dom[0]-mpl_max_x_bounds[0])/plotting_width
    x1 = (mpl_x_dom[1]-mpl_max_x_bounds[0])/plotting_width
    return [x0, x1]


def convert_y_domain(mpl_plot_bounds, mpl_max_y_bounds):
    """Map y dimension of current plot to plotly's domain space.

    The bbox used to locate an axes object in mpl differs from the
    method used to locate axes in plotly. The mpl version locates each
    axes in the figure so that axes in a single-plot figure might have
    the bounds, [0.125, 0.125, 0.775, 0.775] (x0, y0, width, height),
    in mpl's figure coordinates. However, the axes all share one space in
    plotly such that the domain will always be [0, 0, 1, 1]
    (x0, y0, x1, y1). To convert between the two, the mpl figure bounds
    need to be mapped to a [0, 1] domain for x and y. The margins set
    upon opening a new figure will appropriately match the mpl margins.

    Optionally, setting margins=0 and simply copying the domains from
    mpl to plotly would place axes appropriately. However,
    this would throw off axis and title labeling.

    Positional arguments:
    mpl_plot_bounds -- the (x0, y0, width, height) params for current ax **
    mpl_max_y_bounds -- overall (y0, y1) bounds for all axes **

    ** these are all specified in mpl figure coordinates

    """
    mpl_y_dom = [mpl_plot_bounds[1], mpl_plot_bounds[1]+mpl_plot_bounds[3]]
    plotting_height = (mpl_max_y_bounds[1]-mpl_max_y_bounds[0])
    y0 = (mpl_y_dom[0]-mpl_max_y_bounds[0])/plotting_height
    y1 = (mpl_y_dom[1]-mpl_max_y_bounds[0])/plotting_height
    return [y0, y1]


def display_to_paper(x, y, layout):
    """Convert mpl display coordinates to plotly paper coordinates.

    Plotly references object positions with an (x, y) coordinate pair in either
    'data' or 'paper' coordinates which reference actual data in a plot or
    the entire plotly axes space where the bottom-left of the bottom-left
    plot has the location (x, y) = (0, 0) and the top-right of the top-right
    plot has the location (x, y) = (1, 1). Display coordinates in mpl reference
    objects with an (x, y) pair in pixel coordinates, where the bottom-left
    corner is at the location (x, y) = (0, 0) and the top-right corner is at
    the location (x, y) = (figwidth*dpi, figheight*dpi). Here, figwidth and
    figheight are in inches and dpi are the dots per inch resolution.

    """
    num_x = x - layout['margin']['l']
    den_x = layout['width'] - (layout['margin']['l'] + layout['margin']['r'])
    num_y = y - layout['margin']['b']
    den_y = layout['height'] - (layout['margin']['b'] + layout['margin']['t'])
    return num_x/den_x, num_y/den_y


def get_axes_bounds(fig):
    """Return the entire axes space for figure.

    An axes object in mpl is specified by its relation to the figure where
    (0,0) corresponds to the bottom-left part of the figure and (1,1)
    corresponds to the top-right. Margins exist in matplotlib because axes
    objects normally don't go to the edges of the figure.

    In plotly, the axes area (where all subplots go) is always specified with
    the domain [0,1] for both x and y. This function finds the smallest box,
    specified by two points, that all of the mpl axes objects fit into. This
    box is then used to map mpl axes domains to plotly axes domains.

    """
    x_min, x_max, y_min, y_max = [], [], [], []
    for axes_obj in fig.get_axes():
        bounds = axes_obj.get_position().bounds
        x_min.append(bounds[0])
        x_max.append(bounds[0]+bounds[2])
        y_min.append(bounds[1])
        y_max.append(bounds[1]+bounds[3])
    x_min, y_min, x_max, y_max = min(x_min), min(y_min), max(x_max), max(y_max)
    return (x_min, x_max), (y_min, y_max)


def get_axis_mirror(main_spine, mirror_spine):
    if main_spine and mirror_spine:
        return 'ticks'
    elif main_spine and not mirror_spine:
        return False
    elif not main_spine and mirror_spine:
        return False  # can't handle this case yet!
    else:
        return False  # nuttin'!


def get_bar_gap(bar_starts, bar_ends, tol=1e-10):
    if len(bar_starts) == len(bar_ends) and len(bar_starts) > 1:
        sides1 = bar_starts[1:]
        sides2 = bar_ends[:-1]
        gaps = [s2-s1 for s2, s1 in zip(sides1, sides2)]
        gap0 = gaps[0]
        uniform = all([abs(gap0-gap) < tol for gap in gaps])
        if uniform:
            return gap0


def convert_rgba_array(color_list):
    clean_color_list = list()
    for c in color_list:
        clean_color_list += [(dict(r=int(c[0]*255),
                                   g=int(c[1]*255),
                                   b=int(c[2]*255),
                                   a=c[3]
        ))]
    plotly_colors = list()
    for rgba in clean_color_list:
        plotly_colors += ["rgba({r},{g},{b},{a})".format(**rgba)]
    if len(plotly_colors) == 1:
        return plotly_colors[0]
    else:
        return plotly_colors


def convert_path_array(path_array):
    symbols = list()
    for path in path_array:
        symbols += [convert_path(path)]
    if len(symbols) == 1:
        return symbols[0]
    else:
        return symbols

def convert_linewidth_array(width_array):
    if len(width_array) == 1:
        return width_array[0]
    else:
        return width_array


def convert_size_array(size_array):
    size = [math.sqrt(s) for s in size_array]
    if len(size) == 1:
        return size[0]
    else:
        return size


def get_markerstyle_from_collection(props):
    markerstyle=dict(
        alpha=None,
        facecolor=convert_rgba_array(props['styles']['facecolor']),
        marker=convert_path_array(props['paths']),
        edgewidth=convert_linewidth_array(props['styles']['linewidth']),
        # markersize=convert_size_array(props['styles']['size']),  # TODO!
        markersize=convert_size_array(props['mplobj'].get_sizes()),
        edgecolor=convert_rgba_array(props['styles']['edgecolor'])
    )
    return markerstyle


def get_rect_xmin(data):
    """Find minimum x value from four (x,y) vertices."""
    return min(data[0][0], data[1][0], data[2][0], data[3][0])


def get_rect_xmax(data):
    """Find maximum x value from four (x,y) vertices."""
    return max(data[0][0], data[1][0], data[2][0], data[3][0])


def get_rect_ymin(data):
    """Find minimum y value from four (x,y) vertices."""
    return min(data[0][1], data[1][1], data[2][1], data[3][1])


def get_rect_ymax(data):
    """Find maximum y value from four (x,y) vertices."""
    return max(data[0][1], data[1][1], data[2][1], data[3][1])

def get_spine_visible(ax, spine_key):
    """Return some spine parameters for the spine, `spine_key`."""
    spine = ax.spines[spine_key]
    ax_frame_on = ax.get_frame_on()
    spine_frame_like = spine.is_frame_like()
    if not spine.get_visible():
        return False
    elif not spine._edgecolor[-1]:  # user's may have set edgecolor alpha==0
        return False
    elif not ax_frame_on and spine_frame_like:
        return False
    elif ax_frame_on and spine_frame_like:
        return True
    elif not ax_frame_on and not spine_frame_like:
        return True  # we've already checked for that it's visible.
    else:
        return False # oh man, and i thought we exhausted the options...


def is_bar(bar_containers, **props):
    """A test to decide whether a path is a bar from a vertical bar chart."""

    # is this patch in a bar container?
    for container in bar_containers:
        if props['mplobj'] in container:
            return True
    return False


def make_bar(**props):
    """Make an intermediate bar dictionary.

    This creates a bar dictionary which aids in the comparison of new bars to
    old bars from other bar chart (patch) collections. This is not the
    dictionary that needs to get passed to plotly as a data dictionary. That
    happens in PlotlyRenderer in that class's draw_bar method. In other
    words, this dictionary describes a SINGLE bar, whereas, plotly will
    require a set of bars to be passed in a data dictionary.

    """
    return {
        'bar': props['mplobj'],
        'x0': get_rect_xmin(props['data']),
        'y0': get_rect_ymin(props['data']),
        'x1': get_rect_xmax(props['data']),
        'y1': get_rect_ymax(props['data']),
        'alpha': props['style']['alpha'],
        'edgecolor': props['style']['edgecolor'],
        'facecolor': props['style']['facecolor'],
        'edgewidth': props['style']['edgewidth'],
        'dasharray': props['style']['dasharray'],
        'zorder': props['style']['zorder']
    }

def prep_ticks(ax, index, ax_type, props):
    """Prepare axis obj belonging to axes obj.

    positional arguments:
    ax - the mpl axes instance
    index - the index of the axis in `props`
    ax_type - 'x' or 'y' (for now)
    props - an mplexporter poperties dictionary

    """
    axis_dict = dict()
    if ax_type == 'x':
        axis = ax.get_xaxis()
    elif ax_type == 'y':
        axis = ax.get_yaxis()
    else:
        return dict() # whoops!

    scale = props['axes'][index]['scale']
    if scale == 'linear':
        # get tick location information
        try:
            tickvalues = props['axes'][index]['tickvalues']
            tick0 = tickvalues[0]
            dticks = [round(tickvalues[i]-tickvalues[i-1], 12)
                      for i in range(1, len(tickvalues) - 1)]
            if all([dticks[i] == dticks[i-1]
                    for i in range(1, len(dticks) - 1)]):
                dtick = tickvalues[1] - tickvalues[0]
            else:
                warnings.warn("'linear' {0}-axis tick spacing not even, "
                              "ignoring mpl tick formatting.".format(ax_type))
                raise TypeError
        except (IndexError, TypeError):
            axis_dict['nticks'] = props['axes'][index]['nticks']
        else:
            axis_dict['tick0'] = tick0
            axis_dict['dtick'] = dtick
            axis_dict['tickmode'] = None
    elif scale == 'log':
        try:
            axis_dict['tick0'] = props['axes'][index]['tickvalues'][0]
            axis_dict['dtick'] = props['axes'][index]['tickvalues'][1] - \
                            props['axes'][index]['tickvalues'][0]
            axis_dict['tickmode'] = None
        except (IndexError, TypeError):
            axis_dict = dict(nticks=props['axes'][index]['nticks'])
        base = axis.get_transform().base
        if base == 10:
            if ax_type == 'x':
                axis_dict['range'] = [math.log10(props['xlim'][0]),
                                 math.log10(props['xlim'][1])]
            elif ax_type == 'y':
                axis_dict['range'] = [math.log10(props['ylim'][0]),
                                 math.log10(props['ylim'][1])]
        else:
            axis_dict = dict(range=None, type='linear')
            warnings.warn("Converted non-base10 {0}-axis log scale to 'linear'"
                          "".format(ax_type))
    else:
        return dict()
    # get tick label formatting information
    formatter = axis.get_major_formatter().__class__.__name__
    if ax_type == 'x' and 'DateFormatter' in formatter:
        axis_dict['type'] = 'date'
        try:
            axis_dict['tick0'] = mpl_dates_to_datestrings(
                axis_dict['tick0'], formatter
            )
        except KeyError:
            pass
        finally:
            axis_dict.pop('dtick', None)
            axis_dict.pop('tickmode', None)
            axis_dict['range'] = mpl_dates_to_datestrings(
                props['xlim'], formatter
            )

    if formatter == 'LogFormatterMathtext':
        axis_dict['exponentformat'] = 'e'
    return axis_dict


def prep_xy_axis(ax, props, x_bounds, y_bounds):
    xaxis = dict(
        type=props['axes'][0]['scale'],
        range=list(props['xlim']),
        showgrid=props['axes'][0]['grid']['gridOn'],
        domain=convert_x_domain(props['bounds'], x_bounds),
        side=props['axes'][0]['position'],
        tickfont=dict(size=props['axes'][0]['fontsize'])
    )
    xaxis.update(prep_ticks(ax, 0, 'x', props))
    yaxis = dict(
        type=props['axes'][1]['scale'],
        range=list(props['ylim']),
        showgrid=props['axes'][1]['grid']['gridOn'],
        domain=convert_y_domain(props['bounds'], y_bounds),
        side=props['axes'][1]['position'],
        tickfont=dict(size=props['axes'][1]['fontsize'])
    )
    yaxis.update(prep_ticks(ax, 1, 'y', props))
    return xaxis, yaxis


def mpl_dates_to_datestrings(dates, mpl_formatter):
    """Convert matplotlib dates to iso-formatted-like time strings.

    Plotly's accepted format: "YYYY-MM-DD HH:MM:SS" (e.g., 2001-01-01 00:00:00)

    Info on mpl dates: http://matplotlib.org/api/dates_api.html

    """
    _dates = dates

    # this is a pandas datetime formatter, times show up in floating point days
    # since the epoch (1970-01-01T00:00:00+00:00)
    if mpl_formatter == "TimeSeries_DateFormatter":
        try:
            dates = matplotlib.dates.epoch2num(
                [date*24*60*60 for date in dates]
            )
            dates = matplotlib.dates.num2date(dates, tz=pytz.utc)
        except:
            return _dates

    # the rest of mpl dates are in floating point days since
    # (0001-01-01T00:00:00+00:00) + 1. I.e., (0001-01-01T00:00:00+00:00) == 1.0
    # according to mpl --> try num2date(1)
    else:
        try:
            dates = matplotlib.dates.num2date(dates, tz=pytz.utc)
        except:
            return _dates

    time_stings = [' '.join(date.isoformat().split('+')[0].split('T'))
                   for date in dates]
    return time_stings

# dashed is dash in matplotlib
DASH_MAP = {
    '10,0': 'solid',
    '6,6': 'dash',
    '2,2': 'circle',
    '4,4,2,4': 'dashdot',
    'none': 'solid',
    '7.4,3.2': 'dash',
}

PATH_MAP = {
    ('M', 'C', 'C', 'C', 'C', 'C', 'C', 'C', 'C', 'Z'): 'o',
    ('M', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'Z'): '*',
    ('M', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'Z'): '8',
    ('M', 'L', 'L', 'L', 'L', 'L', 'Z'): 'h',
    ('M', 'L', 'L', 'L', 'L', 'Z'): 'p',
    ('M', 'L', 'M', 'L', 'M', 'L'): '1',
    ('M', 'L', 'L', 'L', 'Z'): 's',
    ('M', 'L', 'M', 'L'): '+',
    ('M', 'L', 'L', 'Z'): '^',
    ('M', 'L'): '|'
}

SYMBOL_MAP = {
    'o': 'circle',
    'v': 'triangle-down',
    '^': 'triangle-up',
    '<': 'triangle-left',
    '>': 'triangle-right',
    's': 'square',
    '+': 'cross',
    'x': 'x',
    '*': 'star',
    'D': 'diamond',
    'd': 'diamond',
}

VA_MAP = {
    'center': 'middle',
    'baseline': 'bottom',
    'top': 'top'
}
