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

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

   JUCE is an open source library subject to commercial or open-source
   licensing.

   By using JUCE, you agree to the terms of both the JUCE 5 End-User License
   Agreement and JUCE 5 Privacy Policy (both updated and effective as of the
   27th April 2017).

   End User License Agreement: www.juce.com/juce-5-licence
   Privacy Policy: www.juce.com/juce-5-privacy-policy

   Or: You may also use this code under the terms of the GPL v3 (see
   www.gnu.org/licenses).

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

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

namespace juce
{

#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \
 STATICMETHOD (getAndroidBluetoothManager, "getAndroidBluetoothManager", "(Landroid/content/Context;)Lcom/roli/juce/JuceMidiSupport$BluetoothManager;")

DECLARE_JNI_CLASS_WITH_MIN_SDK (AndroidJuceMidiSupport, "com/roli/juce/JuceMidiSupport", 23)
#undef JNI_CLASS_MEMBERS

#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \
 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 (getBluetoothDeviceStatus, "getBluetoothDeviceStatus", "(Ljava/lang/String;)I") \
 METHOD (startStopScan, "startStopScan", "(Z)V")

DECLARE_JNI_CLASS_WITH_MIN_SDK (AndroidBluetoothManager, "com/roli/juce/JuceMidiSupport$BluetoothManager", 23)
#undef JNI_CLASS_MEMBERS

//==============================================================================
struct AndroidBluetoothMidiInterface
{
    static void startStopScan (bool startScanning)
    {
        JNIEnv* env = getEnv();
        LocalRef<jobject> btManager (env->CallStaticObjectMethod (AndroidJuceMidiSupport, AndroidJuceMidiSupport.getAndroidBluetoothManager, getAppContext().get()));

        if (btManager.get() != nullptr)
            env->CallVoidMethod (btManager.get(), AndroidBluetoothManager.startStopScan, (jboolean) (startScanning ? 1 : 0));
    }

    static StringArray getBluetoothMidiDevicesNearby()
    {
        StringArray retval;

        JNIEnv* env = getEnv();

        LocalRef<jobject> btManager (env->CallStaticObjectMethod (AndroidJuceMidiSupport, AndroidJuceMidiSupport.getAndroidBluetoothManager, getAppContext().get()));

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

        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 (env->CallStaticObjectMethod (AndroidJuceMidiSupport, AndroidJuceMidiSupport.getAndroidBluetoothManager, getAppContext().get()));
        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 (env->CallStaticObjectMethod (AndroidJuceMidiSupport, AndroidJuceMidiSupport.getAndroidBluetoothManager, getAppContext().get()));

        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 (env->CallStaticObjectMethod (AndroidJuceMidiSupport, AndroidJuceMidiSupport.getAndroidBluetoothManager, getAppContext().get()));

        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);
    }

    //==============================================================================
    enum PairStatus
    {
        unpaired = 0,
        paired = 1,
        pairing = 2
    };

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

        LocalRef<jobject> btManager (env->CallStaticObjectMethod (AndroidJuceMidiSupport, AndroidJuceMidiSupport.getAndroidBluetoothManager, getAppContext().get()));

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

        return static_cast<PairStatus> (env->CallIntMethod (btManager.get(), AndroidBluetoothManager.getBluetoothDeviceStatus,
                                                            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);
        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) 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,
                        Rectangle<float> (xmargin, ymargin, deviceNameWidth - (2.0f * xmargin), height - (2.0f * ymargin)),
                        Justification::topLeft, true);

            g.setColour (getDeviceStatusFontColour (device.connectionStatus));
            g.drawText (statusString,
                        Rectangle<float> (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;
            switch (AndroidBluetoothMidiInterface::isBluetoothDevicePaired (*address))
            {
                case AndroidBluetoothMidiInterface::pairing:
                    status = AndroidBluetoothMidiDevice::connecting;
                    break;
                case AndroidBluetoothMidiInterface::paired:
                    status = AndroidBluetoothMidiDevice::connected;
                    break;
                default:
                    status = 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 (ModalComponentManager::Callback* exitCallbackToUse,
                                  const Rectangle<int>& boundsToUse)
        : bounds (boundsToUse)
    {
        std::unique_ptr<ModalComponentManager::Callback> exitCallback (exitCallbackToUse);

        AndroidBluetoothMidiInterface::startStopScan (true);

        setAlwaysOnTop (true);
        setVisible (true);
        addToDesktop (ComponentPeer::windowHasDropShadow);

        if (bounds.isEmpty())
            setBounds (0, 0, getParentWidth(), getParentHeight());
        else
            setBounds (bounds);

        toFront (true);
        setOpaque (! bounds.isEmpty());

        addAndMakeVisible (bluetoothDevicesList);
        enterModalState (true, exitCallback.release(), true);
    }

    ~BluetoothMidiSelectorOverlay() override
    {
        AndroidBluetoothMidiInterface::startStopScan (false);
    }

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

        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:
    Rectangle<int> bounds;

    void update()
    {
        if (bounds.isEmpty())
            setBounds (0, 0, getParentWidth(), getParentHeight());
        else
            setBounds (bounds);

        bluetoothDevicesList.setBounds (getOverlayBounds().withTrimmedTop (40));
    }

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

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

        return bounds.withZeroOrigin();
    }

    AndroidBluetoothMidiDevicesListBox bluetoothDevicesList;

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (BluetoothMidiSelectorOverlay)
};

//==============================================================================
bool BluetoothMidiDevicePairingDialogue::open (ModalComponentManager::Callback* exitCallbackPtr,
                                               Rectangle<int>* btBounds)
{
    std::unique_ptr<ModalComponentManager::Callback> exitCallback (exitCallbackPtr);

    if (getAndroidSDKVersion() < 23)
        return false;

    auto boundsToUse = (btBounds != nullptr ? *btBounds : Rectangle<int> {});

    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;
    }

    new BluetoothMidiSelectorOverlay (exitCallback.release(), boundsToUse);
    return true;
}

bool BluetoothMidiDevicePairingDialogue::isAvailable()
{
    if (getAndroidSDKVersion() < 23)
        return false;

    auto* env = getEnv();

    LocalRef<jobject> btManager (env->CallStaticObjectMethod (AndroidJuceMidiSupport, AndroidJuceMidiSupport.getAndroidBluetoothManager, getAppContext().get()));
    return btManager != nullptr;
}

} // namespace juce
