/*
 * Copyright (C) 2007 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.ddmlib;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Provides {@link Device} side file listing service.
 * <p/>To get an instance for a known {@link Device}, call {@link Device#getFileListingService()}.
 */
public final class FileListingService {

    /** Pattern to find filenames that match "*.apk" */
    private final static Pattern sApkPattern =
        Pattern.compile(".*\\.apk", Pattern.CASE_INSENSITIVE); //$NON-NLS-1$

    private final static String PM_FULL_LISTING = "pm list packages -f"; //$NON-NLS-1$

    /** Pattern to parse the output of the 'pm -lf' command.<br>
     * The output format looks like:<br>
     * /data/app/myapp.apk=com.mypackage.myapp */
    private final static Pattern sPmPattern = Pattern.compile("^package:(.+?)=(.+)$"); //$NON-NLS-1$

    /** Top level data folder. */
    public final static String DIRECTORY_DATA = "data"; //$NON-NLS-1$
    /** Top level sdcard folder. */
    public final static String DIRECTORY_SDCARD = "sdcard"; //$NON-NLS-1$
    /** Top level mount folder. */
    public final static String DIRECTORY_MNT = "mnt"; //$NON-NLS-1$
    /** Top level system folder. */
    public final static String DIRECTORY_SYSTEM = "system"; //$NON-NLS-1$
    /** Top level temp folder. */
    public final static String DIRECTORY_TEMP = "tmp"; //$NON-NLS-1$
    /** Application folder. */
    public final static String DIRECTORY_APP = "app"; //$NON-NLS-1$

    private final static String[] sRootLevelApprovedItems = {
        DIRECTORY_DATA,
        DIRECTORY_SDCARD,
        DIRECTORY_SYSTEM,
        DIRECTORY_TEMP,
        DIRECTORY_MNT,
    };

    public static final long REFRESH_RATE = 5000L;
    /**
     * Refresh test has to be slightly lower for precision issue.
     */
    static final long REFRESH_TEST = (long)(REFRESH_RATE * .8);

    /** Entry type: File */
    public static final int TYPE_FILE = 0;
    /** Entry type: Directory */
    public static final int TYPE_DIRECTORY = 1;
    /** Entry type: Directory Link */
    public static final int TYPE_DIRECTORY_LINK = 2;
    /** Entry type: Block */
    public static final int TYPE_BLOCK = 3;
    /** Entry type: Character */
    public static final int TYPE_CHARACTER = 4;
    /** Entry type: Link */
    public static final int TYPE_LINK = 5;
    /** Entry type: Socket */
    public static final int TYPE_SOCKET = 6;
    /** Entry type: FIFO */
    public static final int TYPE_FIFO = 7;
    /** Entry type: Other */
    public static final int TYPE_OTHER = 8;

    /** Device side file separator. */
    public static final String FILE_SEPARATOR = "/"; //$NON-NLS-1$

    private static final String FILE_ROOT = "/"; //$NON-NLS-1$


    /**
     * Regexp pattern to parse the result from ls.
     */
    private static Pattern sLsPattern = Pattern.compile(
        "^([bcdlsp-][-r][-w][-xsS][-r][-w][-xsS][-r][-w][-xstST])\\s+(\\S+)\\s+(\\S+)\\s+([\\d\\s,]*)\\s+(\\d{4}-\\d\\d-\\d\\d)\\s+(\\d\\d:\\d\\d)\\s+(.*)$"); //$NON-NLS-1$

    private Device mDevice;
    private FileEntry mRoot;

    private ArrayList<Thread> mThreadList = new ArrayList<Thread>();

    /**
     * Represents an entry in a directory. This can be a file or a directory.
     */
    public final static class FileEntry {
        /** Pattern to escape filenames for shell command consumption. */
        private final static Pattern sEscapePattern = Pattern.compile(
                "([\\\\()*+?\"'#/\\s])"); //$NON-NLS-1$

        /**
         * Comparator object for FileEntry
         */
        private static Comparator<FileEntry> sEntryComparator = new Comparator<FileEntry>() {
            public int compare(FileEntry o1, FileEntry o2) {
                if (o1 instanceof FileEntry && o2 instanceof FileEntry) {
                    FileEntry fe1 = (FileEntry)o1;
                    FileEntry fe2 = (FileEntry)o2;
                    return fe1.name.compareTo(fe2.name);
                }
                return 0;
            }
        };

        FileEntry parent;
        String name;
        String info;
        String permissions;
        String size;
        String date;
        String time;
        String owner;
        String group;
        int type;
        boolean isAppPackage;

        boolean isRoot;

        /**
         * Indicates whether the entry content has been fetched yet, or not.
         */
        long fetchTime = 0;

        final ArrayList<FileEntry> mChildren = new ArrayList<FileEntry>();

        /**
         * Creates a new file entry.
         * @param parent parent entry or null if entry is root
         * @param name name of the entry.
         * @param type entry type. Can be one of the following: {@link FileListingService#TYPE_FILE},
         * {@link FileListingService#TYPE_DIRECTORY}, {@link FileListingService#TYPE_OTHER}.
         */
        private FileEntry(FileEntry parent, String name, int type, boolean isRoot) {
            this.parent = parent;
            this.name = name;
            this.type = type;
            this.isRoot = isRoot;

            checkAppPackageStatus();
        }

        /**
         * Returns the name of the entry
         */
        public String getName() {
            return name;
        }

        /**
         * Returns the size string of the entry, as returned by <code>ls</code>.
         */
        public String getSize() {
            return size;
        }

        /**
         * Returns the size of the entry.
         */
        public int getSizeValue() {
            return Integer.parseInt(size);
        }

        /**
         * Returns the date string of the entry, as returned by <code>ls</code>.
         */
        public String getDate() {
            return date;
        }

        /**
         * Returns the time string of the entry, as returned by <code>ls</code>.
         */
        public String getTime() {
            return time;
        }

        /**
         * Returns the permission string of the entry, as returned by <code>ls</code>.
         */
        public String getPermissions() {
            return permissions;
        }

        /**
         * Returns the extra info for the entry.
         * <p/>For a link, it will be a description of the link.
         * <p/>For an application apk file it will be the application package as returned
         * by the Package Manager.
         */
        public String getInfo() {
            return info;
        }

        /**
         * Return the full path of the entry.
         * @return a path string using {@link FileListingService#FILE_SEPARATOR} as separator.
         */
        public String getFullPath() {
            if (isRoot) {
                return FILE_ROOT;
            }
            StringBuilder pathBuilder = new StringBuilder();
            fillPathBuilder(pathBuilder, false);

            return pathBuilder.toString();
        }

        /**
         * Return the fully escaped path of the entry. This path is safe to use in a
         * shell command line.
         * @return a path string using {@link FileListingService#FILE_SEPARATOR} as separator
         */
        public String getFullEscapedPath() {
            StringBuilder pathBuilder = new StringBuilder();
            fillPathBuilder(pathBuilder, true);

            return pathBuilder.toString();
        }

        /**
         * Returns the path as a list of segments.
         */
        public String[] getPathSegments() {
            ArrayList<String> list = new ArrayList<String>();
            fillPathSegments(list);

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

        /**
         * Returns true if the entry is a directory, false otherwise;
         */
        public int getType() {
            return type;
        }

        /**
         * Returns if the entry is a folder or a link to a folder.
         */
        public boolean isDirectory() {
            return type == TYPE_DIRECTORY || type == TYPE_DIRECTORY_LINK;
        }

        /**
         * Returns the parent entry.
         */
        public FileEntry getParent() {
            return parent;
        }

        /**
         * Returns the cached children of the entry. This returns the cache created from calling
         * <code>FileListingService.getChildren()</code>.
         */
        public FileEntry[] getCachedChildren() {
            return mChildren.toArray(new FileEntry[mChildren.size()]);
        }

        /**
         * Returns the child {@link FileEntry} matching the name.
         * This uses the cached children list.
         * @param name the name of the child to return.
         * @return the FileEntry matching the name or null.
         */
        public FileEntry findChild(String name) {
            for (FileEntry entry : mChildren) {
                if (entry.name.equals(name)) {
                    return entry;
                }
            }
            return null;
        }

        /**
         * Returns whether the entry is the root.
         */
        public boolean isRoot() {
            return isRoot;
        }

        void addChild(FileEntry child) {
            mChildren.add(child);
        }

        void setChildren(ArrayList<FileEntry> newChildren) {
            mChildren.clear();
            mChildren.addAll(newChildren);
        }

        boolean needFetch() {
            if (fetchTime == 0) {
                return true;
            }
            long current = System.currentTimeMillis();
            if (current-fetchTime > REFRESH_TEST) {
                return true;
            }

            return false;
        }

        /**
         * Returns if the entry is a valid application package.
         */
        public boolean isApplicationPackage() {
            return isAppPackage;
        }

        /**
         * Returns if the file name is an application package name.
         */
        public boolean isAppFileName() {
            Matcher m = sApkPattern.matcher(name);
            return m.matches();
        }

        /**
         * Recursively fills the pathBuilder with the full path
         * @param pathBuilder a StringBuilder used to create the path.
         * @param escapePath Whether the path need to be escaped for consumption by
         * a shell command line.
         */
        protected void fillPathBuilder(StringBuilder pathBuilder, boolean escapePath) {
            if (isRoot) {
                return;
            }

            if (parent != null) {
                parent.fillPathBuilder(pathBuilder, escapePath);
            }
            pathBuilder.append(FILE_SEPARATOR);
            pathBuilder.append(escapePath ? escape(name) : name);
        }

        /**
         * Recursively fills the segment list with the full path.
         * @param list The list of segments to fill.
         */
        protected void fillPathSegments(ArrayList<String> list) {
            if (isRoot) {
                return;
            }

            if (parent != null) {
                parent.fillPathSegments(list);
            }

            list.add(name);
        }

        /**
         * Sets the internal app package status flag. This checks whether the entry is in an app
         * directory like /data/app or /system/app
         */
        private void checkAppPackageStatus() {
            isAppPackage = false;

            String[] segments = getPathSegments();
            if (type == TYPE_FILE && segments.length == 3 && isAppFileName()) {
                isAppPackage = DIRECTORY_APP.equals(segments[1]) &&
                    (DIRECTORY_SYSTEM.equals(segments[0]) || DIRECTORY_DATA.equals(segments[0]));
            }
        }

        /**
         * Returns an escaped version of the entry name.
         * @param entryName
         */
        public static String escape(String entryName) {
            return sEscapePattern.matcher(entryName).replaceAll("\\\\$1"); //$NON-NLS-1$
        }
    }

    private class LsReceiver extends MultiLineReceiver {

        private ArrayList<FileEntry> mEntryList;
        private ArrayList<String> mLinkList;
        private FileEntry[] mCurrentChildren;
        private FileEntry mParentEntry;

        /**
         * Create an ls receiver/parser.
         * @param currentChildren The list of current children. To prevent
         *      collapse during update, reusing the same FileEntry objects for
         *      files that were already there is paramount.
         * @param entryList the list of new children to be filled by the
         *      receiver.
         * @param linkList the list of link path to compute post ls, to figure
         *      out if the link pointed to a file or to a directory.
         */
        public LsReceiver(FileEntry parentEntry, ArrayList<FileEntry> entryList,
                ArrayList<String> linkList) {
            mParentEntry = parentEntry;
            mCurrentChildren = parentEntry.getCachedChildren();
            mEntryList = entryList;
            mLinkList = linkList;
        }

        @Override
        public void processNewLines(String[] lines) {
            for (String line : lines) {
                // no need to handle empty lines.
                if (line.length() == 0) {
                    continue;
                }

                // run the line through the regexp
                Matcher m = sLsPattern.matcher(line);
                if (m.matches() == false) {
                    continue;
                }

                // get the name
                String name = m.group(7);

                // if the parent is root, we only accept selected items
                if (mParentEntry.isRoot()) {
                    boolean found = false;
                    for (String approved : sRootLevelApprovedItems) {
                        if (approved.equals(name)) {
                            found = true;
                            break;
                        }
                    }

                    // if it's not in the approved list we skip this entry.
                    if (found == false) {
                        continue;
                    }
                }

                // get the rest of the groups
                String permissions = m.group(1);
                String owner = m.group(2);
                String group = m.group(3);
                String size = m.group(4);
                String date = m.group(5);
                String time = m.group(6);
                String info = null;

                // and the type
                int objectType = TYPE_OTHER;
                switch (permissions.charAt(0)) {
                    case '-' :
                        objectType = TYPE_FILE;
                        break;
                    case 'b' :
                        objectType = TYPE_BLOCK;
                        break;
                    case 'c' :
                        objectType = TYPE_CHARACTER;
                        break;
                    case 'd' :
                        objectType = TYPE_DIRECTORY;
                        break;
                    case 'l' :
                        objectType = TYPE_LINK;
                        break;
                    case 's' :
                        objectType = TYPE_SOCKET;
                        break;
                    case 'p' :
                        objectType = TYPE_FIFO;
                        break;
                }


                // now check what we may be linking to
                if (objectType == TYPE_LINK) {
                    String[] segments = name.split("\\s->\\s"); //$NON-NLS-1$

                    // we should have 2 segments
                    if (segments.length == 2) {
                        // update the entry name to not contain the link
                        name = segments[0];

                        // and the link name
                        info = segments[1];

                        // now get the path to the link
                        String[] pathSegments = info.split(FILE_SEPARATOR);
                        if (pathSegments.length == 1) {
                            // the link is to something in the same directory,
                            // unless the link is ..
                            if ("..".equals(pathSegments[0])) { //$NON-NLS-1$
                                // set the type and we're done.
                                objectType = TYPE_DIRECTORY_LINK;
                            } else {
                                // either we found the object already
                                // or we'll find it later.
                            }
                        }
                    }

                    // add an arrow in front to specify it's a link.
                    info = "-> " + info; //$NON-NLS-1$;
                }

                // get the entry, either from an existing one, or a new one
                FileEntry entry = getExistingEntry(name);
                if (entry == null) {
                    entry = new FileEntry(mParentEntry, name, objectType, false /* isRoot */);
                }

                // add some misc info
                entry.permissions = permissions;
                entry.size = size;
                entry.date = date;
                entry.time = time;
                entry.owner = owner;
                entry.group = group;
                if (objectType == TYPE_LINK) {
                    entry.info = info;
                }

                mEntryList.add(entry);
            }
        }

        /**
         * Queries for an already existing Entry per name
         * @param name the name of the entry
         * @return the existing FileEntry or null if no entry with a matching
         * name exists.
         */
        private FileEntry getExistingEntry(String name) {
            for (int i = 0 ; i < mCurrentChildren.length; i++) {
                FileEntry e = mCurrentChildren[i];

                // since we're going to "erase" the one we use, we need to
                // check that the item is not null.
                if (e != null) {
                    // compare per name, case-sensitive.
                    if (name.equals(e.name)) {
                        // erase from the list
                        mCurrentChildren[i] = null;

                        // and return the object
                        return e;
                    }
                }
            }

            // couldn't find any matching object, return null
            return null;
        }

        public boolean isCancelled() {
            return false;
        }

        public void finishLinks() {
            // TODO Handle links in the listing service
        }
    }

    /**
     * Classes which implement this interface provide a method that deals with asynchronous
     * result from <code>ls</code> command on the device.
     *
     * @see FileListingService#getChildren(com.android.ddmlib.FileListingService.FileEntry, boolean, com.android.ddmlib.FileListingService.IListingReceiver)
     */
    public interface IListingReceiver {
        public void setChildren(FileEntry entry, FileEntry[] children);

        public void refreshEntry(FileEntry entry);
    }

    /**
     * Creates a File Listing Service for a specified {@link Device}.
     * @param device The Device the service is connected to.
     */
    FileListingService(Device device) {
        mDevice = device;
    }

    /**
     * Returns the root element.
     * @return the {@link FileEntry} object representing the root element or
     * <code>null</code> if the device is invalid.
     */
    public FileEntry getRoot() {
        if (mDevice != null) {
            if (mRoot == null) {
                mRoot = new FileEntry(null /* parent */, "" /* name */, TYPE_DIRECTORY,
                        true /* isRoot */);
            }

            return mRoot;
        }

        return null;
    }

    /**
     * Returns the children of a {@link FileEntry}.
     * <p/>
     * This method supports a cache mechanism and synchronous and asynchronous modes.
     * <p/>
     * If <var>receiver</var> is <code>null</code>, the device side <code>ls</code>
     * command is done synchronously, and the method will return upon completion of the command.<br>
     * If <var>receiver</var> is non <code>null</code>, the command is launched is a separate
     * thread and upon completion, the receiver will be notified of the result.
     * <p/>
     * The result for each <code>ls</code> command is cached in the parent
     * <code>FileEntry</code>. <var>useCache</var> allows usage of this cache, but only if the
     * cache is valid. The cache is valid only for {@link FileListingService#REFRESH_RATE} ms.
     * After that a new <code>ls</code> command is always executed.
     * <p/>
     * If the cache is valid and <code>useCache == true</code>, the method will always simply
     * return the value of the cache, whether a {@link IListingReceiver} has been provided or not.
     *
     * @param entry The parent entry.
     * @param useCache A flag to use the cache or to force a new ls command.
     * @param receiver A receiver for asynchronous calls.
     * @return The list of children or <code>null</code> for asynchronous calls.
     *
     * @see FileEntry#getCachedChildren()
     */
    public FileEntry[] getChildren(final FileEntry entry, boolean useCache,
            final IListingReceiver receiver) {
        // first thing we do is check the cache, and if we already have a recent
        // enough children list, we just return that.
        if (useCache && entry.needFetch() == false) {
            return entry.getCachedChildren();
        }

        // if there's no receiver, then this is a synchronous call, and we
        // return the result of ls
        if (receiver == null) {
            doLs(entry);
            return entry.getCachedChildren();
        }

        // this is a asynchronous call.
        // we launch a thread that will do ls and give the listing
        // to the receiver
        Thread t = new Thread("ls " + entry.getFullPath()) { //$NON-NLS-1$
            @Override
            public void run() {
                doLs(entry);

                receiver.setChildren(entry, entry.getCachedChildren());

                final FileEntry[] children = entry.getCachedChildren();
                if (children.length > 0 && children[0].isApplicationPackage()) {
                    final HashMap<String, FileEntry> map = new HashMap<String, FileEntry>();

                    for (FileEntry child : children) {
                        String path = child.getFullPath();
                        map.put(path, child);
                    }

                    // call pm.
                    String command = PM_FULL_LISTING;
                    try {
                        mDevice.executeShellCommand(command, new MultiLineReceiver() {
                            @Override
                            public void processNewLines(String[] lines) {
                                for (String line : lines) {
                                    if (line.length() > 0) {
                                        // get the filepath and package from the line
                                        Matcher m = sPmPattern.matcher(line);
                                        if (m.matches()) {
                                            // get the children with that path
                                            FileEntry entry = map.get(m.group(1));
                                            if (entry != null) {
                                                entry.info = m.group(2);
                                                receiver.refreshEntry(entry);
                                            }
                                        }
                                    }
                                }
                            }
                            public boolean isCancelled() {
                                return false;
                            }
                        });
                    } catch (Exception e) {
                        // adb failed somehow, we do nothing.
                    }
                }


                // if another thread is pending, launch it
                synchronized (mThreadList) {
                    // first remove ourselves from the list
                    mThreadList.remove(this);

                    // then launch the next one if applicable.
                    if (mThreadList.size() > 0) {
                        Thread t = mThreadList.get(0);
                        t.start();
                    }
                }
            }
        };

        // we don't want to run multiple ls on the device at the same time, so we
        // store the thread in a list and launch it only if there's no other thread running.
        // the thread will launch the next one once it's done.
        synchronized (mThreadList) {
            // add to the list
            mThreadList.add(t);

            // if it's the only one, launch it.
            if (mThreadList.size() == 1) {
                t.start();
            }
        }

        // and we return null.
        return null;
    }

    /**
     * Returns the children of a {@link FileEntry}.
     * <p/>
     * This method is the explicit synchronous version of
     * {@link #getChildren(FileEntry, boolean, IListingReceiver)}. It is roughly equivalent to
     * calling
     * getChildren(FileEntry, false, null)
     *
     * @param entry The parent entry.
     * @return The list of children
     * @throws TimeoutException in case of timeout on the connection when sending the command.
     * @throws AdbCommandRejectedException if adb rejects the command.
     * @throws ShellCommandUnresponsiveException in case the shell command doesn't send any output
     *            for a period longer than <var>maxTimeToOutputResponse</var>.
     * @throws IOException in case of I/O error on the connection.
     */
    public FileEntry[] getChildrenSync(final FileEntry entry) throws TimeoutException,
            AdbCommandRejectedException, ShellCommandUnresponsiveException, IOException {
        doLsAndThrow(entry);
        return entry.getCachedChildren();
    }

    private void doLs(FileEntry entry) {
        try {
            doLsAndThrow(entry);
        } catch (Exception e) {
            // do nothing
        }
    }

    private void doLsAndThrow(FileEntry entry) throws TimeoutException,
            AdbCommandRejectedException, ShellCommandUnresponsiveException, IOException {
        // create a list that will receive the list of the entries
        ArrayList<FileEntry> entryList = new ArrayList<FileEntry>();

        // create a list that will receive the link to compute post ls;
        ArrayList<String> linkList = new ArrayList<String>();

        try {
            // create the command
            String command = "ls -l " + entry.getFullEscapedPath(); //$NON-NLS-1$

            // create the receiver object that will parse the result from ls
            LsReceiver receiver = new LsReceiver(entry, entryList, linkList);

            // call ls.
            mDevice.executeShellCommand(command, receiver);

            // finish the process of the receiver to handle links
            receiver.finishLinks();
        } finally {
            // at this point we need to refresh the viewer
            entry.fetchTime = System.currentTimeMillis();

            // sort the children and set them as the new children
            Collections.sort(entryList, FileEntry.sEntryComparator);
            entry.setChildren(entryList);
        }
    }

}
