/*
 * CDDL HEADER START
 *
 * The contents of this file are subject to the terms of the
 * Common Development and Distribution License (the "License").
 * You may not use this file except in compliance with the License.
 *
 * See LICENSE.txt included in this distribution for the specific
 * language governing permissions and limitations under the License.
 *
 * When distributing Covered Code, include this CDDL HEADER in each
 * file and include the License file at LICENSE.txt.
 * If applicable, add the following below this CDDL HEADER, with the
 * fields enclosed by brackets "[]" replaced with your own identifying
 * information: Portions Copyright [yyyy] [name of copyright owner]
 *
 * CDDL HEADER END
 */

/*
 * Copyright (c) 2006, 2014, Oracle and/or its affiliates. All rights reserved.
 */
package org.opensolaris.opengrok.history;

import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.*;
import java.util.logging.Level;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.opensolaris.opengrok.OpenGrokLogger;
import org.opensolaris.opengrok.configuration.RuntimeEnvironment;
import org.opensolaris.opengrok.util.Executor;
import org.opensolaris.opengrok.web.Util;

/**
 * Access to a Mercurial repository.
 *
 */
public class MercurialRepository extends Repository {
    private static final long serialVersionUID = 1L;

    /**
     * the property name used to obtain the client command for
     * this repository
     */
    public static final String CMD_PROPERTY_KEY =
        "org.opensolaris.opengrok.history.Mercurial";
    /**
     * the command to use to access the repository if none was given
     * explicitly
     */
    public static final String CMD_FALLBACK = "hg";

    private String branch = null;

    /**
     * The boolean property and environment variable name to indicate
     * whether forest-extension in Mercurial adds repositories inside the
     * repositories.
     */
    public static final String NOFOREST_PROPERTY_KEY =
        "org.opensolaris.opengrok.history.mercurial.disableForest";

    static final String CHANGESET = "changeset: ";
    static final String USER = "user: ";
    static final String DATE = "date: ";
    static final String DESCRIPTION = "description: ";
    static final String FILE_COPIES = "file_copies: ";
    static final String FILES = "files: ";
    static final String END_OF_ENTRY =
        "mercurial_history_end_of_entry";

    private static final String TEMPLATE_STUB =
        CHANGESET + "{rev}:{node|short}\\n"
        + USER + "{author}\\n" + DATE + "{date|isodate}\\n"
        + DESCRIPTION + "{desc|strip|obfuscate}\\n";

    private static final String FILE_LIST = FILES + "{files}\\n";

    /** Templates for formatting hg log output for files. */
    private static final String FILE_TEMPLATE = TEMPLATE_STUB
        + END_OF_ENTRY + "\\n";
    private static final String FILE_TEMPLATE_LIST = TEMPLATE_STUB
        + FILE_LIST + END_OF_ENTRY + "\\n";

    /** Template for formatting hg log output for directories. */
    private static final String DIR_TEMPLATE_RENAMED =
        TEMPLATE_STUB + FILE_LIST
        + FILE_COPIES + "{file_copies}\\n" + END_OF_ENTRY + "\\n";
    private static final String DIR_TEMPLATE =
        TEMPLATE_STUB + FILE_LIST
        + END_OF_ENTRY + "\\n";

    /** Pattern used to extract author/revision from hg annotate. */
    private static final Pattern ANNOTATION_PATTERN =
        Pattern.compile("^\\s*(\\d+):");

    private static final Pattern LOG_COPIES_PATTERN =
        Pattern.compile("^(\\d+):(.*)");

    public MercurialRepository() {
        type = "Mercurial";
        datePattern = "yyyy-MM-dd hh:mm ZZZZ";
    }

    /** Return name of the branch or "default" */
    private String getBranch() {
        if (branch == null) {
            List<String> cmd = new ArrayList<String>();
            ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK);
            cmd.add(this.cmd);
            cmd.add("branch");

            Executor e = new Executor(cmd, new File(directoryName));
            e.exec();

            String output = e.getOutputString();
            branch = output.trim();
        }

        return branch;
    }

    /**
     * Get an executor to be used for retrieving the history log for the
     * named file or directory.
     *
     * @param file The file or directory to retrieve history for
     * @param changeset the oldest changeset to return from the executor,
     * or {@code null} if all changesets should be returned
     * @return An Executor ready to be started
     */
    Executor getHistoryLogExecutor(File file, String changeset)
             throws HistoryException, IOException
    {
        String abs = file.getCanonicalPath();
        String filename = "";
        RuntimeEnvironment env = RuntimeEnvironment.getInstance();

        if (abs.length() > directoryName.length()) {
            filename = abs.substring(directoryName.length() + 1);
        }

        List<String> cmd = new ArrayList<String>();
        ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK);
        cmd.add(this.cmd);
        cmd.add("log");

        // For plain files we would like to follow the complete history
        // (this is necessary for getting the original name in given revision
        // when handling renamed files)
        if (!file.isDirectory()) {
            cmd.add("-f");
        }

        // If this is non-default branch we would like to get the changesets
        // on that branch and also any changesets from the parent branch(es).
        if (changeset != null) {
            cmd.add("-r");
            String[] parts = changeset.split(":");
            if (parts.length == 2) {
                cmd.add("reverse(" + parts[0] + "::'" + getBranch() + "')");
            } else {
                throw new HistoryException(
                        "Don't know how to parse changeset identifier: " +
                        changeset);
            }
        } else {
            cmd.add("-r");
            cmd.add("reverse(0::'" + getBranch() + "')");
        }

        cmd.add("--template");
        if (file.isDirectory()) {
            cmd.add(RuntimeEnvironment.isRenamedFilesEnabled() ? DIR_TEMPLATE_RENAMED : DIR_TEMPLATE);
        } else {
            /* JDBC requires complete list of files. */
            cmd.add(env.storeHistoryCacheInDB() ? FILE_TEMPLATE_LIST : FILE_TEMPLATE);
        }
        if (!filename.isEmpty()) {
            cmd.add(filename);
        }

        return new Executor(cmd, new File(directoryName));
    }

    /**
     * Try to get file contents for given revision.
     * 
     * @param fullpath full pathname of the file
     * @param rev revision
     * @return contents of the file in revision rev
     */
    private InputStream getHistoryRev(String fullpath, String rev)
    {
        InputStream ret = null;

        File directory = new File(directoryName);

        Process process = null;
        String revision = rev;

        if (rev.indexOf(':') != -1) {
            revision = rev.substring(0, rev.indexOf(':'));
        }
        try {
            String filename = 
                fullpath.substring(directoryName.length() + 1);
            ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK);
            String argv[] = {cmd, "cat", "-r", revision, filename};
            process = Runtime.getRuntime().exec(argv, null, directory);

            ByteArrayOutputStream out = new ByteArrayOutputStream();
            byte[] buffer = new byte[32 * 1024];
            try (InputStream in = process.getInputStream()) {
                int len;

                while ((len = in.read(buffer)) != -1) {
                    if (len > 0) {
                        out.write(buffer, 0, len);
                    }
                }
            }

            /*
             * If exit value of the process was not 0 then the file did
             * not exist or internal hg error occured.
             */
            if (process.waitFor() == 0) {
                ret = new ByteArrayInputStream(out.toByteArray());
            } else {
                ret = null;
            }
        } catch (Exception exp) {
            OpenGrokLogger.getLogger().log(Level.SEVERE,
                "Failed to get history: " + exp.getClass().toString());
        } finally {
            // Clean up zombie-processes...
            if (process != null) {
                try {
                    process.exitValue();
                } catch (IllegalThreadStateException exp) {
                    // the process is still running??? just kill it..
                    process.destroy();
                }
            }
        }

        return ret;
    }

    /**
     * Get the name of file in given revision
     * @param fullpath file path
     * @param full_rev_to_find revision number (in the form of {rev}:{node|short})
     * @returns original filename
     */
    private String findOriginalName(String fullpath, String full_rev_to_find) 
            throws IOException {
        Matcher matcher = LOG_COPIES_PATTERN.matcher("");
        String file = fullpath.substring(directoryName.length() + 1);
        ArrayList<String> argv = new ArrayList<String>();

        // Extract {rev} from the full revision specification string.
        String[] rev_array = full_rev_to_find.split(":");
        String rev_to_find = rev_array[0];
        if (rev_to_find.isEmpty()) {
            OpenGrokLogger.getLogger().log(Level.SEVERE,
                "Invalid revision string: {0}", full_rev_to_find);
            return null;
        }

        /*
         * Get the list of file renames for given file to the specified
         * revision. We need to get them from the newest to the oldest
         * (hence the reverse()) so that we can follow the renames down
         * to the revision we are after.
         */
        argv.add(ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK));
        argv.add("log");
        argv.add("-f");
        argv.add("-r");
        argv.add("reverse(" + rev_to_find + ":)");
        argv.add("--template");
        argv.add("{rev}:{file_copies}\\n");
        argv.add(fullpath);
        
        ProcessBuilder pb = new ProcessBuilder(argv);
        Process process = null;
        
        try {
           process = pb.start();
           try (BufferedReader in = new BufferedReader(
                   new InputStreamReader(process.getInputStream()))) {
               String line;
               while ((line = in.readLine()) != null) {
                    matcher.reset(line);
                    if (!matcher.find()) {
                        OpenGrokLogger.getLogger().log(Level.SEVERE,
                            "Failed to match: {0}", line);
                        return (null);
                    }
                    String rev = matcher.group(1);
                    String content = matcher.group(2);

                    if (rev.equals(rev_to_find)) {
                        break;
                    }

                    if (!content.isEmpty()) {
                        /*
                         * Split string of 'newfile1 (oldfile1)newfile2
                         * (oldfile2) ...' into pairs of renames.
                         */
                        String[] splitArray = content.split("\\)");
                        for (String s: splitArray) {
                            /*
                             * This will fail for file names containing ' ('.
                             */
                            String[] move = s.split(" \\(");

                            if (file.equals(move[0])) {
                                file = move[1];
                                break;
                            }
                        }
                    }

                    if (rev.equals(rev_to_find)) {
                        break;
                    }
               }
           }
         } finally {
            if (process != null) {
                try {
                    process.exitValue();
                } catch (IllegalThreadStateException e) {
                    // the process is still running??? just kill it..
                    process.destroy();
                }
            }
        }
        
        return (fullpath.substring(0, directoryName.length() + 1) + file);
    }

    @Override
    public InputStream getHistoryGet(String parent, String basename, String rev)
    {
       String fullpath;
       
       try {
           fullpath = new File(parent, basename).getCanonicalPath();
       } catch (IOException exp) {
           OpenGrokLogger.getLogger().log(Level.SEVERE,
               "Failed to get canonical path: {0}", exp.getClass().toString());
           return null;
       }

       InputStream ret = getHistoryRev(fullpath, rev);
       if (ret == null) {
           /*
            * If we failed to get the contents it might be that the file was
            * renamed so we need to find its original name in that revision
            * and retry with the original name.
            */
           String origpath;
           try {
            origpath = findOriginalName(fullpath, rev);
           } catch (IOException exp) {
               OpenGrokLogger.getLogger().log(Level.SEVERE,
                "Failed to get original revision: {0}",
                exp.getClass().toString());
               return null;
           }
           if (origpath != null) {
               ret = getHistoryRev(origpath, rev);
           }
       }
       
       return ret;
    }
    
    /**
     * Annotate the specified file/revision.
     *
     * @param file file to annotate
     * @param revision revision to annotate
     * @return file annotation
     */
    @Override
    public Annotation annotate(File file, String revision) throws IOException {
        ArrayList<String> argv = new ArrayList<String>();
        ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK);
        argv.add(cmd);
        argv.add("annotate");
        argv.add("-n");
        if (revision != null) {
            argv.add("-r");
            if (revision.indexOf(':') == -1) {
                argv.add(revision);
            } else {
                argv.add(revision.substring(0, revision.indexOf(':')));
            }
        }
        argv.add(file.getName());
        ProcessBuilder pb = new ProcessBuilder(argv);
        pb.directory(file.getParentFile());
        Process process = null;
        Annotation ret = null;
        HashMap<String,HistoryEntry> revs = new HashMap<String,HistoryEntry>();

        // Construct hash map for history entries from history cache. This is
        // needed later to get user string for particular revision.
        try {
            History hist = HistoryGuru.getInstance().getHistory(file, false);
            for (HistoryEntry e : hist.getHistoryEntries()) {
                // Chop out the colon and all hexadecimal what follows.
                // This is because the whole changeset identification is
                // stored in history index while annotate only needs the
                // revision identifier.
                revs.put(e.getRevision().replaceFirst(":[a-f0-9]+", ""), e);
            }
        } catch (HistoryException he) {
            OpenGrokLogger.getLogger().log(Level.SEVERE,
                "Error: cannot get history for file " + file);
            return null;
        }

        try {
            process = pb.start();
            try (BufferedReader in = new BufferedReader(
                    new InputStreamReader(process.getInputStream()))) {
                ret = new Annotation(file.getName());
                String line;
                int lineno = 0;
                Matcher matcher = ANNOTATION_PATTERN.matcher("");
                while ((line = in.readLine()) != null) {
                    ++lineno;
                    matcher.reset(line);
                    if (matcher.find()) {
                        String rev = matcher.group(1);
                        String author = "N/A";
                        // Use the history index hash map to get the author.
                        if (revs.get(rev) != null) {
                             author = revs.get(rev).getAuthor();
                        }
                        ret.addLine(rev, Util.getEmail(author.trim()), true);
                    } else {
                        OpenGrokLogger.getLogger().log(Level.SEVERE,
                            "Error: did not find annotation in line "
                            + lineno + ": [" + line + "]");
                    }
                }
            }
        } finally {
            if (process != null) {
                try {
                    process.exitValue();
                } catch (IllegalThreadStateException e) {
                    // the process is still running??? just kill it..
                    process.destroy();
                }
            }
        }
        return ret;
    }

    @Override
    protected String getRevisionForAnnotate(String history_revision)
    {
        String[] brev = history_revision.split(":");

        return brev[0];
    }

    @Override
    public boolean fileHasAnnotation(File file) {
        return true;
    }

    @Override
    public void update() throws IOException {
        File directory = new File(directoryName);

        List<String> cmd = new ArrayList<String>();
        ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK);
        cmd.add(this.cmd);
        cmd.add("showconfig");
        Executor executor = new Executor(cmd, directory);
        if (executor.exec() != 0) {
            throw new IOException(executor.getErrorString());
        }

        if (executor.getOutputString().indexOf("paths.default=") != -1) {
            cmd.clear();
            cmd.add(this.cmd);
            cmd.add("pull");
            cmd.add("-u");
            executor = new Executor(cmd, directory);
            if (executor.exec() != 0) {
                throw new IOException(executor.getErrorString());
            }
        }
    }

    @Override
    public boolean fileHasHistory(File file) {
        // Todo: is there a cheap test for whether mercurial has history
        // available for a file?
        // Otherwise, this is harmless, since mercurial's commands will just
        // print nothing if there is no history.
        return true;
    }

    @Override
    boolean isRepositoryFor(File file) {
        if (file.isDirectory()) {
            File f = new File(file, ".hg");
            return f.exists() && f.isDirectory();
        }
        return false;
    }

    @Override
    boolean supportsSubRepositories() {
        String val = System.getenv(NOFOREST_PROPERTY_KEY);
        return !(val == null
            ? Boolean.getBoolean(NOFOREST_PROPERTY_KEY)
            : Boolean.parseBoolean(val));
    }

    @Override
    public boolean isWorking() {
        if (working == null) {
            ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK);
            working = checkCmd(cmd);
        }
        return working.booleanValue();
    }

    @Override
    boolean hasHistoryForDirectories() {
        return true;
    }

    @Override
    History getHistory(File file) throws HistoryException {
        return getHistory(file, null);
    }

    @Override
    History getHistory(File file, String sinceRevision)
            throws HistoryException {
        RuntimeEnvironment env = RuntimeEnvironment.getInstance();
        History result = new MercurialHistoryParser(this).parse(file,
            sinceRevision);
        // Assign tags to changesets they represent
        // We don't need to check if this repository supports tags,
        // because we know it :-)
        if (env.isTagsEnabled()) {
            assignTagsInHistory(result);
        }
        return result;
    }
    
    /**
     * We need to create list of all tags prior to creation of HistoryEntries
     * per file.
     * @return true.
     */
    @Override
    boolean hasFileBasedTags() {
        return true;
    }
    
    @Override
    protected void buildTagList(File directory) {
        this.tagList = new TreeSet<TagEntry>();
        ArrayList<String> argv = new ArrayList<String>();
        ensureCommand(CMD_PROPERTY_KEY, CMD_FALLBACK);
        argv.add(cmd);
        argv.add("tags");
        ProcessBuilder pb = new ProcessBuilder(argv);
        pb.directory(directory);
        Process process = null;

        try {
            process = pb.start();
            try (BufferedReader in = new BufferedReader(
                    new InputStreamReader(process.getInputStream()))) {
                String line;
                while ((line = in.readLine()) != null) {
                    String parts[] = line.split("  *");
                    if (parts.length < 2) {
                        OpenGrokLogger.getLogger().log(Level.WARNING,
                            "Failed to parse tag list: {0}",
                            "Tag line contains more than 2 columns: " + line);
                        this.tagList = null;
                        break;
                    }
                    // Grrr, how to parse tags with spaces inside?
                    // This solution will lose multiple spaces ;-/
                    String tag = parts[0];
                    for (int i = 1; i < parts.length - 1; ++i) {
                        tag.concat(" ");
                        tag.concat(parts[i]);
                    }
                    // The implicit 'tip' tag only causes confusion so ignore it. 
                    if (tag.contentEquals("tip")) {
                        continue;
                    }
                    String revParts[] = parts[parts.length - 1].split(":");
                    if (revParts.length != 2) {
                        OpenGrokLogger.getLogger().log(Level.WARNING,
                            "Failed to parse tag list: {0}",
                            "Mercurial revision parsing error: " +
                            parts[parts.length - 1]);
                        this.tagList = null;
                        break;
                    }
                    TagEntry tagEntry =
                        new MercurialTagEntry(Integer.parseInt(revParts[0]),
                        tag);
                    // Reverse the order of the list
                    this.tagList.add(tagEntry);
                }
            }
        } catch (IOException e) {
            OpenGrokLogger.getLogger().log(Level.WARNING,
                "Failed to read tag list: {0}", e.getMessage());
            this.tagList = null;
        }
        
        if (process != null) {
            try {
                process.exitValue();
            } catch (IllegalThreadStateException e) {
                // the process is still running??? just kill it..
                process.destroy();
            }
        }
    }
}
