/*
 * $Id$
 *
 * Copyright (c) 2004, 2009, Oracle and/or its affiliates. All rights reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.  Oracle designates this
 * particular file as subject to the "Classpath" exception as provided
 * by Oracle in the LICENSE file that accompanied this code.
 *
 * This code is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 * or visit www.oracle.com if you need additional information or have any
 * questions.
 */
package com.sun.jct.utils.indexgen;

import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.FileScanner;
import org.apache.tools.ant.taskdefs.MatchingTask;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintStream;
import java.io.PrintWriter;
import java.io.Reader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.Vector;

/**
 * <p>A utility to generate an index from a set of HTML files and directories.
 * Index entries are indicated inline by HTML of the form
 * <pre>
 *    &lt;a name=XXX&gt;&lt;!-- index: abc; pqr --&gt;&lt;/a&gt;
 * </pre>
 * This will create entries in the index under "abc" and "pqr" that link back to "XXX".
 * The entries can have sub-entries, separated by ":", as in:
 * <pre>
 *    &lt;a name=XXX&gt;&lt;!-- index: abc:def; pqr:stu --&gt;&lt;/a&gt;
 * This will create entries in the index under "abc"/"def" and "pqr"/"stu"
 * that link back to "XXX". The format is the same as for FrameMaker index marker
 * entries.
 *
 * <p>The output can be in one or both of two forms.
 *
 * <p>The index can be output as a JavaHelp-compatible index.xml file,
 * with an associated map file. The map file can be merged with any other
 * maps with the "mapmerge" utility.
 *
 * <p>Or, the index can be output as a single index.html file, containing the
 * sorted set of index entries and their references.
 */
public class Main {
    private static Iterator<Node> nullIterator = new Iterator<Node>() {
        @Override
        public boolean hasNext() {
            return false;
        }

        @Override
        public Node next() {
            return null;
        }

        @Override
        public void remove() {
        }
    };
    private File[] path;
    private File[] inFiles;
    private File htmlOutFile;
    private File mapOutFile;
    private File mapDir;
    private File xmlOutFile;
    private Reader in;
    private int c;
    private int line;
    private File currFile;
    private String currName;
    private Node root;
    private Comparator<Node> indexComparator = new Comparator<Node>() {
        @Override
        public int compare(Node n1, Node n2) {
            return n1.getName().compareToIgnoreCase(n2.getName());
        }
    };

    public Main() {
    }

    /**
     * Create an object based on command line args.
     * It is an error if no input files or no output file is given.
     *
     * @param args Command line args.
     * @throws Main.BadArgs if problems are found in the given arguments.
     * @see #main
     */
    public Main(String... args) {
        for (int i = 0; i < args.length; i++) {
            if (args[i].equalsIgnoreCase("-htmlout") && i + 1 < args.length) {
                htmlOutFile = new File(args[++i]);
            } else if (args[i].equalsIgnoreCase("-xmlout") && i + 1 < args.length) {
                xmlOutFile = new File(args[++i]);
            } else if (args[i].equalsIgnoreCase("-mapout") && i + 1 < args.length) {
                mapOutFile = new File(args[++i]);
            } else if (args[i].equalsIgnoreCase("-mapdir") && i + 1 < args.length) {
                mapDir = new File(args[++i]);
            } else if (args[i].equalsIgnoreCase("-srcPath") && i + 1 < args.length) {
                path = splitPath(args[++i]);
            } else {
                inFiles = new File[args.length - i];
                for (int j = 0; j < inFiles.length; j++) {
                    inFiles[j] = new File(args[i++]);
                }
            }
        }
    }

    /**
     * Command line entry point.<br>
     *
     * @param args Command line arguments, per the usage as described.
     */
    public static void main(String... args) {
        try {
            if (args.length == 0) {
                usage(System.err);
            } else {
                Main m = new Main(args);
                m.run();
            }
        } catch (BadArgs e) {
            System.err.println(e);
            usage(System.err);
            System.exit(1);
        } catch (Throwable t) {
            t.printStackTrace();
            System.exit(2);
        }
    }

    /**
     * Write out short command line help.
     *
     * @param out A stream to which to write the help.
     */
    private static void usage(PrintStream out) {
        String program = System.getProperty("program", "java " + Main.class.getName());
        out.println("Usage:");
        out.println("   " + program + " options files...");
        out.println("");
        out.println("Arguments:");
        out.println("-mapOut map.xml");
        out.println("        Specify the location of the map.xml file to be written.");
        out.println("-htmlOut index.html");
        out.println("        Specify the location of the index.html file.");
        out.println("-xmlOut index.xml");
        out.println("        Specify the location of the index.xml file.");
        out.println("-srcpath dir;dir;...");
        out.println("        Specify the a path in which to look for source files.");
        out.println("files...");
        out.println("        HTML files and directories.");
    }

    private static String escape(String s) {
        for (int i = 0; i < s.length(); i++) {
            switch (s.charAt(i)) {
                case '<':
                case '>':
                case '&':
                    StringBuilder sb = new StringBuilder(s.length() * 2);
                    for (int j = 0; j < s.length(); j++) {
                        char c = s.charAt(j);
                        switch (c) {
                            case '<':
                                sb.append("&lt;");
                                break;
                            case '>':
                                sb.append("&gt;");
                                break;
                            case '&':
                                sb.append("&amp;");
                                break;
                            default:
                                sb.append(c);
                        }
                    }
                    return sb.toString();
            }
        }
        return s;
    }

    private static String[] split(String s, char sep) {
        Vector<String> v = new Vector<>();
        int start = -1;
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            if (c == sep) {
                if (start != -1) {
                    v.add(s.substring(start, i).trim());
                }
                start = -1;
            } else {
                if (start == -1) {
                    start = i;
                }
            }
        }
        if (start != -1) {
            v.add(s.substring(start).trim());
        }
        return v.toArray(new String[v.size()]);
    }

    private static File[] splitPath(String s) {
        Vector<File> files = new Vector<>();
        int start = -1;
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            if (c == File.pathSeparatorChar) {
                if (start != -1) {
                    files.add(new File(s.substring(start, i)));
                }
                start = -1;
            } else {
                if (start == -1) {
                    start = i;
                }
            }
        }
        if (start != -1) {
            files.add(new File(s.substring(start)));
        }
        return files.toArray(new File[files.size()]);
    }

    private static String getTarget(String key) {
        String file;
        String ref;

        int hash = key.lastIndexOf('#');
        if (hash == -1) {
            file = key;
            ref = null;
        } else {
            file = key.substring(0, hash);
            ref = key.substring(hash + 1);
        }

        if (file.endsWith(".html")) {
            file = file.substring(0, file.length() - 5);
        }

        if (ref == null) {
            key = file;
        } else {
            key = file + "#" + ref;
        }

        StringBuilder sb = new StringBuilder();
        sb.append("index.");
        boolean needUpper = false;
        for (int i = 0; i < key.length(); i++) {
            char c = key.charAt(i);
            if (Character.isLetter(c)) {
                sb.append(needUpper ? Character.toUpperCase(c) : c);
                needUpper = false;
            } else {
                needUpper = true;
            }
        }
        return sb.toString();
    }

    private static String escapeString(String text) {

        // check to see if there are any special characters
        boolean specialChars = false;
        for (int i = 0; i < text.length() && !specialChars; i++) {
            switch (text.charAt(i)) {
                case '<':
                case '>':
                case '&':
                case '"':
                    specialChars = true;
            }
        }

        // if there are special characters rewrite the string with escaped characters
        // otherwise, return it as is
        if (specialChars) {
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < text.length(); i++) {
                char c = text.charAt(i);
                switch (c) {
                    case '<':
                        sb.append("&lt;");
                        break;
                    case '>':
                        sb.append("&gt;");
                        break;
                    case '&':
                        sb.append("&amp;");
                        break;
                    case '"':
                        sb.append("&quot;");
                        break;
                    default:
                        sb.append(c);
                }
            }
            return sb.toString();
        } else {
            return text;
        }
    }

    public void addFiles(String... paths) {
        if (paths == null) {
            return;
        }
        List<File> files = new ArrayList<>();
        if (inFiles != null) {
            files.addAll(Arrays.asList(inFiles));
        }
        for (String path1 : paths) {
            files.add(new File(path1));
        }
        inFiles = files.toArray(new File[files.size()]);
    }

    private void run() throws BadArgs, IOException {
        if (inFiles == null || inFiles.length == 0) {
            throw new BadArgs("no input files specified");
        }

        if (htmlOutFile == null && mapOutFile == null && xmlOutFile == null) {
            throw new BadArgs("no output files specified");
        }

        if (xmlOutFile != null && mapOutFile == null) {
            throw new BadArgs("no map output file specified");
        }

        if (mapOutFile != null && xmlOutFile == null) {
            throw new BadArgs("no XML output file specified");
        }

        root = new Node();

        read(inFiles);

        writeIndex();
    }

    private void read(File... files) throws IOException {
        for (File file : files) {
            read(file);
        }
    }

    private void read(File file) throws IOException {
        if (path == null) {
            read(file, file);
        } else {
            for (File aPath : path) {
                File f = new File(aPath, file.getPath());
                if (f.exists()) {
                    read(f, file);
                    return;
                }
            }
            throw new FileNotFoundException(file.getPath());
        }
    }

    private void read(File absFile, File relFile) throws IOException {

        if (absFile.isDirectory()) {
            if (!absFile.getName().equals("SCCS")) {
                String[] files = absFile.list();
                for (String file : files) {
                    read(new File(absFile, file),
                            relFile.getPath().equals(".") ? new File(file) : new File(relFile, file));
                }
            }
            return;
        }

        if (!absFile.getName().endsWith(".html")) {
            return;
        }

        // ordinary file -- scan it looking for index entries
        in = new BufferedReader(new FileReader(absFile));
        currFile = relFile;
        currName = null;
        line = 1;
        nextCh();
        while (c >= 0) {
            if (c == '<') {

                nextCh();
                skipSpace();
                switch (c) {
                    case '!':
                        nextCh();
                        if (c == '-') {
                            nextCh();
                            if (c == '-') {
                                nextCh();
                                scanComment();
                            }
                        }
                        break;

                    case '/':
                        nextCh();
                        String endTag = scanIdentifier();
                        if (isLink(endTag)) {
                            currName = "";
                            skipTag();
                        } else {
                            skipTag();
                        }
                        break;

                    default:
                        String startTag = scanIdentifier();
                        if (isLink(startTag)) {
                            scanLink();
                        } else {
                            skipTag();
                        }
                }
            } else {
                nextCh();
            }
        }

    }

    private boolean isLink(String tag) {
        return tag.equals("a");
    }

    /**
     * Process the contents of <a href=...>
     */
    private void scanLink() throws IOException {
        skipSpace();
        while (c != '>') {
            String att = scanIdentifier();
            String value = scanValue();
            if (att.equalsIgnoreCase("name")) {
                currName = value;
            }
            skipSpace();
        }
        nextCh();
    }

    /**
     * Read an identifier, and lowercase it
     */
    private String scanIdentifier() throws IOException {
        StringBuilder buf = new StringBuilder();
        while (true) {
            if ((c >= 'a') && (c <= 'z')) {
                buf.append((char) c);
                nextCh();
            } else if ((c >= 'A') && (c <= 'Z')) {
                buf.append((char) ('a' + (c - 'A')));
                nextCh();
            } else if ((c >= '0') && (c <= '9')) {
                buf.append((char) c);
                nextCh();
            } else if (c == '-') {  // needed for <META HTTP-EQUIV ....>
                buf.append((char) c);
                nextCh();
            } else if (buf.length() == 0) {
                throw new IOException("Identifier expected (" + currFile + ":" + line + ")");
            } else {
                return buf.toString();
            }
        }
    }

    /**
     * Read the value of an HTML attribute, which may be quoted.
     */
    private String scanValue() throws IOException {
        skipSpace();
        if (c != '=') {
            return "";
        }

        int quote = -1;
        nextCh();
        skipSpace();
        if ((c == '\'') || (c == '\"')) {
            quote = c;
            nextCh();
            skipSpace();
        }
        StringBuilder buf = new StringBuilder();
        while (((quote < 0) && (c != ' ') && (c != '\t') &&
                (c != '\n') && (c != '\r') && (c != '>')) ||
                ((quote >= 0) && (c != quote))) {
            if (c == -1 || c == '\n' || c == '\r') {
                throw new IOException("mismatched quotes (" + currFile + ":" + line + ")");
            }
            buf.append((char) c);
            nextCh();
        }
        if (c == quote) {
            nextCh();
        }
        skipSpace();
        return buf.toString();
    }

    /**
     * Scan an HTML comment  <!-- ... -->
     */
    private void scanComment() throws IOException {
        // a comment sequence is "<!--" ... "-->"
        // at the time this is called, "<!--" has been read;
        StringBuilder text = new StringBuilder("<!--");
        int numHyphens = 0;
        while (c != -1 && (numHyphens < 2 || c != '>')) {
            if (c == '-') {
                numHyphens++;
            } else {
                numHyphens = 0;
            }
            text.append((char) c);
            nextCh();
            //System.out.print((char)c);
        }
        text.append((char) c);
        nextCh();

        String comment = text.toString().substring(4, text.length() - 3).trim();

        if (comment.startsWith("index:")) {
            String[] entries = split(comment.substring(6).trim(), ';');
            for (String entry : entries) {
                addToIndex(split(entry.trim(), ':'), currFile, currName);
            }
        }
    }

    /**
     * Skip whitespace.
     */
    private void skipSpace() throws IOException {
        while ((c == ' ') || (c == '\t') || (c == '\n') || (c == '\r')) {
            nextCh();
        }
    }

    /**
     * Skip the contents of an HTML tag i.e. <...>
     */
    private void skipTag() throws IOException {
        skipSpace();
        while (c != '>') {
            String att = scanIdentifier();
            if (Objects.equals(att, "")) {
                throw new IOException("error parsing HTML input (" + currFile + ":" + line + ")");
            }
            String value = scanValue();
            skipSpace();
        }
        nextCh();
    }

    /**
     * Read the next character.
     */
    private void nextCh() throws IOException {
        c = in.read();
        if (c == '\n') {
            line++;
        }
    }

    private void addToIndex(String[] path, File file, String ref) {
        Node node = root.getChild(path);
        String href = file.getPath();
        if (ref != null && !ref.isEmpty()) {
            href = href + "#" + ref;
        }
        node.setInfo(href);
    }

    private void writeIndex() throws IOException {

        PrintWriter indexOut = xmlOutFile == null ? null
                : new PrintWriter(new BufferedWriter(new FileWriter(xmlOutFile)));
        if (indexOut != null) {
            indexOut.println("<?xml version='1.0' encoding='ISO-8859-1'  ?>");
            indexOut.println("<!DOCTYPE index");
            indexOut.println("  PUBLIC \"-//Sun Microsystems Inc.//DTD JavaHelp Index Version 1.0//EN\"");
            indexOut.println("         \"http://java.sun.com/products/javahelp/index_1_0.dtd\">");
            indexOut.println("");
            indexOut.println("<index version=\"1.0\">");
        }

        PrintWriter mapOut = mapOutFile == null ? null
                : new PrintWriter(new BufferedWriter(new FileWriter(mapOutFile)));
        if (mapOut != null) {
            mapOut.println("<?xml version='1.0' encoding='ISO-8859-1' ?>");
            mapOut.println("<!DOCTYPE map");
            mapOut.println("  PUBLIC \"-//Sun Microsystems Inc.//DTD JavaHelp Map Version 1.0//EN\"");
            mapOut.println("         \"http://java.sun.com/products/javahelp/map_1_0.dtd\">");
            mapOut.println("<map version=\"1.0\">");
        }

        PrintWriter htmlOut = htmlOutFile == null ? null
                : new PrintWriter(new BufferedWriter(new FileWriter(htmlOutFile)));
        if (htmlOut != null) {
            htmlOut.println("<!DOCTYPE HTML>");
            htmlOut.println("<html>");
            htmlOut.println("<head>");
            htmlOut.println("<title>");
            htmlOut.println("Index");
            htmlOut.println("</title>");
            htmlOut.println("<LINK REL=\"stylesheet\" TYPE=\"text/css\" HREF=\"../jthelp.css\" TITLE=\"Style\">");
            htmlOut.println("</head>");
            htmlOut.println("<body>");
            htmlOut.println("<h1>Index</h1>");
        }

        char currLetter = 0;

        for (Iterator<Node> iter = root.iterator(); iter.hasNext(); ) {
            Node node = iter.next();
            String name = node.getName();
            char initial = Character.toUpperCase(name.charAt(0));
            if (Character.isLetter(initial) && initial != currLetter) {
                for (char c = currLetter == 0 ? 'A' : (char) (currLetter + 1);
                     c <= initial; c++) {
                    if (htmlOut != null) {
                        htmlOut.println("");
                        htmlOut.println("<p class=\"index0\">" + c + "</p>");
                    }
                }
                currLetter = initial;
            }

            write(indexOut, mapOut, htmlOut, node, 0);
        }

        if (htmlOut != null) {
            for (char c = (char) (currLetter + 1); c <= 'Z'; c++) {
                htmlOut.println("<p class=\"index0\">" + c + "</p>");
            }
            htmlOut.println("</body>");
            htmlOut.println("</html>");
            htmlOut.close();
        }

        if (mapOut != null) {
            mapOut.println("</map>");
            mapOut.close();
        }

        if (indexOut != null) {
            indexOut.println("</index>");
            indexOut.close();
        }

    }

    private void write(PrintWriter xmlOut, PrintWriter mapOut, PrintWriter htmlOut, Node node, int depth) {
        String href = node.getInfo();

        if (htmlOut != null) {
            htmlOut.write("<p class=\"index");
            htmlOut.write(String.valueOf(depth + 1));
            htmlOut.write("\">");
            if (href != null) {
                htmlOut.write("<a href=\"");
                htmlOut.write(escapeString(href));
                htmlOut.write("\">");
            }
            htmlOut.write(node.getName());
            if (href != null) {
                htmlOut.write("</a>");
            }
            htmlOut.write("</p>\n");
        }

        if (xmlOut != null) {
            xmlOut.write("<indexitem text=\"");
            xmlOut.write(escapeString(node.getName()));
            xmlOut.write("\" ");
            if (href != null) {
                xmlOut.write(" target=\"");
                xmlOut.write(escapeString(getTarget(href)));
                xmlOut.write("\" ");
                if (mapOut != null) {
                    mapOut.println("<mapID target=\""
                            + escapeString(getTarget(href))
                            + "\" url=\""
                            + escapeString(href)
                            + "\" />");
                }
            }
            xmlOut.println(node.getChildCount() == 0 ? "/>" : ">");
        }

        if (node.getChildCount() > 0) {
            for (Iterator<Node> iter = node.iterator(); iter.hasNext(); ) {
                Node child = iter.next();
                write(xmlOut, mapOut, htmlOut, child, depth + 1);
            }

            if (xmlOut != null) {
                xmlOut.println("</indexitem>");
            }
        }
    }

    /**
     * An exception to report bad command line arguments.
     */
    public static class BadArgs extends Exception {
        BadArgs(String msg) {
            super(msg);
        }
    }

    public static class Ant extends MatchingTask {
        private Main m = new Main();

        public void setHtmlOutFile(File file) {
            m.htmlOutFile = file;
        }

        public void setXmlOutFile(File file) {
            m.xmlOutFile = file;
        }

        public void setMapOutFile(File file) {
            m.mapOutFile = file;
        }

        public void setMapDir(File file) {
            m.mapDir = file;
        }

        public void setDir(File dir) {
            getImplicitFileSet().setDir(dir);
        }

        @Override
        public void execute() {
            FileScanner s = getImplicitFileSet().getDirectoryScanner(getProject());
            m.path = new File[]{s.getBasedir()};
            m.addFiles(s.getIncludedFiles());

            try {
                m.run();
            } catch (BadArgs e) {
                throw new BuildException(e.getMessage());
            } catch (IOException e) {
                throw new BuildException(e);
            }
        }
    }

    private class Node {
        private String name;
        private Set<Node> children;
        private String info;

        Node() {
        }

        Node(Node parent, String name) {
            this.name = name;
            parent.add(this);
        }

        String getName() {
            return name;
        }

        String getInfo() {
            return info;
        }

        void setInfo(String info) {
            this.info = info;
        }

        Node getChild(String name) {
            if (children != null) {
                for (Node child : children) {
                    if (child.name.equals(name)) {
                        return child;
                    }
                }
            }

            return new Node(this, name);
        }

        Node getChild(String... path) {
            Node c = this;
            for (String aPath : path) {
                c = c.getChild(aPath);
            }
            return c;
        }

        int getChildCount() {
            return children == null ? 0 : children.size();
        }

        Iterator<Node> iterator() {
            return children == null ? nullIterator : children.iterator();
        }

        private void add(Node child) {
            if (children == null) {
                children = new TreeSet<>(indexComparator);
            }
            children.add(child);
        }
    }

}
