/*
 * Copyright (C) 2023 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 com.android.settings.bluetooth;

import static android.os.UserManager.DISALLOW_CONFIG_BLUETOOTH;

import android.app.settings.SettingsEnums;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.content.Context;
import android.content.DialogInterface;
import android.content.res.Resources;
import android.graphics.drawable.Drawable;
import android.os.UserManager;
import android.text.Html;
import android.text.TextUtils;
import android.util.Log;
import android.util.Pair;
import android.util.TypedValue;
import android.view.View;
import android.widget.ImageView;

import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.appcompat.app.AlertDialog;
import androidx.preference.Preference;
import androidx.preference.PreferenceViewHolder;

import com.android.settings.R;
import com.android.settings.overlay.FeatureFactory;
import com.android.settings.widget.GearPreference;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.core.instrumentation.MetricsFeatureProvider;
import com.android.settingslib.flags.Flags;
import com.android.settingslib.utils.ThreadUtils;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;

/**
 * BluetoothDevicePreference is the preference type used to display each remote
 * Bluetooth device in the Bluetooth Settings screen.
 */
public final class BluetoothDevicePreference extends GearPreference {
    private static final String TAG = "BluetoothDevicePref";

    private static int sDimAlpha = Integer.MIN_VALUE;

    @Retention(RetentionPolicy.SOURCE)
    @IntDef({SortType.TYPE_DEFAULT,
            SortType.TYPE_FIFO,
            SortType.TYPE_NO_SORT})
    public @interface SortType {
        int TYPE_DEFAULT = 1;
        int TYPE_FIFO = 2;
        int TYPE_NO_SORT = 3;
    }

    private final CachedBluetoothDevice mCachedDevice;
    private Set<CachedBluetoothDevice> mCachedDeviceGroup;

    private final UserManager mUserManager;
    private final LocalBluetoothManager mLocalBtManager;

    private Set<BluetoothDevice> mBluetoothDevices;
    @VisibleForTesting
    BluetoothAdapter mBluetoothAdapter;
    private final boolean mShowDevicesWithoutNames;
    @NonNull
    private static final AtomicInteger sNextId = new AtomicInteger();
    private final int mId;
    private final int mType;

    private AlertDialog mDisconnectDialog;
    @Nullable private AlertDialog mBlockPairingDialog;
    private String contentDescription = null;
    private boolean mHideSecondTarget = false;
    private boolean mIsCallbackRemoved = true;
    @VisibleForTesting
    boolean mNeedNotifyHierarchyChanged = false;
    /* Talk-back descriptions for various BT icons */
    Resources mResources;
    final BluetoothDevicePreferenceCallback mCallback;
    @VisibleForTesting
    final BluetoothAdapter.OnMetadataChangedListener mMetadataListener =
            new BluetoothAdapter.OnMetadataChangedListener() {
                @Override
                public void onMetadataChanged(BluetoothDevice device, int key, byte[] value) {
                    Log.d(TAG, String.format("Metadata updated in Device %s: %d = %s.",
                            device.getAnonymizedAddress(),
                            key, value == null ? null : new String(value)));
                    onPreferenceAttributesChanged();
                }
            };

    private class BluetoothDevicePreferenceCallback implements CachedBluetoothDevice.Callback {

        @Override
        public void onDeviceAttributesChanged() {
            onPreferenceAttributesChanged();
            Set<CachedBluetoothDevice> newCachedDeviceGroup = new HashSet<>(
                    Utils.findAllCachedBluetoothDevicesByGroupId(mLocalBtManager, mCachedDevice));
            if (!mCachedDeviceGroup.equals(newCachedDeviceGroup)) {
                for (CachedBluetoothDevice cachedBluetoothDevice : mCachedDeviceGroup) {
                    cachedBluetoothDevice.unregisterCallback(this);
                }
                unregisterMetadataChangedListener();

                mCachedDeviceGroup = newCachedDeviceGroup;

                for (CachedBluetoothDevice cachedBluetoothDevice : mCachedDeviceGroup) {
                    cachedBluetoothDevice.registerCallback(getContext().getMainExecutor(), this);
                }
                registerMetadataChangedListener();
            }
        }
    }

    public BluetoothDevicePreference(Context context, CachedBluetoothDevice cachedDevice,
            boolean showDeviceWithoutNames, @SortType int type) {
        super(context, null);
        mResources = getContext().getResources();
        mUserManager = (UserManager) context.getSystemService(Context.USER_SERVICE);
        mLocalBtManager = Utils.getLocalBluetoothManager(context);
        mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
        mShowDevicesWithoutNames = showDeviceWithoutNames;

        if (sDimAlpha == Integer.MIN_VALUE) {
            TypedValue outValue = new TypedValue();
            context.getTheme().resolveAttribute(android.R.attr.disabledAlpha, outValue, true);
            sDimAlpha = (int) (outValue.getFloat() * 255);
        }

        mCachedDevice = cachedDevice;
        mCachedDeviceGroup = new HashSet<>(
                Utils.findAllCachedBluetoothDevicesByGroupId(mLocalBtManager, mCachedDevice));
        mCallback = new BluetoothDevicePreferenceCallback();
        mId = sNextId.getAndIncrement();
        mType = type;
        setVisible(false);

        onPreferenceAttributesChanged();
    }

    public void setNeedNotifyHierarchyChanged(boolean needNotifyHierarchyChanged) {
        mNeedNotifyHierarchyChanged = needNotifyHierarchyChanged;
    }

    @Override
    protected boolean shouldHideSecondTarget() {
        return mCachedDevice == null
                || mCachedDevice.getBondState() != BluetoothDevice.BOND_BONDED
                || mUserManager.hasUserRestriction(DISALLOW_CONFIG_BLUETOOTH)
                || mHideSecondTarget;
    }

    @Override
    protected int getSecondTargetResId() {
        return R.layout.preference_widget_gear;
    }

    public CachedBluetoothDevice getCachedDevice() {
        return mCachedDevice;
    }

    @Override
    protected void onPrepareForRemoval() {
        super.onPrepareForRemoval();
        if (!mIsCallbackRemoved) {
            for (CachedBluetoothDevice cachedBluetoothDevice : mCachedDeviceGroup) {
                cachedBluetoothDevice.unregisterCallback(mCallback);
            }
            unregisterMetadataChangedListener();
            mIsCallbackRemoved = true;
        }
        if (mDisconnectDialog != null) {
            mDisconnectDialog.dismiss();
            mDisconnectDialog = null;
        }
    }

    @Override
    public void onAttached() {
        super.onAttached();
        if (mIsCallbackRemoved) {
            for (CachedBluetoothDevice cachedBluetoothDevice : mCachedDeviceGroup) {
                cachedBluetoothDevice.registerCallback(getContext().getMainExecutor(), mCallback);
            }
            registerMetadataChangedListener();
            mIsCallbackRemoved = false;
        }
        onPreferenceAttributesChanged();
    }

    @Override
    public void onDetached() {
        super.onDetached();
        if (!mIsCallbackRemoved) {
            for (CachedBluetoothDevice cachedBluetoothDevice : mCachedDeviceGroup) {
                cachedBluetoothDevice.unregisterCallback(mCallback);
            }
            unregisterMetadataChangedListener();
            mIsCallbackRemoved = true;
        }
    }

    private void registerMetadataChangedListener() {
        if (mBluetoothAdapter == null) {
            Log.d(TAG, "No mBluetoothAdapter");
            return;
        }

        mBluetoothDevices = mCachedDeviceGroup.stream()
                .map(CachedBluetoothDevice::getDevice)
                .collect(Collectors.toCollection(HashSet::new));

        if (mBluetoothDevices.isEmpty()) {
            Log.d(TAG, "No BT device to register.");
            return;
        }
        Set<BluetoothDevice> errorDevices = new HashSet<>();
        mBluetoothDevices.forEach(bd -> {
            try {
                boolean isSuccess = mBluetoothAdapter.addOnMetadataChangedListener(bd,
                        getContext().getMainExecutor(), mMetadataListener);
                if (!isSuccess) {
                    Log.e(TAG, bd.getAnonymizedAddress() + ": add into Listener failed");
                    errorDevices.add(bd);
                }
            } catch (NullPointerException e) {
                errorDevices.add(bd);
                Log.e(TAG, bd.getAnonymizedAddress() + ":" + e.toString());
            } catch (IllegalArgumentException e) {
                errorDevices.add(bd);
                Log.e(TAG, bd.getAnonymizedAddress() + ":" + e.toString());
            }
        });
        for (BluetoothDevice errorDevice : errorDevices) {
            mBluetoothDevices.remove(errorDevice);
            Log.d(TAG, "mBluetoothDevices remove " + errorDevice.getAnonymizedAddress());
        }
    }

    private void unregisterMetadataChangedListener() {
        if (mBluetoothAdapter == null) {
            Log.d(TAG, "No mBluetoothAdapter");
            return;
        }
        if (mBluetoothDevices == null || mBluetoothDevices.isEmpty()) {
            Log.d(TAG, "No BT device to unregister.");
            return;
        }
        mBluetoothDevices.forEach(bd -> {
            try {
                mBluetoothAdapter.removeOnMetadataChangedListener(bd, mMetadataListener);
            } catch (NullPointerException e) {
                Log.e(TAG, bd.getAnonymizedAddress() + ":" + e.toString());
            } catch (IllegalArgumentException e) {
                Log.e(TAG, bd.getAnonymizedAddress() + ":" + e.toString());
            }
        });
        mBluetoothDevices.clear();
    }

    public CachedBluetoothDevice getBluetoothDevice() {
        return mCachedDevice;
    }

    public void hideSecondTarget(boolean hideSecondTarget) {
        mHideSecondTarget = hideSecondTarget;
    }

    @SuppressWarnings("FutureReturnValueIgnored")
    void onPreferenceAttributesChanged() {
        try {
            ThreadUtils.postOnBackgroundThread(() -> {
                if (mCachedDevice.getDevice() != null) {
                    Log.d(TAG, "onPreferenceAttributesChanged, start updating for device "
                            + mCachedDevice.getDevice().getAnonymizedAddress());
                }
                @Nullable String name = mCachedDevice.getName();
                // Null check is done at the framework
                @Nullable String connectionSummary = getConnectionSummary();
                @NonNull Pair<Drawable, String> pair = mCachedDevice.getDrawableWithDescription();
                boolean isBusy = mCachedDevice.isBusy();
                // Device is only visible in the UI if it has a valid name besides MAC address or
                // when user allows showing devices without user-friendly name in developer settings
                boolean isVisible =
                        mShowDevicesWithoutNames || mCachedDevice.hasHumanReadableName();

                ThreadUtils.postOnMainThread(() -> {
                    /*
                     * The preference framework takes care of making sure the value has
                     * changed before proceeding. It will also call notifyChanged() if
                     * any preference info has changed from the previous value.
                     */
                    setTitle(name);
                    setSummary(connectionSummary);
                    setIcon(pair.first);
                    contentDescription = pair.second;
                    // Used to gray out the item
                    setEnabled(!isBusy);
                    setVisible(isVisible);

                    // This could affect ordering, so notify that
                    if (mNeedNotifyHierarchyChanged) {
                        notifyHierarchyChanged();
                    }
                });
                Log.d(TAG, "onPreferenceAttributesChanged, complete updating for device " + name);
            });
        } catch (RejectedExecutionException e) {
            Log.w(TAG, "Handler thread unavailable, skipping getConnectionSummary!");
        }
    }

    @Override
    public void onBindViewHolder(PreferenceViewHolder view) {
        // Disable this view if the bluetooth enable/disable preference view is off
        if (null != findPreferenceInHierarchy("bt_checkbox")) {
            setDependency("bt_checkbox");
        }

        if (mCachedDevice.getBondState() == BluetoothDevice.BOND_BONDED) {
            ImageView deviceDetails = (ImageView) view.findViewById(R.id.settings_button);
            deviceDetails.setContentDescription(
                    getContext().getResources().getString(
                            R.string.device_detail_icon_content_description, getTitle()));

            if (deviceDetails != null) {
                deviceDetails.setOnClickListener(this);
            }
        }
        final ImageView imageView = (ImageView) view.findViewById(android.R.id.icon);
        if (imageView != null) {
            imageView.setContentDescription(contentDescription);
            // Set property to prevent Talkback from reading out.
            imageView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
            imageView.setElevation(
                    getContext().getResources().getDimension(R.dimen.bt_icon_elevation));
        }
        super.onBindViewHolder(view);
    }

    @Override
    public boolean equals(Object o) {
        if ((o == null) || !(o instanceof BluetoothDevicePreference)) {
            return false;
        }
        return mCachedDevice.equals(
                ((BluetoothDevicePreference) o).mCachedDevice);
    }

    @Override
    public int hashCode() {
        return mCachedDevice.hashCode();
    }

    @Override
    public int compareTo(Preference another) {
        if (!(another instanceof BluetoothDevicePreference)) {
            // Rely on default sort
            return super.compareTo(another);
        }

        switch (mType) {
            case SortType.TYPE_DEFAULT:
                return mCachedDevice
                        .compareTo(((BluetoothDevicePreference) another).mCachedDevice);
            case SortType.TYPE_FIFO:
                return mId > ((BluetoothDevicePreference) another).mId ? 1 : -1;
            default:
                return super.compareTo(another);
        }
    }

    /**
     * Performs different actions according to the device connected and bonded state after
     * clicking on the preference.
     */
    public void onClicked() {
        Context context = getContext();
        int bondState = mCachedDevice.getBondState();

        final MetricsFeatureProvider metricsFeatureProvider =
                FeatureFactory.getFeatureFactory().getMetricsFeatureProvider();

        if (mCachedDevice.isConnected()) {
            metricsFeatureProvider.action(context,
                    SettingsEnums.ACTION_SETTINGS_BLUETOOTH_DISCONNECT);
            askDisconnect();
        } else if (bondState == BluetoothDevice.BOND_BONDED) {
            metricsFeatureProvider.action(context,
                    SettingsEnums.ACTION_SETTINGS_BLUETOOTH_CONNECT);
            mCachedDevice.connect();
        } else if (bondState == BluetoothDevice.BOND_NONE) {
            var unused = ThreadUtils.postOnBackgroundThread(() -> {
                if (Flags.enableTemporaryBondDevicesUi() && Utils.shouldBlockPairingInAudioSharing(
                        mLocalBtManager)) {
                    // TODO: collect metric
                    context.getMainExecutor().execute(() ->
                            mBlockPairingDialog =
                                    Utils.showBlockPairingDialog(context, mBlockPairingDialog,
                                            mLocalBtManager));
                    return;
                }
                metricsFeatureProvider.action(context,
                        SettingsEnums.ACTION_SETTINGS_BLUETOOTH_PAIR);
                if (!mCachedDevice.hasHumanReadableName()) {
                    metricsFeatureProvider.action(context,
                            SettingsEnums.ACTION_SETTINGS_BLUETOOTH_PAIR_DEVICES_WITHOUT_NAMES);
                }
                context.getMainExecutor().execute(() -> pair());
            });
        }
    }

    // Show disconnect confirmation dialog for a device.
    private void askDisconnect() {
        Context context = getContext();
        String name = mCachedDevice.getName();
        if (TextUtils.isEmpty(name)) {
            name = context.getString(R.string.bluetooth_device);
        }
        String message = context.getString(R.string.bluetooth_disconnect_all_profiles, name);
        String title = context.getString(R.string.bluetooth_disconnect_title);

        DialogInterface.OnClickListener disconnectListener = new DialogInterface.OnClickListener() {
            public void onClick(DialogInterface dialog, int which) {
                mCachedDevice.disconnect();
            }
        };

        mDisconnectDialog = Utils.showDisconnectDialog(context,
                mDisconnectDialog, disconnectListener, title, Html.fromHtml(message));
    }

    private void pair() {
        if (!mCachedDevice.startPairing()) {
            Utils.showError(getContext(), mCachedDevice.getName(),
                    com.android.settingslib.R.string.bluetooth_pairing_error_message);
        }
    }

    private String getConnectionSummary() {
        String summary = null;
        if (mCachedDevice.getBondState() != BluetoothDevice.BOND_NONE) {
            summary = mCachedDevice.getConnectionSummary();
        }
        return summary;
    }
}
