#include "DeepPluckGuiSupport.h"

#include <cmath>

namespace deeppluck::gui
{
namespace
{
constexpr float twoPi = juce::MathConstants<float>::twoPi;

std::shared_ptr<DeepPluckGuiPreferences>& sharedPreferencesInstance()
{
    static std::shared_ptr<DeepPluckGuiPreferences> instance;
    return instance;
}

float safeUnit (float value, float fallback = 0.0f)
{
    return std::isfinite (value) ? juce::jlimit (0.0f, 1.0f, value) : fallback;
}

float normalizedEnvelopeTime (float value, float maximum)
{
    constexpr auto minimum = 0.001f;
    const auto safe = std::isfinite (value) ? juce::jlimit (minimum, maximum, value) : minimum;
    if (safe <= minimum)
        return 0.0f;
    return juce::jlimit (0.0f, 1.0f,
                         std::log (safe / minimum) / std::log (maximum / minimum));
}

void drawGrid (juce::Graphics& graphics, juce::Rectangle<float> bounds)
{
    graphics.setColour (Colours::border.withAlpha (0.32f));
    for (auto index = 1; index < 5; ++index)
    {
        const auto x = bounds.getX() + bounds.getWidth() * static_cast<float> (index) / 5.0f;
        graphics.drawVerticalLine (juce::roundToInt (x), bounds.getY(), bounds.getBottom());
    }
    for (auto index = 1; index < 4; ++index)
    {
        const auto y = bounds.getY() + bounds.getHeight() * static_cast<float> (index) / 4.0f;
        graphics.drawHorizontalLine (juce::roundToInt (y), bounds.getX(), bounds.getRight());
    }
}

void drawAmberGlow (juce::Graphics& graphics, const juce::Path& path)
{
    for (const auto [width, alpha] : { std::pair { 10.0f, 0.045f },
                                       std::pair { 6.0f, 0.085f },
                                       std::pair { 3.5f, 0.14f } })
    {
        graphics.setColour (Colours::amber.withAlpha (alpha));
        graphics.strokePath (path, juce::PathStrokeType (
            width, juce::PathStrokeType::curved, juce::PathStrokeType::rounded));
    }

    graphics.setColour (Colours::amber);
    graphics.strokePath (path, juce::PathStrokeType (
        1.8f, juce::PathStrokeType::curved, juce::PathStrokeType::rounded));
}

}

WorkflowIconKind getWorkflowIconKind (const juce::String& id)
{
    if (id == "random.undo") return WorkflowIconKind::undo;
    if (id.startsWith ("random.")) return WorkflowIconKind::dice;
    if (id.startsWith ("lock.")) return WorkflowIconKind::lock;
    if (id == "preset.save" || id == "preset.saveAs") return WorkflowIconKind::save;
    if (id == "preset.delete") return WorkflowIconKind::deleteAction;
    if (id == "preset.refresh") return WorkflowIconKind::refresh;
    if (id == "preset.openFolder") return WorkflowIconKind::folder;
    if (id == "preset.previous") return WorkflowIconKind::previous;
    if (id == "preset.next") return WorkflowIconKind::next;
    return WorkflowIconKind::none;
}

juce::Path createWaveformPath (int waveformIndex, float pulseWidthValue,
                               juce::Rectangle<float> bounds)
{
    juce::Path path;
    if (bounds.isEmpty())
        return path;

    const auto centreY = bounds.getCentreY();
    const auto amplitude = bounds.getHeight() * 0.42f;
    const auto pulseWidth = juce::jlimit (0.05f, 0.95f,
                                         std::isfinite (pulseWidthValue) ? pulseWidthValue : 0.5f);
    constexpr auto points = 96;
    for (auto index = 0; index <= points; ++index)
    {
        const auto phase = static_cast<float> (index) / static_cast<float> (points);
        float sample = 0.0f;
        switch (juce::jlimit (0, 4, waveformIndex))
        {
            case 0: sample = 1.0f - 2.0f * phase; break;
            case 1: sample = 1.0f - 4.0f * std::abs (phase - 0.5f); break;
            case 2: sample = phase < 0.5f ? 1.0f : -1.0f; break;
            case 3: sample = std::sin (twoPi * phase); break;
            case 4: sample = phase < pulseWidth ? 1.0f : -1.0f; break;
            default: break;
        }
        const auto point = juce::Point<float> { bounds.getX() + phase * bounds.getWidth(),
                                                centreY - sample * amplitude };
        if (index == 0) path.startNewSubPath (point); else path.lineTo (point);
    }
    return path;
}

EnvelopeDisplayGeometry calculateEnvelopeDisplayGeometry (
    float attackValue, float decayValue, float sustainValue, float releaseValue,
    juce::Rectangle<float> bounds)
{
    EnvelopeDisplayGeometry geometry;
    if (bounds.isEmpty())
        return geometry;

    const auto attack = normalizedEnvelopeTime (attackValue, 2.0f);
    const auto decay = normalizedEnvelopeTime (decayValue, 5.0f);
    const auto release = normalizedEnvelopeTime (releaseValue, 10.0f);
    const auto sustain = safeUnit (sustainValue);
    geometry.attackX = bounds.getX() + bounds.getWidth() * 0.22f * attack;
    geometry.decayX = geometry.attackX + bounds.getWidth() * 0.22f * decay;
    geometry.sustainX = juce::jmax (geometry.decayX,
                                    bounds.getX() + bounds.getWidth() * 0.70f);
    geometry.releaseX = juce::jmin (bounds.getRight(), geometry.sustainX
                                     + bounds.getWidth() * 0.28f * release);
    geometry.sustainY = bounds.getBottom() - sustain * bounds.getHeight();
    return geometry;
}

juce::Path createEnvelopePath (float attackValue, float decayValue, float sustainValue,
                               float releaseValue, juce::Rectangle<float> bounds)
{
    juce::Path path;
    if (bounds.isEmpty())
        return path;

    const auto geometry = calculateEnvelopeDisplayGeometry (
        attackValue, decayValue, sustainValue, releaseValue, bounds);
    const auto bottom = bounds.getBottom();
    const auto top = bounds.getY();
    path.startNewSubPath (bounds.getX(), bottom);
    path.lineTo (geometry.attackX, top);
    path.lineTo (geometry.decayX, geometry.sustainY);
    path.lineTo (geometry.sustainX, geometry.sustainY);
    path.lineTo (geometry.releaseX, bottom);
    path.lineTo (bounds.getRight(), bottom);
    return path;
}

float normaliseMeterDecibels (float decibels) noexcept
{
    const auto safe = std::isfinite (decibels) ? decibels : -60.0f;
    return juce::jlimit (0.0f, 1.0f, (safe + 60.0f) / 66.0f);
}

DeepPluckLookAndFeel::DeepPluckLookAndFeel()
{
    setColour (juce::Label::textColourId, Colours::text);
    setColour (juce::Slider::textBoxTextColourId, Colours::teal);
    setColour (juce::Slider::textBoxBackgroundColourId, Colours::background);
    setColour (juce::Slider::textBoxOutlineColourId, Colours::border);
    setColour (juce::ComboBox::backgroundColourId, Colours::background);
    setColour (juce::ComboBox::textColourId, Colours::text);
    setColour (juce::ComboBox::outlineColourId, Colours::border);
    setColour (juce::PopupMenu::backgroundColourId, Colours::surface);
    setColour (juce::PopupMenu::textColourId, Colours::text);
    setColour (juce::PopupMenu::highlightedBackgroundColourId, Colours::tealMuted);
    setColour (juce::TooltipWindow::backgroundColourId, Colours::surfaceRaised);
    setColour (juce::TooltipWindow::textColourId, Colours::text);
    setColour (juce::TooltipWindow::outlineColourId, Colours::tealMuted);
}

void DeepPluckLookAndFeel::drawRotarySlider (juce::Graphics& graphics, int x, int y,
                                              int width, int height, float sliderPosition,
                                              float rotaryStartAngle, float rotaryEndAngle,
                                              juce::Slider& slider)
{
    const auto diameter = juce::jmin (static_cast<float> (width), static_cast<float> (height)) - 8.0f;
    auto bounds = juce::Rectangle<float> (static_cast<float> (x), static_cast<float> (y),
                                          static_cast<float> (width), static_cast<float> (height))
                      .withSizeKeepingCentre (diameter, diameter);
    const auto radius = bounds.getWidth() * 0.5f;
    const auto angle = rotaryStartAngle + sliderPosition * (rotaryEndAngle - rotaryStartAngle);

    graphics.setColour (juce::Colours::black.withAlpha (0.46f));
    graphics.fillEllipse (bounds.translated (0.8f, 1.2f));

    graphics.setGradientFill (juce::ColourGradient (
        Colours::surfaceRaised.brighter (0.20f), bounds.getTopLeft(),
        Colours::background.darker (0.38f), bounds.getBottomRight(), false));
    graphics.fillEllipse (bounds);
    graphics.setColour (Colours::border.brighter (0.28f));
    graphics.drawEllipse (bounds, 1.25f);

    const auto face = bounds.reduced (4.2f);
    const auto highlightPoint = juce::Point<float> {
        face.getX() + face.getWidth() * 0.32f,
        face.getY() + face.getHeight() * 0.28f
    };
    juce::ColourGradient faceGradient (
        Colours::surfaceRaised.brighter (0.34f), highlightPoint,
        Colours::background.darker (0.26f), face.getBottomRight(), true);
    faceGradient.addColour (0.54, Colours::surfaceRaised.darker (0.10f));
    graphics.setGradientFill (faceGradient);
    graphics.fillEllipse (face);

    graphics.setGradientFill (juce::ColourGradient (
        Colours::text.withAlpha (0.20f), face.getTopLeft(),
        juce::Colours::black.withAlpha (0.52f), face.getBottomRight(), false));
    graphics.drawEllipse (face, 1.15f);
    graphics.setColour (juce::Colours::black.withAlpha (0.48f));
    graphics.drawEllipse (face.reduced (2.0f), 1.0f);

    juce::Path faceHighlight;
    const auto faceRadius = face.getWidth() * 0.5f;
    faceHighlight.addCentredArc (face.getCentreX(), face.getCentreY(),
                                 faceRadius - 2.2f, faceRadius - 2.2f, 0.0f,
                                 -1.22f, 0.18f, true);
    graphics.setColour (Colours::text.withAlpha (0.12f));
    graphics.strokePath (faceHighlight, juce::PathStrokeType (
        1.05f, juce::PathStrokeType::curved, juce::PathStrokeType::rounded));

    juce::Path track;
    track.addCentredArc (bounds.getCentreX(), bounds.getCentreY(), radius - 2.0f, radius - 2.0f,
                         0.0f, rotaryStartAngle, rotaryEndAngle, true);
    graphics.setColour (Colours::border.brighter (0.25f));
    graphics.strokePath (track, juce::PathStrokeType (2.2f));
    juce::Path value;
    value.addCentredArc (bounds.getCentreX(), bounds.getCentreY(), radius - 2.0f, radius - 2.0f,
                         0.0f, rotaryStartAngle, angle, true);
    graphics.setColour (slider.isEnabled() ? Colours::teal : Colours::tealMuted);
    graphics.strokePath (value, juce::PathStrokeType (2.4f));

    const auto pointerLength = faceRadius * 0.72f;
    const auto centre = bounds.getCentre();
    const auto end = centre + juce::Point<float> (std::sin (angle), -std::cos (angle)) * pointerLength;
    graphics.setColour (Colours::amber);
    graphics.drawLine ({ centre, end }, 1.7f);
}

void DeepPluckLookAndFeel::drawButtonBackground (juce::Graphics& graphics,
                                                  juce::Button& button,
                                                  const juce::Colour&, bool highlighted,
                                                  bool down)
{
    auto bounds = button.getLocalBounds().toFloat().reduced (0.5f);
    const auto active = button.getToggleState();
    const auto top = active ? Colours::tealMuted.withMultipliedBrightness (0.72f)
                            : highlighted ? Colours::surfaceRaised.brighter (0.08f)
                                          : Colours::surfaceRaised;
    const auto bottom = down ? Colours::background.brighter (0.025f)
                             : Colours::background.darker (0.18f);

    graphics.setColour (juce::Colours::black.withAlpha (0.34f));
    graphics.fillRoundedRectangle (bounds.translated (0.0f, 1.0f), 4.0f);
    graphics.setGradientFill (juce::ColourGradient (
        top, bounds.getTopLeft(), bottom, bounds.getBottomLeft(), false));
    graphics.fillRoundedRectangle (bounds, 4.0f);
    graphics.setColour (active ? Colours::teal : Colours::border.brighter (0.12f));
    graphics.drawRoundedRectangle (bounds, 4.0f, 1.0f);

    const auto inner = bounds.reduced (1.5f);
    graphics.setColour (Colours::text.withAlpha (highlighted ? 0.13f : 0.075f));
    graphics.drawLine (inner.getX() + 3.0f, inner.getY(),
                       inner.getRight() - 3.0f, inner.getY(), 1.0f);
    graphics.setColour (Colours::amber.withAlpha (active ? 0.38f : 0.18f));
    graphics.drawLine (inner.getX() + 4.0f, inner.getBottom(),
                       inner.getRight() - 4.0f, inner.getBottom(), 1.0f);
    if (button.hasKeyboardFocus (true))
    {
        graphics.setColour (Colours::teal.withAlpha (0.92f));
        graphics.drawRoundedRectangle (bounds.reduced (2.0f), 3.0f, 1.4f);
    }
}

void DeepPluckLookAndFeel::drawIcon (juce::Graphics& graphics, const juce::String& id,
                                     juce::Rectangle<float> bounds, juce::Colour colour)
{
    const auto kind = getWorkflowIconKind (id);
    if (kind == WorkflowIconKind::none) return;
    graphics.setColour (colour);
    bounds = bounds.withSizeKeepingCentre (juce::jmin (18.0f, bounds.getWidth()),
                                           juce::jmin (18.0f, bounds.getHeight()));
    if (kind == WorkflowIconKind::dice)
    {
        graphics.drawRoundedRectangle (bounds.reduced (1.0f), 2.5f, 1.5f);
        for (const auto point : { juce::Point<float> { 0.28f, 0.28f }, { 0.72f, 0.28f },
                                  { 0.50f, 0.50f }, { 0.28f, 0.72f }, { 0.72f, 0.72f } })
            graphics.fillEllipse (bounds.getX() + point.x * bounds.getWidth() - 1.25f,
                                  bounds.getY() + point.y * bounds.getHeight() - 1.25f, 2.5f, 2.5f);
    }
    else if (kind == WorkflowIconKind::lock)
    {
        const auto body = juce::Rectangle<float> (
            bounds.getX() + 2.0f, bounds.getY() + 8.0f,
            bounds.getWidth() - 4.0f, bounds.getHeight() - 9.0f);
        graphics.drawRoundedRectangle (body, 2.5f, 1.7f);
        juce::Path shackle;
        shackle.startNewSubPath (bounds.getX() + 5.0f, bounds.getY() + 9.0f);
        shackle.lineTo (bounds.getX() + 5.0f, bounds.getY() + 6.0f);
        shackle.cubicTo (bounds.getX() + 5.0f, bounds.getY() + 1.5f,
                         bounds.getRight() - 5.0f, bounds.getY() + 1.5f,
                         bounds.getRight() - 5.0f, bounds.getY() + 6.0f);
        shackle.lineTo (bounds.getRight() - 5.0f, bounds.getY() + 9.0f);
        graphics.strokePath (shackle, juce::PathStrokeType (
            1.7f, juce::PathStrokeType::curved, juce::PathStrokeType::rounded));
        const auto keyholeX = bounds.getCentreX();
        graphics.fillEllipse (keyholeX - 1.5f, body.getY() + 3.0f, 3.0f, 3.0f);
        graphics.drawVerticalLine (juce::roundToInt (keyholeX),
                                   body.getY() + 5.5f, body.getBottom() - 2.0f);
    }
    else if (kind == WorkflowIconKind::folder)
    {
        juce::Path path;
        path.startNewSubPath (bounds.getX(), bounds.getY() + 5.0f);
        path.lineTo (bounds.getX() + 7.0f, bounds.getY() + 5.0f);
        path.lineTo (bounds.getX() + 9.0f, bounds.getY() + 2.0f);
        path.lineTo (bounds.getRight(), bounds.getY() + 2.0f);
        path.lineTo (bounds.getRight() - 2.0f, bounds.getBottom() - 2.0f);
        path.lineTo (bounds.getX() + 1.0f, bounds.getBottom() - 2.0f);
        path.closeSubPath();
        graphics.strokePath (path, juce::PathStrokeType (1.7f));
    }
    else if (kind == WorkflowIconKind::deleteAction)
    {
        graphics.drawRect (bounds.reduced (5.0f, 4.0f).withTrimmedTop (3.0f), 1.5f);
        graphics.drawHorizontalLine (juce::roundToInt (bounds.getY() + 4.0f),
                                     bounds.getX() + 3.0f, bounds.getRight() - 3.0f);
    }
    else if (kind == WorkflowIconKind::save)
    {
        graphics.drawRoundedRectangle (bounds.reduced (2.0f), 1.5f, 1.5f);
        graphics.drawRect (bounds.getX() + 5.0f, bounds.getY() + 2.0f, 8.0f, 6.0f, 1.2f);
        graphics.fillEllipse (bounds.getCentreX() - 2.0f, bounds.getBottom() - 7.0f, 4.0f, 4.0f);
    }
    else if (kind == WorkflowIconKind::undo)
    {
        const auto lineY = bounds.getY() + 6.5f;
        juce::Path body;
        body.startNewSubPath (bounds.getRight() - 3.0f, bounds.getBottom() - 2.5f);
        body.cubicTo (bounds.getRight() + 0.5f, bounds.getBottom() - 2.5f,
                      bounds.getRight() + 0.5f, lineY,
                      bounds.getRight() - 4.5f, lineY);
        body.lineTo (bounds.getX() + 6.0f, lineY);
        graphics.strokePath (body, juce::PathStrokeType (
            1.8f, juce::PathStrokeType::curved, juce::PathStrokeType::rounded));

        juce::Path head;
        head.startNewSubPath (bounds.getX() + 1.5f, lineY);
        head.lineTo (bounds.getX() + 7.0f, lineY - 4.0f);
        head.lineTo (bounds.getX() + 7.0f, lineY + 4.0f);
        head.closeSubPath();
        graphics.fillPath (head);
    }
    else
    {
        juce::Path path;
        const auto clockwise = kind == WorkflowIconKind::refresh;
        if (kind == WorkflowIconKind::previous || kind == WorkflowIconKind::next)
        {
            const auto direction = kind == WorkflowIconKind::previous ? 1.0f : -1.0f;
            path.startNewSubPath (bounds.getCentreX() + direction * 4.0f, bounds.getY() + 3.0f);
            path.lineTo (bounds.getCentreX() - direction * 4.0f, bounds.getCentreY());
            path.lineTo (bounds.getCentreX() + direction * 4.0f, bounds.getBottom() - 3.0f);
        }
        else
        {
            path.addArc (bounds.getX() + 2.0f, bounds.getY() + 2.0f,
                         bounds.getWidth() - 4.0f, bounds.getHeight() - 4.0f,
                         clockwise ? 0.1f : 1.7f, clockwise ? 5.1f : 6.7f, true);
        }
        graphics.strokePath (path, juce::PathStrokeType (1.8f,
                              juce::PathStrokeType::curved, juce::PathStrokeType::rounded));
    }
}

void DeepPluckLookAndFeel::drawButtonText (juce::Graphics& graphics,
                                            juce::TextButton& button,
                                            bool, bool)
{
    const auto kind = getWorkflowIconKind (button.getComponentID());
    auto bounds = button.getLocalBounds().toFloat().reduced (5.0f, 2.0f);
    const auto colour = button.isEnabled() ? Colours::text : Colours::textMuted;
    if (kind != WorkflowIconKind::none)
    {
        auto iconBounds = button.getButtonText().isEmpty() ? bounds
                                                           : bounds.removeFromLeft (20.0f);
        drawIcon (graphics, button.getComponentID(), iconBounds, colour);
    }
    if (button.getButtonText().isNotEmpty())
    {
        graphics.setColour (colour);
        graphics.setFont (juce::FontOptions { 12.0f });
        graphics.drawFittedText (button.getButtonText(), bounds.toNearestInt(),
                                 juce::Justification::centred, 1);
    }
}

void DeepPluckLookAndFeel::drawToggleButton (juce::Graphics& graphics,
                                              juce::ToggleButton& button,
                                              bool highlighted, bool down)
{
    if (button.getComponentID().startsWith ("lock."))
    {
        drawButtonBackground (graphics, button, {}, highlighted, down);
        drawIcon (graphics, button.getComponentID(), button.getLocalBounds().toFloat(),
                  button.getToggleState() ? Colours::amber : Colours::amberSoft);
        return;
    }

    auto bounds = button.getLocalBounds().toFloat().reduced (4.0f);
    auto switchBounds = bounds.removeFromLeft (34.0f).withSizeKeepingCentre (32.0f, 16.0f);
    graphics.setColour (button.getToggleState() ? Colours::tealMuted : Colours::background);
    graphics.fillRoundedRectangle (switchBounds, 8.0f);
    graphics.setColour (button.getToggleState() ? Colours::teal : Colours::border);
    graphics.drawRoundedRectangle (switchBounds, 8.0f, 1.0f);
    const auto dotX = button.getToggleState() ? switchBounds.getRight() - 14.0f
                                               : switchBounds.getX() + 2.0f;
    graphics.fillEllipse (dotX, switchBounds.getY() + 2.0f, 12.0f, 12.0f);
    graphics.setColour (button.isEnabled() ? Colours::text : Colours::textMuted);
    graphics.setFont (juce::FontOptions { 11.0f });
    graphics.drawFittedText (button.getButtonText(), bounds.toNearestInt(),
                             juce::Justification::centredLeft, 1);
    if (button.hasKeyboardFocus (true))
    {
        graphics.setColour (Colours::teal.withAlpha (0.92f));
        graphics.drawRoundedRectangle (button.getLocalBounds().toFloat().reduced (1.5f),
                                       3.0f, 1.2f);
    }
}

void DeepPluckLookAndFeel::drawComboBox (juce::Graphics& graphics, int width, int height,
                                         bool, int, int, int, int, juce::ComboBox& box)
{
    auto bounds = juce::Rectangle<float> (0.5f, 0.5f, static_cast<float> (width) - 1.0f,
                                          static_cast<float> (height) - 1.0f);
    graphics.setColour (Colours::background);
    graphics.fillRoundedRectangle (bounds, 4.0f);
    graphics.setColour (box.hasKeyboardFocus (true) ? Colours::teal : Colours::border);
    graphics.drawRoundedRectangle (bounds, 4.0f, 1.0f);
    juce::Path arrow;
    const auto cx = static_cast<float> (width) - 13.0f;
    const auto cy = static_cast<float> (height) * 0.5f;
    arrow.startNewSubPath (cx - 4.0f, cy - 2.0f);
    arrow.lineTo (cx, cy + 2.0f);
    arrow.lineTo (cx + 4.0f, cy - 2.0f);
    graphics.setColour (Colours::textMuted);
    graphics.strokePath (arrow, juce::PathStrokeType (1.4f));
}

void DeepPluckLookAndFeel::drawPopupMenuBackground (juce::Graphics& graphics,
                                                     int width, int height)
{
    graphics.fillAll (Colours::surface);
    graphics.setColour (Colours::border);
    graphics.drawRect (0, 0, width, height, 1);
}

void DeepPluckLookAndFeel::drawTooltip (juce::Graphics& graphics, const juce::String& textValue,
                                        int width, int height)
{
    graphics.fillAll (Colours::surfaceRaised);
    graphics.setColour (Colours::tealMuted);
    graphics.drawRect (0, 0, width, height, 1);
    graphics.setColour (Colours::text);
    graphics.setFont (juce::FontOptions { 13.0f });
    graphics.drawFittedText (textValue, juce::Rectangle<int> (width, height).reduced (8),
                             juce::Justification::centredLeft, 3);
}

juce::Slider::SliderLayout DeepPluckLookAndFeel::getSliderLayout (juce::Slider& slider)
{
    auto layout = LookAndFeel_V4::getSliderLayout (slider);
    if (slider.getSliderStyle() == juce::Slider::RotaryHorizontalVerticalDrag)
    {
        const auto available = slider.getLocalBounds();
        const auto textHeight = slider.getTextBoxPosition() == juce::Slider::NoTextBox ? 0 : 24;
        layout.textBoxBounds = juce::Rectangle<int> (available.getX(), available.getBottom() - textHeight,
                                                     available.getWidth(), textHeight).reduced (5, 0);
        auto rotary = available.withTrimmedBottom (textHeight);
        const auto rotaryDiameter = juce::jlimit (
            16, 160, static_cast<int> (slider.getProperties().getWithDefault (
                         Colours::rotaryDiameterProperty, 64)));
        const auto diameter = juce::jmax (
            16, juce::jmin (rotaryDiameter, juce::jmin (rotary.getWidth(), rotary.getHeight())));
        layout.sliderBounds = rotary.withSizeKeepingCentre (diameter, diameter);
    }
    return layout;
}

DeepPluckTooltipWindow::DeepPluckTooltipWindow (juce::Component* parent)
    : TooltipWindow (parent, 650)
{
}

void DeepPluckTooltipWindow::setEnabled (bool shouldBeEnabled)
{
    enabled = shouldBeEnabled;
    if (! enabled) hideTip();
}

juce::String DeepPluckTooltipWindow::getTipFor (juce::Component& component)
{
    return enabled ? TooltipWindow::getTipFor (component) : juce::String {};
}

DeepPluckGuiPreferences::DeepPluckGuiPreferences (juce::File storageFile)
    : file (std::move (storageFile))
{
    load();
}

std::shared_ptr<DeepPluckGuiPreferences> DeepPluckGuiPreferences::shared()
{
    auto& instance = sharedPreferencesInstance();
    if (instance == nullptr)
        instance = std::shared_ptr<DeepPluckGuiPreferences> (new DeepPluckGuiPreferences (
            juce::File::getSpecialLocation (juce::File::userApplicationDataDirectory)
                .getChildFile ("ToneTrax").getChildFile ("DeepPluck")
                .getChildFile ("GUI.properties")));
    return instance;
}

std::shared_ptr<DeepPluckGuiPreferences> DeepPluckGuiPreferences::createForTests (
    const juce::File& storageFile)
{
    return std::shared_ptr<DeepPluckGuiPreferences> (new DeepPluckGuiPreferences (storageFile));
}

void DeepPluckGuiPreferences::installSharedForTests (
    std::shared_ptr<DeepPluckGuiPreferences> preferences)
{
    sharedPreferencesInstance() = std::move (preferences);
}

void DeepPluckGuiPreferences::resetSharedForTests()
{
    sharedPreferencesInstance().reset();
}

void DeepPluckGuiPreferences::load()
{
    const juce::ScopedLock lock (mutex);
    state = {};
    if (! file.existsAsFile()) return;
    std::unique_ptr<juce::XmlElement> xml (juce::XmlDocument::parse (file));
    if (xml == nullptr || ! xml->hasTagName ("DeepPluckGuiPreferences")) return;
    state.hoverHelp = xml->getBoolAttribute ("hoverHelp", true);
    state.welcomeEnabled = xml->getBoolAttribute ("welcomeEnabled", true);
    state.welcomeSeen = xml->getBoolAttribute ("welcomeSeen", false);
}

void DeepPluckGuiPreferences::saveLocked()
{
    juce::XmlElement xml ("DeepPluckGuiPreferences");
    xml.setAttribute ("format", 1);
    xml.setAttribute ("hoverHelp", state.hoverHelp);
    xml.setAttribute ("welcomeEnabled", state.welcomeEnabled);
    xml.setAttribute ("welcomeSeen", state.welcomeSeen);
    static juce::CriticalSection processFileMutex;
    const juce::ScopedLock processLock (processFileMutex);
    static_cast<void> (file.getParentDirectory().createDirectory());
    juce::TemporaryFile temporary (file);
    if (xml.writeTo (temporary.getFile()))
        static_cast<void> (temporary.overwriteTargetFileWithTemporary());
}

GuiPreferenceState DeepPluckGuiPreferences::get() const
{
    const juce::ScopedLock lock (mutex);
    return state;
}

void DeepPluckGuiPreferences::setHoverHelp (bool enabledValue)
{
    const juce::ScopedLock lock (mutex);
    state.hoverHelp = enabledValue;
    saveLocked();
}

void DeepPluckGuiPreferences::setWelcomeEnabled (bool enabledValue)
{
    const juce::ScopedLock lock (mutex);
    state.welcomeEnabled = enabledValue;
    if (enabledValue) state.welcomeSeen = false;
    saveLocked();
}

void DeepPluckGuiPreferences::markWelcomeSeen()
{
    const juce::ScopedLock lock (mutex);
    state.welcomeSeen = true;
    saveLocked();
}

void DeepPluckGuiPreferences::requestWelcomeAgain()
{
    const juce::ScopedLock lock (mutex);
    state.welcomeEnabled = true;
    state.welcomeSeen = false;
    saveLocked();
}

bool DeepPluckGuiPreferences::shouldShowWelcome() const
{
    const auto value = get();
    return value.welcomeEnabled && ! value.welcomeSeen;
}

juce::File DeepPluckGuiPreferences::getStorageFile() const { return file; }

WaveformDisplayComponent::WaveformDisplayComponent (juce::ComboBox& waveformControl,
                                                    juce::Slider& pulseWidthControl)
    : waveform (waveformControl), pulseWidth (pulseWidthControl)
{
    setInterceptsMouseClicks (false, false);
    waveform.onChange = [this] { repaint(); };
    pulseWidth.onValueChange = [this] { repaint(); };
}

void WaveformDisplayComponent::paint (juce::Graphics& graphics)
{
    auto bounds = getLocalBounds().toFloat().reduced (1.0f);
    graphics.setColour (Colours::background);
    graphics.fillRoundedRectangle (bounds, 4.0f);
    graphics.setColour (Colours::border);
    graphics.drawRoundedRectangle (bounds, 4.0f, 1.0f);
    bounds.reduce (10.0f, 9.0f);
    drawGrid (graphics, bounds);
    graphics.setColour (Colours::amber.withAlpha (0.14f));
    auto fill = createWaveformPath (waveform.getSelectedItemIndex(),
                                    static_cast<float> (pulseWidth.getValue()), bounds);
    juce::Path area (fill);
    area.lineTo (bounds.getRight(), bounds.getCentreY());
    area.lineTo (bounds.getX(), bounds.getCentreY());
    area.closeSubPath();
    graphics.fillPath (area);
    drawAmberGlow (graphics, fill);
}

EnvelopeDisplayComponent::EnvelopeDisplayComponent (juce::Slider& attackControl,
                                                    juce::Slider& decayControl,
                                                    juce::Slider& sustainControl,
                                                    juce::Slider& releaseControl)
    : attack (attackControl), decay (decayControl), sustain (sustainControl), release (releaseControl)
{
    setInterceptsMouseClicks (false, false);
    const auto repaintDisplay = [this] { repaint(); };
    attack.onValueChange = repaintDisplay;
    decay.onValueChange = repaintDisplay;
    sustain.onValueChange = repaintDisplay;
    release.onValueChange = repaintDisplay;
}

void EnvelopeDisplayComponent::paint (juce::Graphics& graphics)
{
    auto bounds = getLocalBounds().toFloat().reduced (1.0f);
    graphics.setColour (Colours::background);
    graphics.fillRoundedRectangle (bounds, 4.0f);
    graphics.setColour (Colours::border);
    graphics.drawRoundedRectangle (bounds, 4.0f, 1.0f);
    bounds.reduce (10.0f, 8.0f);
    drawGrid (graphics, bounds);
    const auto path = createEnvelopePath (static_cast<float> (attack.getValue()),
                                          static_cast<float> (decay.getValue()),
                                          static_cast<float> (sustain.getValue()),
                                          static_cast<float> (release.getValue()), bounds);
    juce::Path fill (path);
    fill.lineTo (bounds.getRight(), bounds.getBottom());
    fill.lineTo (bounds.getX(), bounds.getBottom());
    fill.closeSubPath();
    graphics.setGradientFill (juce::ColourGradient (Colours::amber.withAlpha (0.28f),
                                                    bounds.getCentre(), Colours::amber.withAlpha (0.02f),
                                                    bounds.getBottomRight(), false));
    graphics.fillPath (fill);
    drawAmberGlow (graphics, path);
}

MeterBarComponent::MeterBarComponent (const juce::String& channelName) : name (channelName)
{
    setComponentID ("output.meter." + channelName.toLowerCase());
    setInterceptsMouseClicks (false, false);
}

void MeterBarComponent::setLevel (float newLevel)
{
    const auto safe = std::isfinite (newLevel) ? juce::jlimit (0.0f, 4.0f, newLevel) : 0.0f;
    level = juce::jmax (safe, level * 0.86f);
    repaint();
}

float MeterBarComponent::getDisplayedLevel() const noexcept { return level; }

void MeterBarComponent::paint (juce::Graphics& graphics)
{
    auto bounds = getLocalBounds();
    auto label = bounds.removeFromTop (16);
    graphics.setColour (Colours::textMuted);
    graphics.setFont (juce::FontOptions { 11.0f, juce::Font::bold });
    graphics.drawText (name, label, juce::Justification::centred);
    auto meter = bounds.reduced (3, 0).toFloat();
    graphics.setColour (Colours::background);
    graphics.fillRect (meter);
    const auto db = juce::jmax (-60.0f, 20.0f * std::log10 (juce::jmax (level, 0.001f)));
    const auto normalized = normaliseMeterDecibels (db);
    auto lit = meter.withY (meter.getBottom() - meter.getHeight() * normalized)
                    .withHeight (meter.getHeight() * normalized);
    juce::ColourGradient gradient (Colours::teal, lit.getBottomLeft(),
                                   Colours::amber, lit.getTopLeft(), false);
    gradient.addColour (0.55, juce::Colour::fromRGB (63, 207, 211));
    graphics.setGradientFill (gradient);
    graphics.fillRect (lit);
    graphics.setColour (Colours::border);
    graphics.drawRect (meter, 1.0f);
}

StereoPeakMeterComponent::StereoPeakMeterComponent()
{
    setComponentID ("output.meter");
    addAndMakeVisible (left);
    addAndMakeVisible (right);
}

void StereoPeakMeterComponent::setLevels (float leftValue, float rightValue)
{
    left.setLevel (leftValue);
    right.setLevel (rightValue);
}

std::array<float, 2> StereoPeakMeterComponent::getDisplayedLevels() const noexcept
{
    return { left.getDisplayedLevel(), right.getDisplayedLevel() };
}

void StereoPeakMeterComponent::paint (juce::Graphics& graphics)
{
    graphics.setColour (Colours::surface.withAlpha (0.8f));
    graphics.fillRoundedRectangle (getLocalBounds().toFloat(), 5.0f);
    graphics.setColour (Colours::border);
    graphics.drawRoundedRectangle (getLocalBounds().toFloat().reduced (0.5f), 5.0f, 1.0f);
}

void StereoPeakMeterComponent::resized()
{
    auto bounds = getLocalBounds().reduced (5, 0);
    const auto width = (bounds.getWidth() - 3) / 2;
    left.setBounds (bounds.removeFromLeft (width));
    bounds.removeFromLeft (3);
    right.setBounds (bounds);
}
}
