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

   This file is part of the JUCE library.
   Copyright (c) 2015 - ROLI Ltd.

   Permission is granted to use this software under the terms of either:
   a) the GPL v2 (or any later version)
   b) the Affero GPL v3

   Details of these licenses can be found at: www.gnu.org/licenses

   JUCE is distributed in the hope that it will be useful, but WITHOUT ANY
   WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
   A PARTICULAR PURPOSE.  See the GNU General Public License for more details.

   ------------------------------------------------------------------------------

   To release a closed-source product which uses JUCE, commercial licenses are
   available: visit www.juce.com for more information.

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

//==============================================================================
#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD) \
 METHOD (getMidiBluetoothAddresses, "getMidiBluetoothAddresses", "()[Ljava/lang/String;") \
 METHOD (pairBluetoothMidiDevice, "pairBluetoothMidiDevice", "(Ljava/lang/String;)Z") \
 METHOD (unpairBluetoothMidiDevice, "unpairBluetoothMidiDevice", "(Ljava/lang/String;)V") \
 METHOD (getHumanReadableStringForBluetoothAddress, "getHumanReadableStringForBluetoothAddress", "(Ljava/lang/String;)Ljava/lang/String;") \
 METHOD (isBluetoothDevicePaired, "isBluetoothDevicePaired", "(Ljava/lang/String;)Z")

DECLARE_JNI_CLASS (AndroidBluetoothManager, JUCE_ANDROID_ACTIVITY_CLASSPATH "$BluetoothManager");
#undef JNI_CLASS_MEMBERS

//==============================================================================
struct AndroidBluetoothMidiInterface
{
    static StringArray getBluetoothMidiDevicesNearby()
    {
        StringArray retval;

        JNIEnv* env = getEnv();

        LocalRef<jobject> btManager (android.activity.callObjectMethod (JuceAppActivity.getAndroidBluetoothManager));

        // if this is null then bluetooth is not enabled
        if (btManager.get() == nullptr)
            return StringArray();

        jobjectArray jDevices = (jobjectArray) env->CallObjectMethod (btManager.get(),
                                                                      AndroidBluetoothManager.getMidiBluetoothAddresses);
        LocalRef<jobjectArray> devices (jDevices);

        const int count = env->GetArrayLength (devices.get());

        for (int i = 0; i < count; ++i)
        {
            LocalRef<jstring> string ((jstring)  env->GetObjectArrayElement (devices.get(), i));
            retval.add (juceString (string));
        }

        return retval;
    }

    //==============================================================================
    static bool pairBluetoothMidiDevice (const String& bluetoothAddress)
    {
        JNIEnv* env = getEnv();

        LocalRef<jobject> btManager (android.activity.callObjectMethod (JuceAppActivity.getAndroidBluetoothManager));
        if (btManager.get() == nullptr)
            return false;

        jboolean result = env->CallBooleanMethod (btManager.get(), AndroidBluetoothManager.pairBluetoothMidiDevice,
                                                  javaString (bluetoothAddress).get());

        return result;
    }

    static void unpairBluetoothMidiDevice (const String& bluetoothAddress)
    {
        JNIEnv* env = getEnv();

        LocalRef<jobject> btManager (android.activity.callObjectMethod (JuceAppActivity.getAndroidBluetoothManager));

        if (btManager.get() != nullptr)
            env->CallVoidMethod (btManager.get(), AndroidBluetoothManager.unpairBluetoothMidiDevice,
                                 javaString (bluetoothAddress).get());
    }

    //==============================================================================
    static String getHumanReadableStringForBluetoothAddress (const String& address)
    {
        JNIEnv* env = getEnv();

        LocalRef<jobject> btManager (android.activity.callObjectMethod (JuceAppActivity.getAndroidBluetoothManager));

        if (btManager.get() == nullptr)
            return address;

        LocalRef<jstring> string ((jstring) env->CallObjectMethod (btManager.get(),
                                                                   AndroidBluetoothManager.getHumanReadableStringForBluetoothAddress,
                                                                   javaString (address).get()));


        if (string.get() == nullptr)
            return address;

        return juceString (string);
    }

    //==============================================================================
    static bool isBluetoothDevicePaired (const String& address)
    {
        JNIEnv* env = getEnv();

        LocalRef<jobject> btManager (android.activity.callObjectMethod (JuceAppActivity.getAndroidBluetoothManager));

        if (btManager.get() == nullptr)
            return false;

        return env->CallBooleanMethod (btManager.get(), AndroidBluetoothManager.isBluetoothDevicePaired,
                                       javaString (address).get());
    }
};

//==============================================================================
struct AndroidBluetoothMidiDevice
{
    enum ConnectionStatus
    {
        offline,
        connected,
        disconnected,
        connecting,
        disconnecting
    };

    AndroidBluetoothMidiDevice (String deviceName, String address, ConnectionStatus status)
        : name (deviceName), bluetoothAddress (address), connectionStatus (status)
    {
        // can't create a device without a valid name and bluetooth address!
        jassert (! name.isEmpty());
        jassert (! bluetoothAddress.isEmpty());
    }

    bool operator== (const AndroidBluetoothMidiDevice& other) const noexcept
    {
        return bluetoothAddress == other.bluetoothAddress;
    }

    bool operator!= (const AndroidBluetoothMidiDevice& other) const noexcept
    {
        return ! operator== (other);
    }

    const String name, bluetoothAddress;
    ConnectionStatus connectionStatus;
};

//==============================================================================
class AndroidBluetoothMidiDevicesListBox   : public ListBox,
                                             private ListBoxModel,
                                             private Timer
{
public:
    //==============================================================================
    AndroidBluetoothMidiDevicesListBox()
        : timerPeriodInMs (1000)
    {
        setRowHeight (40);
        setModel (this);
        setOutlineThickness (1);
        updateDeviceList();
        startTimer (timerPeriodInMs);
    }

    void pairDeviceThreadFinished() // callback from PairDeviceThread
    {
        updateDeviceList();
        startTimer (timerPeriodInMs);
    }

private:
    //==============================================================================
    typedef AndroidBluetoothMidiDevice::ConnectionStatus DeviceStatus;

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

    void paintListBoxItem (int rowNumber, Graphics& g,
                           int width, int height, bool rowIsSelected) override
    {
        if (isPositiveAndBelow (rowNumber, devices.size()))
        {
            const AndroidBluetoothMidiDevice& device = devices.getReference (rowNumber);
            const String statusString (getDeviceStatusString (device.connectionStatus));

            g.fillAll (Colours::white);

            const float xmargin = 3.0f;
            const float ymargin = 3.0f;
            const float fontHeight = 0.4f * height;
            const float deviceNameWidth = 0.6f * width;

            g.setFont (fontHeight);

            g.setColour (getDeviceNameFontColour (device.connectionStatus));
            g.drawText (device.name,
                        xmargin, ymargin,
                        deviceNameWidth - (2.0f * xmargin), height - (2.0f * ymargin),
                        Justification::topLeft, true);

            g.setColour (getDeviceStatusFontColour (device.connectionStatus));
            g.drawText (statusString,
                        deviceNameWidth + xmargin, ymargin,
                        width - deviceNameWidth - (2.0f * xmargin), height - (2.0f * ymargin),
                        Justification::topRight, true);

            g.setColour (Colours::grey);
            g.drawHorizontalLine (height - 1, xmargin, width);
        }
    }

    //==============================================================================
    static Colour getDeviceNameFontColour (DeviceStatus deviceStatus) noexcept
    {
        if (deviceStatus == AndroidBluetoothMidiDevice::offline)
            return Colours::grey;

        return Colours::black;
    }

    static Colour getDeviceStatusFontColour (DeviceStatus deviceStatus) noexcept
    {
        if (deviceStatus == AndroidBluetoothMidiDevice::offline
            || deviceStatus == AndroidBluetoothMidiDevice::connecting
            || deviceStatus == AndroidBluetoothMidiDevice::disconnecting)
            return Colours::grey;

        if (deviceStatus == AndroidBluetoothMidiDevice::connected)
            return Colours::green;

        return Colours::black;
    }

    static String getDeviceStatusString (DeviceStatus deviceStatus) noexcept
    {
        if (deviceStatus == AndroidBluetoothMidiDevice::offline)        return "Offline";
        if (deviceStatus == AndroidBluetoothMidiDevice::connected)      return "Connected";
        if (deviceStatus == AndroidBluetoothMidiDevice::disconnected)   return "Not connected";
        if (deviceStatus == AndroidBluetoothMidiDevice::connecting)     return "Connecting...";
        if (deviceStatus == AndroidBluetoothMidiDevice::disconnecting)  return "Disconnecting...";

        // unknown device state!
        jassertfalse;
        return "Status unknown";
    }

    //==============================================================================
    void listBoxItemClicked (int row, const MouseEvent&) override
    {
        const AndroidBluetoothMidiDevice& device = devices.getReference (row);

        if (device.connectionStatus == AndroidBluetoothMidiDevice::disconnected)
            disconnectedDeviceClicked (row);

        else if (device.connectionStatus == AndroidBluetoothMidiDevice::connected)
            connectedDeviceClicked (row);
    }

    void timerCallback() override
    {
        updateDeviceList();
    }

    //==============================================================================
    struct PairDeviceThread  : public Thread,
                               private AsyncUpdater
    {
        PairDeviceThread (const String& bluetoothAddressOfDeviceToPair,
                          AndroidBluetoothMidiDevicesListBox& ownerListBox)
            : Thread ("JUCE Bluetooth MIDI Device Pairing Thread"),
              bluetoothAddress (bluetoothAddressOfDeviceToPair),
              owner (&ownerListBox)
        {
            startThread();
        }

        void run() override
        {
            AndroidBluetoothMidiInterface::pairBluetoothMidiDevice (bluetoothAddress);
            triggerAsyncUpdate();
        }

        void handleAsyncUpdate() override
        {
            if (owner != nullptr)
                owner->pairDeviceThreadFinished();

            delete this;
        }

    private:
        String bluetoothAddress;
        Component::SafePointer<AndroidBluetoothMidiDevicesListBox> owner;
    };

    //==============================================================================
    void disconnectedDeviceClicked (int row)
    {
        stopTimer();

        AndroidBluetoothMidiDevice& device = devices.getReference (row);
        device.connectionStatus = AndroidBluetoothMidiDevice::connecting;
        updateContent();
        repaint();

        new PairDeviceThread (device.bluetoothAddress, *this);
    }

    void connectedDeviceClicked (int row)
    {
        AndroidBluetoothMidiDevice& device = devices.getReference (row);
        device.connectionStatus = AndroidBluetoothMidiDevice::disconnecting;
        updateContent();
        repaint();
        AndroidBluetoothMidiInterface::unpairBluetoothMidiDevice (device.bluetoothAddress);
    }

    //==============================================================================
    void updateDeviceList()
    {
        StringArray bluetoothAddresses = AndroidBluetoothMidiInterface::getBluetoothMidiDevicesNearby();

        Array<AndroidBluetoothMidiDevice> newDevices;

        for (String* address = bluetoothAddresses.begin();
             address != bluetoothAddresses.end(); ++address)
        {
            String name = AndroidBluetoothMidiInterface::getHumanReadableStringForBluetoothAddress (*address);
            DeviceStatus status =  AndroidBluetoothMidiInterface::isBluetoothDevicePaired (*address)
                                      ? AndroidBluetoothMidiDevice::connected
                                      : AndroidBluetoothMidiDevice::disconnected;

            newDevices.add (AndroidBluetoothMidiDevice (name, *address, status));
        }

        devices.swapWith (newDevices);
        updateContent();
        repaint();
    }

    Array<AndroidBluetoothMidiDevice> devices;
    const int timerPeriodInMs;
};

//==============================================================================
class BluetoothMidiSelectorOverlay  : public Component
{
public:
    BluetoothMidiSelectorOverlay()
    {
        setAlwaysOnTop (true);
        setVisible (true);
        addToDesktop (ComponentPeer::windowHasDropShadow);
        setBounds (0, 0, getParentWidth(), getParentHeight());
        toFront (true);

        addAndMakeVisible (bluetoothDevicesList);
        enterModalState (true, nullptr, true);
    }

    void paint (Graphics& g) override
    {
        g.fillAll (Colours::black.withAlpha (0.6f));

        g.setColour (Colour (0xffdfdfdf));
        Rectangle<int> overlayBounds = getOverlayBounds();
        g.fillRect (overlayBounds);

        g.setColour (Colours::black);
        g.setFont (16);
        g.drawText ("Bluetooth MIDI Devices",
                    overlayBounds.removeFromTop (20).reduced (3, 3),
                    Justification::topLeft, true);

        overlayBounds.removeFromTop (2);

        g.setFont (12);
        g.drawText ("tap to connect/disconnect",
                    overlayBounds.removeFromTop (18).reduced (3, 3),
                    Justification::topLeft, true);
    }

    void inputAttemptWhenModal() override           { exitModalState (0); }
    void mouseDrag (const MouseEvent&) override     {}
    void mouseDown (const MouseEvent&) override     { exitModalState (0); }
    void resized() override                         { update(); }
    void parentSizeChanged() override               { update(); }

private:
    void update()
    {
        setBounds (0, 0, getParentWidth(), getParentHeight());
        bluetoothDevicesList.setBounds (getOverlayBounds().withTrimmedTop (40));
    }

    Rectangle<int> getOverlayBounds() const noexcept
    {
        const int pw = getParentWidth();
        const int ph = getParentHeight();

        return Rectangle<int> (pw, ph).withSizeKeepingCentre (jmin (400, pw - 14),
                                                              jmin (300, ph - 40));
    }

    AndroidBluetoothMidiDevicesListBox bluetoothDevicesList;

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (BluetoothMidiSelectorOverlay)
};

//==============================================================================
bool BluetoothMidiDevicePairingDialogue::open()
{
    if (! RuntimePermissions::isGranted (RuntimePermissions::bluetoothMidi))
    {
        // If you hit this assert, you probably forgot to get RuntimePermissions::bluetoothMidi.
        // This is not going to work, boo! The pairing dialogue won't be able to scan for or
        // find any devices, it will just display an empty list, so don't bother opening it.
        jassertfalse;
        return false;
    }

    BluetoothMidiSelectorOverlay* overlay = new BluetoothMidiSelectorOverlay;
    return true;
}

bool BluetoothMidiDevicePairingDialogue::isAvailable()
{
    jobject btManager (android.activity.callObjectMethod (JuceAppActivity.getAndroidBluetoothManager));
    return btManager != nullptr;
}
