/*
 * Copyright (C) 2018 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.car.cluster;

import static android.car.VehicleAreaType.VEHICLE_AREA_TYPE_GLOBAL;

import android.annotation.Nullable;
import android.app.Application;
import android.car.Car;
import android.car.CarAppFocusManager;
import android.car.CarNotConnectedException;
import android.car.VehicleAreaType;
import android.car.VehiclePropertyIds;
import android.car.cluster.sensors.Sensor;
import android.car.cluster.sensors.Sensors;
import android.car.hardware.CarPropertyValue;
import android.car.hardware.property.CarPropertyManager;
import android.content.ComponentName;
import android.content.ServiceConnection;
import android.os.IBinder;
import android.util.Log;
import android.util.TypedValue;

import androidx.annotation.NonNull;
import androidx.core.util.Preconditions;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;

import java.text.DecimalFormat;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

/**
 * {@link AndroidViewModel} for cluster information.
 */
public class ClusterViewModel extends AndroidViewModel {
    private static final String TAG = "Cluster.ViewModel";

    private static final float PROPERTIES_REFRESH_RATE_UI = 5f;

    private float mSpeedFactor;
    private float mDistanceFactor;

    public enum NavigationActivityState {
        /** No activity has been selected to be displayed on the navigation fragment yet */
        NOT_SELECTED,
        /** An activity has been selected, but it is not yet visible to the user */
        LOADING,
        /** Navigation activity is visible to the user */
        VISIBLE,
    }

    private ComponentName mFreeNavigationActivity;
    private ComponentName mCurrentNavigationActivity;
    private final MutableLiveData<NavigationActivityState> mNavigationActivityStateLiveData =
            new MutableLiveData<>();
    private final MutableLiveData<Boolean> mNavigationFocus = new MutableLiveData<>(false);
    private Car mCar;
    private CarAppFocusManager mCarAppFocusManager;
    private CarPropertyManager mCarPropertyManager;
    private Map<Sensor<?>, MutableLiveData<?>> mSensorLiveDatas = new HashMap<>();

    private ServiceConnection mCarServiceConnection = new ServiceConnection() {
        @Override
        public void onServiceConnected(ComponentName name, IBinder service) {
            try {
                Log.i(TAG, "onServiceConnected, name: " + name + ", service: " + service);

                registerAppFocusListener();
                registerCarPropertiesListener();
            } catch (CarNotConnectedException e) {
                Log.e(TAG, "onServiceConnected: error obtaining manager", e);
            }
        }

        @Override
        public void onServiceDisconnected(ComponentName name) {
            Log.i(TAG, "onServiceDisconnected, name: " + name);
            mCarAppFocusManager = null;
            mCarPropertyManager = null;
        }
    };

    private void registerAppFocusListener() throws CarNotConnectedException {
        mCarAppFocusManager = (CarAppFocusManager) mCar.getCarManager(
                Car.APP_FOCUS_SERVICE);
        if (mCarAppFocusManager != null) {
            mCarAppFocusManager.addFocusListener(
                    (appType, active) -> setNavigationFocus(active),
                    CarAppFocusManager.APP_FOCUS_TYPE_NAVIGATION);
        } else {
            Log.e(TAG, "onServiceConnected: unable to obtain CarAppFocusManager");
        }
    }

    private void registerCarPropertiesListener() throws CarNotConnectedException {
        Sensors sensors = Sensors.getInstance();
        mCarPropertyManager = (CarPropertyManager) mCar.getCarManager(Car.PROPERTY_SERVICE);
        for (Integer propertyId : sensors.getPropertyIds()) {
            try {
                mCarPropertyManager.subscribePropertyEvents(propertyId,
                        PROPERTIES_REFRESH_RATE_UI, mCarPropertyEventCallback);
            } catch (SecurityException ex) {
                Log.e(TAG, "onServiceConnected: Unable to listen to car property: " + propertyId
                        + " sensors: " + sensors.getSensorsForPropertyId(propertyId), ex);
            }
        }
    }

    private CarPropertyManager.CarPropertyEventCallback mCarPropertyEventCallback =
            new CarPropertyManager.CarPropertyEventCallback() {
                @Override
                public void onChangeEvent(CarPropertyValue value) {
                    if (Log.isLoggable(TAG, Log.DEBUG)) {
                        Log.d(TAG,
                                "CarProperty change: property " + value.getPropertyId() + ", area"
                                        + value.getAreaId() + ", value: " + value.getValue());
                    }
                    for (Sensor<?> sensorId : Sensors.getInstance()
                            .getSensorsForPropertyId(value.getPropertyId())) {
                        if (sensorId.mAreaId == VEHICLE_AREA_TYPE_GLOBAL
                                || (sensorId.mAreaId & value.getAreaId()) != 0) {
                            setSensorValue(sensorId, value);
                        }
                    }
                }

                @Override
                public void onErrorEvent(int propId, int zone) {
                    for (Sensor<?> sensorId : Sensors.getInstance().getSensorsForPropertyId(
                            propId)) {
                        if (sensorId.mAreaId == VehicleAreaType.VEHICLE_AREA_TYPE_GLOBAL
                                || (sensorId.mAreaId & zone) != 0) {
                            setSensorValue(sensorId, null);
                        }
                    }
                }

                private <T> void setSensorValue(Sensor<T> id, CarPropertyValue<?> value) {
                    T newValue = value != null ? id.mAdapter.apply(value) : null;
                    if (Log.isLoggable(TAG, Log.DEBUG)) {
                        Log.d(TAG, "Sensor " + id.mName + " = " + newValue);
                    }
                    getSensorMutableLiveData(id).setValue(newValue);
                }
            };

    /**
     * New {@link ClusterViewModel} instance
     */
    public ClusterViewModel(@NonNull Application application) {
        super(application);
        mCar = Car.createCar(application, mCarServiceConnection);
        mCar.connect();

        TypedValue tv = new TypedValue();
        getApplication().getResources().getValue(R.dimen.speed_factor, tv, true);
        mSpeedFactor = tv.getFloat();

        getApplication().getResources().getValue(R.dimen.distance_factor, tv, true);
        mDistanceFactor = tv.getFloat();
    }

    @Override
    protected void onCleared() {
        super.onCleared();
        mCar.disconnect();
        mCar = null;
        mCarAppFocusManager = null;
        mCarPropertyManager = null;
    }

    /**
     * Returns a {@link LiveData} providing the current state of the activity displayed on the
     * navigation fragment.
     */
    public LiveData<NavigationActivityState> getNavigationActivityState() {
        return mNavigationActivityStateLiveData;
    }

    /**
     * Returns a {@link LiveData} indicating whether navigation focus is currently being granted
     * or not. This indicates whether a navigation application is currently providing driving
     * directions.
     */
    public LiveData<Boolean> getNavigationFocus() {
        return mNavigationFocus;
    }

    /**
     * Returns a {@link LiveData} that tracks the value of a given car sensor. Each sensor has its
     * own data type. The list of all supported sensors can be found at {@link Sensors}
     *
     * @param sensor sensor to observe
     * @param <T>    data type of such sensor
     */
    @SuppressWarnings("unchecked")
    @NonNull
    public <T> LiveData<T> getSensor(@NonNull Sensor<T> sensor) {
        return getSensorMutableLiveData(Preconditions.checkNotNull(sensor));
    }

    /**
     * Returns the current value of the sensor, directly from the VHAL.
     *
     * @param sensor sensor to read
     * @param <T>    data type of such sensor
     */
    @Nullable
    public <T> T getSensorValue(@NonNull Sensor<T> sensor) {
        if (mCarPropertyManager == null) {
            Log.e(TAG, "CarPropertyManager reference is null, car service is disconnected.");
            return null;
        }
        CarPropertyValue<?> carPropertyValue = mCarPropertyManager.getProperty(sensor.mPropertyId,
                sensor.mAreaId);
        if (carPropertyValue == null) {
            Log.w(TAG, "Property ID: " + VehiclePropertyIds.toString(sensor.mPropertyId)
                    + " Area ID: 0x" + Integer.toHexString(sensor.mAreaId)
                    + " returned null from CarPropertyManager#getProperty()");
            return null;
        }
        return sensor.mAdapter.apply(carPropertyValue);
    }

    /**
     * Returns a {@link LiveData} that tracks the fuel level in a range from 0 to 100.
     */
    public LiveData<Integer> getFuelLevel() {
        return Transformations.map(getSensor(Sensors.SENSOR_FUEL), (fuelValue) -> {
            Float fuelCapacityValue = getSensorValue(Sensors.SENSOR_FUEL_CAPACITY);
            if (fuelValue == null || fuelCapacityValue == null || fuelCapacityValue == 0) {
                return null;
            }
            if (fuelValue < 0.0f) {
                return 0;
            }
            if (fuelValue > fuelCapacityValue) {
                return 100;
            }
            return Math.round(fuelValue / fuelCapacityValue * 100f);
        });
    }

    /**
     * Returns a {@link LiveData} that tracks the RPM x 1000
     */
    public LiveData<String> getRPM() {
        return Transformations.map(getSensor(Sensors.SENSOR_RPM), (rpmValue) -> {
            return new DecimalFormat("#0.0").format(rpmValue / 1000f);
        });
    }

    /**
     * Returns a {@link LiveData} that tracks the speed in either mi/h or km/h depending on locale.
     */
    public LiveData<Integer> getSpeed() {
        return Transformations.map(getSensor(Sensors.SENSOR_SPEED), (speedValue) -> {
            return Math.round(speedValue * mSpeedFactor);
        });
    }

    /**
     * Returns a {@link LiveData} that tracks the range the vehicle has until it runs out of gas.
     */
    public LiveData<Integer> getRange() {
        return Transformations.map(getSensor(Sensors.SENSOR_FUEL_RANGE), (rangeValue) -> {
            return Math.round(rangeValue / mDistanceFactor);
        });
    }

    /**
     * Sets the activity selected to be displayed on the cluster when no driving directions are
     * being provided.
     */
    public void setFreeNavigationActivity(ComponentName activity) {
        if (!Objects.equals(activity, mFreeNavigationActivity)) {
            mFreeNavigationActivity = activity;
            updateNavigationActivityLiveData();
        }
    }

    /**
     * Sets the activity currently being displayed on the cluster.
     */
    public void setCurrentNavigationActivity(ComponentName activity) {
        if (!Objects.equals(activity, mCurrentNavigationActivity)) {
            mCurrentNavigationActivity = activity;
            updateNavigationActivityLiveData();
        }
    }

    /**
     * Sets whether navigation focus is currently being granted or not.
     */
    public void setNavigationFocus(boolean navigationFocus) {
        if (mNavigationFocus.getValue() == null || mNavigationFocus.getValue() != navigationFocus) {
            mNavigationFocus.setValue(navigationFocus);
            updateNavigationActivityLiveData();
        }
    }

    private void updateNavigationActivityLiveData() {
        NavigationActivityState newState = calculateNavigationActivityState();
        if (newState != mNavigationActivityStateLiveData.getValue()) {
            mNavigationActivityStateLiveData.setValue(newState);
        }
    }

    private NavigationActivityState calculateNavigationActivityState() {
        if (Log.isLoggable(TAG, Log.DEBUG)) {
            Log.d(TAG, String.format("Current state: current activity = '%s', free nav activity = "
                            + "'%s', focus = %s", mCurrentNavigationActivity,
                    mFreeNavigationActivity,
                    mNavigationFocus.getValue()));
        }
        if (mNavigationFocus.getValue() != null && mNavigationFocus.getValue()) {
            // Car service controls which activity is displayed while driving, so we assume this
            // has already been taken care of.
            return NavigationActivityState.VISIBLE;
        } else if (mFreeNavigationActivity == null) {
            return NavigationActivityState.NOT_SELECTED;
        } else if (Objects.equals(mFreeNavigationActivity, mCurrentNavigationActivity)) {
            return NavigationActivityState.VISIBLE;
        } else {
            return NavigationActivityState.LOADING;
        }
    }

    @SuppressWarnings("unchecked")
    private <T> MutableLiveData<T> getSensorMutableLiveData(Sensor<T> sensor) {
        return (MutableLiveData<T>) mSensorLiveDatas
                .computeIfAbsent(sensor, x -> new MutableLiveData<>());
    }
}
