/*
 * Copyright (C) 2012 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.sdklib.devices;

import com.android.SdkConstants;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.prefs.AndroidLocation;
import com.android.prefs.AndroidLocation.AndroidLocationException;
import com.android.repository.io.FileOpUtils;
import com.android.resources.Keyboard;
import com.android.resources.KeyboardState;
import com.android.resources.Navigation;
import com.android.sdklib.internal.avd.AvdManager;
import com.android.sdklib.internal.avd.HardwareProperties;
import com.android.repository.io.FileOp;
import com.android.sdklib.repository.PkgProps;
import com.android.utils.ILogger;
import com.google.common.base.Charsets;
import com.google.common.collect.HashBasedTable;
import com.google.common.collect.Table;
import com.google.common.hash.HashFunction;
import com.google.common.hash.Hasher;
import com.google.common.hash.Hashing;
import com.google.common.io.Closeables;

import org.xml.sax.SAXException;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactoryConfigurationError;

/**
 * Manager class for interacting with {@link Device}s within the SDK
 */
public class DeviceManager {

    private static final String  DEVICE_PROFILES_PROP = "DeviceProfiles";
    private static final Pattern PATH_PROPERTY_PATTERN =
        Pattern.compile('^' + PkgProps.EXTRA_PATH + '=' + DEVICE_PROFILES_PROP + '$');
    private ILogger mLog;
    private Table<String, String, Device> mVendorDevices;
    private Table<String, String, Device> mSysImgDevices;
    private Table<String, String, Device> mUserDevices;
    private Table<String, String, Device> mDefaultDevices;
    private final Object mLock = new Object();
    private final List<DevicesChangedListener> sListeners = new ArrayList<DevicesChangedListener>();
    private final String mOsSdkPath;

    public enum DeviceFilter {
        /** getDevices() flag to list default devices from the bundled devices.xml definitions. */
        DEFAULT,
        /** getDevices() flag to list user devices saved in the .android home folder. */
        USER,
        /** getDevices() flag to list vendor devices -- the bundled nexus.xml devices
         *  as well as all those coming from extra packages. */
        VENDOR,
        /** getDevices() flag to list devices from system-images/platform-N/tag/abi/devices.xml */
        SYSTEM_IMAGES,
    }

    /** getDevices() flag to list all devices. */
    public static final EnumSet<DeviceFilter> ALL_DEVICES  = EnumSet.allOf(DeviceFilter.class);

    public enum DeviceStatus {
        /**
         * The device exists unchanged from the given configuration
         */
        EXISTS,
        /**
         * A device exists with the given name and manufacturer, but has a different configuration
         */
        CHANGED,
        /**
         * There is no device with the given name and manufacturer
         */
        MISSING
    }

    /**
     * Creates a new instance of DeviceManager.
     *
     * @param sdkLocation Path to the current SDK. If null or invalid, vendor and system images
     *                    devices are ignored.
     * @param log SDK logger instance. Should be non-null.
     */
    public static DeviceManager createInstance(@Nullable File sdkLocation, @NonNull ILogger log) {
        // TODO consider using a cache and reusing the same instance of the device manager
        // for the same manager/log combo.
        return new DeviceManager(sdkLocation == null ? null : sdkLocation.getPath(), log);
    }

    /**
     * Creates a new instance of DeviceManager.
     *
     * @param osSdkPath Path to the current SDK. If null or invalid, vendor devices are ignored.
     * @param log SDK logger instance. Should be non-null.
     */
    private DeviceManager(@Nullable String osSdkPath, @NonNull ILogger log) {
        mOsSdkPath = osSdkPath;
        mLog = log;
    }

    /**
     * Interface implemented by objects which want to know when changes occur to the {@link Device}
     * lists.
     */
    public interface DevicesChangedListener {
        /**
         * Called after one of the {@link Device} lists has been updated.
         */
        void onDevicesChanged();
    }

    /**
     * Register a listener to be notified when the device lists are modified.
     *
     * @param listener The listener to add. Ignored if already registered.
     */
    public void registerListener(@NonNull DevicesChangedListener listener) {
        synchronized (sListeners) {
            if (!sListeners.contains(listener)) {
                sListeners.add(listener);
            }
        }
    }

    /**
     * Removes a listener from the notification list such that it will no longer receive
     * notifications when modifications to the {@link Device} list occur.
     *
     * @param listener The listener to remove.
     */
    public boolean unregisterListener(@NonNull DevicesChangedListener listener) {
        synchronized (sListeners) {
            return sListeners.remove(listener);
        }
    }

    @NonNull
    public DeviceStatus getDeviceStatus(@NonNull String name, @NonNull String manufacturer) {
        Device d = getDevice(name, manufacturer);
        if (d == null) {
            return DeviceStatus.MISSING;
        }

        return DeviceStatus.EXISTS;
    }

    @Nullable
    public Device getDevice(@NonNull String id, @NonNull String manufacturer) {
        initDevicesLists();
        Device d = mUserDevices.get(id, manufacturer);
        if (d != null) {
            return d;
        }
        d = mSysImgDevices.get(id, manufacturer);
        if (d != null) {
            return d;
        }
        d = mDefaultDevices.get(id, manufacturer);
        if (d != null) {
            return d;
        }
        d = mVendorDevices.get(id, manufacturer);
        return d;
    }

    @Nullable
    private Device getDeviceImpl(@NonNull Iterable<Device> devicesList,
                                 @NonNull String id,
                                 @NonNull String manufacturer) {
        for (Device d : devicesList) {
            if (d.getId().equals(id) && d.getManufacturer().equals(manufacturer)) {
                return d;
            }
        }
        return null;
    }

    /**
     * Returns the known {@link Device} list.
     *
     * @param deviceFilter One of the {@link DeviceFilter} constants.
     * @return A copy of the list of {@link Device}s. Can be empty but not null.
     */
    @NonNull
    public Collection<Device> getDevices(@NonNull DeviceFilter deviceFilter) {
        return getDevices(EnumSet.of(deviceFilter));
    }

    /**
     * Returns the known {@link Device} list.
     *
     * @param deviceFilter A combination of the {@link DeviceFilter} constants
     *                     or the constant {@link DeviceManager#ALL_DEVICES}.
     * @return A copy of the list of {@link Device}s. Can be empty but not null.
     */
    @NonNull
    public Collection<Device> getDevices(@NonNull EnumSet<DeviceFilter> deviceFilter) {
        initDevicesLists();
        Table<String, String, Device> devices = HashBasedTable.create();
        if (mUserDevices != null && (deviceFilter.contains(DeviceFilter.USER))) {
            devices.putAll(mUserDevices);
        }
        if (mDefaultDevices != null && (deviceFilter.contains(DeviceFilter.DEFAULT))) {
            devices.putAll(mDefaultDevices);
        }
        if (mVendorDevices != null && (deviceFilter.contains(DeviceFilter.VENDOR))) {
            devices.putAll(mVendorDevices);
        }
        if (mSysImgDevices != null && (deviceFilter.contains(DeviceFilter.SYSTEM_IMAGES))) {
            devices.putAll(mSysImgDevices);
        }
        return Collections.unmodifiableCollection(devices.values());
    }

    private void initDevicesLists() {
        boolean changed = initDefaultDevices();
        changed |= initVendorDevices();
        changed |= initSysImgDevices();
        changed |= initUserDevices();
        if (changed) {
            notifyListeners();
        }
    }

    /**
     * Initializes the {@link Device}s packaged with the SDK.
     * @return True if the list has changed.
     */
    private boolean initDefaultDevices() {
        synchronized (mLock) {
            if (mDefaultDevices != null) {
                return false;
            }
            InputStream stream = DeviceManager.class
                    .getResourceAsStream(SdkConstants.FN_DEVICES_XML);
            try {
                assert stream != null : SdkConstants.FN_DEVICES_XML + " not bundled in sdklib.";
                mDefaultDevices = DeviceParser.parse(stream);
                return true;
            } catch (IllegalStateException e) {
                // The device builders can throw IllegalStateExceptions if
                // build gets called before everything is properly setup
                mLog.error(e, null);
                mDefaultDevices = HashBasedTable.create();
            } catch (Exception e) {
                mLog.error(e, "Error reading default devices");
                mDefaultDevices = HashBasedTable.create();
            } finally {
                Closeables.closeQuietly(stream);
            }
        }
        return false;
    }

    /**
     * Initializes all vendor-provided {@link Device}s: the bundled nexus.xml devices
     * as well as all those coming from extra packages.
     * @return True if the list has changed.
     */
    private boolean initVendorDevices() {
        synchronized (mLock) {
            if (mVendorDevices != null) {
                return false;
            }

            mVendorDevices = HashBasedTable.create();

            // Load builtin devices
            InputStream stream = DeviceManager.class.getResourceAsStream("nexus.xml");
            try {
                mVendorDevices.putAll(DeviceParser.parse(stream));
            } catch (Exception e) {
                mLog.error(e, "Could not load nexus devices");
            } finally {
                Closeables.closeQuietly(stream);
            }

            stream = DeviceManager.class.getResourceAsStream("wear.xml");
            try {
                mVendorDevices.putAll(DeviceParser.parse(stream));
            } catch (Exception e) {
                mLog.error(e, "Could not load wear devices");
            } finally {
                Closeables.closeQuietly(stream);
            }

            stream = DeviceManager.class.getResourceAsStream("tv.xml");
            try {
                mVendorDevices.putAll(DeviceParser.parse(stream));
            } catch (Exception e) {
                mLog.error(e, "Could not load tv devices");
            } finally {
                Closeables.closeQuietly(stream);
            }

            if (mOsSdkPath != null) {
                // Load devices from vendor extras
                File extrasFolder = new File(mOsSdkPath, SdkConstants.FD_EXTRAS);
                List<File> deviceDirs = getExtraDirs(extrasFolder);
                for (File deviceDir : deviceDirs) {
                    File deviceXml = new File(deviceDir, SdkConstants.FN_DEVICES_XML);
                    if (deviceXml.isFile()) {
                        mVendorDevices.putAll(loadDevices(deviceXml));
                    }
                }
                return true;
            }
        }
        return false;
    }

    /**
     * Initializes all system-image provided {@link Device}s.
     * @return True if the list has changed.
     */
    private boolean initSysImgDevices() {
        synchronized (mLock) {
            if (mSysImgDevices != null) {
                return false;
            }
            mSysImgDevices = HashBasedTable.create();

            if (mOsSdkPath == null) {
                return false;
            }

            // Load devices from tagged system-images
            // Path pattern is /sdk/system-images/<platform-N>/<tag>/<abi>/devices.xml

            FileOp fop = FileOpUtils.create();
            File sysImgFolder = new File(mOsSdkPath, SdkConstants.FD_SYSTEM_IMAGES);

            for (File platformFolder : fop.listFiles(sysImgFolder)) {
                if (!fop.isDirectory(platformFolder)) {
                    continue;
                }

                for (File tagFolder : fop.listFiles(platformFolder)) {
                    if (!fop.isDirectory(tagFolder)) {
                        continue;
                    }

                    for (File abiFolder : fop.listFiles(tagFolder)) {
                        if (!fop.isDirectory(abiFolder)) {
                            continue;
                        }

                        File deviceXml = new File(abiFolder, SdkConstants.FN_DEVICES_XML);
                        if (fop.isFile(deviceXml)) {
                            mSysImgDevices.putAll(loadDevices(deviceXml));
                        }
                    }
                }
            }
            return true;
        }
    }

    /**
     * Initializes all user-created {@link Device}s
     * @return True if the list has changed.
     */
    private boolean initUserDevices() {
        synchronized (mLock) {
            if (mUserDevices != null) {
                return false;
            }
            // User devices should be saved out to
            // $HOME/.android/devices.xml
            mUserDevices = HashBasedTable.create();
            File userDevicesFile = null;
            try {
                userDevicesFile = new File(
                        AndroidLocation.getFolder(),
                        SdkConstants.FN_DEVICES_XML);
                if (userDevicesFile.exists()) {
                    mUserDevices.putAll(DeviceParser.parse(userDevicesFile));
                    return true;
                }
            } catch (AndroidLocationException e) {
                mLog.warning("Couldn't load user devices: %1$s", e.getMessage());
            } catch (SAXException e) {
                // Probably an old config file which we don't want to overwrite.
                if (userDevicesFile != null) {
                    String base = userDevicesFile.getAbsoluteFile() + ".old";
                    File renamedConfig = new File(base);
                    int i = 0;
                    while (renamedConfig.exists()) {
                        renamedConfig = new File(base + '.' + (i++));
                    }
                    mLog.error(e, "Error parsing %1$s, backing up to %2$s",
                            userDevicesFile.getAbsolutePath(),
                            renamedConfig.getAbsolutePath());
                    userDevicesFile.renameTo(renamedConfig);
                }
            } catch (ParserConfigurationException e) {
                mLog.error(e, "Error parsing %1$s",
                        userDevicesFile == null ? "(null)" : userDevicesFile.getAbsolutePath());
            } catch (IOException e) {
                mLog.error(e, "Error parsing %1$s",
                        userDevicesFile == null ? "(null)" : userDevicesFile.getAbsolutePath());
            }
        }
        return false;
    }

    public void addUserDevice(@NonNull Device d) {
        boolean changed = false;
        synchronized (mLock) {
            if (mUserDevices == null) {
                initUserDevices();
                assert mUserDevices != null;
            }
            if (mUserDevices != null) {
                mUserDevices.put(d.getId(), d.getManufacturer(), d);
            }
            changed = true;
        }
        if (changed) {
            notifyListeners();
        }
    }

    public void removeUserDevice(@NonNull Device d) {
        synchronized (mLock) {
            if (mUserDevices == null) {
                initUserDevices();
                assert mUserDevices != null;
            }
            if (mUserDevices != null) {
                if (mUserDevices.contains(d.getId(), d.getManufacturer())) {
                    mUserDevices.remove(d.getId(), d.getManufacturer());
                    notifyListeners();
                }
            }
        }
    }

    public void replaceUserDevice(@NonNull Device d) {
        synchronized (mLock) {
            if (mUserDevices == null) {
                initUserDevices();
            }
            removeUserDevice(d);
            addUserDevice(d);
        }
    }

    /**
     * Saves out the user devices to {@link SdkConstants#FN_DEVICES_XML} in
     * {@link AndroidLocation#getFolder()}.
     */
    public void saveUserDevices() {
        if (mUserDevices == null) {
            return;
        }

        File userDevicesFile = null;
        try {
            userDevicesFile = new File(AndroidLocation.getFolder(),
                    SdkConstants.FN_DEVICES_XML);
        } catch (AndroidLocationException e) {
            mLog.warning("Couldn't find user directory: %1$s", e.getMessage());
            return;
        }

        if (mUserDevices.isEmpty()) {
            userDevicesFile.delete();
            return;
        }

        synchronized (mLock) {
            if (!mUserDevices.isEmpty()) {
                try {
                    DeviceWriter.writeToXml(new FileOutputStream(userDevicesFile), mUserDevices.values());
                } catch (FileNotFoundException e) {
                    mLog.warning("Couldn't open file: %1$s", e.getMessage());
                } catch (ParserConfigurationException e) {
                    mLog.warning("Error writing file: %1$s", e.getMessage());
                } catch (TransformerFactoryConfigurationError e) {
                    mLog.warning("Error writing file: %1$s", e.getMessage());
                } catch (TransformerException e) {
                    mLog.warning("Error writing file: %1$s", e.getMessage());
                }
            }
        }
    }

    /**
     * Returns hardware properties (defined in hardware.ini) as a {@link Map}.
     *
     * @param s The {@link State} from which to derive the hardware properties.
     * @return A {@link Map} of hardware properties.
     */
    @NonNull
    public static Map<String, String> getHardwareProperties(@NonNull State s) {
        Hardware hw = s.getHardware();
        Map<String, String> props = new HashMap<String, String>();
        props.put(HardwareProperties.HW_MAINKEYS,
                getBooleanVal(hw.getButtonType().equals(ButtonType.HARD)));
        props.put(HardwareProperties.HW_TRACKBALL,
                getBooleanVal(hw.getNav().equals(Navigation.TRACKBALL)));
        props.put(HardwareProperties.HW_KEYBOARD,
                getBooleanVal(hw.getKeyboard().equals(Keyboard.QWERTY)));
        props.put(HardwareProperties.HW_DPAD,
                getBooleanVal(hw.getNav().equals(Navigation.DPAD)));

        Set<Sensor> sensors = hw.getSensors();
        props.put(HardwareProperties.HW_GPS, getBooleanVal(sensors.contains(Sensor.GPS)));
        props.put(HardwareProperties.HW_BATTERY,
                getBooleanVal(hw.getChargeType().equals(PowerType.BATTERY)));
        props.put(HardwareProperties.HW_ACCELEROMETER,
                getBooleanVal(sensors.contains(Sensor.ACCELEROMETER)));
        props.put(HardwareProperties.HW_ORIENTATION_SENSOR,
                getBooleanVal(sensors.contains(Sensor.GYROSCOPE)));
        props.put(HardwareProperties.HW_AUDIO_INPUT, getBooleanVal(hw.hasMic()));
        props.put(HardwareProperties.HW_SDCARD, getBooleanVal(!hw.getRemovableStorage().isEmpty()));
        props.put(HardwareProperties.HW_LCD_DENSITY,
                Integer.toString(hw.getScreen().getPixelDensity().getDpiValue()));
        props.put(HardwareProperties.HW_PROXIMITY_SENSOR,
                getBooleanVal(sensors.contains(Sensor.PROXIMITY_SENSOR)));
        return props;
    }

    /**
     * Returns the hardware properties defined in
     * {@link AvdManager#HARDWARE_INI} as a {@link Map}.
     *
     * This is intended to be dumped in the config.ini and already contains
     * the device name, manufacturer and device hash.
     *
     * @param d The {@link Device} from which to derive the hardware properties.
     * @return A {@link Map} of hardware properties.
     */
    @NonNull
    public static Map<String, String> getHardwareProperties(@NonNull Device d) {
        Map<String, String> props = getHardwareProperties(d.getDefaultState());
        for (State s : d.getAllStates()) {
            if (s.getKeyState().equals(KeyboardState.HIDDEN)) {
                props.put("hw.keyboard.lid", getBooleanVal(true));
            }
        }

        HashFunction md5 = Hashing.md5();
        Hasher hasher = md5.newHasher();

        ArrayList<String> keys = new ArrayList<String>(props.keySet());
        Collections.sort(keys);
        for (String key : keys) {
            if (key != null) {
                hasher.putString(key, Charsets.UTF_8);
                String value = props.get(key);
                hasher.putString(value == null ? "null" : value, Charsets.UTF_8);
            }
        }
        // store the hash method for potential future compatibility
        String hash = "MD5:" + hasher.hash().toString();
        props.put(AvdManager.AVD_INI_DEVICE_HASH_V2, hash);
        props.remove(AvdManager.AVD_INI_DEVICE_HASH_V1);

        props.put(AvdManager.AVD_INI_DEVICE_NAME, d.getId());
        props.put(AvdManager.AVD_INI_DEVICE_MANUFACTURER, d.getManufacturer());
        return props;
    }

    /**
     * Checks whether the the hardware props have changed.
     * If the hash is the same, returns null for success.
     * If the hash is not the same or there's not enough information to indicate it's
     * the same (e.g. if in the future we change the digest method), simply return the
     * new hash, indicating it would be best to update it.
     *
     * @param d The device.
     * @param hashV2 The previous saved AvdManager.AVD_INI_DEVICE_HASH_V2 property.
     * @return Null if the same, otherwise returns the new and different hash.
     */
    @Nullable
    public static String hasHardwarePropHashChanged(@NonNull Device d, @NonNull String hashV2) {
        Map<String, String> props = getHardwareProperties(d);
        String newHash = props.get(AvdManager.AVD_INI_DEVICE_HASH_V2);

        // Implementation detail: don't just return the hash and let the caller decide whether
        // the hash is the same. That's because the hash contains the digest method so if in
        // the future we decide to change it, we could potentially recompute the hash here
        // using an older digest method here and still determine its validity, whereas the
        // caller cannot determine that.

        if (newHash != null && newHash.equals(hashV2)) {
            return null;
        }
        return newHash;
    }


    /**
     * Takes a boolean and returns the appropriate value for
     * {@link HardwareProperties}
     *
     * @param bool The boolean value to turn into the appropriate
     *            {@link HardwareProperties} value.
     * @return {@code HardwareProperties#BOOLEAN_YES} if true,
     *         {@code HardwareProperties#BOOLEAN_NO} otherwise.
     */
    private static String getBooleanVal(boolean bool) {
        if (bool) {
            return HardwareProperties.BOOLEAN_YES;
        }
        return HardwareProperties.BOOLEAN_NO;
    }

    @NonNull
    private Table<String, String, Device> loadDevices(@NonNull File deviceXml) {
        try {
            return DeviceParser.parse(deviceXml);
        } catch (SAXException e) {
            mLog.error(e, "Error parsing %1$s", deviceXml.getAbsolutePath());
        } catch (ParserConfigurationException e) {
            mLog.error(e, "Error parsing %1$s", deviceXml.getAbsolutePath());
        } catch (IOException e) {
            mLog.error(e, "Error reading %1$s", deviceXml.getAbsolutePath());
        } catch (AssertionError e) {
            mLog.error(e, "Error parsing %1$s", deviceXml.getAbsolutePath());
        } catch (IllegalStateException e) {
            // The device builders can throw IllegalStateExceptions if
            // build gets called before everything is properly setup
            mLog.error(e, null);
        }
        return HashBasedTable.create();
    }

    private void notifyListeners() {
        synchronized (sListeners) {
            for (DevicesChangedListener listener : sListeners) {
                listener.onDevicesChanged();
            }
        }
    }

    /* Returns all of DeviceProfiles in the extras/ folder */
    @NonNull
    private List<File> getExtraDirs(@NonNull File extrasFolder) {
        List<File> extraDirs = new ArrayList<File>();
        // All OEM provided device profiles are in
        // $SDK/extras/$VENDOR/$ITEM/devices.xml
        if (extrasFolder != null && extrasFolder.isDirectory()) {
            for (File vendor : extrasFolder.listFiles()) {
                if (vendor.isDirectory()) {
                    for (File item : vendor.listFiles()) {
                        if (item.isDirectory() && isDevicesExtra(item)) {
                            extraDirs.add(item);
                        }
                    }
                }
            }
        }

        return extraDirs;
    }

    /*
     * Returns whether a specific folder for a specific vendor is a
     * DeviceProfiles folder
     */
    private boolean isDevicesExtra(@NonNull File item) {
        File properties = new File(item, SdkConstants.FN_SOURCE_PROP);
        try {
            BufferedReader propertiesReader = new BufferedReader(new FileReader(properties));
            try {
                String line;
                while ((line = propertiesReader.readLine()) != null) {
                    Matcher m = PATH_PROPERTY_PATTERN.matcher(line);
                    if (m.matches()) {
                        return true;
                    }
                }
            } finally {
                propertiesReader.close();
            }
        } catch (IOException ignore) {
        }
        return false;
    }
}
