/*
 * Copyright (C) 2011 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package android.server;

import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothHealth;
import android.bluetooth.BluetoothHealthAppConfiguration;
import android.bluetooth.BluetoothProfile;
import android.bluetooth.IBluetoothHealthCallback;
import android.content.Context;
import android.os.Handler;
import android.os.Message;
import android.os.ParcelFileDescriptor;
import android.os.RemoteException;
import android.util.Log;

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map.Entry;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * This handles all the operations on the Bluetooth Health profile.
 * All functions are called by BluetoothService, as Bluetooth Service
 * is the Service handler for the HDP profile.
 *
 * @hide
 */
final class BluetoothHealthProfileHandler {
    private static final String TAG = "BluetoothHealthProfileHandler";
    private static final boolean DBG = false;

    private static BluetoothHealthProfileHandler sInstance;
    private BluetoothService mBluetoothService;
    private ArrayList<HealthChannel> mHealthChannels;
    private HashMap <BluetoothHealthAppConfiguration, String> mHealthAppConfigs;
    private HashMap <BluetoothDevice, Integer> mHealthDevices;
    private HashMap <BluetoothHealthAppConfiguration, IBluetoothHealthCallback> mCallbacks;

    private static final int MESSAGE_REGISTER_APPLICATION = 0;
    private static final int MESSAGE_UNREGISTER_APPLICATION = 1;
    private static final int MESSAGE_CONNECT_CHANNEL = 2;
    private static final AtomicInteger sChannelId = new AtomicInteger();

    class HealthChannel {
        private ParcelFileDescriptor mChannelFd;
        private boolean mMainChannel;
        private String mChannelPath;
        private BluetoothDevice mDevice;
        private BluetoothHealthAppConfiguration mConfig;
        private int mState;
        private int mChannelType;
        private int mId;

        HealthChannel(BluetoothDevice device, BluetoothHealthAppConfiguration config,
                ParcelFileDescriptor fd, boolean mainChannel, String channelPath) {
             mChannelFd = fd;
             mMainChannel = mainChannel;
             mChannelPath = channelPath;
             mDevice = device;
             mConfig = config;
             mState = BluetoothHealth.STATE_CHANNEL_DISCONNECTED;
             mId = getChannelId();
        }
    }

    private final Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
            case MESSAGE_REGISTER_APPLICATION:
                BluetoothHealthAppConfiguration registerApp =
                    (BluetoothHealthAppConfiguration) msg.obj;
                int role = registerApp.getRole();
                String path = null;

                if (role == BluetoothHealth.SINK_ROLE) {
                    path = mBluetoothService.registerHealthApplicationNative(
                            registerApp.getDataType(), getStringRole(role), registerApp.getName());
                } else {
                    path = mBluetoothService.registerHealthApplicationNative(
                            registerApp.getDataType(), getStringRole(role), registerApp.getName(),
                            getStringChannelType(registerApp.getChannelType()));
                }

                if (path == null) {
                    callHealthApplicationStatusCallback(registerApp,
                            BluetoothHealth.APP_CONFIG_REGISTRATION_FAILURE);
                    mCallbacks.remove(registerApp);
                } else {
                    mHealthAppConfigs.put(registerApp, path);
                    callHealthApplicationStatusCallback(registerApp,
                            BluetoothHealth.APP_CONFIG_REGISTRATION_SUCCESS);
                }

                break;
            case MESSAGE_UNREGISTER_APPLICATION:
                BluetoothHealthAppConfiguration unregisterApp =
                    (BluetoothHealthAppConfiguration) msg.obj;

                // Disconnect all the channels
                for (HealthChannel chan : mHealthChannels) {
                    if (chan.mConfig.equals(unregisterApp) &&
                            chan.mState != BluetoothHealth.STATE_CHANNEL_DISCONNECTED) {
                        disconnectChannel(chan.mDevice, unregisterApp, chan.mId);
                    }
                }

                boolean result = mBluetoothService.unregisterHealthApplicationNative(
                        mHealthAppConfigs.get(unregisterApp));
                if (result) {
                    callHealthApplicationStatusCallback(unregisterApp,
                            BluetoothHealth.APP_CONFIG_UNREGISTRATION_SUCCESS);
                    mCallbacks.remove(unregisterApp);
                    mHealthAppConfigs.remove(unregisterApp);
                } else {
                    callHealthApplicationStatusCallback(unregisterApp,
                            BluetoothHealth.APP_CONFIG_UNREGISTRATION_FAILURE);
                }
                break;
            case MESSAGE_CONNECT_CHANNEL:
                HealthChannel chan = (HealthChannel)msg.obj;
                String deviceObjectPath =
                    mBluetoothService.getObjectPathFromAddress(chan.mDevice.getAddress());
                String configPath = mHealthAppConfigs.get(chan.mConfig);
                String channelType = getStringChannelType(chan.mChannelType);

                if (!mBluetoothService.createChannelNative(deviceObjectPath, configPath,
                          channelType, chan.mId)) {
                    int prevState = chan.mState;
                    int state = BluetoothHealth.STATE_CHANNEL_DISCONNECTED;
                    callHealthChannelCallback(chan.mConfig, chan.mDevice, prevState, state, null,
                            chan.mId);
                    mHealthChannels.remove(chan);
                }
            }
        }
    };

    private BluetoothHealthProfileHandler(Context context, BluetoothService service) {
        mBluetoothService = service;
        mHealthAppConfigs = new HashMap<BluetoothHealthAppConfiguration, String>();
        mHealthChannels = new ArrayList<HealthChannel>();
        mHealthDevices = new HashMap<BluetoothDevice, Integer>();
        mCallbacks = new HashMap<BluetoothHealthAppConfiguration, IBluetoothHealthCallback>();
    }

    static synchronized BluetoothHealthProfileHandler getInstance(Context context,
            BluetoothService service) {
        if (sInstance == null) sInstance = new BluetoothHealthProfileHandler(context, service);
        return sInstance;
    }

    boolean registerAppConfiguration(BluetoothHealthAppConfiguration config,
                                     IBluetoothHealthCallback callback) {
        Message msg = mHandler.obtainMessage(MESSAGE_REGISTER_APPLICATION);
        msg.obj = config;
        mHandler.sendMessage(msg);
        mCallbacks.put(config, callback);
        return true;
    }

    boolean unregisterAppConfiguration(BluetoothHealthAppConfiguration config) {
        String path = mHealthAppConfigs.get(config);
        if (path == null) return false;

        Message msg = mHandler.obtainMessage(MESSAGE_UNREGISTER_APPLICATION);
        msg.obj = config;
        mHandler.sendMessage(msg);
        return true;
    }

    boolean connectChannelToSource(BluetoothDevice device,
            BluetoothHealthAppConfiguration config) {
        return connectChannel(device, config, BluetoothHealth.CHANNEL_TYPE_ANY);
    }

    private HealthChannel getMainChannel(BluetoothDevice device,
            BluetoothHealthAppConfiguration config) {
        for (HealthChannel chan: mHealthChannels) {
            if (chan.mDevice.equals(device) && chan.mConfig.equals(config)) {
                if (chan.mMainChannel) return chan;
            }
        }
        return null;
    }

    boolean connectChannel(BluetoothDevice device,
            BluetoothHealthAppConfiguration config, int channelType) {
        String deviceObjectPath =
            mBluetoothService.getObjectPathFromAddress(device.getAddress());
        if (deviceObjectPath == null) return false;

        String configPath = mHealthAppConfigs.get(config);
        if (configPath == null) return false;

        HealthChannel chan = new HealthChannel(device, config, null, false, null);
        chan.mState = BluetoothHealth.STATE_CHANNEL_CONNECTING;
        chan.mChannelType = channelType;
        mHealthChannels.add(chan);

        int prevState = BluetoothHealth.STATE_CHANNEL_DISCONNECTED;
        int state = BluetoothHealth.STATE_CHANNEL_CONNECTING;
        callHealthChannelCallback(config, device, prevState, state, null, chan.mId);

        Message msg = mHandler.obtainMessage(MESSAGE_CONNECT_CHANNEL);
        msg.obj = chan;
        mHandler.sendMessage(msg);

        return true;
    }

    private String getStringChannelType(int type) {
        if (type == BluetoothHealth.CHANNEL_TYPE_RELIABLE) {
            return "Reliable";
        } else if (type == BluetoothHealth.CHANNEL_TYPE_STREAMING) {
            return "Streaming";
        } else {
            return "Any";
        }
    }

    private String getStringRole(int role) {
        if (role == BluetoothHealth.SINK_ROLE) {
            return "Sink";
        } else if (role == BluetoothHealth.SOURCE_ROLE) {
            return "Streaming";
        } else {
            return null;
        }
    }

    private int getChannelId() {
        // The function doesn't need to be synchronized, as the health profile handler
        // will only allow one health channel object creation at a time.
        // In the worst case the while loop will have to break out at some point of
        // time, because only a limited number of L2CAP channels are possible.
        int id;
        boolean found;
        do {
            id = sChannelId.incrementAndGet();
            found = false;
            for (HealthChannel chan: mHealthChannels) {
                if (chan.mId == id) found = true;
            }
        } while (found);
        return id;
    }

    boolean disconnectChannel(BluetoothDevice device,
            BluetoothHealthAppConfiguration config, int id) {
        HealthChannel chan = findChannelById(id);
        if (chan == null) {
          return false;
        }

        String deviceObjectPath =
                mBluetoothService.getObjectPathFromAddress(device.getAddress());

        mBluetoothService.releaseChannelFdNative(chan.mChannelPath);

        int prevState = chan.mState;
        chan.mState = BluetoothHealth.STATE_CHANNEL_DISCONNECTING;
        callHealthChannelCallback(config, device, prevState, chan.mState,
                null, chan.mId);

        if (!mBluetoothService.destroyChannelNative(deviceObjectPath, chan.mChannelPath,
                                                    chan.mId)) {
            prevState = chan.mState;
            chan.mState = BluetoothHealth.STATE_CHANNEL_CONNECTED;
            callHealthChannelCallback(config, device, prevState, chan.mState,
                    chan.mChannelFd, chan.mId);
            return false;
        } else {
            return true;
        }
    }

    private HealthChannel findChannelById(int id) {
        for (HealthChannel chan : mHealthChannels) {
            if (chan.mId == id) return chan;
        }
        return null;
    }

    private HealthChannel findChannelByPath(BluetoothDevice device, String path) {
        for (HealthChannel chan : mHealthChannels) {
            if (path.equals(chan.mChannelPath) && device.equals(chan.mDevice)) return chan;
        }
        return null;
    }

    private List<HealthChannel> findChannelByStates(BluetoothDevice device, int[] states) {
        List<HealthChannel> channels = new ArrayList<HealthChannel>();
        for (HealthChannel chan: mHealthChannels) {
            if (chan.mDevice.equals(device)) {
                for (int state : states) {
                    if (chan.mState == state) {
                        channels.add(chan);
                    }
                }
            }
        }
        return channels;
    }

    private HealthChannel findConnectingChannel(BluetoothDevice device,
            BluetoothHealthAppConfiguration config) {
        for (HealthChannel chan : mHealthChannels) {
            if (chan.mDevice.equals(device) && chan.mConfig.equals(config) &&
                chan.mState == BluetoothHealth.STATE_CHANNEL_CONNECTING) return chan;
        }
        return null;
    }

    ParcelFileDescriptor getMainChannelFd(BluetoothDevice device,
            BluetoothHealthAppConfiguration config) {
        HealthChannel chan = getMainChannel(device, config);
        if (chan != null) {
            ParcelFileDescriptor pfd =  null;
            try {
                pfd = chan.mChannelFd.dup();
                return pfd;
            } catch (IOException e) {
                return null;
            }
        }

        String objectPath =
                mBluetoothService.getObjectPathFromAddress(device.getAddress());
        if (objectPath == null) return null;

        String mainChannelPath = mBluetoothService.getMainChannelNative(objectPath);
        if (mainChannelPath == null) return null;

        // We had no record of the main channel but querying Bluez we got a
        // main channel. We might not have received the PropertyChanged yet for
        // the main channel creation so update our data structure here.
        chan = findChannelByPath(device, mainChannelPath);
        if (chan == null) {
            errorLog("Main Channel present but we don't have any account of it:" +
                    device +":" + config);
            return null;
        }
        chan.mMainChannel = true;
        try {
            return chan.mChannelFd.dup();
        } catch (IOException e) {
            return null;
        }
    }

    /*package*/ void onHealthDevicePropertyChanged(String devicePath,
            String channelPath) {
        BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
        String address = mBluetoothService.getAddressFromObjectPath(devicePath);
        if (address == null) return;

        //TODO: Fix this in Bluez
        if (channelPath.equals("/")) {
            // This means that the main channel is being destroyed.
            return;
        }

        BluetoothDevice device = adapter.getRemoteDevice(address);
        BluetoothHealthAppConfiguration config = findHealthApplication(device,
                channelPath);
        if (config != null) {
            HealthChannel chan = findChannelByPath(device, channelPath);
            if (chan == null) {
                errorLog("Health Channel is not present:" + channelPath);
            } else {
                chan.mMainChannel = true;
            }
        }
    }

    /*package*/ void onHealthDeviceChannelConnectionError(int chanCode,
                                                          int state) {
        HealthChannel channel = findChannelById(chanCode);
        if (channel == null) errorLog("No record of this channel:" + chanCode);

        callHealthChannelCallback(channel.mConfig, channel.mDevice, channel.mState, state, null,
                chanCode);
    }

    private BluetoothHealthAppConfiguration findHealthApplication(
            BluetoothDevice device, String channelPath) {
        BluetoothHealthAppConfiguration config = null;
        HealthChannel chan = findChannelByPath(device, channelPath);

        if (chan != null) {
            config = chan.mConfig;
        } else {
            String configPath = mBluetoothService.getChannelApplicationNative(channelPath);
            if (configPath == null) {
                errorLog("Config path is null for application");
            } else {
                for (Entry<BluetoothHealthAppConfiguration, String> e :
                        mHealthAppConfigs.entrySet()) {
                    if (e.getValue().equals(configPath)) {
                        config = e.getKey();
                    }
                }
                if (config == null) errorLog("No associated application for path:" + configPath);
            }
        }
        return config;
    }

    /*package*/ void onHealthDeviceChannelChanged(String devicePath,
            String channelPath, boolean exists) {
        debugLog("onHealthDeviceChannelChanged: devicePath: " + devicePath +
                "ChannelPath: " + channelPath + "Exists: " + exists);
        BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
        String address = mBluetoothService.getAddressFromObjectPath(devicePath);
        if (address == null) return;

        BluetoothDevice device = adapter.getRemoteDevice(address);
        BluetoothHealthAppConfiguration config;
        int state, prevState = BluetoothHealth.STATE_CHANNEL_DISCONNECTED;
        ParcelFileDescriptor fd;
        HealthChannel channel;
        config = findHealthApplication(device, channelPath);

        if (exists) {
            channel = findConnectingChannel(device, config);
            if (channel == null) {
               channel = new HealthChannel(device, config, null, false,
                       channelPath);
               channel.mState = BluetoothHealth.STATE_CHANNEL_DISCONNECTED;
               mHealthChannels.add(channel);
            }
            channel.mChannelPath = channelPath;

            fd = mBluetoothService.getChannelFdNative(channelPath);
            if (fd == null) {
                errorLog("Error obtaining fd for channel:" + channelPath);
                disconnectChannel(device, config, channel.mId);
                return;
            }
            boolean mainChannel =
                    getMainChannel(device, config) == null ? false : true;
            if (!mainChannel) {
                String mainChannelPath =
                        mBluetoothService.getMainChannelNative(devicePath);
                if (mainChannelPath == null) {
                    errorLog("Main Channel Path is null for devicePath:" + devicePath);
                    return;
                }
                if (mainChannelPath.equals(channelPath)) mainChannel = true;
            }

            channel.mChannelFd = fd;
            channel.mMainChannel = mainChannel;
            prevState = channel.mState;
            state = BluetoothHealth.STATE_CHANNEL_CONNECTED;
        } else {
            channel = findChannelByPath(device, channelPath);
            if (channel == null) {
                errorLog("Channel not found:" + config + ":" + channelPath);
                return;
            }
            mHealthChannels.remove(channel);

            channel.mChannelFd = null;
            prevState = channel.mState;
            state = BluetoothHealth.STATE_CHANNEL_DISCONNECTED;
        }
        channel.mState = state;
        callHealthChannelCallback(config, device, prevState, state, channel.mChannelFd,
                channel.mId);
    }

    private void callHealthChannelCallback(BluetoothHealthAppConfiguration config,
            BluetoothDevice device, int prevState, int state, ParcelFileDescriptor fd, int id) {
        broadcastHealthDeviceStateChange(device, prevState, state);

        debugLog("Health Device Callback: " + device + " State Change: "
                + prevState + "->" + state);

        ParcelFileDescriptor dupedFd = null;
        if (fd != null) {
            try {
                dupedFd = fd.dup();
            } catch (IOException e) {
                dupedFd = null;
                errorLog("Exception while duping: " + e);
            }
        }

        IBluetoothHealthCallback callback = mCallbacks.get(config);
        if (callback != null) {
            try {
                callback.onHealthChannelStateChange(config, device, prevState, state, dupedFd, id);
            } catch (RemoteException e) {
                errorLog("Remote Exception:" + e);
            }
        }
    }

    private void callHealthApplicationStatusCallback(
            BluetoothHealthAppConfiguration config, int status) {
        debugLog("Health Device Application: " + config + " State Change: status:"
                + status);
        IBluetoothHealthCallback callback = mCallbacks.get(config);
        if (callback != null) {
            try {
                callback.onHealthAppConfigurationStatusChange(config, status);
            } catch (RemoteException e) {
                errorLog("Remote Exception:" + e);
            }
        }
    }

    int getHealthDeviceConnectionState(BluetoothDevice device) {
        if (mHealthDevices.get(device) == null) {
            return BluetoothHealth.STATE_DISCONNECTED;
        }
        return mHealthDevices.get(device);
    }

    List<BluetoothDevice> getConnectedHealthDevices() {
        List<BluetoothDevice> devices = lookupHealthDevicesMatchingStates(
                    new int[] {BluetoothHealth.STATE_CONNECTED});
        return devices;
    }

    List<BluetoothDevice> getHealthDevicesMatchingConnectionStates(int[] states) {
        List<BluetoothDevice> devices = lookupHealthDevicesMatchingStates(states);
        return devices;
    }

    List<BluetoothDevice> lookupHealthDevicesMatchingStates(int[] states) {
        List<BluetoothDevice> healthDevices = new ArrayList<BluetoothDevice>();

        for (BluetoothDevice device: mHealthDevices.keySet()) {
            int healthDeviceState = getHealthDeviceConnectionState(device);
            for (int state : states) {
                if (state == healthDeviceState) {
                    healthDevices.add(device);
                    break;
                }
            }
        }
        return healthDevices;
    }

    /**
     * This function sends the intent for the updates on the connection status to the remote device.
     * Note that multiple channels can be connected to the remote device by multiple applications.
     * This sends an intent for the update to the device connection status and not the channel
     * connection status. Only the following state transitions are possible:
     *
     * {@link BluetoothHealth#STATE_DISCONNECTED} to {@link BluetoothHealth#STATE_CONNECTING}
     * {@link BluetoothHealth#STATE_CONNECTING} to {@link BluetoothHealth#STATE_CONNECTED}
     * {@link BluetoothHealth#STATE_CONNECTED} to {@link BluetoothHealth#STATE_DISCONNECTING}
     * {@link BluetoothHealth#STATE_DISCONNECTING} to {@link BluetoothHealth#STATE_DISCONNECTED}
     * {@link BluetoothHealth#STATE_DISCONNECTED} to {@link BluetoothHealth#STATE_CONNECTED}
     * {@link BluetoothHealth#STATE_CONNECTED} to {@link BluetoothHealth#STATE_DISCONNECTED}
     * {@link BluetoothHealth#STATE_CONNECTING} to {{@link BluetoothHealth#STATE_DISCONNECTED}
     *
     * @param device
     * @param prevChannelState
     * @param newChannelState
     * @hide
     */
    private void broadcastHealthDeviceStateChange(BluetoothDevice device, int prevChannelState,
            int newChannelState) {
        if (mHealthDevices.get(device) == null) {
            mHealthDevices.put(device, BluetoothHealth.STATE_DISCONNECTED);
        }

        int currDeviceState = mHealthDevices.get(device);
        int newDeviceState = convertState(newChannelState);

        if (currDeviceState != newDeviceState) {
            List<HealthChannel> chan;
            switch (currDeviceState) {
                case BluetoothHealth.STATE_DISCONNECTED:
                    updateAndSendIntent(device, currDeviceState, newDeviceState);
                    break;
                case BluetoothHealth.STATE_CONNECTING:
                    // Channel got connected.
                    if (newDeviceState == BluetoothHealth.STATE_CONNECTED) {
                        updateAndSendIntent(device, currDeviceState, newDeviceState);
                    } else {
                        // Channel got disconnected
                        chan = findChannelByStates(device, new int [] {
                                    BluetoothHealth.STATE_CHANNEL_CONNECTING,
                                    BluetoothHealth.STATE_CHANNEL_DISCONNECTING});
                        if (chan.isEmpty()) {
                            updateAndSendIntent(device, currDeviceState, newDeviceState);
                        }
                    }
                    break;
                case BluetoothHealth.STATE_CONNECTED:
                    // Channel got disconnected or is in disconnecting state.
                    chan = findChannelByStates(device, new int [] {
                                BluetoothHealth.STATE_CHANNEL_CONNECTING,
                                BluetoothHealth.STATE_CHANNEL_CONNECTED});
                    if (chan.isEmpty()) {
                        updateAndSendIntent(device, currDeviceState, newDeviceState);
                    }
                    break;
                case BluetoothHealth.STATE_DISCONNECTING:
                    // Channel got disconnected.
                    chan = findChannelByStates(device, new int [] {
                                BluetoothHealth.STATE_CHANNEL_CONNECTING,
                                BluetoothHealth.STATE_CHANNEL_DISCONNECTING});
                    if (chan.isEmpty()) {
                        updateAndSendIntent(device, currDeviceState, newDeviceState);
                    }
                    break;
            }
        }
    }

    private void updateAndSendIntent(BluetoothDevice device, int prevDeviceState,
            int newDeviceState) {
        mHealthDevices.put(device, newDeviceState);
        mBluetoothService.sendConnectionStateChange(device, BluetoothProfile.HEALTH,
                                                    newDeviceState, prevDeviceState);
    }

    /**
     * This function converts the channel connection state to device connection state.
     *
     * @param state
     * @return
     */
    private int convertState(int state) {
        switch (state) {
            case BluetoothHealth.STATE_CHANNEL_CONNECTED:
                return BluetoothHealth.STATE_CONNECTED;
            case BluetoothHealth.STATE_CHANNEL_CONNECTING:
                return BluetoothHealth.STATE_CONNECTING;
            case BluetoothHealth.STATE_CHANNEL_DISCONNECTING:
                return BluetoothHealth.STATE_DISCONNECTING;
            case BluetoothHealth.STATE_CHANNEL_DISCONNECTED:
                return BluetoothHealth.STATE_DISCONNECTED;
        }
        errorLog("Mismatch in Channel and Health Device State");
        return -1;
    }

    private static void debugLog(String msg) {
        if (DBG) Log.d(TAG, msg);
    }

    private static void errorLog(String msg) {
        Log.e(TAG, msg);
    }
}
