"""
Generate report on the results of the optimization.
"""
import base64
import datetime
from functools import partial
import io
import os
import pathlib
import sys
import time

import numpy as np

from openmdao.utils.reports_system import register_report

try:
    import matplotlib as mpl
    from matplotlib import pyplot as plt
    from matplotlib import patches
except ImportError:
    mpl = None


from openmdao.core.constants import INF_BOUND
from openmdao.utils.mpi import MPI
from openmdao.utils.om_warnings import issue_warning, DriverWarning
from openmdao.visualization.tables.table_builder import generate_table


# Report file constants
_default_optimizer_report_filename = 'opt_report.html'
_optimizer_report_template = 'opt_report_template.html'

MAX_VARNAME_LENGTH = 48

# value tolerances
_bounds_tolerance = 1.0E-4
_near_bounds_tolerance = 1.0E-4
_equality_constraint_tolerance = 1e-4

# Colors
#     from https://jfly.uni-koeln.de/color/ some colorblind friendly colors
_out_of_bounds_test_color = 'rgb(213, 94, 0)'
_out_of_bounds_plot_color = (0.83529, 0.36862, 0.)
_out_of_bounds_plot_alpha = 0.2

_out_of_bounds_plot_hatch_pattern = 'xxxxx'
_out_of_bounds_constraint_visual_hatch_pattern = 'xxxxxxxxx'

_out_of_bounds_hatch_color = (0., 0., 0.)
_out_of_bounds_hatch_width = 0.3

_in_bounds_plot_color = (0., 0.61960, 0.45098)
_in_bounds_plot_alpha = 0.2
_in_bounds_constraint_visual_alpha = 0.2

_value_plot_color = 'black'
_plot_marker_size = 0.1
_plot_value_linewidth = 0.5
_equality_constraint_dot_size = 3

# overall image parameters and layout
_sparkline_figsize = (3, .5)
_scalar_visual_figsize = (2.0, .2)
_plot_dpi = 150
_plot_pad_inches = 0
_plot_padding_fraction = 0.2

# Font sizes
_sparkline_font_size = 7
_scalar_visual_font_size = 4

# variable value formatting
_variable_label_format = '5.3g'

# https://jfly.uni-koeln.de/color/
# scalar visual parameters
_plot_x_max = 1.0
_plot_y_max = 1.0
_plot_y_margin = 0.2
_lower_plot = _plot_x_max / 3.
_upper_plot = 2 * _plot_x_max / 3.
_pointer_half_width = 0.04
_pointer_height = 0.4
_text_height = 0.3
_pointer_line_width = 0.05
_near_bound_highlight_half_width = 0.05
_near_bound_highlight_half_width_y_min = 0.0
_near_bound_highlight_half_width_y_max = _plot_y_max
_near_bound_highlight_alpha = 0.7
_equality_bound_width = 0.01
# For the ellipsis that are displayed when scalar is way outside of bounds
_ellipsis_x_offset = 0.2
_ellipse_width = 0.01
_ellipse_height = 0.15


def _optimizer_report_register():
    register_report('optimizer', opt_report, 'Summary of optimization',
                    'Problem', 'run_driver', 'post')


def opt_report(prob, outfile=None):
    """
    Write a summary of the optimization to the given file.

    Parameters
    ----------
    prob : Problem
        The Problem for which the optimization report is being generated.
    outfile : str or None
        The path to the HTML file to be written.  If None (default), write to the default report
        output path.
    """
    driver = prob.driver
    if not driver.supports['optimization']:
        driver_class = type(driver).__name__
        issue_warning(f"The optimizer report is not applicable for Driver type '{driver_class}', "
                      "which does not support optimization", category=DriverWarning)
        return

    # only create report on rank 0
    create = MPI is None or MPI.COMM_WORLD.rank == 0

    if not outfile:
        outfile = _default_optimizer_report_filename

    outfilepath = pathlib.Path(prob.get_reports_dir(force=True)).joinpath(outfile)

    driver_scaling = True

    # Collect data from the problem
    abs2prom = prob.model._var_abs2prom

    def get_prom_name(abs_name):
        if abs_name in abs2prom['input']:
            return abs2prom['input'][abs_name]
        elif abs_name in abs2prom['output']:
            return abs2prom['output'][abs_name]
        else:
            return abs_name

    # Collect the entire array of array valued desvars and constraints (ignore indices)
    objs_vals = {}
    desvars_vals = {}
    cons_vals = {}

    objs_meta = {}
    desvars_meta = {}
    cons_meta = {}

    with prob.model._scaled_context_all():
        for abs_name, meta in driver._objs.items():
            prom_name = get_prom_name(abs_name)
            objs_meta[prom_name] = meta
            objs_vals[prom_name] = \
                driver.get_objective_values(driver_scaling=driver_scaling)[abs_name]

        for abs_name, meta in driver._designvars.items():
            prom_name = get_prom_name(abs_name)
            desvars_meta[prom_name] = meta
            desvars_vals[prom_name] = \
                driver.get_design_var_values(driver_scaling=driver_scaling)[abs_name]

        for abs_name, meta in prob.driver._cons.items():
            if meta.get('alias') is not None:
                prom_name = abs_name
            else:
                prom_name = get_prom_name(abs_name)
            cons_meta[prom_name] = meta
            cons_vals[prom_name] = \
                driver.get_constraint_values(driver_scaling=driver_scaling)[abs_name]

    header_html = _make_header_table(prob)

    objs_html = _make_obj_table(objs_meta=objs_meta, objs_vals=objs_vals,
                                cols=['val', 'ref', 'ref0', 'adder', 'scaler', 'units'])

    desvars_html = _make_dvcons_table(meta_dict=desvars_meta, vals_dict=desvars_vals, kind='desvar',
                                      cols=['min', 'max', 'mean', 'lower', 'upper', 'equals', 'ref',
                                            'ref0', 'units', 'visual'])

    cons_html = _make_dvcons_table(meta_dict=cons_meta, vals_dict=cons_vals, kind='constraint',
                                   cols=['min', 'max', 'mean', 'lower', 'upper', 'equals', 'ref',
                                         'ref0', 'units', 'visual'])

    driver_info_html = _make_opt_value_table(driver)

    this_dir = os.path.dirname(os.path.abspath(__file__))

    if create:
        with open(os.path.join(this_dir, _optimizer_report_template), 'r', encoding='utf-8') as f:
            template = f.read()

        with open(outfilepath, 'w') as f:
            s = template.format(title=prob._name, header=header_html, objs=objs_html,
                                desvars=desvars_html, cons=cons_html, driver=driver_info_html,
                                driver_class=type(driver).__name__)
            f.write(s)


def _make_header_table(prob):
    """
    Make the HTML table at the top of the report with basic info about the optimization run.

    Parameters
    ----------
    prob : OpenMDAO Problem object
        The report will be run on this Problem.

    Returns
    -------
    str
        HTML table that displays basic optimization run info.
    """
    t = datetime.datetime.now()
    time_stamp = t.strftime("%Y-%m-%d %H:%M:%S %Z")
    runtime = prob.driver.opt_result['runtime']
    runtime_ms = (runtime * 1000.0) % 1000.0
    runtime_formatted = \
        f"{time.strftime('%H hours %M minutes %S seconds', time.gmtime(runtime))} " \
        f"{runtime_ms:.1f} milliseconds"

    rows = list()
    rows.append(['Problem:', prob._name])
    rows.append(['Script:', sys.argv[0]])
    rows.append(['Optimizer:', prob.driver._get_name()])
    rows.append(['Number of driver iterations:', prob.driver.opt_result['iter_count']])
    rows.append(['Number of objective calls:', prob.driver.opt_result['obj_calls']])
    rows.append(['Number of derivative calls:', prob.driver.opt_result['deriv_calls']])
    rows.append(['Execution start time:', time_stamp])
    rows.append(['Wall clock run time:', runtime_formatted])
    rows.append(['Exit status:', prob.driver.opt_result['exit_status']])

    return generate_table(rows, tablefmt='html')


def _make_opt_value_table(driver):
    """
    Make the HTML table that summarizes the optimizer settings.

    Parameters
    ----------
    driver : OpenMDAO Driver object
        The table will include info on the settings for this driver.

    Returns
    -------
    str
        HTML table that displays driver settings info.
    """
    opt_settings = []
    for key, meta in driver.options.items():
        meta = driver.options._dict[key]
        opt_settings.append((key, meta['val'], meta['desc']))
    opt_settings_table = generate_table(opt_settings, headers=['Setting', 'Val', 'Description'],
                                        tablefmt='html')

    html = ''
    if opt_settings_table is not None:
        html += f'\n{opt_settings_table}'

    return html


def _make_obj_table(objs_meta, objs_vals,
                    cols=['size', 'index', 'val', 'ref', 'ref0', 'adder', 'scaler', 'units']):
    """
    Make a table of info about the objective.

    Parameters
    ----------
    objs_meta : dict
        Dictionary of metadata about the objectives.
    objs_vals : dict
        Dictionary of values for the objectives.
    cols : list
        List of columns to be displayed in the table.

    Returns
    -------
    str
        An HTML image tag containing the table holding the info about the variable.
    """
    _col_names = cols if cols is not None else []
    col_names = ['name'] + _col_names

    # Get the values for all the elements in the tables
    rows = []
    for prom_name, meta in objs_meta.items():
        row = {}
        if meta['ref0'] is None and meta['ref'] is None and meta['scaler'] is None and \
                meta['adder'] is None:
            meta['ref'] = 1.0
            meta['ref0'] = 0.0
        for col_name in col_names:
            if col_name == 'name':
                row[col_name] = prom_name
                if len(row[col_name]) > MAX_VARNAME_LENGTH:
                    split_name = row[col_name].split('.')
                    row[col_name] = '.'.join(split_name[:2]) + '...' + split_name[-1]
            elif col_name == 'val':
                row[col_name] = objs_vals[prom_name]
            elif col_name == 'units':
                if meta['units'] is not None:
                    row[col_name] = meta[col_name]
                else:
                    row[col_name] = objs_meta[prom_name][col_name]
            else:
                row[col_name] = meta[col_name]
        rows.append(row)

    return generate_table(rows, headers='keys', tablefmt='html')


def _make_dvcons_table(meta_dict, vals_dict, kind,
                       cols=['lower', 'upper', 'ref', 'ref0', 'adder', 'scaler', 'units', 'min',
                             'max', 'visual']):
    """
    Make a table of info about either design variables or constraints.

    Parameters
    ----------
    meta_dict : dict
        Dictionary of metadata about the variables.
    vals_dict : dict
        Dictionary of values for the variables.
    kind : str, must be 'desvar' or 'constraint'
        Indicates whether table is for 'desvar' or 'constraint'.
    cols : list
        List of columns to be displayed in the table.

    Returns
    -------
    str
        An HTML image tag containing the table holding the info about the variable.
    """
    _col_names = cols if cols is not None else []
    col_names = ['name', 'alias', 'size'] + _col_names

    # Get the values for all the elements in the tables
    rows = []

    for name, meta in meta_dict.items():

        row = {}

        if meta['ref0'] is None and meta['ref'] is None and meta['scaler'] is None and \
                meta['adder'] is None:
            meta['ref'] = 1.0
            meta['ref0'] = 0.0

        alias = meta.get('alias', '')

        # the scipy optimizer, when using COBYLA, creates constraints under the hood.
        #  But the values are not given by the driver, so use this as a sign that this
        #  variable should be skipped
        if name not in vals_dict:
            continue

        for col_name in col_names:
            if col_name == 'name':
                row[col_name] = name
                if len(row[col_name]) > MAX_VARNAME_LENGTH:
                    split_name = row[col_name].split('.')
                    row[col_name] = '.'.join(split_name[:2]) + '...' + split_name[-1]
            elif col_name == 'alias':
                row[col_name] = alias
            elif col_name == 'mean':
                mean_val = np.mean(vals_dict[name])
                row[col_name] = _indicate_value_is_derived_from_array(mean_val, vals_dict[name])
            elif col_name == 'min':
                if isinstance(vals_dict[name], np.ndarray):
                    min_val = min(vals_dict[name])  # get min. Could be an array
                    min_val_as_str = _indicate_value_is_derived_from_array(min_val, vals_dict[name])
                else:
                    min_val_as_str = str(vals_dict[name])
                comp = (vals_dict[name] - meta['lower']) < _bounds_tolerance
                if np.any(comp):
                    row[col_name] = \
                        f'<span style="color:{_out_of_bounds_test_color}">({min_val_as_str})</span>'
                else:
                    row[col_name] = min_val_as_str
            elif col_name == 'max':
                if isinstance(vals_dict[name], np.ndarray):
                    max_val = max(vals_dict[name])  # get max. Could be an array
                    max_val_as_str = _indicate_value_is_derived_from_array(max_val, vals_dict[name])
                else:
                    max_val_as_str = str(vals_dict[name])
                comp = (meta['upper'] - vals_dict[name]) < _bounds_tolerance
                if np.any(comp):
                    row[col_name] = \
                        f'<span style="color:{_out_of_bounds_test_color}">({max_val_as_str})</span>'
                else:
                    row[col_name] = max_val_as_str
            elif col_name == 'lower':
                if np.all(meta[col_name] == -INF_BOUND):
                    row[col_name] = None
                else:
                    lower_val = np.mean(meta[col_name])
                    row[col_name] = _indicate_value_is_derived_from_array(lower_val, meta[col_name])
            elif col_name == 'upper':
                if np.all(meta[col_name] == INF_BOUND):
                    row[col_name] = None
                else:
                    upper_val = np.mean(meta[col_name])
                    row[col_name] = _indicate_value_is_derived_from_array(upper_val, meta[col_name])
            elif col_name == 'equals':
                if 'equals' not in meta or meta['equals'] is None:
                    row[col_name] = ''
                else:
                    equals_val = np.mean(meta[col_name])
                    row[col_name] = _indicate_value_is_derived_from_array(equals_val,
                                                                          meta[col_name])
            elif col_name == 'units':
                if meta['units'] is not None:
                    row[col_name] = meta[col_name]
                else:
                    if alias:
                        row[col_name] = meta_dict[alias][col_name]
                    else:
                        row[col_name] = meta_dict[name][col_name]
            elif col_name == 'visual':
                if mpl:
                    val = vals_dict[name]
                    if np.isscalar(val) or val.shape == (1,):
                        row['visual'] = _constraint_plot(meta=meta, val=vals_dict[name], kind=kind)
                    else:
                        row['visual'] = _sparkline(meta=meta, val=vals_dict[name], kind=kind)
                else:
                    row['visual'] = \
                        '<span class="plot-unavailable">Visuals require matplotlib</span>'
            elif col_name == 'size':
                row[col_name] = int(meta[col_name])  # sometimes size in the meta data is a numpy
                # array so generate_table does different formatting for that
            elif col_name in ['ref', 'ref0']:
                if meta[col_name] is not None:
                    derived_val = np.mean(meta[col_name])
                    row[col_name] = _indicate_value_is_derived_from_array(derived_val,
                                                                          meta[col_name])
                else:
                    row[col_name] = 'None'
            else:
                row[col_name] = meta[col_name]

        rows.append(row)

    return generate_table(rows, headers='keys', tablefmt='html', safe=False, precision='.4e')


def _sparkline(kind, meta, val, width=300):
    """
    Given the metadata & value of design variable or constraint, make an html-embeddable sparkline.

    Parameters
    ----------
    kind : str
        One of 'desvar' or 'constraint' to specify which type of sparkline is being made.
        This has a slight impact on how the bounds are plotted.
    meta : dict-like
        The metadata associated with the design variable or constraint.
    val : np.array
        The value of the design variable or constraint.
    width : int
        The width of the figure in the returned HTML tag.

    Returns
    -------
    str
        An HTML image tag containing the encoded sparkline image.
    """
    # Prepare the matplotlib figure/axes
    _backend = mpl.get_backend()  # Save it so we can set it back at the end
    mpl.use('Agg')

    fig, ax = plt.subplots(nrows=1, ncols=1, figsize=_sparkline_figsize,
                           tight_layout=False, dpi=_plot_dpi)

    # settings across all plots
    ax.yaxis.set_major_formatter(mpl.ticker.FormatStrFormatter(f"%{_variable_label_format}"))
    ax.tick_params(axis='x', labelsize=_sparkline_font_size)
    ax.tick_params(axis='y', labelsize=_sparkline_font_size)

    try:
        if kind == 'desvar':
            _desvar_or_ineq_constraint_sparkline(ax, meta, val)
        elif 'equals' not in meta or meta['equals'] is None:
            _desvar_or_ineq_constraint_sparkline(ax, meta, val)
        else:
            _eq_constraint_sparkline(ax, meta, val)
    except (ValueError, IndexError):
        mpl.use(_backend)  # set it back
        plt.close()
        return '<span class="plot-unavailable">Plot unavailable</span>'

    tmpfile = io.BytesIO()
    fig.patch.set_alpha(0.0)  # So that the figures are transparent
    fig.savefig(tmpfile, format='png', bbox_inches='tight', pad_inches=_plot_pad_inches)
    encoded = base64.b64encode(tmpfile.getvalue()).decode('utf-8')
    html = f'<img width={width} src=\'data:image/png;base64,{encoded}\'>'

    mpl.use(_backend)  # set it back
    plt.close()

    return html


def _desvar_or_ineq_constraint_sparkline(ax, meta, val):
    """
    Use matplotlib to plot a visual showing the values of the desvar or constraint and any bounds.

    Parameters
    ----------
    ax : Matplotlib Axes instance
        Contains most of the figure elements.
    meta : dict
        The metadata associated with the design variable.
    val : np.array
        The value of the design variable.
    """
    indices = np.s_[...] if meta['indices'] is None else meta['indices']()

    _val = np.asarray(val).ravel()

    # get array for the lower and upper bounds
    if isinstance(meta['lower'], float):
        _lower = (meta['lower'] * np.ones_like(val)[indices]).ravel()
    else:
        _lower = np.asarray(meta['lower']).ravel()

    if isinstance(meta['upper'], float):
        _upper = (meta['upper'] * np.ones_like(val)[indices]).ravel()
    else:
        _upper = np.asarray(meta['upper']).ravel()

    # Get the x values and their range
    ar = np.arange(_val.size)
    x_min = 0
    x_max = val.size - 1

    _lower, _lower_no_inf_min, _lower_no_inf_max = _get_bound_array_min_max(_lower, indices)
    _upper, _upper_no_inf_min, _upper_no_inf_max = _get_bound_array_min_max(_upper, indices)

    # get y min and max
    y_min = min(_lower_no_inf_min, _upper_no_inf_min, np.min(_val))
    y_max = max(_lower_no_inf_max, _upper_no_inf_max, np.max(_val))

    # add a little to the top and bottom of the plot
    yrange = y_max - y_min
    ymin_plot = y_min - yrange * _plot_padding_fraction
    ymax_plot = y_max + yrange * _plot_padding_fraction

    xrange = x_max - x_min
    xmin_plot = x_min - xrange * _plot_padding_fraction
    xmax_plot = x_max + xrange * _plot_padding_fraction

    # set plot limits
    ax.set_xlim([xmin_plot, xmax_plot])
    ax.set_ylim([ymin_plot, ymax_plot])

    # set ticks
    ax.set_xticks([x_min, x_max])
    ax.set_yticks([y_min, y_max])

    # plot actual values
    ax.plot(ar, _val, '-o', markersize=_plot_marker_size, color=_value_plot_color,
            linewidth=_plot_value_linewidth)

    # Need to do this because of a bug in matplotlib. If the upper or lowers include INF_BOUND
    #  it affects how the other side of the fill_between is drawn
    _lower = np.clip(_lower, ymin_plot, ymax_plot)
    _upper = np.clip(_upper, ymin_plot, ymax_plot)

    # plot lower area, if exists
    if not (isinstance(meta['lower'], float) and meta['lower'] == -INF_BOUND):
        ax.fill_between(ar, ymin_plot, _lower, color=_out_of_bounds_plot_color,
                        hatch=_out_of_bounds_plot_hatch_pattern, alpha=_out_of_bounds_plot_alpha)
        ax.plot(ar, _lower, color=_out_of_bounds_plot_color, linewidth=_plot_value_linewidth)

    # plot upper area, if exists
    if not (isinstance(meta['upper'], float) and meta['upper'] == INF_BOUND):
        ax.fill_between(ar, _upper, ymax_plot, color=_out_of_bounds_plot_color,
                        hatch=_out_of_bounds_plot_hatch_pattern, alpha=_out_of_bounds_plot_alpha)
        ax.plot(ar, _upper, color=_out_of_bounds_plot_color, linewidth=_plot_value_linewidth)

    # Plot area where bounds are satisfied
    ax.fill_between(ar, _lower, _upper, color=_in_bounds_plot_color, alpha=_in_bounds_plot_alpha)


def _eq_constraint_sparkline(ax, meta, val):
    """
    Plot to matplotlib a visual showing the values of the equality constraint and also the value.

    Parameters
    ----------
    ax : Matplotlib Axes instance
        Contains most of the figure elements.
    meta : dict
        The metadata associated with the variables, including info about the constraint.
    val : np.array
        The value of the variable.
    """
    indices = np.s_[...] if meta['indices'] is None else meta['indices']

    # get value array flattened
    _val = np.asarray(val).ravel()

    # get equal constraint array
    if 'equals' not in meta:
        raise ValueError("Equality constraint sparkline cannot be "
                         "drawn without equality constraint value")
    elif isinstance(meta['equals'], float):
        _equals = (meta['equals'] * np.ones_like(val)[indices]).ravel()
    else:
        _equals = np.asarray(meta['equals']).ravel()

    # get x coordinate array and info
    ar = np.arange(val.size)
    x_min = 0
    x_max = val.size - 1
    xrange = x_max - x_min
    xmin_plot = x_min - xrange * _plot_padding_fraction
    xmax_plot = x_max + xrange * _plot_padding_fraction
    ax.set_xlim([xmin_plot, xmax_plot])
    ax.set_xticks([x_min, x_max])

    # Get y coordinate plotting info
    y_min = min(np.min(_val), np.min(_equals))
    y_max = max(np.max(_val), np.max(_equals))
    yrange = y_max - y_min
    ymin_plot = y_min - yrange * _plot_padding_fraction
    ymax_plot = y_max + yrange * _plot_padding_fraction
    ax.set_ylim([ymin_plot, ymax_plot])
    ax.set_yticks([y_min, y_max])

    # plot the constraint as a line
    ax.plot(ar, _equals, color=_value_plot_color, linewidth=_plot_value_linewidth)

    # plot the actual values as dots colored by whether they satisfy the constraint
    err = _val - _equals
    colors = []
    for e in err:
        if np.abs(e) < _equality_constraint_tolerance:
            colors.append(_in_bounds_plot_color)
        else:
            colors.append(_out_of_bounds_plot_color)
    ax.scatter(ar, _val, color=colors, s=_equality_constraint_dot_size)


def _constraint_plot(kind, meta, val, width=300):
    """
    Given the metadata and value of a design variable or constraint, make an html-embeddable plot.

    Only for scalars

    Parameters
    ----------
    kind : str
        One of 'desvar' or 'constraint' to specify which type of sparkline is being made.
        This has a slight impact on how the bounds are plotted.
    meta : dict-like
        The metadata associated with the design variable or constraint.
    val : np.array
        The value of the design variable or constraint.
    width : int
        The width of the figure in the returned HTML tag.

    Returns
    -------
    str
        An HTML image tag containing the encoded sparkline image.
    """
    if not (np.isscalar(val) or val.shape == (1,)):
        raise ValueError("Value for the _constraint_plot function must be a "
                         f"scalar. Variable {meta['name']} is not a scalar")

    if kind == 'desvar' and meta['upper'] == INF_BOUND and meta['lower'] == -INF_BOUND:
        return   # nothing to plot

    # Equality constraints are visualized differently
    equals = meta['equals'] if 'equals' in meta else None
    if equals is not None:
        if abs(val - equals) < _equality_constraint_tolerance:
            html = '<span class="equality-constraint equality-constraint-satisfied">&#10003;</span>'
        else:
            html = '<span class="equality-constraint equality-constraint-violated">&#10007;</span>'
        return html

    # If lower and upper are the same value, visualize the same as an equality constraint
    if (
        'lower' in meta and meta['lower'] != -INF_BOUND
        and
        'upper' in meta and meta['upper'] != INF_BOUND
        and
        meta['lower'] == meta['upper']
    ):
        if abs(val - meta['lower']) < _equality_constraint_tolerance:
            html = '<span class="equality-constraint equality-constraint-satisfied">&#10003;</span>'
        else:
            html = '<span class="equality-constraint equality-constraint-violated">&#10007;</span>'
        return html

    _backend = mpl.get_backend()
    mpl.use('Agg')

    fig, ax = plt.subplots(nrows=1, ncols=1, figsize=_scalar_visual_figsize, dpi=_plot_dpi)

    var_bounds_plot(kind, ax, float(val), meta['lower'], meta['upper'])
    tmpfile = io.BytesIO()
    fig.savefig(tmpfile, format='png', transparent=True, bbox_inches='tight',
                pad_inches=_plot_pad_inches)
    encoded = base64.b64encode(tmpfile.getvalue()).decode('utf-8')

    html = f'<img width={width} src=\'data:image/png;base64,{encoded}\'>'

    mpl.use(_backend)
    plt.close()

    return html


def _indicate_value_is_derived_from_array(derived_value, original_value):
    """
    Given a value or bound, & its derived value for use in report, format the output for the tables.

    Parameters
    ----------
    derived_value : float
        The value of the variable to be shown in the table.
    original_value : float or np.ndarray
        The original value of the variable. Need this to know if it was a scalar.

    Returns
    -------
    str
        A formatted string to be used to display the value of this variable in the tables.
    """
    displayed_string = f'{derived_value:{_variable_label_format}}'.strip()
    if isinstance(original_value, np.ndarray) and original_value.size > 1:
        out = '|{}|'.format(displayed_string)
    else:
        out = displayed_string
    return out


def _get_bound_array_min_max(bounds, indices):
    """
    Given a bounds (either lower or upper) & indices, return the full array bounds, min,& max.

    Parameters
    ----------
    bounds : float
        An array representing either lower or upper bounds of a desvar or constraint.
    indices : sequence of int
        If variable is an array, these indicate which entries are of
        interest for this particular response.

    Returns
    -------
    _bounds
        An array representing the bounds.
    _bounds_min
        The minimum of the bounds, excluding any entries in the _bounds array that
          equal -INF_BOUND or INF_BOUND. If all the values are those, return INF_BOUND
          so that when mins are taken that include this value, it doesn't affect the result
    _bounds_max
        The maximum of the bounds, excluding any entries in the _bounds array that
          equal -INF_BOUND or INF_BOUND. If all the values are those, return -INF_BOUND
          so that when maxes are taken that include this value, it doesn't affect the result
    """
    _bounds_no_inf = bounds[np.where(((bounds != -INF_BOUND) & (bounds != INF_BOUND)))]
    if _bounds_no_inf.size > 0:
        _bounds_min = np.min(_bounds_no_inf)
        _bounds_max = np.max(_bounds_no_inf)
    else:  # so that when we do min and max on these they are not involved in getting the min/max
        _bounds_min = INF_BOUND
        _bounds_max = - INF_BOUND

    if isinstance(bounds, float):
        _bounds = (bounds * np.ones_like(bounds)[indices]).ravel()
    else:
        _bounds = np.asarray(bounds).ravel()

    return _bounds, _bounds_min, _bounds_max


def var_bounds_plot(kind, ax, value, lower, upper):
    """
    Make a plot to show where a variable is relative to constraints.

    Parameters
    ----------
    kind : str
        One of 'desvar' or 'constraint' to specify which type of plot is being made.
    ax : Matplotlib Axes instance
        Contains most of the figure elements.
    value : float
        The design var value.
    lower : float or None
        Lower constraint.
    upper : float or None
        Upper constraint.
    """
    # must handle 5 cases if both upper and lower are given:
    #  - value much less than lower
    #  - value a little less than lower
    #  - value between lower and upper
    #  - value a little greater than upper
    #  - value much greater than upper

    # also need to handle one-sided constraints where only one of lower and upper is given
    if kind == 'constraint' and upper == INF_BOUND and lower == -INF_BOUND:
        raise ValueError("Upper and lower bounds cannot all be None for a constraint")

    # Basic plot setup
    plt.rcParams['hatch.linewidth'] = _out_of_bounds_hatch_width  # can't seem to do this any other
    #                                                              way. Cannot control hatch
    #                                                              pattern in Rectangle with
    #                                                              set_linewidth
    plt.rcParams['hatch.color'] = _out_of_bounds_plot_color
    ax.set_axis_off()
    ax.set_xlim([-_pointer_half_width, _plot_x_max + _pointer_half_width])
    ax.set_ylim(-_pointer_height - _text_height - _plot_y_margin,
                _plot_y_max + _text_height + _plot_y_margin)

    func_val_to_plot_coord = partial(_val_to_plot_coord, lower=lower, upper=upper)
    value_in_plot_coord = func_val_to_plot_coord(value)

    if upper == INF_BOUND:  # there is a lower bound
        _draw_in_or_out_bound_section(ax, 0, _plot_x_max / 2, False)
        _draw_in_or_out_bound_section(ax, _plot_x_max / 2, _plot_x_max / 2, True)
        _draw_boundary_label(ax, _plot_x_max / 2,
                             "lower = " + f"{lower:{_variable_label_format}}".strip())

        if abs(value - lower) < _near_bounds_tolerance:
            pointer_plot_coord = _plot_x_max / 2
            _draw_bound_highlight(ax, _plot_x_max / 2)
            pointer_color = _in_bounds_plot_color
        elif value >= lower:
            pointer_plot_coord = 3. * _plot_x_max / 4
            pointer_color = _in_bounds_plot_color
        else:
            pointer_color = _out_of_bounds_plot_color
            pointer_plot_coord = 1. * _plot_x_max / 4
        _draw_pointer_and_label(ax, pointer_plot_coord, pointer_color, value)
        return

    if lower == -INF_BOUND:  # there is an upper bound
        _draw_in_or_out_bound_section(ax, 0, _plot_x_max / 2, True)
        _draw_in_or_out_bound_section(ax, _plot_x_max / 2, _plot_x_max / 2, False)
        _draw_boundary_label(ax, _plot_x_max / 2,
                             "upper = " + f"{upper:{_variable_label_format}}".strip())

        if abs(value - upper) < _near_bounds_tolerance:  # value is close to bound
            pointer_plot_coord = _plot_x_max / 2
            _draw_bound_highlight(ax, _plot_x_max / 2)
            pointer_color = _in_bounds_plot_color
        elif value <= upper:  # value satisfies bound
            pointer_plot_coord = 1. * _plot_x_max / 4
            pointer_color = _in_bounds_plot_color
        else:  # value violates the bound
            pointer_plot_coord = 3. * _plot_x_max / 4
            pointer_color = _out_of_bounds_plot_color

        _draw_pointer_and_label(ax, pointer_plot_coord, pointer_color, value)
        return

    # If we get this far, there are both lower and upper bounds

    # in bounds visual is always the same
    _draw_in_or_out_bound_section(ax, _lower_plot, _upper_plot - _lower_plot, True)

    # draw below bound
    if value_in_plot_coord >= 0.0:
        _draw_in_or_out_bound_section(ax, 0, _lower_plot, False)
    else:  # value is off to the left of the plot
        _draw_in_or_out_bound_section(ax, _lower_plot / 3., 2 * _lower_plot / 3., False)
        _draw_ellipsis(ax, 0.0)

    # draw upper bound
    if value_in_plot_coord <= _plot_x_max:
        _draw_in_or_out_bound_section(ax, _upper_plot, _lower_plot, False)
    else:  # value is off to the right of the plot
        _draw_in_or_out_bound_section(ax, _upper_plot, 2 * _lower_plot / 3., False)
        _draw_ellipsis(ax, _upper_plot + 2 * _lower_plot / 3)

    # draw upper and lower labels
    _draw_boundary_label(ax, func_val_to_plot_coord(lower), str(lower))
    _draw_boundary_label(ax, func_val_to_plot_coord(upper), str(upper))

    # add highlight if value near a bound
    if abs(value - lower) / abs(upper - lower) < _near_bounds_tolerance:
        _draw_bound_highlight(ax, func_val_to_plot_coord(lower))
    elif abs(value - upper) / abs(upper - lower) < _near_bounds_tolerance:
        _draw_bound_highlight(ax, func_val_to_plot_coord(upper))

    # pointer and pointer label
    if value_in_plot_coord < 0.0:
        pointer_plot_coord = 0.0
    elif value_in_plot_coord > _plot_x_max:
        pointer_plot_coord = _plot_x_max
    else:
        pointer_plot_coord = value_in_plot_coord
    pointer_color = _in_bounds_plot_color if (lower <= value <= upper) \
        else _out_of_bounds_plot_color
    _draw_pointer_and_label(ax, pointer_plot_coord, pointer_color, value)


# A series of functions used to draw parts of the scalar constraints visual
def _draw_in_or_out_bound_section(ax, x_left, width, is_in_bound):
    if is_in_bound:
        color = _in_bounds_plot_color
        hatch = None
        alpha = _in_bounds_plot_alpha
    else:
        color = _out_of_bounds_plot_color
        hatch = _out_of_bounds_constraint_visual_hatch_pattern
        # hatch = None
        alpha = _out_of_bounds_plot_alpha
    rectangle = patches.Rectangle((x_left, 0), width, _plot_y_max, facecolor=color,
                                  hatch=hatch, alpha=alpha)
    ax.add_patch(rectangle)


def _draw_bound_highlight(ax, x):
    rectangle = patches.Rectangle((
        x - _near_bound_highlight_half_width, _near_bound_highlight_half_width_y_min),
        2 * _near_bound_highlight_half_width, _near_bound_highlight_half_width_y_max,
        edgecolor='black', facecolor='yellow', alpha=_near_bound_highlight_alpha)
    ax.add_patch(rectangle)


def _draw_ellipsis(ax, x_left):
    # Draw three dots as an ellipsis to show that the value is beyond
    #   either the left or right edge of the plot
    for i in [1, 2, 3]:
        circle = patches.Ellipse((x_left + i * _lower_plot / 12., _ellipsis_x_offset),
                                 _ellipse_width, _ellipse_height,
                                 facecolor=_out_of_bounds_plot_color)
        ax.add_patch(circle)


def _draw_boundary_label(ax, pointer_plot_coord, s):
    ax.text(pointer_plot_coord, _plot_y_max + _text_height,
            s,
            horizontalalignment='center',
            verticalalignment='bottom',
            size=_scalar_visual_font_size
            )


def _draw_pointer_and_label(ax, pointer_plot_coord, pointer_color, value):
    pts = np.array([
        [pointer_plot_coord - _pointer_half_width, -_pointer_height],
        [pointer_plot_coord + _pointer_half_width, -_pointer_height],
        [pointer_plot_coord, 0.0]
    ])
    p = patches.Polygon(pts, closed=True, facecolor=pointer_color, edgecolor='black',
                        linewidth=_pointer_line_width)
    ax.add_patch(p)
    plt.text(pointer_plot_coord, -_pointer_height - _text_height,
             f"{value:{_variable_label_format}}".strip(),
             horizontalalignment='center', verticalalignment='top', size=_scalar_visual_font_size)


def _val_to_plot_coord(value, lower, upper):
    # need to get function that maps actual values to 0.0 to 1.0
    # and where lower maps to 1./3 and upper to 2/3
    # Used with Python's functools to make a Python function out of this
    plot_coord = 1. / 3. + (value - lower) / (upper - lower) * 1. / 3.
    if isinstance(plot_coord, np.ndarray):
        return plot_coord[0]
    return plot_coord
