/*
 * Copyright (C) 2008 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;

import com.android.prefs.AndroidLocation;
import com.android.prefs.AndroidLocation.AndroidLocationException;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * The SDK manager parses the SDK folder and gives access to the content.
 * @see PlatformTarget
 * @see AddOnTarget
 */
public final class SdkManager {

    public final static String PROP_VERSION_SDK = "ro.build.version.sdk";
    public final static String PROP_VERSION_CODENAME = "ro.build.version.codename";
    public final static String PROP_VERSION_RELEASE = "ro.build.version.release";

    private final static String ADDON_NAME = "name";
    private final static String ADDON_VENDOR = "vendor";
    private final static String ADDON_API = "api";
    private final static String ADDON_DESCRIPTION = "description";
    private final static String ADDON_LIBRARIES = "libraries";
    private final static String ADDON_DEFAULT_SKIN = "skin";
    private final static String ADDON_USB_VENDOR = "usb-vendor";
    private final static String ADDON_REVISION = "revision";
    private final static String ADDON_REVISION_OLD = "version";

    private final static Pattern PATTERN_PROP = Pattern.compile(
            "^([a-zA-Z0-9._-]+)\\s*=\\s*(.*)\\s*$");

    private final static Pattern PATTERN_LIB_DATA = Pattern.compile(
            "^([a-zA-Z0-9._-]+\\.jar);(.*)$", Pattern.CASE_INSENSITIVE);

     // usb ids are 16-bit hexadecimal values.
    private final static Pattern PATTERN_USB_IDS = Pattern.compile(
            "^0x[a-f0-9]{4}$", Pattern.CASE_INSENSITIVE);

    /** List of items in the platform to check when parsing it. These paths are relative to the
     * platform root folder. */
    private final static String[] sPlatformContentList = new String[] {
        SdkConstants.FN_FRAMEWORK_LIBRARY,
        SdkConstants.FN_FRAMEWORK_AIDL,
        SdkConstants.OS_SDK_TOOLS_FOLDER + SdkConstants.FN_AAPT,
        SdkConstants.OS_SDK_TOOLS_FOLDER + SdkConstants.FN_AIDL,
        SdkConstants.OS_SDK_TOOLS_FOLDER + SdkConstants.FN_DX,
        SdkConstants.OS_SDK_TOOLS_LIB_FOLDER + SdkConstants.FN_DX_JAR,
    };

    /** Preference file containing the usb ids for adb */
    private final static String ADB_INI_FILE = "adb_usb.ini";
       //0--------90--------90--------90--------90--------90--------90--------90--------9
    private final static String ADB_INI_HEADER =
        "# ANDROID 3RD PARTY USB VENDOR ID LIST -- DO NOT EDIT.\n" +
        "# USE 'android update adb' TO GENERATE.\n" +
        "# 1 USB VENDOR ID PER LINE.\n";

    /** the location of the SDK */
    private final String mSdkLocation;
    private IAndroidTarget[] mTargets;

    /**
     * Creates an {@link SdkManager} for a given sdk location.
     * @param sdkLocation the location of the SDK.
     * @param log the ISdkLog object receiving warning/error from the parsing.
     * @return the created {@link SdkManager} or null if the location is not valid.
     */
    public static SdkManager createManager(String sdkLocation, ISdkLog log) {
        try {
            SdkManager manager = new SdkManager(sdkLocation);
            ArrayList<IAndroidTarget> list = new ArrayList<IAndroidTarget>();
            loadPlatforms(sdkLocation, list, log);
            loadAddOns(sdkLocation, list, log);

            // sort the targets/add-ons
            Collections.sort(list);

            manager.setTargets(list.toArray(new IAndroidTarget[list.size()]));

            return manager;
        } catch (IllegalArgumentException e) {
            if (log != null) {
                log.error(e, "Error parsing the sdk.");
            }
        }

        return null;
    }

    /**
     * Returns the location of the SDK.
     */
    public String getLocation() {
        return mSdkLocation;
    }

    /**
     * Returns the targets that are available in the SDK.
     */
    public IAndroidTarget[] getTargets() {
        return mTargets;
    }

    /**
     * Returns a target from a hash that was generated by {@link IAndroidTarget#hashString()}.
     *
     * @param hash the {@link IAndroidTarget} hash string.
     * @return The matching {@link IAndroidTarget} or null.
     */
    public IAndroidTarget getTargetFromHashString(String hash) {
        if (hash != null) {
            for (IAndroidTarget target : mTargets) {
                if (hash.equals(target.hashString())) {
                    return target;
                }
            }
        }

        return null;
    }

    /**
     * Updates adb with the USB devices declared in the SDK add-ons.
     * @throws AndroidLocationException
     * @throws IOException
     */
    public void updateAdb() throws AndroidLocationException, IOException {
        FileWriter writer = null;
        try {
            // get the android prefs location to know where to write the file.
            File adbIni = new File(AndroidLocation.getFolder(), ADB_INI_FILE);
            writer = new FileWriter(adbIni);

            // first, put all the vendor id in an HashSet to remove duplicate.
            HashSet<Integer> set = new HashSet<Integer>();
            IAndroidTarget[] targets = getTargets();
            for (IAndroidTarget target : targets) {
                if (target.getUsbVendorId() != IAndroidTarget.NO_USB_ID) {
                    set.add(target.getUsbVendorId());
                }
            }

            // write file header.
            writer.write(ADB_INI_HEADER);

            // now write the Id in a text file, one per line.
            for (Integer i : set) {
                writer.write(String.format("0x%04x\n", i));
            }
        } finally {
            if (writer != null) {
                writer.close();
            }
        }
    }

    /**
     * Reloads the content of the SDK.
     * @param log the ISdkLog object receiving warning/error from the parsing.
     */
    public void reloadSdk(ISdkLog log) {
        // get the current target list.
        ArrayList<IAndroidTarget> list = new ArrayList<IAndroidTarget>();
        loadPlatforms(mSdkLocation, list, log);
        loadAddOns(mSdkLocation, list, log);

        // For now replace the old list with the new one.
        // In the future we may want to keep the current objects, so that ADT doesn't have to deal
        // with new IAndroidTarget objects when a target didn't actually change.

        // sort the targets/add-ons
        Collections.sort(list);
        setTargets(list.toArray(new IAndroidTarget[list.size()]));
    }

    private SdkManager(String sdkLocation) {
        mSdkLocation = sdkLocation;
    }

    private void setTargets(IAndroidTarget[] targets) {
        mTargets = targets;
    }

    /**
     * Loads the Platforms from the SDK.
     * @param location Location of the SDK
     * @param list the list to fill with the platforms.
     * @param log the ISdkLog object receiving warning/error from the parsing.
     */
    private static void loadPlatforms(String location, ArrayList<IAndroidTarget> list,
            ISdkLog log) {
        File platformFolder = new File(location, SdkConstants.FD_PLATFORMS);
        if (platformFolder.isDirectory()) {
            File[] platforms  = platformFolder.listFiles();

            for (File platform : platforms) {
                if (platform.isDirectory()) {
                    PlatformTarget target = loadPlatform(platform, log);
                    if (target != null) {
                        list.add(target);
                    }
                } else if (log != null) {
                    log.warning("Ignoring platform '%1$s', not a folder.", platform.getName());
                }
            }

            return;
        }

        String message = null;
        if (platformFolder.exists() == false) {
            message = "%s is missing.";
        } else {
            message = "%s is not a folder.";
        }

        throw new IllegalArgumentException(String.format(message,
                platformFolder.getAbsolutePath()));
    }

    /**
     * Loads a specific Platform at a given location.
     * @param platform the location of the platform.
     * @param log the ISdkLog object receiving warning/error from the parsing.
     */
    private static PlatformTarget loadPlatform(File platform, ISdkLog log) {
        File buildProp = new File(platform, SdkConstants.FN_BUILD_PROP);

        if (buildProp.isFile()) {
            Map<String, String> map = parsePropertyFile(buildProp, log);

            if (map != null) {
                // look for some specific values in the map.

                // version string
                String apiName = map.get(PROP_VERSION_RELEASE);
                if (apiName == null) {
                    if (log != null) {
                        log.error(null,
                                "Ignoring platform '%1$s': %2$s is missing from '%3$s'",
                                platform.getName(), PROP_VERSION_RELEASE,
                                SdkConstants.FN_BUILD_PROP);
                    }
                    return null;
                }

                // api level
                int apiNumber;
                String stringValue = map.get(PROP_VERSION_SDK);
                if (stringValue == null) {
                    if (log != null) {
                        log.error(null,
                                "Ignoring platform '%1$s': %2$s is missing from '%3$s'",
                                platform.getName(), PROP_VERSION_SDK,
                                SdkConstants.FN_BUILD_PROP);
                    }
                    return null;
                } else {
                    try {
                         apiNumber = Integer.parseInt(stringValue);
                    } catch (NumberFormatException e) {
                        // looks like apiNumber does not parse to a number.
                        // Ignore this platform.
                        if (log != null) {
                            log.error(null,
                                    "Ignoring platform '%1$s': %2$s is not a valid number in %3$s.",
                                    platform.getName(), PROP_VERSION_SDK,
                                    SdkConstants.FN_BUILD_PROP);
                        }
                        return null;
                    }
                }

                // codename (optional)
                String apiCodename = map.get(PROP_VERSION_CODENAME);
                if (apiCodename != null && apiCodename.equals("REL")) {
                    apiCodename = null; // REL means it's a release version and therefore the
                                        // codename is irrelevant at this point.
                }

                int revision = 1;
                // FIXME: properly parse the platform revision.

                // api number and name look valid, perform a few more checks
                if (checkPlatformContent(platform, log) == false) {
                    return null;
                }
                // create the target.
                PlatformTarget target = new PlatformTarget(
                        platform.getAbsolutePath(),
                        map,
                        apiNumber,
                        apiCodename,
                        apiName,
                        revision);

                // need to parse the skins.
                String[] skins = parseSkinFolder(target.getPath(IAndroidTarget.SKINS));
                target.setSkins(skins);

                return target;
            }
        } else if (log != null) {
            log.error(null, "Ignoring platform '%1$s': %2$s is missing.", platform.getName(),
                    SdkConstants.FN_BUILD_PROP);
        }

        return null;
    }

    /**
     * Loads the Add-on from the SDK.
     * @param location Location of the SDK
     * @param list the list to fill with the add-ons.
     * @param log the ISdkLog object receiving warning/error from the parsing.
     */
    private static void loadAddOns(String location, ArrayList<IAndroidTarget> list, ISdkLog log) {
        File addonFolder = new File(location, SdkConstants.FD_ADDONS);
        if (addonFolder.isDirectory()) {
            File[] addons  = addonFolder.listFiles();

            for (File addon : addons) {
                // Add-ons have to be folders. Ignore files and no need to warn about them.
                if (addon.isDirectory()) {
                    AddOnTarget target = loadAddon(addon, list, log);
                    if (target != null) {
                        list.add(target);
                    }
                }
            }

            return;
        }

        String message = null;
        if (addonFolder.exists() == false) {
            message = "%s is missing.";
        } else {
            message = "%s is not a folder.";
        }

        throw new IllegalArgumentException(String.format(message,
                addonFolder.getAbsolutePath()));
    }

    /**
     * Loads a specific Add-on at a given location.
     * @param addon the location of the addon.
     * @param targetList The list of Android target that were already loaded from the SDK.
     * @param log the ISdkLog object receiving warning/error from the parsing.
     */
    private static AddOnTarget loadAddon(File addon, ArrayList<IAndroidTarget> targetList,
            ISdkLog log) {
        File addOnManifest = new File(addon, SdkConstants.FN_MANIFEST_INI);

        if (addOnManifest.isFile()) {
            Map<String, String> propertyMap = parsePropertyFile(addOnManifest, log);

            if (propertyMap != null) {
                // look for some specific values in the map.
                // we require name, vendor, and api
                String name = propertyMap.get(ADDON_NAME);
                if (name == null) {
                    displayAddonManifestError(log, addon.getName(), ADDON_NAME);
                    return null;
                }

                String vendor = propertyMap.get(ADDON_VENDOR);
                if (vendor == null) {
                    displayAddonManifestError(log, addon.getName(), ADDON_VENDOR);
                    return null;
                }

                String api = propertyMap.get(ADDON_API);
                PlatformTarget baseTarget = null;
                if (api == null) {
                    displayAddonManifestError(log, addon.getName(), ADDON_API);
                    return null;
                } else {
                    // Look for a platform that has a matching api level or codename.
                    for (IAndroidTarget target : targetList) {
                        if (target.isPlatform() && target.getVersion().equals(api)) {
                            baseTarget = (PlatformTarget)target;
                            break;
                        }
                    }

                    if (baseTarget == null) {
                        // Ignore this add-on.
                        if (log != null) {
                            log.error(null,
                                    "Ignoring add-on '%1$s': Unable to find base platform with API level '%2$s'",
                                    addon.getName(), api);
                        }
                        return null;
                    }
                }

                // get the optional description
                String description = propertyMap.get(ADDON_DESCRIPTION);

                // get the add-on revision
                int revisionValue = 1;
                String revision = propertyMap.get(ADDON_REVISION);
                if (revision == null) {
                    revision = propertyMap.get(ADDON_REVISION_OLD);
                }
                if (revision != null) {
                    try {
                        revisionValue = Integer.parseInt(revision);
                    } catch (NumberFormatException e) {
                        // looks like apiNumber does not parse to a number.
                        // Ignore this add-on.
                        if (log != null) {
                            log.error(null,
                                    "Ignoring add-on '%1$s': %2$s is not a valid number in %3$s.",
                                    addon.getName(), ADDON_REVISION, SdkConstants.FN_BUILD_PROP);
                        }
                        return null;
                    }
                }

                // get the optional libraries
                String librariesValue = propertyMap.get(ADDON_LIBRARIES);
                Map<String, String[]> libMap = null;

                if (librariesValue != null) {
                    librariesValue = librariesValue.trim();
                    if (librariesValue.length() > 0) {
                        // split in the string into the libraries name
                        String[] libraries = librariesValue.split(";");
                        if (libraries.length > 0) {
                            libMap = new HashMap<String, String[]>();
                            for (String libName : libraries) {
                                libName = libName.trim();

                                // get the library data from the properties
                                String libData = propertyMap.get(libName);

                                if (libData != null) {
                                    // split the jar file from the description
                                    Matcher m = PATTERN_LIB_DATA.matcher(libData);
                                    if (m.matches()) {
                                        libMap.put(libName, new String[] {
                                                m.group(1), m.group(2) });
                                    } else if (log != null) {
                                        log.error(null,
                                                "Ignoring library '%1$s', property value has wrong format\n\t%2$s",
                                                libName, libData);
                                    }
                                } else if (log != null) {
                                    log.error(null,
                                            "Ignoring library '%1$s', missing property value",
                                            libName, libData);
                                }
                            }
                        }
                    }
                }

                AddOnTarget target = new AddOnTarget(addon.getAbsolutePath(), name, vendor,
                        revisionValue, description, libMap, baseTarget);

                // need to parse the skins.
                String[] skins = parseSkinFolder(target.getPath(IAndroidTarget.SKINS));

                // get the default skin, or take it from the base platform if needed.
                String defaultSkin = propertyMap.get(ADDON_DEFAULT_SKIN);
                if (defaultSkin == null) {
                    if (skins.length == 1) {
                        defaultSkin = skins[0];
                    } else {
                        defaultSkin = baseTarget.getDefaultSkin();
                    }
                }

                // get the USB ID (if available)
                int usbVendorId = convertId(propertyMap.get(ADDON_USB_VENDOR));
                if (usbVendorId != IAndroidTarget.NO_USB_ID) {
                    target.setUsbVendorId(usbVendorId);
                }

                target.setSkins(skins, defaultSkin);

                return target;
            }
        } else if (log != null) {
            log.error(null, "Ignoring add-on '%1$s': %2$s is missing.", addon.getName(),
                    SdkConstants.FN_MANIFEST_INI);
        }

        return null;
    }

    /**
     * Converts a string representation of an hexadecimal ID into an int.
     * @param value the string to convert.
     * @return the int value, or {@link IAndroidTarget#NO_USB_ID} if the convertion failed.
     */
    private static int convertId(String value) {
        if (value != null && value.length() > 0) {
            if (PATTERN_USB_IDS.matcher(value).matches()) {
                String v = value.substring(2);
                try {
                    return Integer.parseInt(v, 16);
                } catch (NumberFormatException e) {
                    // this shouldn't happen since we check the pattern above, but this is safer.
                    // the method will return 0 below.
                }
            }
        }

        return IAndroidTarget.NO_USB_ID;
    }

    private static void displayAddonManifestError(ISdkLog log, String addonName, String valueName) {
        if (log != null) {
            log.error(null, "Ignoring add-on '%1$s': '%2$s' is missing from %3$s.",
                    addonName, valueName, SdkConstants.FN_MANIFEST_INI);
        }
    }

    /**
     * Checks the given platform has all the required files, and returns true if they are all
     * present.
     * <p/>This checks the presence of the following files: android.jar, framework.aidl, aapt(.exe),
     * aidl(.exe), dx(.bat), and dx.jar
     */
    private static boolean checkPlatformContent(File platform, ISdkLog log) {
        for (String relativePath : sPlatformContentList) {
            File f = new File(platform, relativePath);
            if (f.exists() == false) {
                log.error(null,
                        "Ignoring platform '%1$s': %2$s is missing.",
                        platform.getName(), relativePath);
                return false;
            }
        }
        return true;
    }


    /**
     * Parses a property file (using UTF-8 encoding) and returns
     * @param buildProp the property file to parse
     * @param log the ISdkLog object receiving warning/error from the parsing.
     * @return the map of (key,value) pairs, or null if the parsing failed.
     */
    public static Map<String, String> parsePropertyFile(File buildProp, ISdkLog log) {
        FileInputStream fis = null;
        BufferedReader reader = null;
        try {
            fis = new FileInputStream(buildProp);
            reader = new BufferedReader(new InputStreamReader(fis, SdkConstants.INI_CHARSET));

            String line = null;
            Map<String, String> map = new HashMap<String, String>();
            while ((line = reader.readLine()) != null) {
                if (line.length() > 0 && line.charAt(0) != '#') {

                    Matcher m = PATTERN_PROP.matcher(line);
                    if (m.matches()) {
                        map.put(m.group(1), m.group(2));
                    } else {
                        log.warning("Error parsing '%1$s': \"%2$s\" is not a valid syntax",
                                buildProp.getAbsolutePath(), line);
                        return null;
                    }
                }
            }

            return map;
        } catch (FileNotFoundException e) {
            // this should not happen since we usually test the file existence before
            // calling the method.
            // Return null below.
        } catch (IOException e) {
            if (log != null) {
                log.warning("Error parsing '%1$s': %2$s.", buildProp.getAbsolutePath(),
                        e.getMessage());
            }
        } finally {
            if (reader != null) {
                try {
                    reader.close();
                } catch (IOException e) {
                    // pass
                }
            }
            if (fis != null) {
                try {
                    fis.close();
                } catch (IOException e) {
                    // pass
                }
            }
        }

        return null;
    }

    /**
     * Parses the skin folder and builds the skin list.
     * @param osPath The path of the skin root folder.
     */
    private static String[] parseSkinFolder(String osPath) {
        File skinRootFolder = new File(osPath);

        if (skinRootFolder.isDirectory()) {
            ArrayList<String> skinList = new ArrayList<String>();

            File[] files = skinRootFolder.listFiles();

            for (File skinFolder : files) {
                if (skinFolder.isDirectory()) {
                    // check for layout file
                    File layout = new File(skinFolder, SdkConstants.FN_SKIN_LAYOUT);

                    if (layout.isFile()) {
                        // for now we don't parse the content of the layout and
                        // simply add the directory to the list.
                        skinList.add(skinFolder.getName());
                    }
                }
            }

            return skinList.toArray(new String[skinList.size()]);
        }

        return new String[0];
    }
}
