/*
  ==============================================================================

   This file is part of the JUCE examples.
   Copyright (c) 2022 - Raw Material Software Limited

   The code included in this file is provided under the terms of the ISC license
   http://www.isc.org/downloads/software-support-policy/isc-license. Permission
   To use, copy, modify, and/or distribute this software for any purpose with or
   without fee is hereby granted provided that the above copyright notice and
   this permission notice appear in all copies.

   THE SOFTWARE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES,
   WHETHER EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR
   PURPOSE, ARE DISCLAIMED.

  ==============================================================================
*/

/*******************************************************************************
 The block below describes the properties of this PIP. A PIP is a short snippet
 of code that can be read by the Projucer and used to generate a JUCE project.

 BEGIN_JUCE_PIP_METADATA

 name:             CapabilityInquiryDemo
 version:          1.0.0
 vendor:           JUCE
 website:          http://juce.com
 description:      Performs MIDI Capability Inquiry transactions

 dependencies:     juce_audio_basics, juce_audio_devices, juce_core,
                   juce_data_structures, juce_events, juce_graphics,
                   juce_gui_basics, juce_midi_ci
 exporters:        xcode_mac, vs2022, linux_make, androidstudio, xcode_iphone

 moduleFlags:      JUCE_STRICT_REFCOUNTEDPOINTER=1

 type:             Component
 mainClass:        CapabilityInquiryDemo

 useLocalCopy:     1

 END_JUCE_PIP_METADATA

*******************************************************************************/

#pragma once

/** This allows listening for changes to some data. Unlike ValueTree, it remembers the types
    of all the data that's stored, which makes it a bit nicer to use. Also unlike ValueTree,
    every mutation necessitates a deep copy of the model, so this isn't necessarily suitable
    for large models that change frequently.

    Use operator-> or operator* to find the current state (without copying!).

    Use operator= to assign new state.
    After assigning new state, the old and new states will be compared, and
    observers will be notified if the new state is different to the old state.

    Use operator[] to retrieve nested states.
    This is useful when a component only depends on a small part of a larger
    model. Assigning a new value to the nested state will also cause observers
    of the parent state to be notified. Similarly, assigning a new state to
    the parent state will cause observers of the nested state to be notified,
    if the nested state actually changes in value.
*/
template <typename Value>
class State
{
public:
    State() : State (Value{}) {}

    State (Value v, std::function<Value (const Value&, Value)> n)
        : impl (std::make_unique<Root> (std::move (v), std::move (n))) {}

    explicit State (Value v)
        : State (std::move (v), [] (const Value&, Value newer) { return std::move (newer); }) {}

    template <typename Parent>
    State (State<Parent> p, Value Parent::* member)
        : impl (std::make_unique<Member<Parent>> (std::move (p), member)) {}

    template <typename Parent, typename Ind>
    State (State<Parent> p, Ind index)
        : impl (std::make_unique<Index<Parent, Ind>> (std::move (p), std::move (index))) {}

    State& operator= (const Value& v)
    {
        impl->set (v);
        return *this;
    }

    State& operator= (Value&& v)
    {
        impl->set (std::move (v));
        return *this;
    }

    const Value& operator* () const { return  impl->get(); }
    const Value* operator->() const { return &impl->get(); }

    ErasedScopeGuard observe (std::function<void (const Value&)> onChange)
    {
        // The idea is that observers want to know whenever the state changes, in order to repaint.
        // They'll also want to repaint upon first observing, so that painting is up-to-date.
        onChange (impl->get());

        // In the case that observe is called on a temporary, caching impl means that the
        // ErasedScopeGuard can still detach safely
        return impl->observe ([cached = *this, fn = std::move (onChange)] (const auto& x)
        {
            fn (x);
        });
    }

    template <typename T>
    auto operator[] (T member) const
        -> State<std::decay_t<decltype (std::declval<Value>().*std::declval<T>())>>
    {
        return { *this, std::move (member) };
    }

    template <typename T>
    auto operator[] (T index) const
        -> State<std::decay_t<decltype (std::declval<Value>()[std::declval<T>()])>>
    {
        return { *this, std::move (index) };
    }

private:
    class Provider : public std::enable_shared_from_this<Provider>
    {
    public:
        virtual ~Provider() = default;
        virtual void set (const Value&) = 0;
        virtual void set (Value&&) = 0;
        virtual const Value& get() const = 0;

        ErasedScopeGuard observe (std::function<void (const Value&)> fn)
        {
            return ErasedScopeGuard { [t = this->shared_from_this(), it = list.insert (list.end(), std::move (fn))]
                                      {
                                          t->list.erase (it);
                                      } };
        }

        void notify (const Value& v) const
        {
            for (auto& callback : list)
                callback (v);
        }

    private:
        using List = std::list<std::function<void (const Value&)>>;
        List list;
    };

    class Root final : public Provider
    {
    public:
        explicit Root (Value v, std::function<Value (const Value&, Value&&)> n)
            : value (std::move (v)), normalise (std::move (n)) {}

        void set (const Value& m) override { setImpl (m); }
        void set (Value&& m)      override { setImpl (std::move (m)); }
        const Value& get()  const override { return value; }

    private:
        template <typename T>
        void setImpl (T&& t)
        {
            // If this fails, you're updating the model from inside an observer callback.
            // That's not very unidirectional-data-flow of you!
            jassert (! reentrant);
            const ScopedValueSetter scope { reentrant, true };

            auto normalised = normalise (value, std::forward<T> (t));

            if (normalised != value)
                this->notify (std::exchange (value, std::move (normalised)));
        }

        Value value;
        std::function<Value (const Value&, Value)> normalise;
        bool reentrant = false;
    };

    template <typename Parent>
    class Member final : public Provider
    {
    public:
        Member (State<Parent> p, Value Parent::* m) : parent (std::move (p)), member (m) {}

        void set (const Value& m) override { setImpl (m); }
        void set (Value&& m)      override { setImpl (std::move (m)); }
        const Value& get()  const override { return (*parent).*member; }

    private:
        template <typename T>
        void setImpl (T&& t)
        {
            auto updated = *parent;
            updated.*member = std::forward<T> (t);
            parent = std::move (updated);
        }

        State<Parent> parent;
        Value Parent::*member;
        ErasedScopeGuard listener = parent.observe ([this] (const auto& old)
        {
            if (old.*member != get())
                this->notify (old.*member);
        });
    };

    template <typename Parent, typename Ind>
    class Index final : public Provider
    {
    public:
        Index (State<Parent> p, Ind i) : parent (std::move (p)), index (i) {}

        void set (const Value& m) override { setImpl (m); }
        void set (Value&& m)      override { setImpl (std::move (m)); }
        const Value& get()  const override { return (*parent)[index]; }

    private:
        template <typename T>
        void setImpl (T&& t)
        {
            auto updated = *parent;
            updated[index] = std::forward<T> (t);
            parent = std::move (updated);
        }

        State<Parent> parent;
        Ind index;
        ErasedScopeGuard listener = parent.observe ([this] (const auto& old)
        {
            if (isPositiveAndBelow (index, std::size (*parent))
                && isPositiveAndBelow (index, std::size (old))
                && old[index] != get())
            {
                this->notify (old[index]);
            }
        });
    };

    std::shared_ptr<Provider> impl;
};

/**
    Data types used to represent the state of the application.
    These should all be types with value semantics, so there should not be pointers or references
    between different parts of the model.
*/
struct Model
{
    Model() = delete;

    template <typename Range, typename Fn>
    static Array<var> toVarArray (Range&& range, Fn&& fn)
    {
        Array<var> result;
        result.resize ((int) std::size (range));
        std::transform (std::begin (range),
                        std::end (range),
                        result.begin(),
                        std::forward<Fn> (fn));
        return result;
    }

    template <typename Fn, typename T>
    static auto fromVarArray (var in, Fn&& fn, std::vector<T> fallback) -> std::vector<T>
    {
        auto* list = in.getArray();

        if (list == nullptr)
            return fallback;

        std::vector<T> result;
        for (auto& t : *list)
            result.push_back (fn (t));
        return result;
    }

    #define JUCE_TUPLE_RELATIONAL_OP(classname, op)                                         \
        bool operator op (const classname& other) const                                     \
        {                                                                                   \
            return tie() op other.tie();                                                    \
        }

    #define JUCE_TUPLE_RELATIONAL_OPS(classname) \
        JUCE_TUPLE_RELATIONAL_OP(classname, ==)  \
        JUCE_TUPLE_RELATIONAL_OP(classname, !=)

    template <typename T>
    struct ListWithSelection
    {
        std::vector<T> items;
        int selection = -1;

        static constexpr auto marshallingVersion = std::nullopt;

        template <typename Archive, typename This>
        static auto serialise (Archive& archive, This& t)
        {
            return archive (named ("items", t.items),
                            named ("selection", t.selection));
        }

        auto tie() const { return std::tie (items, selection); }
        JUCE_TUPLE_RELATIONAL_OPS (ListWithSelection)

    private:
        template <typename This, typename Index>
        static auto* get (This& t, Index index)
        {
            if (isPositiveAndBelow (index, t.items.size()))
                return &t.items[(size_t) index];

            return static_cast<decltype (t.items.data())> (nullptr);
        }

        template <typename This>
        static auto* getSelected (This& t)
        {
            return get (t, t.selection);
        }

    public:
        auto* getSelected()       { return getSelected (*this); }
        auto* getSelected() const { return getSelected (*this); }

        auto* get (int index)       { return get (*this, index); }
        auto* get (int index) const { return get (*this, index); }
    };

    enum class ProfileMode
    {
        edit,
        use,
    };

    struct Profiles
    {
        ListWithSelection<ci::Profile> profiles;
        std::map<ci::ProfileAtAddress, ci::SupportedAndActive> channels;
        std::optional<ci::ChannelAddress> selectedChannel;
        ProfileMode profileMode{};

        std::optional<ci::ProfileAtAddress> getSelectedProfileAtAddress() const
        {
            if (selectedChannel.has_value())
                if (auto* item = profiles.getSelected())
                    return ci::ProfileAtAddress { *item, *selectedChannel };

            return {};
        }

        static constexpr auto marshallingVersion = 0;

        template <typename Archive, typename This>
        static auto serialise (Archive& archive, This& t)
        {
            return archive (named ("profiles", t.profiles),
                            named ("channels", t.channels),
                            named ("selectedChannel", t.selectedChannel),
                            named ("profileMode", t.profileMode));
        }

        auto tie() const { return std::tie (profiles, channels, selectedChannel, profileMode); }
        JUCE_TUPLE_RELATIONAL_OPS (Profiles)
    };

    enum class DataViewMode
    {
        ascii,
        hex,
    };

    #define JUCE_CAN_SET X(none) X(full) X(partial)

    enum class CanSet
    {
        #define X(name) name,
        JUCE_CAN_SET
        #undef X
    };

    struct CanSetUtils
    {
        CanSetUtils() = delete;

        static std::optional<CanSet> fromString (const char* str)
        {
            #define X(name) if (StringRef (str) == StringRef (#name)) return CanSet::name;
            JUCE_CAN_SET
            #undef X

            return std::nullopt;
        }

        static const char* toString (CanSet c)
        {
            switch (c)
            {
                #define X(name) case CanSet::name: return #name;
                JUCE_CAN_SET
                #undef X
            }

            return nullptr;
        }
    };

    #undef JUCE_CAN_SET

    struct PropertyValue
    {
        std::vector<std::byte> bytes; ///< decoded bytes
        String mediaType = "application/json";

        static constexpr auto marshallingVersion = 0;

        template <typename Archive, typename This>
        static auto serialise (Archive& archive, This& t)
        {
            return archive (named ("bytes", t.bytes),
                            named ("mediaType", t.mediaType));
        }

        auto tie() const { return std::tie (bytes, mediaType); }
        JUCE_TUPLE_RELATIONAL_OPS (PropertyValue)
    };

    struct ReadableDeviceInfo
    {
        String manufacturer, family, model, version;
    };

    struct Property
    {
        String name;
        var schema;
        std::set<ci::Encoding> encodings { ci::Encoding::ascii };
        std::vector<String> mediaTypes { "application/json" };
        var columns;
        CanSet canSet = CanSet::none;
        bool canGet = true, canSubscribe = false, requireResId = false, canPaginate = false;
        PropertyValue value;

        [[nodiscard]] std::optional<ci::Encoding> getBestCommonEncoding (
            ci::Encoding firstChoice = ci::Encoding::ascii) const
        {
            if (encodings.count (firstChoice) != 0)
                return firstChoice;

            if (! encodings.empty())
                return *encodings.begin();

            return {};
        }

        std::optional<ReadableDeviceInfo> getReadableDeviceInfo() const
        {
            if (name != "DeviceInfo")
                return {};

            const auto parsed = ci::Encodings::jsonFrom7BitText (value.bytes);
            auto* object = parsed.getDynamicObject();

            if (object == nullptr)
                return {};

            if (! object->hasProperty ("manufacturer")
                || ! object->hasProperty ("family")
                || ! object->hasProperty ("model")
                || ! object->hasProperty ("version"))
            {
                return {};
            }

            return ReadableDeviceInfo { object->getProperty ("manufacturer"),
                                        object->getProperty ("family"),
                                        object->getProperty ("model"),
                                        object->getProperty ("version") };
        }

        static Property fromResourceListEntry (var entry)
        {
            Model::Property p;
            p.name           = entry.getProperty ("resource", "");
            p.canGet         = entry.getProperty ("canGet", p.canGet);
            const auto set = entry.getProperty ("canSet", {}).toString();
            p.canSet         = Model::CanSetUtils::fromString (set.toRawUTF8()).value_or (p.canSet);
            p.canSubscribe   = entry.getProperty ("canSubscribe", p.canSubscribe);
            p.requireResId   = entry.getProperty ("requireResId", p.requireResId);
            p.mediaTypes     = fromVarArray (entry.getProperty ("mediaTypes", {}),
                                             [] (String str) { return str; },
                                             p.mediaTypes);
            p.schema         = entry.getProperty ("schema", p.schema);
            p.canPaginate    = entry.getProperty ("canPaginate", p.canPaginate);
            p.columns        = entry.getProperty ("columns", p.columns);

            const auto stringToEncoding = [] (String str)
            {
                return ci::EncodingUtils::toEncoding (str.toRawUTF8());
            };
            const auto parsedEncodings = fromVarArray (entry.getProperty ("encodings", {}),
                                                       stringToEncoding,
                                                       std::vector<std::optional<ci::Encoding>>{});
            std::set<ci::Encoding> encodingsSet;
            for (const auto& parsed : parsedEncodings)
                if (parsed.has_value())
                    encodingsSet.insert (*parsed);

            p.encodings      = encodingsSet.empty() ? p.encodings : encodingsSet;
            return p;
        }

        var getResourceListEntry() const
        {
            const Model::Property defaultInfo;
            auto obj = std::make_unique<DynamicObject>();

            obj->setProperty ("resource", name);

            if (canGet != defaultInfo.canGet)
                obj->setProperty ("canGet", canGet);

            if (canSet != defaultInfo.canSet)
                obj->setProperty ("canSet", Model::CanSetUtils::toString (canSet));

            if (canSubscribe != defaultInfo.canSubscribe)
                obj->setProperty ("canSubscribe", canSubscribe);

            if (requireResId != defaultInfo.requireResId)
                obj->setProperty ("requireResId", requireResId);

            if (mediaTypes != defaultInfo.mediaTypes)
            {
                obj->setProperty ("mediaTypes",
                                  toVarArray (mediaTypes, [] (auto str) { return str; }));
            }

            if (encodings != defaultInfo.encodings)
            {
                obj->setProperty ("encodings",
                                  toVarArray (encodings, ci::EncodingUtils::toString));
            }

            if (schema != defaultInfo.schema)
                obj->setProperty ("schema", schema);

            if (name.endsWith ("List"))
            {
                if (canPaginate != defaultInfo.canPaginate)
                    obj->setProperty ("canPaginate", canPaginate);

                if (columns != defaultInfo.columns)
                    obj->setProperty ("columns", columns);
            }

            return obj.release();
        }

        static constexpr auto marshallingVersion = 0;

        template <typename Archive, typename This>
        static auto serialise (Archive& archive, This& t)
        {
            return archive (named ("name", t.name),
                            named ("schema", t.schema),
                            named ("encodings", t.encodings),
                            named ("mediaTypes", t.mediaTypes),
                            named ("columns", t.columns),
                            named ("canSet", t.canSet),
                            named ("canGet", t.canGet),
                            named ("canSubscribe", t.canSubscribe),
                            named ("requireResId", t.requireResId),
                            named ("canPaginate", t.canPaginate),
                            named ("value", t.value));
        }

        auto tie() const
        {
            return std::tie (name,
                             schema,
                             encodings,
                             mediaTypes,
                             columns,
                             canSet,
                             canGet,
                             canSubscribe,
                             requireResId,
                             canPaginate,
                             value);
        }

        JUCE_TUPLE_RELATIONAL_OPS (Property)
    };

    struct Properties
    {
        ListWithSelection<Property> properties;
        DataViewMode mode{};

        std::optional<ReadableDeviceInfo> getReadableDeviceInfo() const
        {
            for (const auto& prop : properties.items)
                if (auto info = prop.getReadableDeviceInfo())
                    return info;

            return {};
        }

        static constexpr auto marshallingVersion = 0;

        template <typename Archive, typename This>
        static auto serialise (Archive& archive, This& t)
        {
            return archive (named ("properties", t.properties),
                            named ("mode", t.mode));
        }

        auto tie() const { return std::tie (properties, mode); }
        JUCE_TUPLE_RELATIONAL_OPS (Properties)
    };

    struct IOSelection
    {
        std::optional<MidiDeviceInfo> input, output;

        static constexpr auto marshallingVersion = 0;

        template <typename Archive, typename This>
        static auto serialise (Archive& archive, This& t)
        {
            return archive (named ("input", t.input),
                            named ("output", t.output));
        }

        auto tie() const { return std::tie (input, output); }
        JUCE_TUPLE_RELATIONAL_OPS (IOSelection)
    };

    struct DeviceInfo
    {
        ump::DeviceInfo deviceInfo;
        size_t maxSysExSize { 512 };
        uint8_t numPropertyExchangeTransactions { 127 };
        bool profilesSupported { false }, propertiesSupported { false };

        static constexpr auto marshallingVersion = 0;

        template <typename Archive, typename This>
        static auto serialise (Archive& archive, This& t)
        {
            return archive (named ("deviceInfo", t.deviceInfo),
                            named ("maxSysExSize", t.maxSysExSize),
                            named ("numPropertyExchangeTransactions",
                                   t.numPropertyExchangeTransactions),
                            named ("profilesSupported", t.profilesSupported),
                            named ("propertiesSupported", t.propertiesSupported));
        }

        auto tie() const
        {
            return std::tie (deviceInfo,
                             maxSysExSize,
                             numPropertyExchangeTransactions,
                             profilesSupported,
                             propertiesSupported);
        }
        JUCE_TUPLE_RELATIONAL_OPS (DeviceInfo)
    };

    enum class MessageKind
    {
        incoming,
        outgoing,
    };

    struct LogView
    {
        std::optional<MessageKind> filter;
        DataViewMode mode = DataViewMode::ascii;

        static constexpr auto marshallingVersion = 0;

        template <typename Archive, typename This>
        static auto serialise (Archive& archive, This& t)
        {
            return archive (named ("filter", t.filter),
                            named ("mode", t.mode));
        }

        auto tie() const { return std::tie (filter, mode); }
        JUCE_TUPLE_RELATIONAL_OPS (LogView)
    };

    /**
        The bits of the app state that we want to save and restore
    */
    struct Saved
    {
        DeviceInfo fundamentals;
        IOSelection ioSelection;
        Profiles profiles;
        Properties properties;
        LogView logView;

        static constexpr auto marshallingVersion = 0;

        template <typename Archive, typename This>
        static auto serialise (Archive& archive, This& t)
        {
            return archive (named ("fundamentals", t.fundamentals),
                            named ("ioSelection", t.ioSelection),
                            named ("profiles", t.profiles),
                            named ("properties", t.properties),
                            named ("logView", t.logView));
        }

        auto tie() const
        {
            return std::tie (fundamentals,
                             ioSelection,
                             profiles,
                             properties,
                             logView);
        }
        JUCE_TUPLE_RELATIONAL_OPS (Saved)
    };

    struct Device
    {
        ci::MUID muid = ci::MUID::makeUnchecked (0);
        DeviceInfo info;
        Profiles profiles;
        Properties properties;
        std::map<String, String> subscribeIdForResource;

        std::optional<String> getSubscriptionId (const String& resource) const
        {
            const auto iter = subscribeIdForResource.find (resource);
            return iter != subscribeIdForResource.end() ? std::optional (iter->second)
                                                        : std::nullopt;
        }

        template <typename Archive, typename This>
        static auto serialise (Archive& archive, This& t)
        {
            return archive (named ("muid", t.muid),
                            named ("info", t.info),
                            named ("profiles", t.profiles),
                            named ("properties", t.properties),
                            named ("subscribeIdForResource", t.subscribeIdForResource));
        }

        auto tie() const
        {
            return std::tie (muid, info, profiles, properties, subscribeIdForResource);
        }
        JUCE_TUPLE_RELATIONAL_OPS (Device)
    };

    struct LogEntry
    {
        std::vector<std::byte> message;
        uint8_t group{};
        Time time;
        MessageKind kind;

        template <typename Archive, typename This>
        static auto serialise (Archive& archive, This& t)
        {
            return archive (named ("message", t.message),
                            named ("group", t.group),
                            named ("time", t.time),
                            named ("kind", t.kind));
        }

        auto tie() const { return std::tie (message, group, time, kind); }
        JUCE_TUPLE_RELATIONAL_OPS (LogEntry)
    };

    /**
        App state that needs to be displayed somehow, but which shouldn't be saved or restored.
    */
    struct Transient
    {
        // property -> device -> subId
        std::map<String, std::map<ci::MUID, std::set<String>>> subscribers;
        ListWithSelection<Device> devices;
        std::deque<LogEntry> logEntries;

        template <typename Archive, typename This>
        static auto serialise (Archive& archive, This& t)
        {
            return archive (named ("subscribers", t.subscribers),
                            named ("devices", t.devices),
                            named ("logEntries", t.logEntries));
        }

        auto tie() const { return std::tie (subscribers, devices, logEntries); }
        JUCE_TUPLE_RELATIONAL_OPS (Transient)
    };

    struct App
    {
        Saved saved;
        Transient transient;

        /** Removes properties from Transient::subscribers that are no longer present in
            Properties::properties.
        */
        void syncSubscribers()
        {
            std::set<String> currentProperties;
            for (auto& v : saved.properties.properties.items)
                currentProperties.insert (v.name);

            std::set<String> removedProperties;
            for (const auto& oldSubscriber : transient.subscribers)
                if (currentProperties.find (oldSubscriber.first) == currentProperties.end())
                    removedProperties.insert (oldSubscriber.first);

            for (const auto& removed : removedProperties)
                transient.subscribers.erase (removed);
        }

        template <typename Archive, typename This>
        static auto serialise (Archive& archive, This& t)
        {
            return archive (named ("saved", t.saved),
                            named ("transient", t.transient));
        }

        auto tie() const { return std::tie (saved, transient); }
        JUCE_TUPLE_RELATIONAL_OPS (App)
    };

    #undef JUCE_TUPLE_RELATIONAL_OPS
    #undef JUCE_TUPLE_RELATIONAL_OP
};

template <>
struct juce::SerialisationTraits<MidiDeviceInfo>
{
    static constexpr auto marshallingVersion = std::nullopt;

    template <typename Archive, typename This>
    static auto serialise (Archive& archive, This& x)
    {
        archive (named ("name", x.name), named ("identifier", x.identifier));
    }
};

template <>
struct juce::SerialisationTraits<ci::ChannelAddress>
{
    static constexpr auto marshallingVersion = std::nullopt;

    template <typename Archive>
    static auto load (Archive& archive, ci::ChannelAddress& x)
    {
        auto group = x.getGroup();
        auto channel = x.getChannel();
        archive (named ("group", group),
                 named ("channel", channel));
        x = ci::ChannelAddress{}.withGroup (group).withChannel (channel);
    }

    template <typename Archive>
    static auto save (Archive& archive, const ci::ChannelAddress& x)
    {
        archive (named ("group", x.getGroup()),
                 named ("channel", x.getChannel()));
    }
};

template <>
struct juce::SerialisationTraits<ci::ProfileAtAddress>
{
    static constexpr auto marshallingVersion = std::nullopt;

    template <typename Archive, typename This>
    static auto serialise (Archive& archive, This& x)
    {
        archive (named ("profile", x.profile), named ("address", x.address));
    }
};

template <>
struct juce::SerialisationTraits<ci::SupportedAndActive>
{
    static constexpr auto marshallingVersion = std::nullopt;

    template <typename Archive, typename This>
    static auto serialise (Archive& archive, This& x)
    {
        archive (named ("supported", x.supported), named ("active", x.active));
    }
};

class MonospaceEditor : public TextEditor
{
public:
    MonospaceEditor()
    {
        setFont (Font { Font::getDefaultMonospacedFontName(), 12, 0 });
    }

    void onCommit (std::function<void()> fn)
    {
        onEscapeKey = onReturnKey = onFocusLost = std::move (fn);
    }

    void set (const String& str)
    {
        setText (str, false);
    }
};

class MonospaceLabel : public Label
{
public:
    MonospaceLabel()
    {
        setFont (Font { Font::getDefaultMonospacedFontName(), 12, 0 });
        setMinimumHorizontalScale (1.0f);
        setInterceptsMouseClicks (false, false);
    }

    void onCommit (std::function<void()>) {}

    void set (const String& str)
    {
        setText (str, dontSendNotification);
    }
};

enum class Editable
{
    no,
    yes,
};

template <Editable>
class TextField;

template <>
class TextField<Editable::yes> : public MonospaceEditor { using MonospaceEditor::MonospaceEditor; };

template <>
class TextField<Editable::no> : public MonospaceLabel { using MonospaceLabel::MonospaceLabel; };

struct Utils
{
    Utils() = delete;

    static constexpr auto padding = 10;
    static constexpr auto standardControlHeight = 30;

    template <typename T, typename... Ts>
    static auto makeVector (T&& t, Ts&&... ts)
    {
        std::vector<T> result;
        result.reserve (1 + sizeof... (ts));
        result.emplace_back (std::forward<T> (t));
        (result.emplace_back (std::forward<Ts> (ts)), ...);
        return result;
    }

    static std::unique_ptr<Label> makeListRowLabel (StringRef text, bool selected)
    {
        auto label = std::make_unique<TextField<Editable::no>>();
        label->set (text);

        if (selected)
        {
            const auto fg = label->findColour (TextEditor::ColourIds::highlightedTextColourId);
            label->setColour (Label::ColourIds::textColourId, fg);

            const auto bg = label->findColour (TextEditor::ColourIds::highlightColourId);
            label->setColour (Label::ColourIds::backgroundColourId, bg);
        }

        return label;
    }

    static SparseSet<int> setWithSingleIndex (int i)
    {
        SparseSet<int> result;
        result.addRange ({ i, i + 1 });
        return result;
    }

    template <typename... Items>
    static void doTwoColumnLayout (Rectangle<int> bounds, Items&&... items)
    {
        Grid grid;
        grid.autoFlow = Grid::AutoFlow::row;
        grid.autoColumns = grid.autoRows = Grid::TrackInfo { Grid::Fr { 1 } };
        grid.columnGap = grid.rowGap = Grid::Px { Utils::padding };
        grid.templateColumns = { Grid::TrackInfo { Grid::Px { 400 } },
                                 Grid::TrackInfo { Grid::Fr { 1 } } };
        grid.items = { GridItem { items }... };
        grid.performLayout (bounds);
    }

    template <typename... Items>
    static void doColumnLayout (Rectangle<int> bounds, Items&&... items)
    {
        Grid grid;
        grid.autoFlow = Grid::AutoFlow::column;
        grid.autoColumns = grid.autoRows = Grid::TrackInfo { Grid::Fr { 1 } };
        grid.columnGap = grid.rowGap = Grid::Px { Utils::padding };
        grid.items = { GridItem { items }... };
        grid.performLayout (bounds);
    }

    template <size_t N>
    static void stringToByteArray (const String& str, std::array<std::byte, N>& a)
    {
        const auto asInt = str.removeCharacters (" ").getHexValue64();

        for (size_t i = 0; i < N; ++i)
            a[i] = std::byte ((asInt >> (8 * (N - 1 - i))) & 0x7f);
    }

    template <typename Callback>
    static void forAllChannelAddresses (Callback&& callback)
    {
        for (uint8_t group = 0; group < 16; ++group)
        {
            for (uint8_t channel = 0; channel < 17; ++channel)
            {
                const auto channelKind = channel < 16 ? ci::ChannelInGroup (channel)
                                                      : ci::ChannelInGroup::wholeGroup;
                callback (ci::ChannelAddress{}.withGroup (group).withChannel (channelKind));
            }
        }

        callback (ci::ChannelAddress{}.withChannel (ci::ChannelInGroup::wholeBlock));
    }

    template <typename Request>
    static std::tuple<Model::Property, String> attemptSetPartial (Model::Property prop,
                                                                  const Request& request)
    {
        if (request.header.mediaType != "application/json")
        {
            return std::tuple (std::move (prop),
                               "Partial updates are only supported when the request body is JSON");
        }

        if (prop.mediaTypes != std::vector<String> { "application/json" })
        {
            return std::tuple (std::move (prop),
                               "Partial updates are only supported when the target property is "
                               "JSON");
        }

        const auto pointers = JSON::parse (String::fromUTF8 (
            reinterpret_cast<const char*> (request.body.data()),
            (int) request.body.size()));
        const auto* obj = pointers.getDynamicObject();

        if (obj == nullptr)
        {
            return std::tuple (std::move (prop),
                               "Property data for a partial update must be a json object");
        }

        for (const auto& pair : obj->getProperties())
        {
            if (pair.value.isObject() || pair.value.isArray())
            {
                return std::tuple (std::move (prop),
                                   "Property data for a partial update must not contain nested "
                                   "arrays or objects");
            }
        }

        const auto updatedPropertyValue = [&]() -> std::optional<var>
        {
            std::optional result { JSON::parse (String::fromUTF8 (
                reinterpret_cast<const char*> (prop.value.bytes.data()),
                (int) prop.value.bytes.size())) };

            for (const auto& [key, value] : obj->getProperties())
            {
                if (! result.has_value())
                    return {};

                result = JSONUtils::setPointer (*result, key.toString(), value);
            }

            return result;
        }();

        if (! updatedPropertyValue)
        {
            return std::tuple (std::move (prop),
                               "Partial updates do not apply, perhaps the JSON pointer does not "
                               "refer to an extant node");
        }

        prop.value.bytes = ci::Encodings::jsonTo7BitText (*updatedPropertyValue);
        return std::tuple (std::move (prop), String());
    }

    template <typename... Args>
    static auto forwardFunction (std::function<void (Args...)>& fn)
    {
        return [&fn] (auto&&... args)
        {
            NullCheckedInvocation::invoke (fn, std::forward<decltype (args)> (args)...);
        };
    }
};

template <typename Kind>
class IOPickerModel : public ListBoxModel
{
public:
    explicit IOPickerModel (State<std::optional<MidiDeviceInfo>> s)
        : state (s) {}

    int getNumRows() override
    {
        return Kind::getAvailableDevices().size();
    }

    void paintListBoxItem (int, Graphics&, int, int, bool) override {}

    Component* refreshComponentForRow (int rowNumber,
                                       bool rowIsSelected,
                                       Component* existingComponentToUpdate) override
    {
        const auto toDelete = rawToUniquePtr (existingComponentToUpdate);
        const auto info = Kind::getAvailableDevices()[rowNumber];
        return Utils::makeListRowLabel (info.name, rowIsSelected).release();
    }

    void selectedRowsChanged (int newSelection) override
    {
        state = getInfoForRow (newSelection);
    }

    std::optional<MidiDeviceInfo> getInfoForRow (int row) const
    {
        return isPositiveAndBelow (row, Kind::getAvailableDevices().size())
               ? std::optional (Kind::getAvailableDevices()[row])
               : std::nullopt;
    }

private:
    State<std::optional<MidiDeviceInfo>> state;
};

template <typename Kind>
class IOPickerList : public Component
{
public:
    explicit IOPickerList (State<std::optional<MidiDeviceInfo>> s)
        : state (s),
          model (s)
    {
        addAndMakeVisible (label);
        label.setJustificationType (Justification::centred);

        addAndMakeVisible (list);
        list.setMultipleSelectionEnabled (false);
        list.setClickingTogglesRowSelection (true);
        list.updateContent();
    }

    void resized() override
    {
        FlexBox fb;
        fb.flexDirection = FlexBox::Direction::column;
        fb.items = { FlexItem { label }.withHeight (Utils::standardControlHeight),
                     FlexItem{}.withHeight (Utils::padding),
                     FlexItem { list }.withFlex (1) };
        fb.performLayout (getLocalBounds());
    }

private:
    void setSelected (std::optional<MidiDeviceInfo> selected)
    {
        list.setSelectedRows ({}, dontSendNotification);
        list.updateContent();

        for (auto i = 0; i < model.getNumRows(); ++i)
            if (model.getInfoForRow (i) == selected)
                list.setSelectedRows (Utils::setWithSingleIndex (i), dontSendNotification);
    }

    static String getLabel (std::in_place_type_t<MidiInput>)  { return "Input"; }
    static String getLabel (std::in_place_type_t<MidiOutput>) { return "Output"; }

    State<std::optional<MidiDeviceInfo>> state;

    Label label { "", getLabel (std::in_place_type<Kind>) };
    IOPickerModel<Kind> model;
    ListBox list { "Devices", &model };

    MidiDeviceListConnection connection = MidiDeviceListConnection::make ([this]
    {
        list.updateContent();
        setSelected (*state);
    });

    ErasedScopeGuard listener = state.observe ([this] (auto)
    {
        setSelected (*state);
    });
};

class IOPickerLists : public Component
{
public:
    explicit IOPickerLists (State<Model::IOSelection> selection)
        : inputs (selection[&Model::IOSelection::input]),
          outputs (selection[&Model::IOSelection::output])
    {
        addAndMakeVisible (inputs);
        addAndMakeVisible (outputs);
    }

    void resized() override
    {
        Utils::doColumnLayout (getLocalBounds().reduced (Utils::padding), inputs, outputs);
    }

private:
    IOPickerList<MidiInput> inputs;
    IOPickerList<MidiOutput> outputs;
};

class SectionHeader : public Component
{
public:
    explicit SectionHeader (String text)
        : label ("", text)
    {
        addAndMakeVisible (label);
        setInterceptsMouseClicks (false, false);
        setSize (1, Utils::standardControlHeight);
    }

    void resized() override
    {
        label.setBounds (getLocalBounds());
    }

    void paint (Graphics& g) override
    {
        g.setColour (findColour (TextEditor::ColourIds::highlightColourId));
        g.fillRoundedRectangle (getLocalBounds().reduced (2).toFloat(), 2.0f);
    }

    void set (String str)
    {
        label.setText (str, dontSendNotification);
    }

private:
    Label label;
};

class ToggleSectionHeader : public Component
{
public:
    ToggleSectionHeader (String text, State<bool> s)
        : state (s),
          button (text)
    {
        addAndMakeVisible (button);
        setSize (1, Utils::standardControlHeight);
        button.onClick = [this] { state = button.getToggleState(); };
    }

    void resized() override
    {
        button.setBounds (getLocalBounds());
    }

    void paint (Graphics& g) override
    {
        g.setColour (findColour (TextEditor::ColourIds::highlightColourId));
        g.fillRoundedRectangle (getLocalBounds().reduced (2).toFloat(), 2.0f);
    }

private:
    State<bool> state;
    ToggleButton button;
    ErasedScopeGuard listener = state.observe ([this] (auto)
    {
        button.setToggleState (*state, dontSendNotification);
    });
};

class PropertySectionHeader : public Component
{
public:
    explicit PropertySectionHeader (State<Model::DeviceInfo> s)
        : state (s)
    {
        addAndMakeVisible (toggle);

        addAndMakeVisible (numConcurrentLabel);
        addAndMakeVisible (numConcurrent);
        numConcurrent.setJustification (Justification::centredLeft);

        setSize (1, toggle.getHeight());

        numConcurrent.onCommit ([this]
        {
            const auto clamped = (uint8_t) jlimit (1, 127, numConcurrent.getText().getIntValue());
            state[&Model::DeviceInfo::numPropertyExchangeTransactions] = clamped;
            refresh();
        });
    }

    void resized() override
    {
        toggle.setBounds (getLocalBounds());

        Utils::doTwoColumnLayout (getLocalBounds(), nullptr, numConcurrent);

        Grid grid;
        grid.autoFlow = Grid::AutoFlow::row;
        grid.autoColumns = grid.autoRows = Grid::TrackInfo { Grid::Fr { 1 } };
        grid.columnGap = grid.rowGap = Grid::Px { Utils::padding };
        grid.templateColumns = { Grid::TrackInfo { Grid::Fr { 1 } },
                                 Grid::TrackInfo { Grid::Fr { 1 } } };
        grid.items = { GridItem { numConcurrentLabel }, GridItem { numConcurrent } };
        grid.performLayout (numConcurrent.getBounds().reduced (Utils::padding, 4));
    }

private:
    void refresh()
    {
        numConcurrent.set (String (state->numPropertyExchangeTransactions));
    }

    State<Model::DeviceInfo> state;

    ToggleSectionHeader toggle { "Properties", state[&Model::DeviceInfo::propertiesSupported] };
    Label numConcurrentLabel { "", "Max Concurrent Streams" };
    TextField<Editable::yes> numConcurrent;

    std::vector<ErasedScopeGuard> listeners = Utils::makeVector
    (
        state[&Model::DeviceInfo::propertiesSupported].observe ([this] (auto)
        {
            const auto enabled = state->propertiesSupported;
            numConcurrentLabel.setEnabled (enabled);
            numConcurrent.setEnabled (enabled);
        }),
        state[&Model::DeviceInfo::numPropertyExchangeTransactions].observe ([this] (auto)
        {
            refresh();
        })
    );
};

class HeterogeneousListContents : public Component
{
public:
    void addItem (Component& item)
    {
        contents.emplace_back (&item);
        addAndMakeVisible (item);
    }

    void clear()
    {
        for (auto* c : contents)
            removeChildComponent (c);

        contents.clear();
    }

    void resized() override
    {
        auto y = 0;

        for (auto* item : contents)
        {
            item->setBounds ({ 0, y, getWidth(), item->getHeight() });
            y += item->getHeight();
        }
    }

    int getIdealHeight() const
    {
        return std::accumulate (contents.begin(), contents.end(), 0, [] (auto acc, auto& item)
        {
            return acc + item->getHeight();
        });
    }

private:
    std::vector<Component*> contents;
};

class HeterogeneousListView : public Component
{
public:
    HeterogeneousListView()
    {
        addAndMakeVisible (viewport);
        viewport.setViewedComponent (&contents, false);
        viewport.setScrollBarsShown (true, false);
    }

    void resized() override
    {
        viewport.setBounds (getLocalBounds());
        contents.setBounds ({ getWidth(), contents.getIdealHeight() });
    }

    void addItem (Component& item)
    {
        contents.addItem (item);
    }

    void clear()
    {
        contents.clear();
    }

private:
    HeterogeneousListContents contents;
    Viewport viewport;
};

class ChannelStateButtonGrid : public Component
{
public:
    static constexpr auto numChannelColumns = 17;
    static constexpr auto numGroups = 16;

    explicit ChannelStateButtonGrid (State<Model::Profiles> s)
        : state (s)
    {
        auto index = 0;

        for (auto& b : buttons)
        {
            const auto group = index / numChannelColumns;
            const auto channel = index % numChannelColumns;

            addAndMakeVisible (b);
            b.setButtonText ("Group " + String (group) + " Channel " + String (channel));
            b.onClick = [this, group, channel]
            {
                const auto channelKind = channel < 16 ? ci::ChannelInGroup (channel)
                                                      : ci::ChannelInGroup::wholeGroup;
                const auto address = ci::ChannelAddress{}.withGroup (group)
                                                         .withChannel (channelKind);

                auto selected = state[&Model::Profiles::selectedChannel];

                if (*selected != address)
                    selected = address;
                else
                    selected = std::nullopt;
            };

            ++index;
        }

        addAndMakeVisible (block);
        block.setButtonText ("Function Block");
        block.setDimensions ({ numChannelColumns, 1.0f });
        block.onClick = [this]
        {
            const auto address = ci::ChannelAddress{}.withChannel (ci::ChannelInGroup::wholeBlock);

            auto selected = state[&Model::Profiles::selectedChannel];

            if (*selected != address)
                selected = address;
            else
                selected = std::nullopt;
        };

        updateButtonVisibility();
    }

    void resized() override
    {
        const auto availableBounds = getLocalBounds().reduced (Utils::padding)
                                                     .withTrimmedLeft (labelWidth)
                                                     .withTrimmedTop (labelHeight);
        const auto maxButtonSize = std::min (availableBounds.getWidth() / numChannelColumns,
                                             availableBounds.getHeight() / (numGroups + 1));

        Grid grid;
        grid.autoFlow = Grid::AutoFlow::row;
        grid.rowGap = grid.columnGap = Grid::Px { 1 };
        grid.autoColumns = grid.autoRows = Grid::TrackInfo { Grid::Fr { 1 } };
        grid.templateColumns = []
        {
            Array<Grid::TrackInfo> array;
            for (auto i = 0; i < 17; ++i)
                array.add (Grid::TrackInfo { Grid::Fr { 1 } });
            return array;
        }();

        for (auto& b : buttons)
            grid.items.add (GridItem { b });

        grid.items.add (GridItem { block }.withArea ({}, GridItem::Span { numChannelColumns }));

        const Rectangle<int> gridBounds { maxButtonSize * numChannelColumns,
                                          maxButtonSize * (numGroups + 1) };
        const RectanglePlacement placement(RectanglePlacement::yTop | RectanglePlacement::xMid);
        grid.performLayout (placement.appliedTo (gridBounds, availableBounds));

        block.setDimensions (block.getBounds().toFloat());
    }

    void paint (Graphics& g) override
    {
        g.setColour (findColour (Label::ColourIds::textColourId));

        const auto groupWidth = 100;
        GlyphArrangement groupArrangement;
        groupArrangement.addJustifiedText ({},
                                           "Group",
                                           0,
                                           0,
                                           groupWidth,
                                           Justification::horizontallyCentred);

        Path groupPath;
        groupArrangement.createPath (groupPath);

        g.fillPath (groupPath,
                    AffineTransform().rotated (-MathConstants<float>::halfPi, groupWidth / 2, 0)
                                     .translated (-groupWidth / 2, 0)
                                     .translated (Point { buttons[0].getX() - 40,
                                                          buttons[8 * 17].getY() }.toFloat()));

        for (auto i = 0; i < 16; ++i)
        {
            const auto bounds = buttons[(size_t) i * 17].getBounds();
            g.drawSingleLineText (String (i + 1),
                                  bounds.getX() - Utils::padding,
                                  bounds.getBottom(),
                                  Justification::right);
        }

        g.drawSingleLineText ("Block",
                              block.getX() - Utils::padding,
                              block.getBottom(),
                              Justification::right);

        g.drawSingleLineText ("Channel",
                              buttons[8].getBounds().getCentreX(),
                              buttons[0].getY() - 40,
                              Justification::horizontallyCentred);

        for (auto i = 0; i < 17; ++i)
        {
            const auto bounds = buttons[(size_t) i].getBounds();

            GlyphArrangement channelArrangement;
            channelArrangement.addJustifiedText ({},
                                                 i < 16 ? String (i + 1) : "All",
                                                 0,
                                                 0,
                                                 groupWidth,
                                                 Justification::left);

            Path channelPath;
            channelArrangement.createPath (channelPath);

            const auto transform = AffineTransform()
                                       .rotated (-MathConstants<float>::halfPi * 0.8, 0, 0)
                                       .translated (Point { bounds.getRight(),
                                                            bounds.getY() - Utils::padding }
                                                        .toFloat());
            g.fillPath (channelPath, transform);
        }

        if (auto selected = state->selectedChannel)
        {
            auto buttonBounds = getButtonForAddress (*selected).getBounds();
            g.setColour (Colours::cyan);
            g.drawRect (buttonBounds, 2);
        }
    }

private:
    class Button : public ShapeButton
    {
    public:
        Button()
            : ShapeButton ("",
                           Colours::black.withAlpha (0.4f),
                           Colours::black.withAlpha (0.4f).brighter(),
                           Colours::black.withAlpha (0.4f).darker())
        {
            setDimensions ({ 1.0f, 1.0f });

            const auto onColour = findColour (TextEditor::ColourIds::highlightColourId);
            setOnColours (onColour, onColour.brighter(), onColour.darker());
            shouldUseOnColours (true);
            setClickingTogglesState (true);
        }

        void setDimensions (Rectangle<float> dimensions)
        {
            Path s;
            s.addRectangle (dimensions);
            setShape (s, false, true, false);
        }
    };

    Button& getButtonForAddress (ci::ChannelAddress address)
    {
        if (address.isBlock())
            return block;

        const auto channelIndex = address.getChannel() != ci::ChannelInGroup::wholeGroup
                                ? (size_t) address.getChannel()
                                : 16;
        const auto buttonIndex = (size_t) address.getGroup() * numChannelColumns + channelIndex;
        return buttons[buttonIndex];
    }

    void updateButtonVisibility()
    {
        if (auto* profile = state->profiles.getSelected())
        {
            Utils::forAllChannelAddresses ([&] (auto address)
            {
                auto& button = getButtonForAddress (address);

                const auto iter = state->channels.find ({ *profile, address });

                const auto visible = state->profileMode == Model::ProfileMode::edit
                                     || iter != state->channels.end();
                button.setVisible (visible);

                const auto lit = [&]
                {
                    if (iter == state->channels.end())
                        return false;

                    if (state->profileMode == Model::ProfileMode::edit)
                        return iter->second.supported != 0;

                    return iter->second.active != 0;
                }();

                button.setToggleState (lit, dontSendNotification);
            });
        }
    }

    static constexpr auto labelWidth = 50;
    static constexpr auto labelHeight = 50;
    State<Model::Profiles> state;
    std::array<Button, numGroups * numChannelColumns> buttons;
    Button block;

    ErasedScopeGuard listener = state.observe ([this] (const auto& old)
    {
        if (old.selectedChannel != state->selectedChannel)
            repaint();

        updateButtonVisibility();
    });
};

class ProfileListModel : public ListBoxModel
{
public:
    explicit ProfileListModel (State<Model::Profiles> s)
        : state (s) {}

    int getNumRows() override
    {
        return (int) state->profiles.items.size();
    }

    void paintListBoxItem (int, Graphics&, int, int, bool) override {}

    Component* refreshComponentForRow (int rowNumber,
                                       bool rowIsSelected,
                                       Component* existingComponentToUpdate) override
    {
        const auto toDelete = rawToUniquePtr (existingComponentToUpdate);

        const auto currentState = *state;

        if (auto* item = currentState.profiles.get (rowNumber))
        {
            const auto name = String::toHexString (item->data(), (int) item->size());
            return Utils::makeListRowLabel (name, rowIsSelected).release();
        }

        return nullptr;
    }

    void selectedRowsChanged (int newSelection) override
    {
        state[&Model::Profiles::profiles]
             [&Model::ListWithSelection<ci::Profile>::selection] = newSelection;
        state[&Model::Profiles::selectedChannel] = std::nullopt;
    }

private:
    State<Model::Profiles> state;
};

template <Editable editable>
class ProfileList : public Component
{
public:
    explicit ProfileList (State<Model::Profiles> s)
        : state (s)
    {
        addAndMakeVisible (list);

        if constexpr (editable == Editable::yes)
        {
            addAndMakeVisible (add);
            add.onClick = [this]
            {
                auto updated = *state;
                updated.profiles.items.emplace_back();
                updated.profiles.selection = (int) updated.profiles.items.size() - 1;
                updated.selectedChannel = std::nullopt;
                state = std::move (updated);
            };

            addAndMakeVisible (remove);
            remove.onClick = [this]
            {
                auto updated = *state;

                if (auto* item = updated.profiles.getSelected())
                {
                    const auto toErase = *item;

                    Utils::forAllChannelAddresses ([&] (auto address)
                    {
                        updated.channels.erase (ci::ProfileAtAddress { toErase, address });
                    });

                    const auto erase = updated.profiles.items.begin() + updated.profiles.selection;
                    updated.profiles.items.erase (erase);
                    updated.profiles.selection = -1;

                    state = std::move (updated);
                }
            };
        }
    }

    void resized() override
    {
        if constexpr (editable == Editable::yes)
        {
            FlexBox fb;
            fb.flexDirection = FlexBox::Direction::column;
            fb.items = { FlexItem { list }.withFlex (1),
                         FlexItem{}.withHeight (Utils::padding),
                         FlexItem{}.withHeight (Utils::standardControlHeight) };
            fb.performLayout (getLocalBounds());
            Utils::doColumnLayout (fb.items.getLast().currentBounds.getSmallestIntegerContainer(),
                                   add,
                                   remove);
        }
        else
        {
            list.setBounds (getLocalBounds());
        }
    }

private:
    State<Model::Profiles> state;
    ProfileListModel model { state };
    ListBox list { "Profiles", &model };
    TextButton add { "+" }, remove { "-" };

    ErasedScopeGuard listener = state.observe ([this] (auto)
    {
        list.setSelectedRows ({}, dontSendNotification);
        list.updateContent();

        const auto& profs = state->profiles;
        auto* item = profs.getSelected();

        remove.setEnabled (item != nullptr);

        if (item != nullptr)
        {
            list.setSelectedRows (Utils::setWithSingleIndex (profs.selection),
                                  dontSendNotification);
        }
    });
};

class ProfileNamePane : public Component
{
public:
    explicit ProfileNamePane (State<Model::Profiles> s)
        : state (s)
    {
        addAndMakeVisible (label);
        addAndMakeVisible (field);
        field.onCommit ([this]
        {
            auto updated = *state;

            if (auto* item = updated.profiles.getSelected())
            {
                Utils::stringToByteArray (field.getText(), *item);
                state = std::move (updated);
            }

            refresh();
        });
    }

    void resized() override
    {
        Utils::doColumnLayout (getLocalBounds(), label, field);
    }

private:
    void refresh()
    {
        if (auto* item = state->profiles.getSelected())
            field.set (String::toHexString (item->data(), (int) item->size()));
    }

    State<Model::Profiles> state;
    Label label { "", "Profile Identifier" };
    TextField<Editable::yes> field;

    ErasedScopeGuard listener = state.observe ([this] (auto) { refresh(); });
};

class NumChannelsPane : public Component
{
public:
    explicit NumChannelsPane (State<Model::Profiles> s)
        : state (s)
    {
        addAndMakeVisible (label);
        addAndMakeVisible (field);
        field.onCommit ([this]
        {
            NullCheckedInvocation::invoke (onChannelsRequested,
                                           (uint16_t) field.getText().getIntValue());
            refresh();
        });
    }

    void resized() override
    {
        Utils::doColumnLayout (getLocalBounds(), label, field);
    }

    std::function<void (uint16_t)> onChannelsRequested;

private:
    void refresh()
    {
        const auto attributes = [&]() -> ci::SupportedAndActive
        {
            const auto selected = state->getSelectedProfileAtAddress();

            if (! selected.has_value())
                return {};

            const auto iter = state->channels.find (*selected);

            if (iter == state->channels.end())
                return {};

            return iter->second;
        }();

        const auto numToShow = state->profileMode == Model::ProfileMode::edit
                             ? attributes.supported
                             : attributes.active;
        field.set (String (numToShow));
    }

    State<Model::Profiles> state;
    Label label;
    TextField<Editable::yes> field;

    ErasedScopeGuard listener = state.observe ([this] (auto)
    {
        const auto text = state->profileMode == Model::ProfileMode::edit
                        ? "Num Supported Channels"
                        : "Num Active Channels";
        label.setText (text, dontSendNotification);
        refresh();
    });
};

template <Editable editable>
class ProfileDetailsPane : public Component
{
public:
    explicit ProfileDetailsPane (State<Model::Profiles> s)
        : state (s)
    {
        if constexpr (editable == Editable::yes)
        {
            addAndMakeVisible (name);
            addAndMakeVisible (edit);
            edit.setClickingTogglesState (false);
            edit.onClick = [this]
            {
                state[&Model::Profiles::profileMode] = Model::ProfileMode::edit;
            };

            addAndMakeVisible (use);
            use.setClickingTogglesState (false);
            use.onClick = [this]
            {
                state[&Model::Profiles::profileMode] = Model::ProfileMode::use;
            };

            channels.onChannelsRequested = [this] (auto numChannels)
            {
                auto updated = *state;
                const auto selected = updated.getSelectedProfileAtAddress();

                if (! selected.has_value())
                    return;

                auto& value = updated.channels[*selected];

                if (updated.profileMode == Model::ProfileMode::edit)
                    value.supported = jmax ((uint16_t) 0, numChannels);
                else
                    value.active = jlimit ((uint16_t) 0, value.supported, numChannels);

                state = std::move (updated);
            };

            invert.onClick = [this]
            {
                if (const auto selected = state->getSelectedProfileAtAddress())
                {
                    auto updated = *state;
                    auto& value = updated.channels[*selected];

                    auto& toInvert = updated.profileMode == Model::ProfileMode::edit
                                     ? value.supported
                                     : value.active;

                    toInvert = toInvert == 0 ? 1 : 0;
                    state = std::move (updated);
                }
            };
        }
        else
        {
            channels.onChannelsRequested = Utils::forwardFunction (onChannelsRequested);

            invert.onClick = [this]
            {
                if (const auto selected = state->getSelectedProfileAtAddress())
                {
                    const auto iter = state->channels.find (*selected);

                    if (iter == state->channels.end())
                        return;

                    auto& toInvert = state->profileMode == Model::ProfileMode::edit
                                     ? iter->second.supported
                                     : iter->second.active;

                    NullCheckedInvocation::invoke (onChannelsRequested,
                                                   (uint16) (toInvert == 0 ? 1 : 0));
                }
            };
        }

        addAndMakeVisible (grid);
        addAndMakeVisible (channels);
        addAndMakeVisible (invert);
    }

    void resized() override
    {
        Grid g;
        g.autoFlow = Grid::AutoFlow::row;
        g.autoColumns = g.autoRows = Grid::TrackInfo { Grid::Fr { 1 } };
        g.columnGap = g.rowGap = Grid::Px { Utils::padding };
        g.templateColumns = { Grid::TrackInfo { Grid::Fr { 1 } },
                              Grid::TrackInfo { Grid::Fr { 1 } } };

        if constexpr (editable == Editable::yes)
        {
            g.templateRows = { Grid::TrackInfo { Grid::Px { Utils::standardControlHeight } },
                               Grid::TrackInfo { Grid::Px { Utils::standardControlHeight } },
                               Grid::TrackInfo { Grid::Fr { 1 } },
                               Grid::TrackInfo { Grid::Px { Utils::standardControlHeight } },
                               Grid::TrackInfo { Grid::Px { Utils::standardControlHeight } } };
            g.items = { GridItem { name }.withArea ({}, GridItem::Span { 2 }),
                        GridItem { edit }, GridItem { use },
                        GridItem { grid }.withArea ({}, GridItem::Span { 2 }),
                        GridItem { invert }.withArea ({}, GridItem::Span { 2 }),
                        GridItem { channels }.withArea ({}, GridItem::Span { 2 }) };
        }
        else
        {
            g.templateRows = { Grid::TrackInfo { Grid::Fr { 1 } },
                               Grid::TrackInfo { Grid::Px { Utils::standardControlHeight } },
                               Grid::TrackInfo { Grid::Px { Utils::standardControlHeight } } };
            g.items = { GridItem { grid }.withArea ({}, GridItem::Span { 2 }),
                        GridItem { invert }.withArea ({}, GridItem::Span { 2 }),
                        GridItem { channels }.withArea ({}, GridItem::Span { 2 }) };
        }

        g.performLayout (getLocalBounds());
    }

    std::function<void (uint16_t)> onChannelsRequested;

private:
    State<Model::Profiles> state;
    ProfileNamePane name { state };
    ToggleButton edit { "Show Supported Channels" }, use { "Show Active Channels" };
    TextButton invert { "Toggle Member Channels" };
    ChannelStateButtonGrid grid { state };
    NumChannelsPane channels { state };

    std::vector<ErasedScopeGuard> listeners = Utils::makeVector
    (
        state[&Model::Profiles::profileMode].observe ([this] (auto)
        {
            edit.setToggleState (state->profileMode == Model::ProfileMode::edit,
                                 dontSendNotification);
            use.setToggleState (state->profileMode == Model::ProfileMode::use,
                                dontSendNotification);
        }),
        state.observe ([this] (auto)
        {
            invert.setVisible (state->getSelectedProfileAtAddress().has_value());
            channels.setVisible (state->getSelectedProfileAtAddress().has_value());
        })
    );
};

template <Editable editable>
class ProfileConfigPanel : public Component
{
public:
    explicit ProfileConfigPanel (State<Model::Profiles> s)
        : state (s)
    {
        setSize (1, 500);

        addAndMakeVisible (list);
    }

    void resized() override
    {
        auto* d = details.has_value() ? &*details : nullptr;
        Utils::doTwoColumnLayout (getLocalBounds().reduced (Utils::padding), list, d);
    }

    std::function<void (uint16_t)> onChannelsRequested;

private:
    State<Model::Profiles> state;
    ProfileList<editable> list { state };
    std::optional<ProfileDetailsPane<editable>> details;

    ErasedScopeGuard listener = state.observe ([this] (auto)
    {
        if (state->profiles.getSelected() != nullptr)
        {
            if (! details.has_value())
            {
                addAndMakeVisible (details.emplace (state));
                details->onChannelsRequested = Utils::forwardFunction (onChannelsRequested);
                resized();
            }
        }
        else
        {
            details.reset();
        }
    });
};

template <Editable editable>
class DiscoveryInfoPanel : public Component
{
public:
    DiscoveryInfoPanel (State<ci::MUID> m, State<Model::DeviceInfo> s)
        : muidState (m), state (s)
    {
        [&] (auto&&... item)
        {
            (addAndMakeVisible (item), ...);
            ((item.onCommit ([this] { setStateFromUI(); })), ...);
        } (manufacturer, family, modelNumber, revision, maxSysExSize);

        [&] (auto&&... item)
        {
            (addAndMakeVisible (item), ...);
        } (muid,
           muidLabel,
           manufacturerLabel,
           familyLabel,
           modelNumberLabel,
           revisionLabel,
           maxSysExSizeLabel);

        setSize (1, 6 * Utils::standardControlHeight + 7 * Utils::padding);
    }

    void resized() override
    {
        Utils::doTwoColumnLayout (getLocalBounds().reduced (Utils::padding),
                                  muidLabel, muid,
                                  maxSysExSizeLabel, maxSysExSize,
                                  manufacturerLabel, manufacturer,
                                  familyLabel, family,
                                  modelNumberLabel, modelNumber,
                                  revisionLabel, revision);
    }

private:
    void setStateFromUI()
    {
        auto updated = *state;

        Utils::stringToByteArray (manufacturer.getText(), updated.deviceInfo.manufacturer);
        Utils::stringToByteArray (family      .getText(), updated.deviceInfo.family);
        Utils::stringToByteArray (modelNumber .getText(), updated.deviceInfo.modelNumber);
        Utils::stringToByteArray (revision    .getText(), updated.deviceInfo.revision);
        updated.maxSysExSize = (size_t) maxSysExSize.getText().getIntValue();

        state = std::move (updated);
        refresh();
    }

    void refresh()
    {
        maxSysExSize.set (String (state->maxSysExSize));

        auto& info = state->deviceInfo;
        manufacturer.set (byteArrayToString (info.manufacturer));
        family      .set (byteArrayToString (info.family));
        modelNumber .set (byteArrayToString (info.modelNumber));
        revision    .set (byteArrayToString (info.revision));
    }

    static String byteArrayToString (Span<const std::byte> bytes)
    {
        return String::toHexString (bytes.data(), (int) bytes.size());
    }

    State<ci::MUID> muidState;
    State<Model::DeviceInfo> state;

    Label muidLabel             { "", "MUID (hex)" },
          maxSysExSizeLabel     { "", "Maximum SysEx size (decimal)" },
          manufacturerLabel     { "", "Manufacturer (hex, 3 bytes)" },
          familyLabel           { "", "Family (hex, 2 bytes)" },
          modelNumberLabel      { "", "Model (hex, 2 bytes)" },
          revisionLabel         { "", "Revision (hex, 4 bytes)" };

    TextField<Editable::no> muid;
    TextField<editable> maxSysExSize, manufacturer, family, modelNumber, revision;

    std::vector<ErasedScopeGuard> listeners = Utils::makeVector
    (
        muidState.observe ([this] (auto)
        {
            muid.set (String::toHexString (muidState->get()));
        }),
        state.observe ([this] (auto) { refresh(); })
    );
};

class PropertyListModel : public ListBoxModel
{
public:
    explicit PropertyListModel (State<Model::Properties> s)
        : state (s) {}

    int getNumRows() override
    {
        return (int) state->properties.items.size();
    }

    void paintListBoxItem (int, Graphics&, int, int, bool) override {}

    Component* refreshComponentForRow (int rowNumber,
                                       bool rowIsSelected,
                                       Component* existingComponentToUpdate) override
    {
        const auto toDelete = rawToUniquePtr (existingComponentToUpdate);

        const auto currentState = *state;

        if (auto* item = currentState.properties.get (rowNumber))
        {
            const auto name = item->name;
            return Utils::makeListRowLabel (name, rowIsSelected).release();
        }

        return nullptr;
    }

    void selectedRowsChanged (int newSelection) override
    {
        state[&Model::Properties::properties]
             [&Model::ListWithSelection<Model::Property>::selection] = newSelection;
    }

private:
    State<Model::Properties> state;
};

template <Editable editable>
class PropertyList : public Component
{
public:
    explicit PropertyList (State<Model::Properties> s)
        : state (s)
    {
        addAndMakeVisible (list);

        if constexpr (editable == Editable::yes)
        {
            addAndMakeVisible (add);
            add.onClick = [this]
            {
                auto updated = *state;
                updated.properties.items.emplace_back();
                updated.properties.selection = (int) updated.properties.items.size() - 1;
                state = std::move (updated);
            };

            addAndMakeVisible (remove);
            remove.onClick = [this]
            {
                auto updated = *state;
                auto& props = updated.properties;

                if (auto* item = props.getSelected())
                {
                    const auto toErase = props.items.begin() + props.selection;
                    props.items.erase (toErase);
                    props.selection = -1;

                    state = std::move (updated);
                }
            };
        }
    }

    void resized() override
    {
        if constexpr (editable == Editable::yes)
        {
            FlexBox fb;
            fb.flexDirection = FlexBox::Direction::column;
            fb.items = { FlexItem { list }.withFlex (1),
                         FlexItem{}.withHeight (Utils::padding),
                         FlexItem{}.withHeight (Utils::standardControlHeight) };
            fb.performLayout (getLocalBounds());

            Utils::doColumnLayout (fb.items.getLast().currentBounds.getSmallestIntegerContainer(),
                                   add,
                                   remove);
        }
        else
        {
            list.setBounds (getLocalBounds());
        }
    }

private:
    State<Model::Properties> state;
    PropertyListModel model { state };
    ListBox list { "Profiles", &model };
    TextButton add { "+" }, remove { "-" };

    ErasedScopeGuard listener = state.observe ([this] (auto)
    {
        list.setSelectedRows ({}, dontSendNotification);
        list.updateContent();

        const auto& props = state->properties;
        auto* item = props.getSelected();

        remove.setEnabled (item != nullptr);

        if (item != nullptr)
        {
            list.setSelectedRows (Utils::setWithSingleIndex (props.selection),
                                  dontSendNotification);
        }
    });
};

class PropertySubscribersModel : public ListBoxModel
{
public:
    explicit PropertySubscribersModel (State<Model::App> s) : state (s) {}

    int getNumRows() override { return (int) entries.size(); }

    void paintListBoxItem (int, Graphics&, int, int, bool) override {}

    Component* refreshComponentForRow (int rowNumber,
                                       bool,
                                       Component* existingComponentToUpdate) override
    {
        const auto toDelete = rawToUniquePtr (existingComponentToUpdate);

        if (! isPositiveAndBelow (rowNumber, entries.size()))
            return nullptr;

        const auto info = entries[(size_t) rowNumber];
        return Utils::makeListRowLabel (info, false).release();
    }

    void refresh()
    {
        entries = [&]() -> std::vector<String>
        {
            const auto& props = state->saved.properties;

            if (auto* item = props.properties.getSelected())
            {
                const auto selected = item->name;
                const auto& subs = state->transient.subscribers;
                const auto iter = subs.find (selected);

                if (iter == subs.end())
                    return {};

                std::vector<String> result;

                for (const auto& [device, subscriptions] : iter->second)
                    for (const auto& s : subscriptions)
                        result.push_back (String::toHexString (device.get()) + " (" + s + ")");

                return result;
            }

            return {};
        }();
    }

private:
    State<Model::App> state;
    std::vector<String> entries;
};

template <Editable editable>
class PropertySubscribersPanel : public Component
{
public:
    explicit PropertySubscribersPanel (State<Model::App> s)
        : state (s)
    {
        addAndMakeVisible (list);
        list.setMultipleSelectionEnabled (false);
        list.setClickingTogglesRowSelection (false);
    }

    void resized() override
    {
        list.setBounds (getLocalBounds().reduced (Utils::padding));
    }

private:
    State<Model::App> state;
    PropertySubscribersModel model { state };
    ListBox list { "Subscribers", &model };

    ErasedScopeGuard listener = state.observe ([this] (auto)
    {
        model.refresh();
        list.updateContent();
    });
};

template <Editable editable>
class PropertyValuePanel : public Component
{
    enum class Kind { full, partial };

    auto getFileChooserCallback (Kind kind)
    {
        return [this, kind] (const auto&)
        {
            if (propertyFileChooser.getResults().isEmpty())
                return;

            auto updated = *state;

            if (auto* item = updated.properties.getSelected())
            {
                MemoryBlock block;
                propertyFileChooser.getResult().loadFileAsData (block);
                const auto* data = static_cast<const std::byte*> (block.getData());

                if constexpr (editable == Editable::yes)
                {
                    std::vector<std::byte> asByteVec (data, data + block.getSize());

                    if (kind == Kind::full)
                    {
                        item->value.bytes = std::move (asByteVec);
                    }
                    else
                    {
                        struct Header
                        {
                            String mediaType;
                            ci::Encoding mutualEncoding{};
                        };

                        struct Request
                        {
                            Header header;
                            std::vector<std::byte> body;
                        };

                        Request request { { "application/json", ci::Encoding::ascii },
                                          std::move (asByteVec) };

                        auto [newItem, error] = Utils::attemptSetPartial (std::move (*item),
                                                                          std::move (request));
                        *item = std::move (newItem);
                        jassert (error.isEmpty()); // Inspect error to find out what went wrong
                    }

                    state = std::move (updated);
                }
                else
                {
                    NullCheckedInvocation::invoke (kind == Kind::full ? onSetFullRequested
                                                                      : onSetPartialRequested,
                                                   Span (data, block.getSize()));
                }
            }
        };
    }

public:
    explicit PropertyValuePanel (State<Model::Properties> s)
        : PropertyValuePanel (s, {}) {}

    PropertyValuePanel (State<Model::Properties> s, State<std::map<String, String>> subState)
        : state (s), subscriptions (subState)
    {
        addAndMakeVisible (value);
        value.setReadOnly (true);
        value.setMultiLine (true);

        addAndMakeVisible (formatLabel);
        addAndMakeVisible (format);

        format.onCommit ([this]
        {
            auto adjusted = *state;

            if (auto* item = adjusted.properties.getSelected())
            {
                item->value.mediaType = format.getText();
                state = adjusted;
            }

            refresh();
        });

        addAndMakeVisible (viewAsHex);
        viewAsHex.onClick = [this]
        {
            state[&Model::Properties::mode] = Model::DataViewMode::hex;
        };

        addAndMakeVisible (viewAsAscii);
        viewAsAscii.onClick = [this]
        {
            state[&Model::Properties::mode] = Model::DataViewMode::ascii;
        };

        addAndMakeVisible (setFull);
        setFull.onClick = [this]
        {
            propertyFileChooser.launchAsync (FileBrowserComponent::canSelectFiles
                                             | FileBrowserComponent::openMode,
                                             getFileChooserCallback (Kind::full));
        };

        addAndMakeVisible (setPartial);
        setPartial.onClick = [this]
        {
            propertyFileChooser.launchAsync (FileBrowserComponent::canSelectFiles
                                             | FileBrowserComponent::openMode,
                                             getFileChooserCallback (Kind::partial));
        };

        if constexpr (editable == Editable::no)
        {
            addAndMakeVisible (get);
            get.onClick = Utils::forwardFunction (onGetRequested);

            addAndMakeVisible (subscribe);
            subscribe.onClick = Utils::forwardFunction (onSubscribeRequested);
        }
    }

    void resized() override
    {
        Grid grid;
        grid.autoFlow = Grid::AutoFlow::row;
        grid.autoColumns = grid.autoRows = Grid::TrackInfo { Grid::Fr { 1 } };
        grid.columnGap = grid.rowGap = Grid::Px { Utils::padding };
        grid.templateColumns = { Grid::TrackInfo { Grid::Fr { 1 } },
                                 Grid::TrackInfo { Grid::Fr { 1 } } };
        if constexpr (editable == Editable::yes)
        {
            grid.templateRows = { Grid::TrackInfo { Grid::Px { Utils::standardControlHeight } },
                                  Grid::TrackInfo { Grid::Px { Utils::standardControlHeight } },
                                  Grid::TrackInfo { Grid::Px { Utils::standardControlHeight } },
                                  Grid::TrackInfo { Grid::Fr { 1 } } };
            grid.items = { GridItem { setFull }, GridItem { setPartial },
                           GridItem { formatLabel }, GridItem { format },
                           GridItem { viewAsHex },   GridItem { viewAsAscii },
                           GridItem { value }.withArea ({}, GridItem::Span { 2 }) };
        }
        else
        {
            grid.templateRows = { Grid::TrackInfo { Grid::Px { Utils::standardControlHeight } },
                                  Grid::TrackInfo { Grid::Px { Utils::standardControlHeight } },
                                  Grid::TrackInfo { Grid::Px { Utils::standardControlHeight } },
                                  Grid::TrackInfo { Grid::Px { Utils::standardControlHeight } },
                                  Grid::TrackInfo { Grid::Fr { 1 } } };
            grid.items = { GridItem { setFull }, GridItem { setPartial },
                           GridItem { get }, GridItem { subscribe },
                           GridItem { formatLabel }, GridItem { format },
                           GridItem { viewAsHex },   GridItem { viewAsAscii },
                           GridItem { value }.withArea ({}, GridItem::Span { 2 }) };
        }
        grid.performLayout (getLocalBounds().reduced (Utils::padding));
    }

    std::function<void()> onGetRequested, onSubscribeRequested;
    std::function<void (Span<const std::byte>)> onSetFullRequested, onSetPartialRequested;

private:
    void refresh()
    {
        const auto& currentState = *state;

        if (auto* item = currentState.properties.getSelected())
            format.set (item->value.mediaType);

        const auto mode = state->mode;
        viewAsHex.setToggleState (mode == Model::DataViewMode::hex, dontSendNotification);
        viewAsAscii.setToggleState (mode == Model::DataViewMode::ascii, dontSendNotification);
    }

    void updateSubButtonText()
    {
        const auto& sub = *subscriptions;

        if (const auto* selectedProp = state->properties.getSelected())
        {
            const auto text = sub.count (selectedProp->name) != 0 ? "Unsubscribe" : "Subscribe";
            subscribe.setButtonText (text);
        }
    }

    State<Model::Properties> state;
    State<std::map<String, String>> subscriptions;

    MonospaceEditor value;
    TextField<editable> format;
    Label formatLabel { "", "Media Type" };
    ToggleButton viewAsHex { "Hex" };
    ToggleButton viewAsAscii { "ASCII" };
    TextButton setFull { "Set Full..." },
               setPartial { "Set Partial..." },
               get { "Get" },
               subscribe { "Subscribe" };

    FileChooser propertyFileChooser { "Property Data", {}, "*", true, false, this };

    std::vector<ErasedScopeGuard> listeners = Utils::makeVector
    (
        state.observe ([this] (const auto& old)
        {
            updateSubButtonText();
            refresh();

            const auto& currentState = *state;

            if (auto* item = currentState.properties.getSelected())
            {
                const auto mode = currentState.mode;

                if (mode == old.mode
                    && old.properties.getSelected() != nullptr
                    && *old.properties.getSelected() == *item)
                {
                    return;
                }

                const auto canSetFull = item->canSet != Model::CanSet::none
                                        || editable == Editable::yes;
                setFull.setEnabled (canSetFull);
                setPartial.setEnabled (item->canSet == Model::CanSet::partial);
                get.setEnabled (item->canGet);

                auto* oldSelection = old.properties.getSelected();
                const auto needsValueUpdate = old.mode != mode
                                              || oldSelection == nullptr
                                              || oldSelection->value.bytes != item->value.bytes;

                if (! needsValueUpdate)
                    return;

                value.set ("");

                switch (mode)
                {
                    case Model::DataViewMode::hex:
                        value.setColour (TextEditor::ColourIds::textColourId,
                                         findColour (TextEditor::ColourIds::textColourId));
                        value.setText (String::toHexString (item->value.bytes.data(),
                                                            (int) item->value.bytes.size()),
                                       false);
                        break;

                    case Model::DataViewMode::ascii:
                        String toShow;

                        for (auto& b : item->value.bytes)
                        {
                            const char ascii[] { (char) b, 0 };
                            toShow << (b < std::byte { 0x80 } ? ascii : "\xef\xbf\xbd");
                        }

                        value.set (toShow);
                        break;
                }
            }
        }),
        subscriptions.observe ([this] (const auto&)
        {
            updateSubButtonText();
        })
    );
};

template <Editable editable>
class PropertyInfoPanel : public Component
{
public:
    explicit PropertyInfoPanel (State<Model::Properties> s)
        : state (s)
    {
        if constexpr (editable == Editable::yes)
        {

            addAndMakeVisible (canSet);
            canSet.addItemList ({ "None", "Full", "Partial" }, 1);
            canSet.onChange = [this] { updateStateFromUI(); };
        }
        else
        {
            addAndMakeVisible (canSetField);
        }

        [&] (auto&&... args)
        {
            (addAndMakeVisible (args), ...);
            (args.setClickingTogglesState (editable == Editable::yes), ...);
            ((args.onClick = [this] { updateStateFromUI(); }), ...);
        } (canGet,
           canSubscribe,
           canPaginate,
           requireResId,
           canAsciiEncode,
           canMcoded7Encode,
           canZipMcoded7Encode);

        [&] (auto&&... args)
        {
            (addAndMakeVisible (args), ...);
        } (nameLabel, schemaLabel, mediaTypesLabel, columnsLabel, canSetLabel, listLabel);

        [&] (auto&&... args)
        {
            (addAndMakeVisible (args), ...);
            (args.setReadOnly (editable == Editable::no), ...);
            (args.setMultiLine (true), ...);
            ((args.onReturnKey = args.onEscapeKey
                               = args.onFocusLost
                               = [this] { updateStateFromUI(); }), ...);
        } (schema, mediaTypes, columns);

        addAndMakeVisible (name);
        name.onCommit ([this]
        {
            updateStateFromUI();
            refresh();
        });

        updateUIFromState();
    }

    void resized() override
    {
        Grid grid;
        grid.autoFlow = Grid::AutoFlow::row;
        grid.autoColumns = Grid::TrackInfo { Grid::Fr { 1 } };
        const Grid::TrackInfo tallRow { Grid::Px { 100 } },
                              shortRow {  Grid::Px { Utils::standardControlHeight } };
        grid.columnGap = grid.rowGap = Grid::Px { Utils::padding };
        grid.templateColumns = { Grid::TrackInfo { Grid::Fr { 1 } },
                                 Grid::TrackInfo { Grid::Fr { 1 } } };
        grid.templateRows = { shortRow,
                              tallRow,
                              tallRow,
                              shortRow,
                              shortRow,
                              shortRow,
                              shortRow,
                              shortRow,
                              shortRow,
                              tallRow };
        auto* canSetControl = editable == Editable::yes ? static_cast<Component*> (&canSet)
                                                        : static_cast<Component*> (&canSetField);
        grid.items = { GridItem { nameLabel }, GridItem { name },
                       GridItem { mediaTypesLabel }, GridItem { mediaTypes },
                       GridItem { schemaLabel }, GridItem { schema },
                       GridItem { canSetLabel }, GridItem { canSetControl },
                       GridItem { canGet }, GridItem { canSubscribe },
                       GridItem { requireResId }, GridItem { canAsciiEncode },
                       GridItem { canMcoded7Encode }, GridItem { canZipMcoded7Encode },
                       GridItem { listLabel }.withArea ({}, GridItem::Span { 2 }),
                       GridItem { canPaginate }.withArea ({}, GridItem::Span { 2 }),
                       GridItem { columnsLabel }, GridItem { columns } };
        grid.performLayout (getLocalBounds().reduced (Utils::padding));
    }

private:
    void refresh()
    {
        const auto currentState = *state;

        if (auto* item = state->properties.getSelected())
            name.set (item->name);
    }

    void updateStateFromUI()
    {
        if constexpr (editable == Editable::yes)
        {
            auto updated = *state;
            auto& props = updated.properties;

            if (auto* item = props.getSelected())
            {
                auto cachedData = item->value;
                *item = getInfoFromUI();
                item->value = cachedData;

                state = std::move (updated);
            }
        }
    }

    Model::Property getInfoFromUI() const
    {
        Model::Property result;
        result.name = name.getText();
        result.schema = JSON::fromString (schema.getText());
        auto lines = StringArray::fromLines (mediaTypes.getText() + "\n");
        lines.removeEmptyStrings();
        result.mediaTypes = std::vector<String> (lines.begin(), lines.end());
        result.columns = JSON::fromString (columns.getText());

        result.encodings = [&]
        {
            std::set<ci::Encoding> encodings;

            if (canAsciiEncode.getToggleState())
                encodings.insert (ci::Encoding::ascii);

            if (canMcoded7Encode.getToggleState())
                encodings.insert (ci::Encoding::mcoded7);

            if (canZipMcoded7Encode.getToggleState())
                encodings.insert (ci::Encoding::zlibAndMcoded7);

            return encodings;
        }();

        result.canSet = (Model::CanSet) canSet.getSelectedItemIndex();
        result.canGet = canGet.getToggleState();
        result.canSubscribe = canSubscribe.getToggleState();
        result.requireResId = requireResId.getToggleState();
        result.canPaginate = canPaginate.getToggleState();
        return result;
    }

    void updateUIFromState()
    {
        refresh();

        const auto currentState = *state;

        if (auto* item = state->properties.getSelected())
        {
            schema.setText (JSON::toString (item->schema), false);

            const auto pairs = { std::tuple (&canAsciiEncode, ci::Encoding::ascii),
                                 std::tuple (&canMcoded7Encode, ci::Encoding::mcoded7),
                                 std::tuple (&canZipMcoded7Encode, ci::Encoding::zlibAndMcoded7) };

            for (const auto& [button, encoding] : pairs)
            {
                button->setToggleState (item->encodings.count (encoding) != 0,
                                        dontSendNotification);
            }

            mediaTypes.setText (StringArray (item->mediaTypes.data(),
                                             (int) item->mediaTypes.size()).joinIntoString ("\n"),
                                false);

            columns.setText (JSON::toString (item->columns), false);

            canSetField.set (Model::CanSetUtils::toString (item->canSet));
            canSet.setSelectedItemIndex ((int) item->canSet, dontSendNotification);
            canGet.setToggleState (item->canGet, dontSendNotification);
            canSubscribe.setToggleState (item->canSubscribe, dontSendNotification);
            requireResId.setToggleState (item->requireResId, dontSendNotification);
            canPaginate.setToggleState (item->canPaginate, dontSendNotification);

            const auto list = item->name.endsWith ("List");
            canPaginate.setEnabled (list);
            columnsLabel.setEnabled (list);
            columns.setEnabled (list);
        }
    }

    State<Model::Properties> state;
    Label nameLabel          { "", "Name" },
          schemaLabel        { "", "Schema" },
          mediaTypesLabel    { "", "Media Types" },
          columnsLabel       { "", "Columns (json array)" },
          canSetLabel        { "", "Can Set" },
          listLabel          { "", "List Properties (only valid when Name ends with \"List\")" };

    TextField<editable> name, canSetField;
    MonospaceEditor schema, mediaTypes, columns;
    ComboBox canSet;
    ToggleButton canGet                 { "Can Get" },
                 canSubscribe           { "Can Subscribe" },
                 canPaginate            { "Can Paginate" },
                 requireResId           { "Require Res ID" },
                 canAsciiEncode         { "Can ASCII Encode" },
                 canMcoded7Encode       { "Can Mcoded7 Encode" },
                 canZipMcoded7Encode    { "Can zlib+Mcoded7 Encode" };

    ErasedScopeGuard listener = state.observe ([this] (auto)
    {
        updateUIFromState();
    });
};

template <Editable>
class PropertyEditPanel;

template <>
class PropertyEditPanel<Editable::yes> : public Component
{
public:
    explicit PropertyEditPanel (State<Model::App> s)
        : state (s)
    {
        addAndMakeVisible (tabs);
        tabs.addTab ("Info",
                      findColour (DocumentWindow::backgroundColourId),
                      &info,
                      false);
        tabs.addTab ("Value",
                     findColour (DocumentWindow::backgroundColourId),
                     &value,
                     false);
        tabs.addTab ("Subscribers",
                     findColour (DocumentWindow::backgroundColourId),
                     &subscribers,
                     false);
    }

    void resized() override
    {
        tabs.setBounds (getLocalBounds());
    }

private:
    State<Model::App> state;
    PropertyInfoPanel<Editable::yes> info { state[&Model::App::saved][&Model::Saved::properties] };
    PropertyValuePanel<Editable::yes> value { state[&Model::App::saved]
                                                   [&Model::Saved::properties] };
    PropertySubscribersPanel<Editable::yes> subscribers { state };
    TabbedComponent tabs { TabbedButtonBar::Orientation::TabsAtTop };
};

template <>
class PropertyEditPanel<Editable::no> : public Component
{
public:
    explicit PropertyEditPanel (State<Model::App> s)
        : state (s)
    {
        addAndMakeVisible (tabs);
        tabs.addTab ("Info",
                     findColour (DocumentWindow::backgroundColourId),
                     &info,
                     false);
        tabs.addTab ("Value",
                     findColour (DocumentWindow::backgroundColourId),
                     &value,
                     false);

        value.onSetFullRequested    = Utils::forwardFunction (onSetFullRequested);
        value.onSetPartialRequested = Utils::forwardFunction (onSetPartialRequested);
        value.onGetRequested        = Utils::forwardFunction (onGetRequested);
        value.onSubscribeRequested  = Utils::forwardFunction (onSubscribeRequested);
    }

    void resized() override
    {
        tabs.setBounds (getLocalBounds());
    }

    std::function<void()> onGetRequested, onSubscribeRequested;
    std::function<void (Span<const std::byte>)> onSetFullRequested, onSetPartialRequested;

private:
    State<Model::Device> getDeviceState() const
    {
        const auto selected = (size_t) state->transient.devices.selection;
        return state[&Model::App::transient]
        [&Model::Transient::devices]
        [&Model::ListWithSelection<Model::Device>::items]
        [selected];
    }

    State<Model::App> state;
    PropertyInfoPanel<Editable::no> info { getDeviceState()[&Model::Device::properties] };
    PropertyValuePanel<Editable::no> value
    {
        getDeviceState()[&Model::Device::properties],
        getDeviceState()[&Model::Device::subscribeIdForResource]
    };
    TabbedComponent tabs { TabbedButtonBar::Orientation::TabsAtTop };
};

template <Editable editable>
class PropertyConfigPanel : public Component
{
public:
    explicit PropertyConfigPanel (State<Model::App> s)
        : state (s)
    {
        setSize (1, 700);

        addAndMakeVisible (list);
    }

    void resized() override
    {
        Utils::doTwoColumnLayout (getLocalBounds().reduced (Utils::padding),
                                  list,
                                  GridItem { info.has_value() ? &*info : nullptr });
    }

    std::function<void()> onGetRequested, onSubscribeRequested;
    std::function<void (Span<const std::byte>)> onSetFullRequested, onSetPartialRequested;

private:
    State<Model::Properties> getPropertyState() const
    {
        if constexpr (editable == Editable::yes)
        {
            return state[&Model::App::saved][&Model::Saved::properties];
        }
        else
        {
            const auto selected = (size_t) state->transient.devices.selection;
            return state[&Model::App::transient]
                        [&Model::Transient::devices]
                        [&Model::ListWithSelection<Model::Device>::items]
                        [selected]
                        [&Model::Device::properties];
        }
    }

    State<Model::App> state;
    PropertyList<editable> list { getPropertyState() };
    std::optional<PropertyEditPanel<editable>> info;

    ErasedScopeGuard listener = state.observe ([this] (auto)
    {
        if (getPropertyState()->properties.getSelected() != nullptr)
        {
            if (! info.has_value())
            {
                addAndMakeVisible (info.emplace (state));

                if constexpr (editable == Editable::no)
                {
                    info->onSetFullRequested    = Utils::forwardFunction (onSetFullRequested);
                    info->onSetPartialRequested = Utils::forwardFunction (onSetPartialRequested);
                    info->onGetRequested        = Utils::forwardFunction (onGetRequested);
                    info->onSubscribeRequested  = Utils::forwardFunction (onSubscribeRequested);
                }

                resized();
            }
        }
        else
        {
            info.reset();
        }
    });
};

class LocalConfigurationPanel : public Component
{
public:
    LocalConfigurationPanel (State<ci::MUID> m, State<Model::App> s)
        : muidState (m), state (s)
    {
        addAndMakeVisible (list);

        list.addItem (basicsHeader);
        list.addItem (discovery);
        list.addItem (profilesHeader);
        list.addItem (profiles);
        list.addItem (propertiesHeader);
        list.addItem (properties);
    }

    void resized() override
    {
        list.setBounds (getLocalBounds());
    }

private:
    State<ci::MUID> muidState;
    State<Model::App> state;
    SectionHeader basicsHeader { "Basics" };
    ToggleSectionHeader profilesHeader { "Profiles",
                                         state[&Model::App::saved]
                                              [&Model::Saved::fundamentals]
                                              [&Model::DeviceInfo::profilesSupported] };
    PropertySectionHeader propertiesHeader { state[&Model::App::saved]
                                                  [&Model::Saved::fundamentals] };
    DiscoveryInfoPanel<Editable::yes> discovery { muidState,
                                                  state[&Model::App::saved]
                                                       [&Model::Saved::fundamentals] };
    ProfileConfigPanel<Editable::yes> profiles { state[&Model::App::saved]
                                                      [&Model::Saved::profiles] };
    PropertyConfigPanel<Editable::yes> properties { state };
    HeterogeneousListView list;

    ErasedScopeGuard listener = state[&Model::App::saved]
                                     [&Model::Saved::fundamentals].observe ([this] (auto)
    {
        const auto current = state->saved.fundamentals;
        profiles.setEnabled (current.profilesSupported);
        properties.setEnabled (current.propertiesSupported);
    });
};

class RemoteConfigurationPanel : public Component
{
public:
    explicit RemoteConfigurationPanel (State<Model::App> s)
        : state (s)
    {
        addAndMakeVisible (list);
        profiles.onChannelsRequested = Utils::forwardFunction (onChannelsRequested);

        properties.onSetFullRequested    = Utils::forwardFunction (onPropertySetFullRequested);
        properties.onSetPartialRequested = Utils::forwardFunction (onPropertySetPartialRequested);
        properties.onGetRequested        = Utils::forwardFunction (onPropertyGetRequested);
        properties.onSubscribeRequested  = Utils::forwardFunction (onPropertySubscribeRequested);

        rebuildList();
    }

    void resized() override
    {
        list.setBounds (getLocalBounds());
    }

    std::function<void (uint16_t)> onChannelsRequested;
    std::function<void()> onPropertyGetRequested, onPropertySubscribeRequested;
    std::function<void (Span<const std::byte>)> onPropertySetFullRequested,
                                                onPropertySetPartialRequested;

private:
    void rebuildList()
    {
        list.clear();

        list.addItem (basicsHeader);
        list.addItem (discovery);

        if (getDeviceState()->info.profilesSupported)
        {
            list.addItem (profilesHeader);
            list.addItem (profiles);
        }

        if (getDeviceState()->info.propertiesSupported)
        {
            list.addItem (propertiesHeader);
            list.addItem (properties);
        }

        list.resized();
    }

    State<Model::Device> getDeviceState() const
    {
        const auto selected = (size_t) state->transient.devices.selection;
        return state[&Model::App::transient]
                    [&Model::Transient::devices]
                    [&Model::ListWithSelection<Model::Device>::items]
                    [selected];
    }

    State<Model::App> state;
    SectionHeader basicsHeader { "Basics" };
    DiscoveryInfoPanel<Editable::no> discovery { getDeviceState()[&Model::Device::muid],
                                                 getDeviceState()[&Model::Device::info] };
    SectionHeader profilesHeader { "Profiles" }, propertiesHeader { "Properties" };
    ProfileConfigPanel<Editable::no> profiles { getDeviceState()[&Model::Device::profiles] };
    PropertyConfigPanel<Editable::no> properties { state };
    HeterogeneousListView list;

    ErasedScopeGuard listener = getDeviceState().observe ([this] (const auto& old)
    {
        const auto transactions = (int) getDeviceState()->info.numPropertyExchangeTransactions;
        const auto plural = transactions == 1 ? "transaction" : "transactions";
        propertiesHeader.set ("Properties ("
                              + String (transactions)
                              + " simultaneous "
                              + String (plural)
                              + " supported)");

        const auto& newInfo = getDeviceState()->info;

        if (std::tie (old.info.propertiesSupported, old.info.profilesSupported)
            != std::tie (newInfo.propertiesSupported, newInfo.profilesSupported))
        {
            rebuildList();
        }
    });
};

class DiscoveryPanel : public Component
{
public:
    explicit DiscoveryPanel (State<Model::App> s)
        : state (s)
    {
        addAndMakeVisible (discoveryButton);
        discoveryButton.onClick = Utils::forwardFunction (onDiscover);

        addAndMakeVisible (deviceCombo);
        deviceCombo.setTextWhenNoChoicesAvailable ("Press Discover Devices to find new devices");
        deviceCombo.setTextWhenNothingSelected ("No device selected");
    }

    void resized() override
    {
        const auto headerHeight = Utils::standardControlHeight + 2 * Utils::padding;

        auto b = getLocalBounds();

        Utils::doTwoColumnLayout (b.removeFromTop (headerHeight).reduced (Utils::padding),
                                  discoveryButton,
                                  deviceCombo);

        if (configPanel.has_value())
            configPanel->setBounds (b);
    }

    std::function<void (uint16_t)> onChannelsRequested;
    std::function<void()> onDiscover, onPropertyGetRequested, onPropertySubscribeRequested;
    std::function<void (Span<const std::byte>)> onPropertySetFullRequested,
                                                onPropertySetPartialRequested;

private:
    State<Model::App> state;
    TextButton discoveryButton { "Discover Devices" };
    ComboBox deviceCombo;
    std::optional<RemoteConfigurationPanel> configPanel;
    ErasedScopeGuard listener = state[&Model::App::transient]
                                     [&Model::Transient::devices].observe ([this] (const auto& old)
    {
        if (old.items != state->transient.devices.items)
        {
            deviceCombo.getRootMenu()->clear();

            auto indexState = state[&Model::App::transient]
                                   [&Model::Transient::devices]
                                   [&Model::ListWithSelection<Model::Device>::selection];

            auto index = 0;
            for (auto& dev : state->transient.devices.items)
            {
                const auto suffix = [&]() -> String
                {
                    if (const auto readable = dev.properties.getReadableDeviceInfo())
                    {
                        String result;

                        if (readable->model.isNotEmpty())
                            result << " " << readable->model;

                        if (readable->manufacturer.isNotEmpty())
                            result << " (" << readable->manufacturer << ")";

                        return result;
                    }

                    return {};
                }();

                PopupMenu::Item item;
                item.action = [indexState, i = index++]() mutable { indexState = i; };
                item.text = String::toHexString (dev.muid.get()) + suffix;
                item.itemID = index;
                deviceCombo.getRootMenu()->addItem (std::move (item));
            }
        }

        deviceCombo.setSelectedItemIndex (state->transient.devices.selection, dontSendNotification);

        if (state->transient.devices.getSelected() != nullptr)
        {
            const auto newIndex = state->transient.devices.selection;

            if (old.selection != newIndex)
            {
                addAndMakeVisible (configPanel.emplace (state));
                configPanel->onChannelsRequested =
                    Utils::forwardFunction (onChannelsRequested);
                configPanel->onPropertySetFullRequested =
                    Utils::forwardFunction (onPropertySetFullRequested);
                configPanel->onPropertySetPartialRequested =
                    Utils::forwardFunction (onPropertySetPartialRequested);
                configPanel->onPropertyGetRequested =
                    Utils::forwardFunction (onPropertyGetRequested);
                configPanel->onPropertySubscribeRequested =
                    Utils::forwardFunction (onPropertySubscribeRequested);
            }
        }
        else
        {
            configPanel.reset();
        }

        resized();
    });
};

/** Accumulates incoming MIDI messages on the MIDI thread, and passes these messages off to other
    Consumers on the main thread.

    This is useful because the ci::Device is only intended for single-threaded use. It is an error
    to call ci::Device::receiveMessage concurrently with any other member function on a particular
    instance of Device.

    This implementation uses a mutex to protect access to the accumulated packets, and an
    AsyncUpdater to signal the main thread to wake up and process the packets. Alternative
    approaches (e.g. using notify_one and wait from std::atomic_flag, or putting messages into a
    queue and polling periodically on the main thread) may be more suitable in production apps.
*/
class MessageForwarder : public MidiInputCallback,
                         private AsyncUpdater
{
public:
    void handleIncomingMidiMessage (MidiInput*, const MidiMessage& message) override
    {
        if (! message.isSysEx())
            return;

        {
            const std::scoped_lock lock { mutex };
            messages.push_back (message);
        }

        triggerAsyncUpdate();
    }

    [[nodiscard]] ErasedScopeGuard addConsumer (ci::DeviceMessageHandler& c)
    {
        return ErasedScopeGuard { [this, it = consumers.insert (&c).first] { consumers.erase (it); } };
    }

private:
    void handleAsyncUpdate() override
    {
        const std::scoped_lock lock { mutex };

        for (auto* c : consumers)
            for (const auto& message : messages)
                c->processMessage ({ 0, message.getSysExDataSpan() });

        messages.clear();
    }

    std::set<ci::DeviceMessageHandler*> consumers;

    std::mutex mutex;
    std::vector<MidiMessage> messages;
};

class LoggingModel : public TableListBoxModel
{
public:
    enum Columns
    {
        messageTime = 1,
        group,
        direction,
        from,
        to,
        version,
        channel,
        description,
    };

    explicit LoggingModel (State<Model::App> s) : state (s) {}

    Component* refreshComponentForCell (int rowNumber,
                                        int columnId,
                                        bool,
                                        Component* existingComponentToUpdate) override
    {
        auto owned = rawToUniquePtr (existingComponentToUpdate);
        const auto filtered = getFiltered();

        if (! isPositiveAndBelow (rowNumber, filtered.size()))
            return nullptr;

        auto ownedLabel = [&owned]
        {
            auto* label = dynamic_cast<Label*> (owned.get());

            if (label == nullptr)
                return Utils::makeListRowLabel ("", false);

            owned.release();
            return rawToUniquePtr (label);
        }();

        const auto& row = filtered[(size_t) rowNumber];

        const auto text = [&]() -> String
        {
            const auto parsed = ci::Parser::parse (row.message);

            if (! parsed.has_value())
                return {};

            switch (columnId)
            {
                case messageTime:
                    return row.time.toString (false, true, true, true);

                case group:
                    return String (row.group);

                case direction:
                    return row.kind == Model::MessageKind::incoming ? "in" : "out";

                case from:
                    return String::toHexString (parsed->header.source.get());

                case to:
                    return String::toHexString (parsed->header.destination.get());

                case version:
                    return String::toHexString (parsed->header.version);

                case channel:
                    return ci::ChannelInGroupUtils::toString (parsed->header.deviceID);

                case description:
                    return state->saved.logView.mode == Model::DataViewMode::ascii
                         ? ci::Parser::getMessageDescription (*parsed)
                         : String::toHexString (row.message.data(), (int) row.message.size());
            }

            return {};
        }();

        ownedLabel->setText (text, dontSendNotification);
        return ownedLabel.release();
    }

    int getNumRows() override { return (int) getFiltered().size(); }

    void paintRowBackground (Graphics&, int, int, int, bool) override {}
    void paintCell (Graphics&, int, int, int, int, bool) override {}

private:
    std::deque<Model::LogEntry> getFiltered() const
    {
        const auto& filter = state->saved.logView.filter;
        auto copy = state->transient.logEntries;

        if (! filter.has_value())
            return copy;

        const auto iter = std::remove_if (copy.begin(),
                                          copy.end(),
                                          [&] (const auto& e) { return e.kind != *filter; });
        copy.erase (iter, copy.end());
        return copy;
    }

    State<Model::App> state;
};

class LoggingList : public Component,
                    private Timer
{
public:
    explicit LoggingList (State<Model::App> s)
        : state (s)
    {
        addAndMakeVisible (list);

        auto& header = list.getHeader();
        header.addColumn ("Time",
                          LoggingModel::Columns::messageTime,
                          100,
                          100);
        header.addColumn ("Group",
                          LoggingModel::Columns::group,
                          50,
                          50);
        header.addColumn ("IO",
                          LoggingModel::Columns::direction,
                          50,
                          50);
        header.addColumn ("From",
                          LoggingModel::Columns::from,
                          60,
                          50);
        header.addColumn ("To",
                          LoggingModel::Columns::to,
                          60,
                          50);
        header.addColumn ("Version",
                          LoggingModel::Columns::version,
                          50,
                          50);
        header.addColumn ("Channel",
                          LoggingModel::Columns::channel,
                          100,
                          50);
        header.addColumn ("Description",
                          LoggingModel::Columns::description,
                          300,
                          50);
    }

    void resized() override
    {
        list.setBounds (getLocalBounds());
    }

    void updateContent()
    {
        // Using a timer here means that we only repaint the UI after there haven't been any
        // new messages for a while, which avoids doing redundant expensive list-layouts.
        startTimer (16);
    }

private:
    void timerCallback() override
    {
        const auto& vbar = list.getVerticalScrollBar();
        const auto endShowing = vbar.getCurrentRange().getEnd() >= vbar.getMaximumRangeLimit();

        stopTimer();
        list.updateContent();

        if (endShowing)
            list.scrollToEnsureRowIsOnscreen (list.getNumRows() - 1);
    }

    State<Model::App> state;
    LoggingModel model { state };
    TableListBox list { "Logs", &model };
    ErasedScopeGuard listener = state[&Model::App::transient]
                                     [&Model::Transient::logEntries].observe ([this] (auto)
                                                                              {
                                                                                 updateContent();
                                                                              });
};

class LoggingPanel : public Component
{
public:
    explicit LoggingPanel (State<Model::App> stateIn)
        : state (stateIn)
    {
        addAndMakeVisible (list);

        addAndMakeVisible (onlyIncoming);
        onlyIncoming.onClick = [this]
        {
            auto s = state[&Model::App::saved][&Model::Saved::logView][&Model::LogView::filter];
            s = *s == Model::MessageKind::incoming ? std::nullopt
                                                   : std::optional (Model::MessageKind::incoming);
        };

        addAndMakeVisible (onlyOutgoing);
        onlyOutgoing.onClick = [this]
        {
            auto s = state[&Model::App::saved][&Model::Saved::logView][&Model::LogView::filter];
            s = *s == Model::MessageKind::outgoing ? std::nullopt
                                                   : std::optional (Model::MessageKind::outgoing);
        };

        addAndMakeVisible (readable);
        readable.onClick = [this]
        {
            auto s = state[&Model::App::saved][&Model::Saved::logView][&Model::LogView::mode];
            s = readable.getToggleState() ? Model::DataViewMode::ascii : Model::DataViewMode::hex;
        };

        addAndMakeVisible (clearButton);
        clearButton.onClick = [this]
        {
            state[&Model::App::transient]
                 [&Model::Transient::logEntries] = std::deque<Model::LogEntry>();
        };
    }

    void resized() override
    {
        FlexBox fb;
        fb.flexDirection = FlexBox::Direction::column;
        fb.items = { FlexItem{}.withHeight (Utils::standardControlHeight),
                     FlexItem{}.withHeight (Utils::padding),
                     FlexItem { list }.withFlex (1) };
        fb.performLayout (getLocalBounds().reduced (Utils::padding));

        Utils::doColumnLayout (fb.items.getFirst().currentBounds.getSmallestIntegerContainer(),
                               onlyIncoming,
                               onlyOutgoing,
                               readable,
                               clearButton);
    }

private:
    State<Model::App> state;
    LoggingList list { state };
    ToggleButton onlyIncoming { "Only Incoming" }, onlyOutgoing { "Only Outgoing" };
    ToggleButton readable { "Human-Readable" };
    TextButton clearButton { "Clear" };
    ErasedScopeGuard listener = state[&Model::App::saved]
                                     [&Model::Saved::logView].observe ([this] (auto)
    {
        list.updateContent();

        onlyIncoming.setToggleState (state->saved.logView.filter == Model::MessageKind::incoming,
                                     dontSendNotification);
        onlyOutgoing.setToggleState (state->saved.logView.filter == Model::MessageKind::outgoing,
                                     dontSendNotification);
        readable.setToggleState (state->saved.logView.mode == Model::DataViewMode::ascii,
                                 dontSendNotification);
    });
};

class CapabilityInquiryDemo : public Component
{
public:
    CapabilityInquiryDemo()
    {
        PropertiesFile::Options options;
        options.applicationName     = "CapabilityInquiryDemo";
        options.filenameSuffix      = "settings";
        options.osxLibrarySubFolder = "Application Support";
        applicationProperties.setStorageParameters (options);

        if (auto* userSettings = applicationProperties.getUserSettings())
            setSavedState (JSON::parse (userSettings->getValue ("savedState")));

        forwarder.addConsumer (inputHandler).release();

        setSize (800, 800);

        addAndMakeVisible (tabs);
        tabs.addTab ("MIDI IO",
                     findColour (DocumentWindow::backgroundColourId),
                     &lists,
                     false);
        tabs.addTab ("Local Configuration",
                     findColour (DocumentWindow::backgroundColourId),
                     &local,
                     false);
        tabs.addTab ("Discovery",
                     findColour (DocumentWindow::backgroundColourId),
                     &discovery,
                     false);
        tabs.addTab ("Logging",
                     findColour (DocumentWindow::backgroundColourId),
                     &logging,
                     false);

        addAndMakeVisible (loadButton);
        loadButton.onClick = [this] { loadState(); };

        addAndMakeVisible (saveButton);
        saveButton.onClick = [this] { saveState(); };

        discovery.onDiscover = [this] { discoverDevices(); };
        discovery.onPropertyGetRequested = [this] { getProperty(); };
        discovery.onPropertySubscribeRequested = [this] { subscribeToProperty(); };
        discovery.onChannelsRequested = [this] (auto channels) { setProfileChannels (channels); };
        discovery.onPropertySetFullRequested = [this] (Span<const std::byte> bytes)
        {
            setPropertyFull (bytes);
        };
        discovery.onPropertySetPartialRequested = [this] (Span<const std::byte> bytes)
        {
            setPropertyPartial (bytes);
        };
    }

    ~CapabilityInquiryDemo() override
    {
        // In a production app, it'd be a bit risky to write to a file from a destructor as it's
        // bad karma to throw an exception inside a destructor!
        if (auto* userSettings = applicationProperties.getUserSettings())
            userSettings->setValue ("savedState", JSON::toString (getSavedState()));

        if (auto* p = getPeer())
            p->setHasChangedSinceSaved (false);
    }

    void resized() override
    {
        tabs.setBounds (getLocalBounds());

        const auto buttonBounds = getLocalBounds().removeFromTop (tabs.getTabBarDepth())
                                                  .removeFromRight (300)
                                                  .reduced (2);
        Utils::doColumnLayout (buttonBounds, loadButton, saveButton);
    }

private:
    std::optional<std::tuple<ci::MUID, String>> getPropertyRequestInfo() const
    {
        auto* selectedDevice = appState->transient.devices.getSelected();

        if (selectedDevice == nullptr)
            return {};

        auto* selectedProperty = selectedDevice->properties.properties.getSelected();

        if (selectedProperty == nullptr)
            return {};

        return std::tuple (selectedDevice->muid, selectedProperty->name);
    }

    void discoverDevices()
    {
        if (device.has_value())
            device->sendDiscovery();
    }

    void loadState()
    {
        fileChooser.launchAsync (FileBrowserComponent::canSelectFiles | FileBrowserComponent::openMode,
                                 [this] (const auto& fc)
                                 {
                                     if (fc.getResults().isEmpty())
                                         return;

                                     auto stream = fc.getResult().createInputStream();

                                     if (stream == nullptr)
                                         return;

                                     setSavedState (JSON::parse (*stream));
                                 });
    }

    void saveState()
    {
        const auto toWrite = JSON::toString (getSavedState());
        fileChooser.launchAsync (FileBrowserComponent::canSelectFiles | FileBrowserComponent::saveMode,
                                 [this, toWrite] (const auto& fc)
                                 {
                                     if (! fc.getResults().isEmpty())
                                     {
                                         fc.getResult().replaceWithText (toWrite);

                                         if (auto* p = getPeer())
                                             p->setHasChangedSinceSaved (false);
                                     }
                                 });
    }

    void setProfileChannels (uint16_t numChannels)
    {
        if (! device.has_value())
            return;

        if (auto* selectedDevice = appState->transient.devices.getSelected())
        {
            if (const auto selected = selectedDevice->profiles.getSelectedProfileAtAddress())
            {
                device->sendProfileEnablement (selectedDevice->muid,
                                               selected->address.getChannel(),
                                               selected->profile,
                                               numChannels);
            }
        }
    }

    void setPropertyFull (Span<const std::byte> bytes)
    {
        if (! device.has_value())
            return;

        if (const auto details = getPropertyRequestInfo())
        {
            const auto& [muid, propName] = *details;

            const auto encodingToUse = [&, muidCopy = muid, propNameCopy = propName]() -> std::optional<ci::Encoding>
            {
                if (auto* deviceResource = findDeviceResource (appState->transient, muidCopy, propNameCopy))
                    return deviceResource->getBestCommonEncoding();

                return {};
            }();

            if (! encodingToUse.has_value())
            {
                // We can't set property data because we don't have any encodings in common with the other device.
                jassertfalse;
                return;
            }

            ci::PropertyRequestHeader header;
            header.resource = propName;
            header.mutualEncoding = *encodingToUse;
            device->sendPropertySetInquiry (muid, header, bytes, [] (const auto&)
            {
                // Could do error handling here, e.g. retry the request if the responder is busy
            });
        }
    }

    void setPropertyPartial (Span<const std::byte> bytes)
    {
        if (! device.has_value())
            return;

        if (const auto details = getPropertyRequestInfo())
        {
            const auto& [muid, propName] = *details;

            const auto encodingToUse = [&, muidCopy = muid, propNameCopy = propName]() -> std::optional<ci::Encoding>
            {
                if (auto* deviceResource = findDeviceResource (appState->transient, muidCopy, propNameCopy))
                    return deviceResource->getBestCommonEncoding();

                return {};
            }();

            if (! encodingToUse.has_value())
            {
                // We can't set property data because we don't have any encodings in common with the other device.
                jassertfalse;
                return;
            }

            ci::PropertyRequestHeader header;
            header.resource = propName;
            header.mutualEncoding = *encodingToUse;
            header.setPartial = true;
            device->sendPropertySetInquiry (muid, header, bytes, [] (const auto&)
            {
                // Could do error handling here, e.g. retry the request if the responder is busy
            });
        }
    }

    void getProperty()
    {
        if (! device.has_value())
            return;

        if (const auto details = getPropertyRequestInfo())
        {
            const auto& [muid, propName] = *details;
            requestPropertyData (muid, propName);
        }
    }

    void subscribeToProperty()
    {
        if (! device.has_value())
            return;

        auto* selectedDevice = appState->transient.devices.getSelected();

        if (selectedDevice == nullptr)
            return;

        const auto details = getPropertyRequestInfo();

        if (! details.has_value())
            return;

        const auto& [muid, propName] = *details;

        const auto subId = [&, propNameCopy = propName]
        {
            const auto ongoing = device->getOngoingSubscriptionsForMuid (selectedDevice->muid);
            const auto iter = std::find_if (ongoing.begin(), ongoing.end(), [&] (const auto& sub)
            {
                return sub.resource == propNameCopy;
            });

            return iter != ongoing.end() ? iter->subscribeId : String();
        }();

        ci::PropertySubscriptionHeader header;
        header.resource = propName;
        header.command = subId.isEmpty() ? ci::PropertySubscriptionCommand::start : ci::PropertySubscriptionCommand::end;
        header.subscribeId = subId;

        auto callback = [this,
                         target = muid,
                         propertyName = propName,
                         existingSubscription = subId.isNotEmpty()] (const ci::PropertyExchangeResult& response)
        {
            if (response.getError().has_value())
                return;

            auto updated = *appState;

            auto& knownDevices = updated.transient.devices.items;
            const auto deviceIter = std::find_if (knownDevices.begin(),
                                                  knownDevices.end(),
                                                  [target] (const auto& d) { return d.muid == target; });

            if (deviceIter == knownDevices.end())
            {
                // The device has gone away?
                jassertfalse;
                return;
            }

            const auto parsedHeader = response.getHeaderAsSubscriptionHeader();

            if (parsedHeader.subscribeId.isNotEmpty() && ! existingSubscription)
                deviceIter->subscribeIdForResource.emplace (propertyName, parsedHeader.subscribeId);
            else
                deviceIter->subscribeIdForResource.erase (propertyName);

            appState = std::move (updated);
        };

        if (subId.isEmpty())
            device->sendPropertySubscriptionStart (muid, header, callback);
        else
            device->sendPropertySubscriptionEnd (muid, subId, callback);
    }

    template <typename Transient>
    static auto findDeviceResourceImpl (Transient& transient, ci::MUID device, String resource)
        -> decltype (transient.devices.items.front().properties.properties.items.data())
    {
        auto& knownDevices = transient.devices.items;
        const auto deviceIter = std::find_if (knownDevices.begin(),
                                              knownDevices.end(),
                                              [&] (const auto& d) { return d.muid == device; });

        if (deviceIter == knownDevices.end())
            return nullptr;

        auto& props = deviceIter->properties.properties.items;
        const auto propIter = std::find_if (props.begin(), props.end(), [&] (const auto& prop)
        {
            return prop.name == resource;
        });

        if (propIter == props.end())
            return nullptr;

        return &*propIter;
    }

    static Model::Property* findDeviceResource (Model::Transient& transient,
                                                ci::MUID device,
                                                String resource)
    {
        return findDeviceResourceImpl (transient, device, resource);
    }

    static const Model::Property* findDeviceResource (const Model::Transient& transient,
                                                      ci::MUID device,
                                                      String resource)
    {
        return findDeviceResourceImpl (transient, device, resource);
    }

    void requestPropertyData (ci::MUID target, String propertyName)
    {
        const auto encodingToUse = [&]() -> std::optional<ci::Encoding>
        {
            if (auto* deviceResource = findDeviceResource (appState->transient, target, propertyName))
                return deviceResource->getBestCommonEncoding();

            return {};
        }();

        if (! encodingToUse.has_value())
        {
            // We can't request property data because we don't have any encodings in common with the other device.
            jassertfalse;
            return;
        }

        ci::PropertyRequestHeader header;
        header.resource = propertyName;
        header.mutualEncoding = *encodingToUse;

        const auto it = ongoingGetInquiries.insert (ongoingGetInquiries.end(), ErasedScopeGuard{});
        *it = device->sendPropertyGetInquiry (target, header, [this, it, target, propertyName] (const auto& response)
        {
            ongoingGetInquiries.erase (it);

            if (response.getError().has_value())
                return;

            auto updated = *appState;

            if (auto* deviceResource = findDeviceResource (updated.transient, target, propertyName))
            {
                deviceResource->value.bytes = std::vector<std::byte> (response.getBody().begin(),
                                                                      response.getBody().end());
                appState = std::move (updated);
            }
        });
    }

    void setSavedState (var json)
    {
        if (auto newState = FromVar::convert<Model::Saved> (json))
            appState[&Model::App::saved] = std::move (*newState);

        if (auto* p = getPeer())
            p->setHasChangedSinceSaved (false);
    }

    var getSavedState() const
    {
        if (auto json = ToVar::convert (appState->saved))
            return *json;

        return {};
    }

    ci::ProfileHost* getProfileHost()
    {
        if (device.has_value())
            return device->getProfileHost();

        return nullptr;
    }

    ci::PropertyHost* getPropertyHost()
    {
        if (device.has_value())
            return device->getPropertyHost();

        return nullptr;
    }

    [[nodiscard]] std::optional<MidiDeviceInfo> getInputInfo() const
    {
        if (input != nullptr)
            return input->getDeviceInfo();

        return std::nullopt;
    }

    [[nodiscard]] std::optional<MidiDeviceInfo> getOutputInfo() const
    {
        if (output != nullptr)
            return output->getDeviceInfo();

        return std::nullopt;
    }

    [[nodiscard]] Model::IOSelection getIOSelectionFromDevices() const
    {
        return { getInputInfo(), getOutputInfo() };
    }

    static bool isStillConnected (const MidiInput& x)
    {
        const auto devices = MidiInput::getAvailableDevices();
        return std::any_of (devices.begin(),
                            devices.end(),
                            [info = x.getDeviceInfo()] (const auto& d) { return d == info; });
    }

    static bool isStillConnected (const MidiOutput& x)
    {
        const auto devices = MidiOutput::getAvailableDevices();
        return std::any_of (devices.begin(),
                            devices.end(),
                            [info = x.getDeviceInfo()] (const auto& d) { return d == info; });
    }

    [[nodiscard]] static bool isStillConnected (const std::unique_ptr<MidiInput>& x)
    {
        if (x == nullptr)
            return false;

        return isStillConnected (*x);
    }

    [[nodiscard]] static bool isStillConnected (const std::unique_ptr<MidiOutput>& x)
    {
        if (x == nullptr)
            return false;

        return isStillConnected (*x);
    }

    void setDeviceProfileState (ci::ProfileAtAddress profileAtAddress,
                                ci::SupportedAndActive state)
    {
        if (auto* h = getProfileHost())
        {
            if (state.supported == 0)
                h->removeProfile (profileAtAddress);
            else
                h->addProfile (profileAtAddress, state.supported);

            if (state.active == 0)
                h->disableProfile (profileAtAddress);
            else
                h->enableProfile (profileAtAddress, state.active);
        }
    }

    void notifySubscribersForProperty (StringRef propertyName)
    {
        if (! device.has_value())
            return;

        const auto& subscribers = appState->transient.subscribers;
        const auto iter = subscribers.find (propertyName);

        if (iter == subscribers.end())
            return;

        for (const auto& [receiver, subscriptions] : iter->second)
        {
            for (const auto& subId : subscriptions)
            {
                if (auto* host = getPropertyHost())
                {
                    ci::PropertySubscriptionHeader header;
                    header.command = ci::PropertySubscriptionCommand::notify;
                    header.subscribeId = subId;
                    header.resource = propertyName;
                    host->sendSubscriptionUpdate (receiver, header, {}, {}).release();
                }
            }
        }
    }

    void addLogEntry (ump::BytesOnGroup entry,
                      Model::MessageKind kind,
                      Time time = Time::getCurrentTime())
    {
        static constexpr size_t maxNum = 1000;

        auto entries = appState[&Model::App::transient][&Model::Transient::logEntries];
        auto updated = *entries;
        updated.emplace (updated.end(), Model::LogEntry { { entry.bytes.begin(),
                                                            entry.bytes.end() },
                                                          entry.group,
                                                          time,
                                                          kind });

        while (updated.size() > maxNum)
            updated.pop_front();

        entries = std::move (updated);
    }

    static Model::App normalise (const Model::App& older, Model::App&& newer)
    {
        auto modified = std::move (newer);

        if (older.saved.fundamentals != modified.saved.fundamentals)
        {
            modified.transient.devices.items.clear();
            modified.transient.devices.selection = -1;
            modified.transient.subscribers.clear();
        }

        modified.syncSubscribers();

        return modified;
    }

    class DeviceListener : public ci::DeviceListener
    {
    public:
        explicit DeviceListener (CapabilityInquiryDemo& d) : demo (d) {}

        void deviceAdded (ci::MUID added) override
        {
            auto updated = *demo.appState;

            auto& devices = updated.transient.devices;
            auto& deviceVec = devices.items;
            const auto iterForMuid = [&] (auto m)
            {
                return std::find_if (deviceVec.begin(),
                                     deviceVec.end(),
                                     [&] (const auto& d) { return d.muid == m; });
            };

            const auto iter = iterForMuid (added);
            auto& toUpdate = iter != deviceVec.end() ? *iter : deviceVec.emplace_back();
            toUpdate.muid = added;

            if (devices.getSelected() == nullptr)
                devices.selection = (int) std::distance (deviceVec.begin(), iterForMuid (added));

            const auto response = demo.device.has_value() ? demo.device->getDiscoveryInfoForMuid (added)
                                                          : std::nullopt;

            if (! response.has_value())
                return;

            const ci::DeviceFeatures features { response->capabilities };
            toUpdate.info.profilesSupported = features.isProfileConfigurationSupported();
            toUpdate.info.propertiesSupported = features.isPropertyExchangeSupported();
            toUpdate.info.deviceInfo = response->device;
            toUpdate.info.maxSysExSize = response->maximumSysexSize;
            toUpdate.info.numPropertyExchangeTransactions = 0;

            demo.appState = updated;

            if (! demo.device.has_value())
                return;

            if (features.isProfileConfigurationSupported())
                demo.device->sendProfileInquiry (added, ci::ChannelInGroup::wholeBlock);

            if (features.isPropertyExchangeSupported())
                demo.device->sendPropertyCapabilitiesInquiry (added);
        }

        void deviceRemoved (ci::MUID gone) override
        {
            auto updated = *demo.appState;

            auto& subs = updated.transient.subscribers;

            for (auto& u : subs)
                u.second.erase (gone);

            auto& devices = updated.transient.devices;
            const auto selectedMuid = [&]() -> std::optional<ci::MUID>
            {
                if (auto* item = devices.getSelected())
                    return item->muid;

                return {};
            }();

            devices.items.erase (std::remove_if (devices.items.begin(),
                                                 devices.items.end(),
                                                 [&] (const auto& d) { return d.muid == gone; }),
                                 devices.items.end());

            const auto iter = std::find_if (devices.items.begin(),
                                            devices.items.end(),
                                            [&] (const auto& d) { return d.muid == selectedMuid; });
            devices.selection = iter != devices.items.end()
                                ? (int) std::distance (devices.items.begin(), iter)
                                : -1;

            demo.appState = updated;
        }

        void endpointReceived (ci::MUID, ci::Message::EndpointInquiryResponse) override
        {
            // No special handling
        }

        void messageNotAcknowledged (ci::MUID, ci::Message::NAK) override
        {
            // No special handling
        }

        void profileStateReceived (ci::MUID muid,
                                   ci::ChannelInGroup) override
        {
            updateProfilesForMuid (muid);
        }

        void profilePresenceChanged (ci::MUID muid,
                                     ci::ChannelInGroup,
                                     ci::Profile,
                                     bool) override
        {
            updateProfilesForMuid (muid);
        }

        void profileEnablementChanged (ci::MUID muid,
                                       ci::ChannelInGroup,
                                       ci::Profile,
                                       int) override
        {
            updateProfilesForMuid (muid);
        }

        void profileDetailsReceived (ci::MUID,
                                     ci::ChannelInGroup,
                                     ci::Profile,
                                     std::byte,
                                     Span<const std::byte>) override
        {
            // No special handling
        }

        void profileSpecificDataReceived (ci::MUID,
                                          ci::ChannelInGroup,
                                          ci::Profile,
                                          Span<const std::byte>) override
        {
            // No special handling
        }

        void propertyExchangeCapabilitiesReceived (ci::MUID muid) override
        {
            auto updated = *demo.appState;

            auto& devices = updated.transient.devices;
            const auto iter = std::find_if (devices.items.begin(),
                                            devices.items.end(),
                                            [&] (const auto& d) { return d.muid == muid; });

            if (iter == devices.items.end() || ! demo.device.has_value())
                return;

            const auto transactions = demo.device->getNumPropertyExchangeRequestsSupportedForMuid (muid);

            if (! transactions.has_value())
                return;

            iter->info.numPropertyExchangeTransactions = (uint8_t) *transactions;

            if (const auto resourceList = demo.device->getResourceListForMuid (muid); resourceList != var{})
            {
                if (auto* list = resourceList.getArray())
                {
                    auto& items = updated.transient.devices.items;
                    const auto found = std::find_if (items.begin(),
                                                     items.end(),
                                                     [&] (const auto& dev) { return dev.muid == muid; });

                    if (found != updated.transient.devices.items.end())
                    {
                        found->properties.properties = {};
                        auto& propItems = found->properties.properties.items;

                        Model::Property resourceListProp;
                        resourceListProp.name = "ResourceList";
                        resourceListProp.canSet = Model::CanSet::none;
                        resourceListProp.value.bytes = ci::Encodings::jsonTo7BitText (resourceList);
                        propItems.push_back (resourceListProp);

                        for (auto& entry : *list)
                            propItems.push_back (Model::Property::fromResourceListEntry (entry));
                    }
                }
            }

            if (const auto deviceInfo = demo.device->getDeviceInfoForMuid (muid); deviceInfo != var{})
            {
                if (auto* deviceResource = findDeviceResource (updated.transient, muid, "DeviceInfo"))
                {
                    deviceResource->value.bytes = ci::Encodings::jsonTo7BitText (deviceInfo);
                }
            }

            demo.appState = std::move (updated);
        }

        void propertySubscriptionDataReceived (ci::MUID muid,
                                               const ci::PropertySubscriptionData& subscription) override
        {
            const auto resource = [&]
            {
                for (const auto& [subId, res] : demo.device->getOngoingSubscriptionsForMuid (muid))
                    if (subId == subscription.header.subscribeId)
                        return res;

                return String{};
            }();

            if (resource.isEmpty())
            {
                // Got a subscription message for a subscription that's no longer ongoing
                jassertfalse;
                return;
            }

            auto devicesState = demo.appState[&Model::App::transient]
            [&Model::Transient::devices]
            [&Model::ListWithSelection<Model::Device>::items];
            auto copiedDevices = *devicesState;

            const auto matchingDevice = [&]
            {
                const auto iter = std::find_if (copiedDevices.begin(),
                                                copiedDevices.end(),
                                                [&] (const auto& d) { return d.muid == muid; });
                return iter != copiedDevices.end() ? &*iter : nullptr;
            }();

            if (matchingDevice == nullptr)
            {
                // Got a subscription message for a device that we haven't recorded
                jassertfalse;
                return;
            }

            auto& propertyList = matchingDevice->properties.properties.items;

            const auto matchingProperty = [&]
            {
                const auto iter = std::find_if (propertyList.begin(),
                                                propertyList.end(),
                                                [&] (const auto& p) { return p.name == resource; });
                return iter != propertyList.end() ? &*iter : nullptr;
            }();

            if (matchingProperty == nullptr)
            {
                // Got a subscription message for a property that we haven't recorded
                jassertfalse;
                return;
            }

            switch (subscription.header.command)
            {
                case ci::PropertySubscriptionCommand::partial:
                {
                    auto [updated, error] = Utils::attemptSetPartial (std::move (*matchingProperty),
                                                                      subscription);
                    *matchingProperty = std::move (updated);
                    jassert (error.isEmpty()); // Inspect 'error' to see what went wrong
                    break;
                }

                case ci::PropertySubscriptionCommand::full:
                {
                    matchingProperty->value.bytes = std::vector<std::byte> (subscription.body.begin(),
                                                                            subscription.body.end());
                    matchingProperty->value.mediaType = subscription.header.mediaType;
                    break;
                }

                case ci::PropertySubscriptionCommand::notify:
                {
                    demo.requestPropertyData (muid, resource);
                    break;
                }

                case ci::PropertySubscriptionCommand::end:
                {
                    matchingDevice->subscribeIdForResource.erase (resource);
                    break;
                }

                case ci::PropertySubscriptionCommand::start:
                    jassertfalse;
                    return;
            }

            devicesState = std::move (copiedDevices);
        }

    private:
        void updateProfilesForMuid (ci::MUID muid)
        {
            if (! demo.device.has_value())
                return;

            auto innerState = demo.appState[&Model::App::transient][&Model::Transient::devices];
            auto updated = *innerState;

            const auto iter = std::find_if (updated.items.begin(),
                                            updated.items.end(),
                                            [&] (const auto& d) { return d.muid == muid; });

            if (iter == updated.items.end())
                return;

            auto& profiles = iter->profiles;

            const auto lastSelectedProfile = [&]() -> std::optional<ci::Profile>
            {
                if (auto* ptr = profiles.profiles.getSelected())
                    return *ptr;

                return {};
            }();

            profiles.profileMode = Model::ProfileMode::use;
            profiles.profiles.items = [&]
            {
                std::set<ci::Profile> uniqueProfiles;
                Utils::forAllChannelAddresses ([&] (auto address)
                {
                    if (auto* state = demo.device->getProfileStateForMuid (muid, address))
                        for (const auto& p : *state)
                            uniqueProfiles.insert (p.profile);
                });
                return std::vector<ci::Profile> (uniqueProfiles.begin(), uniqueProfiles.end());
            }();
            profiles.profiles.selection = [&]
            {
                if (! lastSelectedProfile.has_value())
                    return -1;

                const auto foundMuid = std::find (profiles.profiles.items.begin(),
                                                  profiles.profiles.items.end(),
                                                  *lastSelectedProfile);

                if (foundMuid == profiles.profiles.items.end())
                    return -1;

                return (int) std::distance (profiles.profiles.items.begin(), foundMuid);
            }();
            profiles.channels = [&]
            {
                std::map<ci::ProfileAtAddress, ci::SupportedAndActive> result;

                Utils::forAllChannelAddresses ([&] (auto address)
                {
                    if (auto* state = demo.device->getProfileStateForMuid (muid, address))
                        for (const auto& p : *state)
                            result[{ p.profile, address }] = p.state;
                });

                return result;
            }();
            profiles.selectedChannel = [&]() -> std::optional<ci::ChannelAddress>
            {
                if (profiles.profileMode == Model::ProfileMode::edit)
                    return profiles.selectedChannel;

                const auto profileAtAddress = profiles.getSelectedProfileAtAddress();

                if (! profileAtAddress.has_value())
                    return std::nullopt;

                const auto found = profiles.channels.find (*profileAtAddress);

                if (found == profiles.channels.end() || found->second.supported == 0)
                    return std::nullopt;

                return profiles.selectedChannel;
            }();

            innerState = std::move (updated);
        }

        CapabilityInquiryDemo& demo;
    };

    class InputHandler : public ci::DeviceMessageHandler
    {
    public:
        explicit InputHandler (CapabilityInquiryDemo& d) : demo (d) {}

        void processMessage (ump::BytesOnGroup msg) override
        {
            demo.addLogEntry (msg, Model::MessageKind::incoming);

            if (demo.device.has_value())
                demo.device->processMessage (msg);
        }

    private:
        CapabilityInquiryDemo& demo;
    };

    class OutputHandler : public ci::DeviceMessageHandler
    {
    public:
        explicit OutputHandler (CapabilityInquiryDemo& d) : demo (d) {}

        void processMessage (ump::BytesOnGroup msg) override
        {
            SafePointer weak { &demo };
            std::vector<std::byte> bytes (msg.bytes.begin(), msg.bytes.end());
            auto group = msg.group;
            auto time = Time::getCurrentTime();

            MessageManager::callAsync ([weak, movedBytes = std::move (bytes), group, time]
            {
                // This call is async because we may send messages in direct response to model updates.
                if (weak != nullptr)
                    weak->addLogEntry ({ group, movedBytes }, Model::MessageKind::outgoing, time);
            });

            if (auto* out = demo.output.get())
                out->sendMessageNow (MidiMessage::createSysExMessage (msg.bytes));
        }

    private:
        CapabilityInquiryDemo& demo;
    };

    class ProfileDelegate : public ci::ProfileDelegate
    {
    public:
        explicit ProfileDelegate (CapabilityInquiryDemo& d) : demo (d) {}

        void profileEnablementRequested (ci::MUID,
                                         ci::ProfileAtAddress profileAtAddress,
                                         int numChannels,
                                         bool enabled) override
        {
            auto state = demo.appState[&Model::App::saved][&Model::Saved::profiles];
            auto profiles = *state;

            if (auto* host = demo.getProfileHost())
            {
                if (enabled)
                    host->enableProfile (profileAtAddress, numChannels);
                else
                    host->disableProfile (profileAtAddress);

                profiles.channels[profileAtAddress].active = (uint16_t) numChannels;

                state = profiles;
            }
        }

    private:
        CapabilityInquiryDemo& demo;
    };

    class PropertyDelegate : public ci::PropertyDelegate
    {
    public:
        explicit PropertyDelegate (CapabilityInquiryDemo& d) : demo (d) {}

        uint8_t getNumSimultaneousRequestsSupported() const override
        {
            return demo.appState->saved.fundamentals.numPropertyExchangeTransactions;
        }

        ci::PropertyReplyData propertyGetDataRequested (ci::MUID,
                                                        const ci::PropertyRequestHeader& header) override
        {
            auto allProperties = demo.appState->saved.properties.properties.items;
            allProperties.insert (allProperties.begin(), getDeviceInfo());

            if (header.resource == "ResourceList")
                return generateResourceListReply (allProperties);

            for (const auto& prop : allProperties)
                if (auto reply = generateReply (header, prop))
                    return *reply;

            ci::PropertyReplyData result;
            result.header.status = 404;
            result.header.message = "Unable to locate resource " + header.resource;
            return result;
        }

        ci::PropertyReplyHeader propertySetDataRequested (ci::MUID,
                                                          const ci::PropertyRequestData& request) override
        {
            const auto makeErrorHeader = [] (auto str, int code = 400)
            {
                ci::PropertyReplyHeader header;
                header.status = code;
                header.message = str;
                return header;
            };

            if (request.header.resource == "ResourceList")
                return makeErrorHeader ("Unable to set ResourceList");

            auto state = demo.appState[&Model::App::saved]
                                      [&Model::Saved::properties]
                                      [&Model::Properties::properties];
            auto props = *state;

            for (auto& prop : props.items)
            {
                if (request.header.resource != prop.name)
                    continue;

                if (prop.canSet == Model::CanSet::none)
                    return makeErrorHeader ("Unable to set resource " + prop.name);

                if (request.header.setPartial)
                {
                    if (prop.canSet != Model::CanSet::partial)
                        return makeErrorHeader ("Resource " + prop.name + " does not support setPartial");

                    auto [updatedProp, error] = Utils::attemptSetPartial (std::move (prop), request);
                    prop = std::move (updatedProp);

                    if (error.isNotEmpty())
                        return makeErrorHeader (error);

                    state = props;
                    return {};
                }

                prop.value.bytes = std::vector<std::byte> (request.body.begin(), request.body.end());
                prop.value.mediaType = request.header.mediaType;

                state = props;

                return {};
            }

            return makeErrorHeader ("Unable to locate resource " + request.header.resource, 404);
        }

        bool subscriptionStartRequested (ci::MUID, const ci::PropertySubscriptionHeader& header) override
        {
            const auto props = demo.appState->saved.properties.properties.items;

            return std::any_of (props.begin(), props.end(), [&] (const auto& p)
            {
                return p.name == header.resource;
            });
        }

        void subscriptionDidStart (ci::MUID initiator,
                                   const String& token,
                                   const ci::PropertySubscriptionHeader& header) override
        {
            auto transient = demo.appState[&Model::App::transient];
            auto updated = *transient;
            updated.subscribers[header.resource][initiator].insert (token);
            transient = updated;
        }

        void subscriptionWillEnd (ci::MUID initiator, const ci::Subscription& subscription) override
        {
            auto transient = demo.appState[&Model::App::transient];
            auto updated = *transient;
            updated.subscribers[subscription.resource][initiator].erase (subscription.subscribeId);
            transient = updated;
        }

    private:
        Model::Property getDeviceInfo() const
        {
            Model::Property result;
            result.name = "DeviceInfo";

            auto obj = std::make_unique<DynamicObject>();

            const auto varFromByteArray = [] (const auto& r)
            {
                return Model::toVarArray (r, [] (auto b) { return (int) b; });
            };

            obj->setProperty ("manufacturerId",
                              varFromByteArray (demo.device->getOptions().getDeviceInfo().manufacturer));
            obj->setProperty ("manufacturer",
                              "JUCE");
            obj->setProperty ("familyId",
                              varFromByteArray (demo.device->getOptions().getDeviceInfo().family));
            obj->setProperty ("family",
                              "MIDI Software");
            obj->setProperty ("modelId",
                              varFromByteArray (demo.device->getOptions().getDeviceInfo().modelNumber));
            obj->setProperty ("model",
                              "Capability Inquiry Demo");
            obj->setProperty ("versionId",
                              varFromByteArray (demo.device->getOptions().getDeviceInfo().revision));
            obj->setProperty ("version",
                              ProjectInfo::versionString);

            result.value.bytes = ci::Encodings::jsonTo7BitText (obj.release());
            return result;
        }

        ci::PropertyReplyData generateResourceListReply (Span<const Model::Property> allProperties) const
        {
            Array<var> resourceList;

            for (const auto& prop : allProperties)
                resourceList.add (prop.getResourceListEntry());

            return ci::PropertyReplyData { {},
                                           ci::Encodings::jsonTo7BitText (std::move (resourceList)) };
        }

        std::optional<ci::PropertyReplyData> generateReply (const ci::PropertyRequestHeader& header,
                                                            const Model::Property& info) const
        {
            if (header.resource != info.name)
                return {};

            const auto encodingToUse = [&]() -> std::optional<ci::Encoding>
            {
                if (info.encodings.count (header.mutualEncoding) != 0)
                    return header.mutualEncoding;

                if (! info.encodings.empty())
                    return *info.encodings.begin();

                return {};
            }();

            if (! encodingToUse.has_value())
            {
                // If this is hit, we don't declare any supported encodings for this property!
                jassertfalse;
                return {};
            }

            const auto basicReplyHeader = [&]
            {
                ci::PropertyReplyHeader h;
                h.mutualEncoding = *encodingToUse;
                h.mediaType = info.value.mediaType;
                return h;
            }();

            const auto [replyHeader, unencoded] = [&]
            {
                if (info.name.endsWith ("List")
                    && info.canPaginate
                    && info.value.mediaType == "application/json")
                {
                    const auto jsonToSend = ci::Encodings::jsonFrom7BitText (info.value.bytes);

                    if (const auto* array = jsonToSend.getArray())
                    {
                        const auto updatedHeader = [&]
                        {
                            auto h = basicReplyHeader;
                            h.extended["totalCount"] = array->size();
                            return h;
                        }();

                        if (const auto pagination = header.pagination)
                        {
                            const auto realOffset = jlimit (0, array->size(), pagination->offset);
                            const auto realLimit = jlimit (0,
                                                           array->size() - realOffset,
                                                           pagination->limit);
                            const auto* data = array->data() + realOffset;
                            const Array<var> slice (data, realLimit);
                            return std::tuple (updatedHeader, ci::Encodings::jsonTo7BitText (slice));
                        }

                        return std::tuple (updatedHeader, info.value.bytes);
                    }
                }

                return std::tuple (basicReplyHeader, info.value.bytes);
            }();

            return ci::PropertyReplyData { replyHeader, unencoded };
        }

        CapabilityInquiryDemo& demo;
    };

    ApplicationProperties applicationProperties;

    State<ci::MUID> ourMuid { ci::MUID::makeUnchecked (0) };
    State<Model::App> appState { Model::App{}, normalise };

    IOPickerLists lists { appState[&Model::App::saved][&Model::Saved::ioSelection] };
    LocalConfigurationPanel local { ourMuid, appState };
    DiscoveryPanel discovery { appState };
    LoggingPanel logging { appState };
    TabbedComponent tabs { TabbedButtonBar::Orientation::TabsAtTop };
    TextButton loadButton { "Load State..." }, saveButton { "Save State..."};

    MessageForwarder forwarder;

    std::unique_ptr<MidiInput> input;
    std::unique_ptr<MidiOutput> output;
    std::optional<ci::Device> device;
    std::list<ErasedScopeGuard> ongoingGetInquiries;

    FileChooser fileChooser { "Pick State JSON File", {}, "*.json", true, false, this };

    InputHandler inputHandler { *this };
    OutputHandler outputHandler { *this };
    DeviceListener deviceListener { *this };
    ProfileDelegate profileDelegate { *this };
    PropertyDelegate propertyDelegate { *this };

    std::vector<ErasedScopeGuard> listeners = Utils::makeVector
    (
        appState[&Model::App::saved][&Model::Saved::fundamentals].observe ([this] (auto)
        {
            Random random;
            random.setSeedRandomly();

            const auto fullState = *appState;
            const auto info = fullState.saved.fundamentals;
            const auto features = ci::DeviceFeatures()
                    .withProfileConfigurationSupported (info.profilesSupported)
                    .withPropertyExchangeSupported (info.propertiesSupported);

            const auto options = ci::DeviceOptions()
                    .withOutputs ({ &outputHandler })
                    .withDeviceInfo (info.deviceInfo)
                    .withFeatures (features)
                    .withMaxSysExSize (info.maxSysExSize)
                    .withProductInstanceId (ci::DeviceOptions::makeProductInstanceId (random))
                    .withPropertyDelegate (&propertyDelegate)
                    .withProfileDelegate (&profileDelegate);

            ongoingGetInquiries.clear();
            device.emplace (options);
            device->addListener (deviceListener);

            for (const auto& [addressAndProfile, channels] : fullState.saved.profiles.channels)
                setDeviceProfileState (addressAndProfile, channels);

            ourMuid = device->getMuid();
        }),
        appState[&Model::App::saved]
                [&Model::Saved::profiles]
                [&Model::Profiles::channels].observe ([this] (auto)
        {
            if (auto* host = getProfileHost())
            {
                std::map<ci::ProfileAtAddress, ci::SupportedAndActive> deviceState;

                Utils::forAllChannelAddresses ([&] (auto address)
                {
                    if (auto* state = host->getProfileStates().getStateForDestination (address))
                        for (const auto& p : *state)
                            deviceState[{ p.profile, address }] = p.state;
                });

                const auto requestedState = appState->saved.profiles.channels;

                std::vector<std::pair<ci::ProfileAtAddress, ci::SupportedAndActive>> removed;
                const auto compare = [] (const auto& a, const auto& b)
                {
                    return a.first < b.first;
                };
                std::set_difference (deviceState.begin(),
                                     deviceState.end(),
                                     requestedState.begin(),
                                     requestedState.end(),
                                     std::back_inserter (removed),
                                     compare);

                for (const auto& p : removed)
                    host->removeProfile (p.first);

                for (const auto& [addressAndProfile, channels] : requestedState)
                    setDeviceProfileState (addressAndProfile, channels);
            }
        }),
        appState[&Model::App::saved]
                [&Model::Saved::ioSelection]
                [&Model::IOSelection::input].observe ([this] (auto)
        {
            if (input != nullptr)
                input->stop();

            input.reset();

            if (const auto selection = appState->saved.ioSelection.input)
            {
                input = MidiInput::openDevice (selection->identifier, &forwarder);

                if (input != nullptr)
                    input->start();
            }
        }),
        appState[&Model::App::saved]
                [&Model::Saved::ioSelection]
                [&Model::IOSelection::output].observe ([this] (auto)
        {
            output.reset();

            if (const auto selection = appState->saved.ioSelection.output)
                output = MidiOutput::openDevice (selection->identifier);
        }),
        appState[&Model::App::saved].observe ([this] (auto)
        {
            if (auto* p = getPeer())
                p->setHasChangedSinceSaved (true);
        }),
        appState[&Model::App::transient]
                [&Model::Transient::subscribers].observe ([this] (const auto& oldSubscribers)
        {
            // Send a subscription end message for any subscribed properties that have been
            // completely removed
            const auto newSubscribers = appState->transient.subscribers;

            if (oldSubscribers == newSubscribers || ! device.has_value())
                return;

            std::set<String> removed;
            for (const auto& p : oldSubscribers)
                if (newSubscribers.count (p.first) == 0)
                    removed.insert (p.first);

            if (auto* host = getPropertyHost())
                for (const auto& r : removed)
                    for (const auto& [dest, subIds] : oldSubscribers.find (r)->second)
                        for (const auto& id : subIds)
                            host->terminateSubscription (dest, id);
        }),
        appState[&Model::App::saved]
                [&Model::Saved::properties]
                [&Model::Properties::properties].observe ([this] (const auto& oldProperties)
        {
            const auto makePropertyMap = [] (const auto& props)
            {
                std::map<String, Model::PropertyValue> result;

                for (const auto& p : props.items)
                    result.emplace (p.name, p.value);

                return result;
            };

            const auto& newProperties = appState->saved.properties.properties;
            const auto oldMap = makePropertyMap (oldProperties);
            const auto newMap = makePropertyMap (newProperties);

            for (const auto& [name, value] : newMap)
            {
                const auto iter = oldMap.find (name);

                if (iter != oldMap.end() && iter->second != value)
                    notifySubscribersForProperty (name);
            }
        })
    );

    MidiDeviceListConnection connection = MidiDeviceListConnection::make ([this]
    {
        if (! isStillConnected (input))
        {
            if (input != nullptr)
                input->stop();

            input.reset();
        }

        if (! isStillConnected (output))
            output.reset();

        appState[&Model::App::saved][&Model::Saved::ioSelection] = getIOSelectionFromDevices();
    });
};
