"""Plot color space using Plotly."""
import sys
import argparse
from scipy.spatial import Delaunay
import plotly.graph_objects as go
import math
import plotly.io as io
import os
import json

sys.path.insert(0, os.getcwd())

try:
    from coloraide_extras.everything import ColorAll as Color
except ImportError:
    from coloraide.everything import ColorAll as Color
from coloraide.channels import ANGLE_DEG
from coloraide.spaces import HSLish, HSVish, HWBish, Labish, LChish, RGBish
from coloraide import algebra as alg
from coloraide.color import POSTFIX
from coloraide.gamut import pointer, visible_spectrum, SPECIAL_GAMUTS
from coloraide.cmfs import CIE_1931_2DEG as cmfs
from coloraide import util

FORCE_OWN_GAMUT = {'ryb', 'ryb-biased'}
QHULL_OPTIONS = "Qbb Qc Qz Qx Q12 QbB"


def get_face_color(cmap, simplex, filters):
    """Get best color."""

    return Color.average([cmap[simplex[0]], cmap[simplex[1]], cmap[simplex[2]]], space='srgb').to_string(hex=True)


def create_custom_hsl(gamut):
    """Create a custom color object that has access to a special `hsl-gamut` space to map surface in."""

    cs = Color.CS_MAP[gamut]
    hsl = Color.CS_MAP['hsl']
    scale = not isinstance(cs, RGBish)

    class HSL(type(hsl)):
        NAME = f'-hsl-{gamut}'
        BASE = gamut
        SERIALIZE = (f'--{NAME}',)
        GAMUT_CHECK = gamut
        CLIP_SPACE = None
        WHITE = cs.WHITE
        DYAMIC_RANGE = cs.DYNAMIC_RANGE
        INDEXES = cs.indexes()

        # Scale channels as needed
        OFFSET_1 = cs.channels[INDEXES[0]].low if scale else 0.0
        OFFSET_2 = cs.channels[INDEXES[1]].low if scale else 0.0
        OFFSET_3 = cs.channels[INDEXES[2]].low if scale else 0.0

        SCALE_1 = cs.channels[INDEXES[0]].high if scale else 1.0
        SCALE_2 = cs.channels[INDEXES[1]].high if scale else 1.0
        SCALE_3 = cs.channels[INDEXES[2]].high if scale else 1.0

        def to_base(self, coords):
            """Convert from RGB to HSL."""

            # Convert from HSL back to its original space
            coords = hsl.to_base(coords)
            # Scale and offset the values back to the origin space's configuration
            coords[0] = coords[0] * (self.SCALE_1 - self.OFFSET_1) + self.OFFSET_1
            coords[1] = coords[1] * (self.SCALE_2 - self.OFFSET_2) + self.OFFSET_2
            coords[2] = coords[2] * (self.SCALE_3 - self.OFFSET_3) + self.OFFSET_3
            ordered = [0.0, 0.0, 0.0]
            # Consistently order a given color spaces points based on its type
            for e, c in enumerate(coords):
                ordered[self.INDEXES[e]] = c
            return ordered

        def from_base(self, coords):
            """Convert from HSL to RGB."""

            # Undo order a given color spaces points based on its type
            coords = [coords[i] for i in self.INDEXES]
            # Scale and offset the values such that channels are between 0 - 1
            coords[0] = (coords[0] - self.OFFSET_1) / (self.SCALE_1 - self.OFFSET_1)
            coords[1] = (coords[1] - self.OFFSET_2) / (self.SCALE_2 - self.OFFSET_2)
            coords[2] = (coords[2] - self.OFFSET_3) / (self.SCALE_3 - self.OFFSET_3)
            # Convert to HSL
            return hsl.from_base(coords)

    class ColorCyl(Color):
        """Custom color."""

    ColorCyl.register(HSL())

    return ColorCyl


def create3d(fig, x, y, z, tri, cmap, edges, faces, ecolor, fcolor, opacity, filters):
    """Create the 3D renders."""

    i, j, k = tri.simplices.T
    if opacity:
        mesh = go.Mesh3d(
            x=x,
            y=y,
            z=z,
            i=i,
            j=j,
            k=k,
            vertexcolor=cmap if not faces else None,
            facecolor=[
                get_face_color(cmap, t, filters) if not fcolor else fcolor for t in tri.simplices
            ] if faces else None,
            flatshading = True,
            lighting = {"vertexnormalsepsilon": 0, "facenormalsepsilon": 0}
        )
        mesh.update(hoverinfo='skip')
        mesh.update(opacity=opacity)
        fig.add_traces([mesh])

    if edges:
        # Draw the triangles, but ensure they are separate by adding `None` to the end.
        xe = []
        ye = []
        ze = []
        tri_colors = []
        for p0, p1, p2 in tri.simplices:
            xe.extend([x[p0], x[p1], x[p2], x[p0], None])
            ye.extend([y[p0], y[p1], y[p2], y[p0], None])
            ze.extend([z[p0], z[p1], z[p2], z[p0], None])
            if ecolor is None:
                tri_colors.extend([cmap[p0], cmap[p1], cmap[p2], cmap[p0], '#000000'])

        # Use a single color for edges.
        if ecolor is not None:
            tri_colors = ecolor

        lines = go.Scatter3d(
            x=xe,
            y=ye,
            z=ze,
            mode='lines',
            marker={'size':0},
            showlegend=False,
            name='',
            line={'color': tri_colors}
        )
        lines.update(hoverinfo='skip')
        fig.add_traces([lines])


def cyl_disc(
    fig,
    ColorCyl,
    space,
    gamut,
    location,
    resolution,
    opacity,
    edges,
    faces,
    ecolor,
    fcolor,
    gmap,
    flags,
    filters,
    limit
):
    """
    Plot cylindrical disc on either top or bottom of an RGB cylinder.

    Expectation is either a HSL, HSV, or HSB style cylinder.
    """

    cs = ColorCyl.CS_MAP[gamut]
    factor = cs.channels[1].high

    # Using a lightness of 0 can sometimes cause the bottom not to show with certain resolutions, so use a very
    # small value instead.
    zpos = alg.ATOL if location == 'bottom' else 1.0 * factor

    x = []
    y = []
    z = []
    u = []
    v = []
    cmap = []

    min_hue = cs.channels[cs.hue_index()].low
    max_hue = cs.channels[cs.hue_index()].high

    # Interpolate a circle on the outer edge
    s1 = ColorCyl.steps(
        [ColorCyl(gamut, [hue, 1 * factor, 1 * zpos]) for hue in alg.linspace(min_hue, max_hue, 2, endpoint=True)],
        steps=max(7, (resolution // 6) * 6 + 1),
        space=gamut,
        hue='specified'
    )
    s2 = ColorCyl(gamut, [alg.NaN, alg.ATOL, alg.NaN])

    # Interpolate concentric circles to the center of the disc
    step = int(resolution / 2)
    for r in range(step):
        for t1 in s1:
            s2['hue'] = t1['hue']
            c = t1.mix(s2, r / (step - 1), space=gamut, hue='specified')
            hue = c._space.hue_index()
            radius = c._space.radial_index()
            u.append(c[radius])
            v.append(c[hue])
            if limit['pointer-gamut']:
                c.fit('pointer-gamut')
            elif limit['macadam-limits']:
                c.fit('macadam-limits')
            elif limit['visible-spectrum']:
                c.fit('visible-spectrum')
            c.convert(space, norm=False, in_place=True)

            store_coords(c, x, y, z, flags)

            # Ensure colors fit in output color gamut.
            s = c.convert('srgb')
            if not s.in_gamut():
                s.fit(**gmap)
            else:
                s.clip()

            if filters:
                s.filter(filters[0], **filters[1], in_place=True, out_space=s.space()).clip()

            cmap.append(s.to_string(hex=True))

    # Calculate triangles
    tri = Delaunay([*zip(u, v)], qhull_options=QHULL_OPTIONS)

    create3d(fig, x, y, z, tri, cmap, edges, faces, ecolor, fcolor, opacity, filters)


def store_coords(c, x, y, z, flags):
    """Store coordinates."""

    # LCh spaces
    if flags['is_lchish']:
        light, chroma, hue = c._space.indexes()
        h = (c[hue] * (360 / c._space.channels[hue].high)) if c._space.channels[hue] != ANGLE_DEG else c[hue]
        a, b = alg.polar_to_rect(c[chroma], h)
        x.append(a)
        y.append(b)
        z.append(c[light])

    # HSL, HSV, or HWB spaces
    elif flags['is_hslish'] or flags['is_hsvish'] or flags['is_hwbish']:
        hue, sat, light = c._space.indexes()
        h = (c[hue] * (360 / c._space.channels[hue].high)) if c._space.channels[hue] != ANGLE_DEG else c[hue]
        a, b = alg.polar_to_rect(c[sat], h)
        x.append(a)
        y.append(b)
        z.append(c[light])

    # Any other generic cylindrical space that doesn't fit in the categories above.
    elif flags['is_cyl']:
        hue = c._space.hue_index()
        h = (c[hue] * (360 / c._space.channels[hue].high)) if c._space.channels[hue] != ANGLE_DEG else c[hue]
        radius = c._space.radial_index()
        a, b = alg.polar_to_rect(c[radius], h)
        x.append(a)
        y.append(b)
        z.append(c[3 - hue - radius])

    # Lab spaces
    elif flags['is_labish']:
        light, a, b = c._space.names()
        x.append(c[a])
        y.append(c[b])
        z.append(c[light])

    # Non-cylindrical spaces could be done here, but normally are not.
    else:
        x.append(c[0])
        y.append(c[1])
        z.append(c[2])


def render_space_cyl(fig, space, gamut, resolution, opacity, edges, faces, ecolor, fcolor, gmap, filters, limit):
    """Renders the color space using an HSL cylinder that is then mapped to the given space."""

    target = Color.CS_MAP[space]
    flags = {
        'is_cyl': target.is_polar(),
        'is_labish': isinstance(target, Labish),
        'is_lchish': isinstance(target, LChish),
        'is_hslish': isinstance(target, HSLish),
        'is_hwbish': isinstance(target, HWBish),
        'is_hsvish': isinstance(target, HSVish)
    }

    # Determine the gamut mapping space to use.
    # Some spaces cannot be generalized (HWB and HPLuv for instance).
    if flags['is_hwbish']:
        ColorCyl = Color
        gspace = space
    elif Color.CS_MAP[gamut].is_polar():
        ColorCyl = Color
        gspace = gamut
    else:
        _gamut = space if space in FORCE_OWN_GAMUT else gamut
        ColorCyl = create_custom_hsl(_gamut)
        gspace = f'-hsl-{_gamut}'
    cs = ColorCyl.CS_MAP[gspace]

    # Adjust scaling factor if the mapping space requires it
    factor = cs.channels[1].high

    # Render the two halves of the cylinder
    u = []
    v = []
    x = []
    y = []
    z = []
    cmap = []

    # Interpolate the cylinder from 0 to 360 degrees.
    # Include, at the very least, 6 evenly spaced hues, and at higher resolutions
    # will include a multiple that will include the same 6 key points.
    # In HSL, this will cover all the corners of the RGB space.
    min_hue = cs.channels[cs.hue_index()].low
    max_hue = cs.channels[cs.hue_index()].high

    s1 = ColorCyl.steps(
        [ColorCyl(gspace, [hue, 1 * factor, 1 * factor]) for hue in alg.linspace(min_hue, max_hue, 2, endpoint=True)],
        steps=max(7, (resolution // 6) * 6 + 1),
        space=gspace,
        hue='specified'
    )
    # A generic color at the bottom of the space which we can rotate for
    # interpolation by changing the hue.
    s2 = ColorCyl(gspace, [alg.NaN, 1 * factor, alg.ATOL])

    # Create a 3D mesh by interpolating ring at each lightness down the cylinder side.
    # Include at least 3 points of lightness: lightest, darkest, and mid, which in
    # HSL is the most colorful colors.
    for color in s1:
        s2['hue'] = color['hue']
        for c in ColorCyl.steps([color, s2], steps=max(3, (resolution // 2) * 2 + 1), space=gspace, hue='specified'):
            u.append(c[2])
            v.append(c['hue'])
            if limit['pointer-gamut']:
                c.fit('pointer-gamut')
            elif limit['macadam-limits']:
                c.fit('macadam-limits')
            elif limit['visible-spectrum']:
                c.fit('visible-spectrum')

            c.convert(space, norm=False, in_place=True)

            store_coords(c, x, y, z, flags)

            # Adjust gamut to fit the display space
            s = c.convert('srgb')
            if not s.in_gamut():
                s.fit(**gmap)
            else:
                s.clip()

            if filters:
                s.filter(filters[0], **filters[1], in_place=True, out_space=s.space()).clip()

            cmap.append(s.to_string(hex=True))

    # Calculate the triangles
    tri = Delaunay([*zip(u, v)], qhull_options=QHULL_OPTIONS)

    create3d(fig, x, y, z, tri, cmap, edges, faces, ecolor, fcolor, opacity, filters)

    # Generate tops for spaces that do not normally get tops automatically.
    if (flags['is_hwbish'] or (flags['is_cyl'] and not flags['is_lchish']) or isinstance(cs, HSVish)):
        cyl_disc(
            fig, ColorCyl, space, gspace, 'top', resolution, opacity, edges,
            faces, ecolor, fcolor, gmap, flags, filters, limit
        )

    cyl_disc(
        fig, ColorCyl, space, gspace, 'bottom', resolution, opacity, edges,
        faces, ecolor, fcolor, gmap, flags, filters, limit
    )

    return fig


def render_special_gamut(gtype, fig, space, resolution, opacity, edges, faces, ecolor, fcolor, gmap, filters, limit):
    """Render the Pointer gamut in 3d."""

    target = Color.CS_MAP[space]
    white = target.WHITE
    flags = {
        'is_cyl': target.is_polar(),
        'is_labish': isinstance(target, Labish),
        'is_lchish': isinstance(target, LChish),
        'is_hslish': isinstance(target, HSLish),
        'is_hwbish': isinstance(target, HWBish),
        'is_hsvish': isinstance(target, HSVish)
    }

    # Render the two halves of the cylinder
    u = []
    v = []
    x = []
    y = []
    z = []
    cmap = []

    if gtype == 'pointer-gamut':
        lightness = [91, *pointer.LIGHTNESS, 14]
        hues = pointer.HUE
    elif gtype == 'macadam-limits':
        lightness = visible_spectrum.LUMINANCE
        hues = visible_spectrum.HUE
    else:
        lightness = alg.linspace(1, alg.ATOL, max(3, (resolution // 2) * 2 + 1))
        xy_pair = [cmfs.xy(r) for r in range(cmfs.start, cmfs.end + 1, cmfs.step)]
        first, last = xy_pair[0], xy_pair[-1]
        xy_pair.extend(
            [[alg.lerp(last[0], first[0], r / 48), alg.lerp(last[1], first[1], r / 48)] for r in range(0, 49, 1)]
        )
        hues = [alg.rect_to_polar(*alg.subtract(r, white))[1] for r in xy_pair]
        lowest = min(hues)
        idx = hues.index(lowest)
        hues = list(reversed(hues[:idx])) + list(reversed(hues[idx + 1:]))
        hues.append(hues[0] + 360)
        xy_pair = list(reversed(xy_pair[:idx])) + list(reversed(xy_pair[idx + 1:]))
        xy_pair.append(xy_pair[0])

    for l in lightness:
        for e, h in enumerate(hues):
            if gtype == 'pointer-gamut':
                # Close out the top or bottom if see lightness beyond the expected range
                if l == 91:
                    _l = l - 1 + 1e-8
                    c = 1e-12
                elif l == 14:
                    _l = l + 1 - 1e-8
                    c = 1e-12
                else:
                    _l = l
                    c = pointer.LUT[pointer.HUE.index(h)][pointer.LIGHTNESS.index(l)]
                u.append(_l)
                v.append(h)
                color = Color('xyz-d65', [0, 0, 0])
                pointer.from_lch_sc(color, [_l, c, h])
            elif gtype == 'macadam-limits':
                color = Color('white').convert('xyy', in_place=True)
                if l == -1:
                    u.append(0)
                    v.append(h)
                    color.update('black')
                else:
                    c = visible_spectrum.LUT[visible_spectrum.HUE.index(h)][visible_spectrum.LUMINANCE.index(l)]
                    xy = alg.add(alg.polar_to_rect(c, h), color.xy())
                    u.append(l)
                    v.append(h)
                    color[:-1] = [*xy, l]
            else:
                color = Color.chromaticity('xyy', [*xy_pair[e], l], 'xy-1931', white=white, scale=False)
                u.append(l)
                v.append(h)

            if gtype != 'pointer-gamut' and limit['pointer-gamut']:
                color.fit('pointer-gamut')
            elif gtype not in ('pointer-gamut', 'macadam-limits') and limit['macadam-limits']:
                color.fit('macadam-limits')

            color.convert(space, norm=False, in_place=True)
            store_coords(color, x, y, z, flags)

            # Adjust gamut to fit the display space
            s = color.convert('srgb')
            if not s.in_gamut():
                s.fit(**gmap)
            else:
                s.clip()

            if filters:
                s.filter(filters[0], **filters[1], in_place=True, out_space=s.space()).clip()

            cmap.append(s.to_string(hex=True))

    # Calculate the triangles
    tri = Delaunay([*zip(u, v)], qhull_options=QHULL_OPTIONS)

    create3d(fig, x, y, z, tri, cmap, edges, faces, ecolor, fcolor, opacity, filters)

    # Draw top and bottom of the visible spectrum
    step = max(int(resolution / 8), 4)
    if gtype == 'visible-spectrum':
        for l in (1, alg.ATOL):
            # 9, 60; 60, 90
            ranges = list(range(0, 300, 4)) + [300]
            for r in ranges:
                start = r
                end = min(r + (4 if r < 300 else 1000), len(hues) - 1)
                u = []
                v = []
                x = []
                y = []
                z = []
                cmap = []
                for r in alg.linspace(0, 1, step):
                    for e in range(start, end + 1, 1):
                        h = hues[e]
                        _c, _ = alg.rect_to_polar(*alg.subtract(xy_pair[e], white))
                        _c = alg.lerp(_c, alg.ATOL, r)
                        u.append(_c)
                        v.append(h)
                        color = Color.chromaticity(
                            'xyy',
                            [*alg.add(alg.polar_to_rect(_c, h), white), l],
                            'xy-1931',
                            white=white,
                            scale=False
                        )

                        if gtype != 'pointer-gamut' and limit['pointer-gamut']:
                            color.fit('pointer-gamut')
                        if gtype not in ('pointer-gamut', 'macadam-limits') and limit['macadam-limits']:
                            color.fit('macadam-limits')
                        color.convert(space, norm=False, in_place=True)
                        store_coords(color, x, y, z, flags)

                        # Adjust gamut to fit the display space
                        s = color.convert('srgb')
                        if not s.in_gamut():
                            s.fit(**gmap)
                        else:
                            s.clip()

                        if filters:
                            s.filter(filters[0], **filters[1], in_place=True, out_space=s.space()).clip()

                        cmap.append(s.to_string(hex=True))

                    # Calculate the triangles
                    tri = Delaunay([*zip(u, v)], qhull_options=QHULL_OPTIONS)

                    create3d(fig, x, y, z, tri, cmap, edges, faces, ecolor, fcolor, opacity, filters)

    return fig


def plot_gamut_in_space(
    space,
    gamuts,
    title="",
    dark=False,
    gmap=None,
    size=(800, 800),
    camera=None,
    aspect=None,
    projection='perspective',
    filters=()
):
    """Plot the given space in sRGB."""

    if gmap is None:
        gmap = {}

    io.templates.default = 'plotly_dark' if dark else 'plotly'

    if camera is None:
        camera = {'a': 45, 'e': 45, 'r': math.sqrt(1.25 ** 2 + 1.25 ** 2 + 1.25 ** 2)}

    a = math.radians((90 - camera['a']) % 360)
    e = math.radians(90 - camera['e'])
    r = camera['r']
    y = r * math.sin(e) * math.cos(a)
    x = r * math.sin(e) * math.sin(a)
    z = r * math.cos(e)

    if aspect is None:
        aspect = {'x': 1, 'y': 1, 'z': 1}

    # Get names for
    target = Color.CS_MAP[space]
    if len(target.CHANNELS) > 3:
        print('Color spaces with dimensions greater than 3 are not supported')
        return None

    names = target.CHANNELS
    is_cyl = target.is_polar()
    is_labish = isinstance(target, Labish)
    is_lchish = isinstance(target, LChish)
    is_hslish_hsvish = isinstance(target, (HSLish, HSVish))

    # Setup axes
    if is_labish:
        c1, c2, c3 = target.indexes()
        axm = [c2, c3, c1]
    elif is_lchish:
        c1, c2, c3 = target.indexes()
        axm = [c3, c2, c1]
    elif is_hslish_hsvish:
        axm = [0, 1, 2]
    else:
        axm = [0, 1, 2]

    showbackground = True
    backgroundcolor = "rgb(230, 230, 230)" if not dark else '#282830'
    gridcolor = "rgb(255, 255, 255)" if not dark else '#111'
    zerolinecolor = "rgb(255, 255, 255)" if not dark else '#111'
    axis = {
        "showbackground": showbackground,
        "backgroundcolor": backgroundcolor,
        "gridcolor": gridcolor,
        "zerolinecolor": zerolinecolor,
    }

    if not is_cyl:
        xaxis = str(names[axm[0]])
    else:
        max_angle = target.channels[target.hue_index()].high
        if max_angle == 360:
            xaxis = f"{names[axm[0]]} (0˚ - 360˚)"
        elif max_angle == math.tau:
            xaxis = f"{names[axm[0]]} (0 rad - 2π rad)"
        else:
            min_angle = target.channels[target.hue_index()].low
            type_angle = target.channels[target.hue_index()].angle
            xaxis = (
                f"{names[axm[0]]} ({alg.round_to(min_angle, 5)} {POSTFIX[type_angle]} - "
                f"{alg.round_to(max_angle, 5)} {POSTFIX[type_angle]})"
            )

    yaxis = str(names[axm[1]])
    zaxis = str(names[axm[2]])

    # Setup plot layout
    layout = go.Layout(
        # General figure characteristics
        title=title,
        width=size[0],
        height=size[1],

        # Specify scene layout
        scene=go.layout.Scene(
            xaxis=go.layout.scene.XAxis(title=xaxis, showticklabels=not is_cyl, **axis),
            yaxis=go.layout.scene.YAxis(title=yaxis, **axis),
            zaxis=go.layout.scene.ZAxis(title=zaxis, **axis),
            aspectratio=aspect
        ),

        # Control camera position
        scene_camera={
            "projection": go.layout.scene.camera.Projection(type=projection),
            "center": {"x": 0, "y": 0, "z": 0},
            "up": {"x": 0, "y": 0, "z": 1},
            "eye": {"x": x, "y": y, "z": z}
        }
    )

    # Create figure to store the plot
    fig = go.Figure(layout=layout)

    for gamut, config in gamuts.items():
        opacity = config.get('opacity', 1)
        resolution = config.get('resolution', 200)
        edges = config.get('edges', False)
        ecolor = None
        limit = {
            'pointer-gamut': config.get('pointer-gamut', False),
            'macadam-limits': config.get('macadam-limits', False),
            'visible-spectrum': config.get('visible-spectrum', False)
        }
        if isinstance(edges, str):
            c = Color(edges).convert('srgb').fit(**gmap)
            if filters:
                c.filter(filters[0], **filters[1], in_place=True, out_space=c.space()).clip()
            ecolor = c.to_string(hex=True, alpha=False)
            edges = True
        faces = config.get('faces', False)
        fcolor = ''
        if isinstance(faces, str):
            c = Color(faces).convert('srgb').fit(**gmap)
            if filters:
                c.filter(filters[0], **filters[1], in_place=True, out_space=c.space()).clip()
            fcolor = c.to_string(hex=True, alpha=False)
            faces = True

        if gamut in SPECIAL_GAMUTS:
            render_special_gamut(
                gamut, fig, space, resolution, opacity, edges, faces, ecolor, fcolor, gmap, filters, limit
            )
        else:
            render_space_cyl(
                fig, space, gamut, resolution, opacity, edges, faces, ecolor, fcolor, gmap, filters, limit
            )

    return fig


def plot_colors(fig, space, gamut, gmap_colors, colors, gmap, filters=()):
    """Plot gamut mapping."""

    if not gmap_colors and not colors:
        return

    gamut_mapping = gmap_colors.split(';') if gmap_colors.strip() else []
    non_mapped = colors.split(';') if colors.strip() else []
    if gamut_mapping or non_mapped:
        target = Color.CS_MAP[space]
        flags = {
            'is_cyl': target.is_polar(),
            'is_labish': isinstance(target, Labish),
            'is_lchish': isinstance(target, LChish),
            'is_hslish': isinstance(target, HSLish),
            'is_hwbish': isinstance(target, HWBish),
            'is_hsvish': isinstance(target, HSVish)
        }

        l = len(gamut_mapping)
        for i, c in enumerate(gamut_mapping + non_mapped):
            c1 = Color(c)
            c2 = Color(c).fit(gamut, **gmap)
            c1.convert(space, in_place=True)
            c2.convert(space, in_place=True)
            x = []
            y = []
            z = []
            for c in ([c1, c2] if i < l else [c1]):
                store_coords(c, x, y, z, flags)

            c2.convert('srgb', in_place=True).fit(**gmap)
            if filters:
                c2.filter(filters[0], **filters[1], in_place=True, out_space=c2.space()).clip()

            fig.add_trace(
                go.Scatter3d(
                    x=x, y=y, z=z,
                    line={'color': 'black', 'width': 2},
                    marker={
                        'color': c2.to_string(hex=True, alpha=False),
                        'size': [16, 0],
                        'opacity': 1,
                        'line': {'width': 2}
                    },
                    showlegend=False
                )
            )


def plot_interpolation(
    fig,
    space,
    interp_colors,
    interp_method,
    gmap,
    simulate_alpha,
    interp_gmap,
    filters=(),
    opacity=1
):
    """Plot interpolations."""

    if not interp_colors:
        return

    inputs = interp_colors.split(';')
    colors = Color.steps(
        inputs,
        **interp_method
    )

    options = interp_method.copy()
    options['steps'] = len(inputs)

    markers = [Color(i) for i in interp_colors.split(';')]

    target = Color.CS_MAP[space]
    flags = {
        'is_cyl': target.is_polar(),
        'is_labish': isinstance(target, Labish),
        'is_lchish': isinstance(target, LChish),
        'is_hslish': isinstance(target, HSLish),
        'is_hwbish': isinstance(target, HWBish),
        'is_hsvish': isinstance(target, HSVish)
    }

    for i in range(2):
        x = []
        y = []
        z = []
        cmap = []
        for c in (markers if i else colors):
            c.convert(space, in_place=True)
            if interp_gmap:
                c.fit('srgb', **gmap)
            store_coords(c, x, y, z, flags)
            c.convert('srgb', in_place=True)
            c.fit(**gmap)
            if filters:
                c.filter(filters[0], **filters[1], in_place=True, out_space=c.space()).clip()
            if simulate_alpha:
                cmap.append(Color.layer([c, 'white'], space='srgb').to_string(hex=True, alpha=False))
            else:
                cmap.append(c.to_string(hex=True, alpha=False))

        if i:
            trace = go.Scatter3d(
                x=x, y=y, z=z,
                mode = 'markers',
                marker={'color': cmap, 'opacity': 1},
                showlegend=False
            )
        else:
            trace = go.Scatter3d(
                x=x, y=y, z=z,
                mode = 'lines',
                line={'color': cmap, 'width': 16},
                showlegend=False
            )

        if opacity < 1:
            trace.update(opacity=opacity)
        fig.add_trace(trace)


def plot_harmony(
    fig,
    space,
    harmony,
    gmap,
    simulate_alpha,
    harmony_gmap,
    filters=()
):
    """Plot color harmony."""

    if not harmony:
        return

    hcolor, options = harmony
    if 'space' not in options:
        options['space'] = space
    options['out_space'] = space

    colors = Color(hcolor).harmony(**options)

    target = Color.CS_MAP[space]
    flags = {
        'is_cyl': target.is_polar(),
        'is_labish': isinstance(target, Labish),
        'is_lchish': isinstance(target, LChish),
        'is_hslish': isinstance(target, HSLish),
        'is_hwbish': isinstance(target, HWBish),
        'is_hsvish': isinstance(target, HSVish)
    }

    cmap = []
    x = []
    y = []
    z = []
    for s in colors:
        c = s.normalize(nans=False)
        if harmony_gmap:
            c.fit('srgb', **gmap)
        store_coords(c, x, y, z, flags)
        c.convert('srgb', in_place=True).fit(**gmap)
        if filters:
            c.filter(filters[0], **filters[1], in_place=True, out_space=c.space()).clip()
        if simulate_alpha:
            cmap.append(Color.layer([c, 'white'], space='srgb').to_string(hex=True, alpha=False))
        else:
           cmap.append(c.to_string(hex=True, alpha=False))

    if options['name'] in ('wheel', 'rectangle', 'square', 'triad'):
        x.append(x[0])
        y.append(y[0])
        z.append(z[0])
        cmap.append(cmap[0])
        size = ([8] * (len(x) - 1)) + [0]
    else:
        size = [8] * len(x)

    trace = go.Scatter3d(
        x=x, y=y, z=z,
        marker={'size': size, 'color': cmap, 'opacity': 1},
        line={'color': cmap, 'width': 3},
        showlegend=False
    )

    fig.add_trace(trace)


def plot_average(
    fig,
    space,
    avg_colors,
    avg_options,
    gmap,
    simulate_alpha,
    avg_gmap,
    filters=()
):
    """Plot interpolations."""

    if not avg_colors:
        return

    parts = avg_colors.split(':')

    colors = parts[0].split(';')
    if len(parts) == 2:
        weights = [float(i.strip()) for i in parts[1].split(',')]
    else:
        weights = None

    color = Color.average(
        colors,
        weights,
        space=avg_options['space']
    )

    target = Color.CS_MAP[space]
    flags = {
        'is_cyl': target.is_polar(),
        'is_labish': isinstance(target, Labish),
        'is_lchish': isinstance(target, LChish),
        'is_hslish': isinstance(target, HSLish),
        'is_hwbish': isinstance(target, HWBish),
        'is_hsvish': isinstance(target, HSVish)
    }

    for s in colors:
        x = []
        y = []
        z = []
        cmap = []
        c = Color(s).convert(space, in_place=True).normalize(nans=False)
        if avg_gmap:
            c.fit('srgb', **gmap)
        store_coords(c, x, y, z, flags)
        c.convert('srgb', in_place=True).fit(**gmap)
        if filters:
            c.filter(filters[0], **filters[1], in_place=True, out_space=c.space()).clip()
        if simulate_alpha:
            cmap.append(Color.layer([c, 'white'], space='srgb').to_string(hex=True, alpha=False))
        else:
           cmap.append(c.to_string(comma=True, alpha=False))

        c = Color(color).convert(space, in_place=True).normalize(nans=False)
        if avg_gmap:
            c.fit('srgb', **gmap)
        store_coords(c, x, y, z, flags)
        c.convert('srgb', in_place=True).fit(**gmap)
        if filters:
            c.filter(filters[0], **filters[1], in_place=True, out_space=c.space()).clip()
        if simulate_alpha:
            cmap.append(Color.layer([c, 'white'], space='srgb').to_string(hex=True, alpha=False))
        else:
            cmap.append(c.to_string(hex=True, alpha=False))

        trace = go.Scatter3d(
            x=x, y=y, z=z,
            marker={'size': [8, 16], 'color': cmap, 'opacity': 1},
            line={'color': cmap[0], 'width': 3},
            showlegend=False
        )

        fig.add_trace(trace)


def plot_spectral_locus(fig, space):
    """Plot the spectral locus."""

    target = Color.CS_MAP[space]
    white = target.WHITE

    flags = {
        'is_cyl': target.is_polar(),
        'is_labish': isinstance(target, Labish),
        'is_lchish': isinstance(target, LChish),
        'is_hslish': isinstance(target, HSLish),
        'is_hwbish': isinstance(target, HWBish),
        'is_hsvish': isinstance(target, HSVish)
    }

    x = []
    y = []
    z = []
    cmap = []
    labels = []
    for w in range(360, 781, 1):
        color = Color.from_wavelength(space, w, scale=False)
        store_coords(color, x, y, z, flags)
        cmap.append(
            color.convert(
                'srgb', in_place=True).fit('srgb-linear', method='scale', max_saturation=True
            ).to_string(hex=True, alpha=False)
        )
        if not w % 20:
            labels.append(str(w))
        else:
            labels.append('')
    first, last = util.xyz_to_xyY(cmfs[360]), util.xyz_to_xyY(cmfs[780])
    for r in range(50):
        color = Color.chromaticity(
            space,
            [alg.lerp(last[x], first[x], r / 49) for x in range(3)],
            'xy-1931',
            white=white,
            scale=False
        )
        store_coords(color, x, y, z, flags)
        cmap.append(
            color.convert(
                'srgb', in_place=True).fit('srgb-linear', method='scale', max_saturation=True
            ).to_string(hex=True, alpha=False)
        )
        labels.append('')

    trace = go.Scatter3d(
        x=x, y=y, z=z,
        text = labels,
        mode='lines',
        line={'color': cmap, 'width': 8},
        showlegend=False
    )
    fig.add_trace(trace)


def main():
    """Main."""

    parser = argparse.ArgumentParser(prog='3d_diagrams', description='Plot 3D gamut in a different color spaces.')
    parser.add_argument('--space', '-s', help='Desired space.')

    # Gamut and gamut mapping
    parser.add_argument(
        '--gamut',
        '-g',
        action='append',
        default=[],
        help=(
            "Gamut space to render space in. Can be followed by a JSON config in the form 'space:{}' to set `edges`,"
            '`faces`, `opacity`, or `resolution`. `edges` and `faces` can be a boolean to disable or enable them or '
            "color to configure them all as a specific color. Additionally, if `pointer-gamut` or `macadam-limits` is "
            "set to true. The current gamut will also have a restriction to force them to be within these gamuts as "
            "well."
        )
    )
    parser.add_argument(
        '--gmap',
        default='raytrace',
        help=(
            "Gamut mapping algorithm. To set additional options, follow the algorithm with with a JSON string and "
            "containing the parameters in the form of 'algorithm:{}'."
        )
    )
    parser.add_argument('--gmap-colors', default='', help='Color(s) to gamut map, separated by semicolons.')
    parser.add_argument(
        '--colors',
        default='',
        help='Plot arbitrary color points. Colors are separated with semicolons.'
    )

    # Interpolation visualization
    parser.add_argument(
        '--avg-colors',
        default='',
        help="Colors that should be averaged together separated by semicolons."
    )
    parser.add_argument(
        '--average-options',
        default='{}',
        help=(
            "Averaging configuration (JSON string)."
        )
    )

    parser.add_argument('--interp-colors', default='', help='Interpolation colors separated by semicolons.')
    parser.add_argument(
        '--interp-method',
        default='linear',
        help=(
            "Interpolation configuration. Interpolation method followed by an optional JSON containing options: "
            "'method: {}'"
        )
    )
    parser.add_argument(
        '--harmony',
        default='',
        help=(
            "Harmony configuration: 'color:harmony'. Harmony can be followed by an optional JSON containing options: "
            "'color:harmony: {}'"
        )
    )
    parser.add_argument(
        '--mix-alpha',
        action='store_true',
        help="Simulate interpolation/averaging/harmony opacity by overlaying on white."
    )
    parser.add_argument(
        '--mix-gmap',
        action='store_true',
        help="Force plotted interpolation/averaging/harmony results to be gamut mapped."
    )

    parser.add_argument(
        '--filter',
        default='',
        help=(
            "Apply filter. Filter options can be provided as a JSON string after filter name: 'filter:{}'."
        )
    )

    # Graphical and plotting options
    parser.add_argument('--title', '-t', default='', help="Provide a title for the diagram.")
    parser.add_argument('--dark', action="store_true", help="Use dark theme.")
    parser.add_argument('--output', '-o', default='', help='Output file.')
    parser.add_argument('--height', '-H', type=int, default=800, help="Height")
    parser.add_argument('--width', '-W', type=int, default=800, help="Width")

    # Camera and perspective
    parser.add_argument('--pos', '-p', default=None, help="Position of camara 'x:y:z'")
    parser.add_argument('--azimuth', '-A', type=float, default=45, help="Camera X position")
    parser.add_argument('--elevation', '-E', type=float, default=45, help="Camera Y position")
    parser.add_argument('--distance', '-D', type=float, default=2.5, help="Camera Z position")
    parser.add_argument(
        '--aspect-ratio', '-R',
        default='1:1:1',
        help="Aspect ratio. Set to 0:0:0 to leave aspect ratio untouched."
    )
    parser.add_argument(
        '--projection', '-P',
        default='perspective',
        help="Projection mode, perspective or orthographic"
    )
    parser.add_argument(
        '--spectral-locus', '-v', action='store_true', help='Plot the spectral for the current space.'
    )

    args = parser.parse_args()

    gamuts = {}
    first = None
    for gamut in args.gamut:
        parts = [p.strip() if not e else json.loads(p) for e, p in enumerate(gamut.split(':', 1))]
        gamuts[parts[0]] = {} if len(parts) == 1 else parts[1]
        first = parts[0]
    if first is None:
        first = 'srgb'
        gamuts['srgb'] = {}

    aspect = {k: float(v) for k, v in zip(['x', 'y', 'z'], args.aspect_ratio.split(':'))}
    parts = [p.strip() if not e else json.loads(p) for e, p in enumerate(args.gmap.split(':', 1))]
    gmap = {'method': parts[0]}
    if len(parts) == 2:
        gmap.update(parts[1])

    filters = []
    if args.filter:
        parts = [p.strip() if not e else json.loads(p) for e, p in enumerate(args.filter.split(':', 1))]
        filters.append(parts[0])
        if len(parts) == 2:
            filters.append(parts[1])
        else:
            filters.append({})

    # Plot the color space(s)
    fig = plot_gamut_in_space(
        args.space,
        gamuts,
        title=args.title,
        dark=args.dark,
        gmap=gmap,
        size=(args.width, args.height),
        camera={'a': args.azimuth, 'e': args.elevation, 'r': args.distance},
        aspect=aspect,
        projection=args.projection,
        filters=filters
    )

    parts = [p.strip() if not e else json.loads(p) for e, p in enumerate(args.interp_method.split(':', 1))]
    interp = {'method': parts[0], 'hue': 'shorter', 'steps': 100}
    if len(parts) == 2:
        interp.update(parts[1])


    if args.spectral_locus:
        plot_spectral_locus(
            fig,
            args.space,
        )

    # Plot interpolation
    plot_interpolation(
        fig,
        args.space,
        args.interp_colors,
        interp,
        gmap,
        args.mix_alpha,
        args.mix_gmap,
        filters
    )

    avg_options = {"space": "srgb-linear"}
    avg_options.update(json.loads(args.average_options))
    plot_average(
        fig,
        args.space,
        args.avg_colors,
        avg_options,
        gmap,
        args.mix_alpha,
        args.mix_gmap,
        filters
    )

    parts = [p.strip() if e < 2 else json.loads(p) for e, p in enumerate(args.harmony.split(':', 2)) if p]
    harmony_config = []
    if parts:
        hcolor = parts[0]
        harmony = {'name': parts[1]}
        if len(parts) == 3:
            harmony.update(parts[2])
        harmony_config = [hcolor, harmony]

    plot_harmony(
        fig,
        args.space,
        harmony_config,
        gmap,
        args.mix_alpha,
        args.mix_gmap,
        filters
    )

    # Plot gamut mapping examples
    plot_colors(fig, args.space, first, args.gmap_colors, args.colors, gmap, filters)

    # Show or save the data as an image, etc.
    if fig:
        if args.output:
            filetype = os.path.splitext(args.output)[1].lstrip('.').lower()
            if filetype == 'html':
                with open(args.output, 'w') as f:
                    f.write(io.to_html(fig))
            elif filetype == 'json':
                io.write_json(fig, args.output)
            else:
                with open(args.output, 'wb') as f:
                    f.write(fig.to_image(format=filetype, width=args.width, height=args.height))
        else:
            fig.show('browser')
        return 0
    return 1


if __name__ == "__main__":
    sys.exit(main())
