/*
 * Copyright (c) 2017-2025 XMLmind Software. All rights reserved.
 *
 * Author: Hussein Shafie
 *
 * This file is part of the XMLmind Assembly Processor project.
 * For conditions of distribution and use, see the accompanying legal.txt file.
 */
package com.xmlmind.transproc;

import java.io.IOException;
import java.io.File;
import java.util.List;
import java.util.ArrayList;
import org.w3c.dom.Node;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Attr;
import org.w3c.dom.Element;
import org.w3c.dom.Document;
import static javax.xml.XMLConstants.XML_NS_URI;
import com.xmlmind.util.ThrowableUtil;
import com.xmlmind.util.ArrayUtil;
import com.xmlmind.util.FileUtil;
import com.xmlmind.util.XMLText;
import com.xmlmind.util.XMLResolver;
import com.xmlmind.util.Console;
import com.xmlmind.util.SimpleConsole;
import com.xmlmind.domutil.DOMUtil;
import com.xmlmind.domutil.ConsoleHelper;
import com.xmlmind.domutil.XMLResolverImpl;
import com.xmlmind.domutil.SaveDocument;
import com.xmlmind.xinclude.XLoader;

/**
 * Transclusion processor: processes a document containing elements 
 * having <code>trans:</code> attributes then removes all
 * these <code>trans:</code> attributes.
 */
public class Processor {
    /**
     * The URI of the DocBook transclusion namespace.
     */
    public static final String TRANS_NS_URI =
        "http://docbook.org/ns/transclusion";

    /**
     * The URI of the XML Linking Language (XLink).
     */
    public static final String XLINK_NS_URI = 
        "http://www.w3.org/1999/xlink";

    /**
     * DocBook 5 ID-list.
     */
    public static final String[] DOCBOOK5_ID_LIST = {
        // Namespace URI, local part
        XML_NS_URI, "id"
    };

    /**
     * DocBook 5 IDREF-list.
     */
    public static final String[] DOCBOOK5_IDREF_LIST = {
        // Namespace URI, local part, value prefix
        null, "linkend", null,
        null, "linkends", null,
        null, "otherterm", null,
        null, "zone", null,
        null, "startref", null,
        null, "arearefs", null,
        // null, "targetptr", null,
        null, "endterm", null,
        XLINK_NS_URI, "href", "#"
    };

    /**
     * Default automatic suffix separator.
     */
    public static final String AUTO_SUFFIX_SEPARATOR = "---";

    protected ConsoleHelper console;

    protected String[] idList;
    protected String[] idRefList;
    protected String autoSuffixSeparator;

    protected long autoSuffixCounter;
    protected Boolean success;

    protected static final String ID_SUFFIX_KEY = "ID_SUFFIX";
    protected static final String SEARCHED_FOR_ID_KEY = "SEARCHED_FOR_ID";

    protected enum Linkscope {
        USER,
        LOCAL,
        NEAR,
        GLOBAL;

        public static Linkscope fromString(String spec) {
            if ("user".equals(spec)) {
                return USER;
            } else if ("local".equals(spec)) {
                return LOCAL;
            } else if ("near".equals(spec)) {
                return NEAR;
            } else if ("global".equals(spec)) {
                return GLOBAL;
            } else {
                return null;
            }
        }
    };

    // -----------------------------------------------------------------------
    // API
    // -----------------------------------------------------------------------

    /**
     * Equivalent to {@link #Processor Processor(null)}.
     */
    public Processor() {
        this(null);
    }

    /**
     * Constructs a Processor using specified console to display 
     * its progress, warning, error, etc, messages.
     *
     * @param c the console. May be <code>null</code>.
     * @see #setConsole
     */
    @SuppressWarnings("this-escape")
    public Processor(Console c) {
        setConsole(c);
        idList = DOCBOOK5_ID_LIST;
        idRefList = DOCBOOK5_IDREF_LIST;
        autoSuffixSeparator = AUTO_SUFFIX_SEPARATOR;
    }

    /**
     * Specifies the console on which messages issued during processing
     * are to be displayed. 
     * 
     * @param c the console; may be <code>null</code>, in which case messages
     * are displayed on <code>System.err</code> and <code>System.out</code>
     *
     * @see #getConsole
     */
    public void setConsole(Console c) {
        if (c == null) {
            c = new SimpleConsole();
        }
        this.console = ((c instanceof ConsoleHelper)? 
                        (ConsoleHelper) c : new ConsoleHelper(c));
    }

    /**
     * Returns the console on which messages issued during preprocessing
     * are to be displayed.
     * 
     * @see #setConsole
     */
    public ConsoleHelper getConsole() {
        return console;
    }

    /**
     * Specifies the ID-list used by this processor.
     * <p>By default, it's {@link #DOCBOOK5_ID_LIST}.
     *
     * @see #getIdList
     */
    public void setIdList(String[] list) {
        if (list == null) {
            list = DOCBOOK5_ID_LIST;
        }
        this.idList = list;
    }

    /**
     * Returns the ID-list used by this processor.
     *
     * @see #setIdList
     */
    public String[] getIdList() {
        return idList;
    }

    /**
     * Specifies the IDREF-list used by this processor.
     * <p>By default, it's {@link #DOCBOOK5_IDREF_LIST}.
     *
     * @see #getIdRefList
     */
    public void setIdRefList(String[] list) {
        if (list == null) {
            list = DOCBOOK5_IDREF_LIST;
        }
        this.idRefList = list;
    }

    /**
     * Returns the IDREF-list used by this processor.
     *
     * @see #setIdRefList
     */
    public String[] getIdRefList() {
        return idRefList;
    }

    /**
     * Specifies the separator used for automatically generated suffixes.
     * <p>By defaut, it's {@link #AUTO_SUFFIX_SEPARATOR}.
     */
    public void setAutoSuffixSeparator(String separ) {
        if (separ == null) {
            separ = AUTO_SUFFIX_SEPARATOR;
        }
        autoSuffixSeparator = separ;
    }

    /**
     * Returns the separator used for automatically generated suffixes.
     */
    public String getAutoSuffixSeparator() {
        return autoSuffixSeparator;
    }

    /**
     * Process specified document.
     *
     * @param doc to be processed
     * @return <code>null</code> if the document has been left unchanged 
     * (no <code>trans:*</code> attributes in it); <code>Boolean.TRUE</code> 
     * if the document has been successfully processed;
     * <code>Boolean.FALSE</code> otherwise
     */
    public Boolean process(Document doc) {
        String docURI = doc.getDocumentURI();
        autoSuffixCounter = (docURI != null)? Math.abs(docURI.hashCode()) : 0;
        success = Boolean.TRUE;

        // A single pass combining idFixup() and idRefFixup() cannot work
        // because idRefFixup() needs to copy already suffixed IDs whatever
        // the locations of these IDs in the document.

        Element root = doc.getDocumentElement();
        if (!idFixup(root, /*currentSuffix*/ null)) {
            String location = docURI;
            if (location == null) {
                location = Msg.msg("realizedDocument");
            }
            console.debug(Msg.msg("noTransclusions", location));
            // Nothing to do.
            return null;
        }

        idRefFixup(root, /*currentLinkscope*/ null);

        cleanUp(root, new ArrayList<String>());

        return success;
    }

    // -----------------------------------------------------------------------
    // Implementation
    // -----------------------------------------------------------------------

    // -----------------------------------
    // idFixup
    // -----------------------------------

    protected boolean idFixup(Element tree, String currentSuffix) {
        String idfixup = getTransAttribute(tree, "idfixup");
        String suffix = getTransAttribute(tree, "suffix");
        String linkscope = getTransAttribute(tree, "linkscope");
        boolean needsProcessing = 
            (idfixup != null || suffix != null || linkscope != null);

        // Update current suffix ---

        String nextSuffix = null;

        if (idfixup != null) {
            if ("auto".equals(idfixup)) {
                nextSuffix = autoSuffix(tree);
            } else if ("suffix".equals(idfixup)) {
                if (suffix == null) {
                    processError(tree, Msg.msg("missingAttr", "trans:suffix"));
                    nextSuffix = "";
                } else {
                    if (currentSuffix == null) {
                        nextSuffix = suffix;
                    } else {
                        nextSuffix = currentSuffix + suffix;
                    }
                }
            } else if ("none".equals(idfixup)) {
                nextSuffix = "";
            } else {
                processError(tree, Msg.msg("invalidAttr",
                                           idfixup, "trans:idfixup"));
                nextSuffix = "";
            }
        } else {
            if (suffix != null) {
                processError(tree, Msg.msg("missingAttr", "trans:idfixup"));
                nextSuffix = "";
            }
        }

        if (nextSuffix != null) {
            currentSuffix = nextSuffix;
        } else {
            if (currentSuffix == null && needsProcessing) {
                // Default value of the suffix property.
                currentSuffix = "";
            }
        }

        // Apply current suffix to ID attribute ---

        if (currentSuffix != null) {
            // Needed by idRefFixup.
            tree.setUserData(ID_SUFFIX_KEY, currentSuffix,
                             DOMUtil.COPY_USER_DATA);

            if (currentSuffix.length() > 0) {
                Attr idAttr = getIdAttribute(tree);
                if (idAttr != null) {
                    tree.setAttributeNS(idAttr.getNamespaceURI(),
                                        idAttr.getName(), 
                                        idAttr.getValue() + currentSuffix);
                }
            }
        }

        // Traverse ---

        Node child = tree.getFirstChild();
        while (child != null) {
            if (child.getNodeType() == Node.ELEMENT_NODE) {
                if (idFixup((Element) child, currentSuffix)) {
                    needsProcessing = true;
                }
            }

            child = child.getNextSibling();
        }

        return needsProcessing;
    }

    protected static String getTransAttribute(Element element,
                                              String localName) {
        return DOMUtil.getAttribute(element, TRANS_NS_URI, localName);
    }

    protected String autoSuffix(Node node) {
        // System.identityHashCode(node) works fine but does not make it easy
        // writing non-regression tests.
        return autoSuffixSeparator + Long.toString(++autoSuffixCounter,
                                                   Character.MAX_RADIX);
    }

    protected void processError(Element element, String message) {
        console.error(element, message);
        success = Boolean.FALSE;
    }

    protected Attr getIdAttribute(Element element) {
        final int count = idList.length;
        for (int i = 0; i < count; i += 2) {
            String nsURI = idList[i];
            String localName = idList[i+1];

            Attr attr = element.getAttributeNodeNS(nsURI, localName);
            if (attr != null) {
                return attr;
            }
        }

        return null;
    }

    // -----------------------------------
    // idRefFixup
    // -----------------------------------

    protected void idRefFixup(Element tree, Linkscope currentLinkscope) {
        // Get current suffix if any ---

        String currentSuffix = (String) tree.getUserData(ID_SUFFIX_KEY);

        // Update current linkscope ---

        String  linkscope = getTransAttribute(tree, "linkscope");
        if (linkscope != null) {
            currentLinkscope = Linkscope.fromString(linkscope);
            if (currentLinkscope == null) {
                processError(tree, Msg.msg("invalidAttr",
                                           linkscope, "trans:linkscope"));
                currentLinkscope = Linkscope.NEAR;
            }
        } else {
            if (currentLinkscope == null && currentSuffix != null) {
                // Default value of the linkscope property.
                currentLinkscope = Linkscope.NEAR;
            }
        }

        // Apply current linkscope to IDREF attributes ---

        if (currentLinkscope != null && currentLinkscope != Linkscope.USER) {
            Attr[] idRefAttrs = getIdRefAttributes(tree);
            if (idRefAttrs != null) {
                for (Attr idRefAttr: idRefAttrs) {
                    StringBuilder buffer = new StringBuilder();

                    String[] idRefs = XMLText.splitList(idRefAttr.getValue());
                    for (String idRef : idRefs) {
                        switch (currentLinkscope) {
                        case LOCAL:
                            // Here idRef may already start with "#".
                            idRef = idRef + currentSuffix;
                            break;
                        case NEAR:
                        case GLOBAL:
                            {
                                String valuePrefix = getValuePrefix(idRefAttr);
                                if (valuePrefix != null) {
                                    idRef = 
                                        idRef.substring(valuePrefix.length());
                                }

                                Attr idAttr;
                                if (currentLinkscope == Linkscope.NEAR) {
                                    idAttr = findNearestId(tree, idRef);
                                } else {
                                    idAttr = findFirstId(tree, idRef);
                                }

                                if (idAttr != null) {
                                    idRef = idAttr.getValue();
                                    if (valuePrefix != null) {
                                        idRef = valuePrefix + idRef;
                                    }
                                }
                            }
                            break;
                        }

                        appendToken(idRef, buffer);
                    }

                    tree.setAttributeNS(idRefAttr.getNamespaceURI(), 
                                        idRefAttr.getName(),
                                        buffer.toString());
                }
            }
        }

        // Traverse ---

        Node child = tree.getFirstChild();
        while (child != null) {
            if (child.getNodeType() == Node.ELEMENT_NODE) {
                idRefFixup((Element) child, currentLinkscope);
            }

            child = child.getNextSibling();
        }
    }

    protected Attr[] getIdRefAttributes(Element element) {
        Attr[] idRefAttrs = null;

        final int count = idRefList.length;
        for (int i = 0; i < count; i += 3) {
            String nsURI = idRefList[i];
            String localName = idRefList[i+1];
            String valuePrefix = idRefList[i+2];

            Attr attr = element.getAttributeNodeNS(nsURI, localName);
            if (attr != null &&
                (valuePrefix == null ||
                 attr.getValue().startsWith(valuePrefix))) {
                if (idRefAttrs == null) {
                    idRefAttrs = new Attr[] { attr };
                } else {
                    idRefAttrs = ArrayUtil.append(idRefAttrs, attr);
                }
            }
        }

        return idRefAttrs;
    }

    protected String getValuePrefix(Attr idRefAttr) {
        final int count = idRefList.length;
        for (int i = 0; i < count; i += 3) {
            String nsURI = idRefList[i];
            String localName = idRefList[i+1];
            String valuePrefix = idRefList[i+2];

            if (DOMUtil.hasName(idRefAttr, nsURI, localName)) {
                return valuePrefix;
            }
        }

        return null;
    }

    protected static final void appendToken(String token,
                                            StringBuilder buffer) {
        if (buffer.length() > 0) {
            buffer.append(' ');
        }
        buffer.append(token);
    }

    protected Attr findNearestId(Element from, String idRef) {
        Attr found = null;

        Element parent = DOMUtil.getParentElement(from);
        while (parent != null) {
            Attr idAttr = findId(parent, idRef);
            if (idAttr != null) {
                found = idAttr;
                break;
            }

            // Accelerator. Mark subtree as already searched.
            parent.setUserData(SEARCHED_FOR_ID_KEY, Boolean.TRUE, 
                               DOMUtil.COPY_USER_DATA);

            parent = DOMUtil.getParentElement(parent);
        }

        // Clean-up ---

        parent = DOMUtil.getParentElement(from);
        while (parent != null) {
            parent.setUserData(SEARCHED_FOR_ID_KEY, null, null);

            parent = DOMUtil.getParentElement(parent);
        }

        return found;
    }

    protected Attr findId(Element tree, String idRef) {
        if (tree.getUserData(SEARCHED_FOR_ID_KEY) != null) {
            return null;
        }

        // ---

        Attr idAttr = getIdAttribute(tree);
        if (idAttr != null) {
            String id = idAttr.getValue();

            String suffix = (String) tree.getUserData(ID_SUFFIX_KEY);
            if (suffix != null && suffix.length() > 0 && id.endsWith(suffix)) {
                id = id.substring(0, id.length() - suffix.length());
            }

            if (id.equals(idRef)) {
                return idAttr;
            }
        }

        // ---

        Node child = tree.getFirstChild();
        while (child != null) {
            if (child.getNodeType() == Node.ELEMENT_NODE) {
                Attr found = findId((Element) child, idRef);
                if (found != null) {
                    return found;
                }
            }

            child = child.getNextSibling();
        }

        return null;
    }

    protected Attr findFirstId(Element from, String idRef) {
        return findId(from.getOwnerDocument().getDocumentElement(), idRef);
    }

    // -----------------------------------
    // cleanUp
    // -----------------------------------

    protected void cleanUp(Element tree, List<String> removeList) {
        NamedNodeMap map = tree.getAttributes();
        if (map != null) {
            removeList.clear();

            final int count = map.getLength();
            for (int i = 0; i < count; ++i) {
                Attr attr = (Attr) map.item(i);

                if (TRANS_NS_URI.equals(attr.getNamespaceURI())) {
                    // NullPointerException if attribute removed here
                    // from map or from childElement. Needs a second
                    // pass to work.
                    removeList.add(attr.getLocalName());
                }
            }

            for (String localName : removeList) {
                tree.removeAttributeNS(TRANS_NS_URI, localName);
            }
        }

        tree.setUserData(ID_SUFFIX_KEY, null, null);

        // ---

        Node child = tree.getFirstChild();
        while (child != null) {
            if (child.getNodeType() == Node.ELEMENT_NODE) {
                cleanUp((Element) child, removeList);
            }

            child = child.getNextSibling();
        }
    }

    // -----------------------------------------------------------------------
    // Main
    // -----------------------------------------------------------------------

    public static void main(String[] args) {
        File outFile = null;
        Console.MessageType verbosity = Console.MessageType.WARNING;

        int i = 0;
        final int argCount = args.length;
        for (; i < argCount; ++i) {
            String arg = args[i];

            if ("-o".equals(arg)) {
                if (i+1 >= argCount) {
                    usage(null);
                    /*NOTREACHED*/
                }

                outFile = (new File(args[i+1])).getAbsoluteFile();
                ++i;
            } else if ("-v".equals(arg)) {
                verbosity = Console.MessageType.INFO;
            } else if ("-vv".equals(arg)) {
                verbosity = Console.MessageType.VERBOSE;
            } else if ("-vvv".equals(arg)) {
                verbosity = Console.MessageType.DEBUG;
            } else {
                if (arg.startsWith("-")) {
                    usage("'" + arg + "', unknown option");
                    /*NOTREACHED*/
                }

                // Done with options.
                break;
            }
        }

        int inputCount = argCount - i;
        if (inputCount <= 0 || outFile == null) {
            usage(null);
            /*NOTREACHED*/
        }

        if (inputCount > 1 && outFile.isFile()) {
            usage("'" + outFile + "', not an output directory");
            /*NOTREACHED*/
        }

        File[] inFiles = new File[inputCount];
        int j = 0;

        for (; i < argCount; ++i) {
            File inFile = new File(args[i]);

            if (!inFile.isFile()) {
                usage("'" + inFile + "', not a file");
                /*NOTREACHED*/
            }
            inFiles[j++] = inFile;
        }

        // ---

        ConsoleHelper console = new ConsoleHelper(
            new SimpleConsole(/*prefix*/ null, /*showMessageType*/ true, 
                              /*errorLevel*/ Console.MessageType.INFO));
        console.setVerbosity(verbosity);

        XMLResolver resolver = null;
        if (System.getProperty("xml.catalog.files") != null) {
            // Works if xmlresolver.jar is in the CLASSPATH.
            try {
                resolver = new XMLResolverImpl(/*urls*/ null);
            } catch (Exception ignored) {}
        }

        Document[] docs = new Document[inputCount];

        XLoader loader = new XLoader();
        if (resolver != null) {
            loader.setEntityResolver(resolver);
        }

        for (int k = 0; k < inputCount; ++k) {
            File inFile = inFiles[k];

            console.info(Msg.msg("loadingDoc", inFile));

            try {
                docs[k] = loader.load(inFile);
            } catch (IOException e) {
                console.error(Msg.msg("cannotLoadDoc", 
                                      inFile, ThrowableUtil.reason(e)));
                System.exit(2);
            }
        }

        // ---

        int exitCode = 0;
        Processor processor = new Processor(console);

        for (int k = 0; k < inputCount; ++k) {
            console.info(Msg.msg("processingDoc", inFiles[k]));

            if (processor.process(docs[k]) == Boolean.FALSE) {
                console.error(Msg.msg("cannotProcessDoc", inFiles[k]));
                exitCode = 3;
                break;
            }
        }

        // ---

        File outDir;
        if (outFile.isDirectory()) {
            outDir = outFile;
        } else {
            outDir = outFile.getParentFile();
        }

        try {
            if (!outDir.isDirectory()) {
                FileUtil.checkedMkdirs(outDir);
            }

            for (int k = 0; k < inputCount; ++k) {
                Document doc = docs[k];

                File saveFile;
                if (inputCount == 1 && !outFile.isDirectory()) {
                    saveFile = outFile;
                } else {
                    saveFile = new File(outDir, inFiles[k].getName());
                }

                console.info(Msg.msg("savingDoc", saveFile));

                SaveDocument.save(doc, saveFile);
            }
        } catch (IOException e) {
            console.error(Msg.msg("cannotSaveDocs", ThrowableUtil.reason(e)));
            if (exitCode == 0) {
                exitCode = 4;
            }
        }

        System.exit(exitCode);
    }

    private static void usage(String error) {
        if (error != null) {
            System.err.println("*** ERROR: " + error);
        }
        System.err.println(
            "usage: java com.xmlmind.transproc.Processor [ -v|-vv|-vvv ]\n" +
            " -o out_file_or_dir in_file ... in_file\n" +
            "Processes input XML files in_file and creates processed files\n" +
            "in out_file_or_dir.\n" +
            "Options:\n" +
            "  -o out_file_or_dir Required. Specifies an output file or\n" +
            "      directory. If multiple input files are specified,\n" +
            "      out_file_or_dir must be a directory.\n" +
            "  -v|-vv|-vvv Verbose");
        System.exit(1);
    }
}
