/*
 * Copyright (c) 2017-2022 XMLmind Software. All rights reserved.
 *
 * Author: Hussein Shafie
 *
 * This file is part of the XMLmind DITA Converter project.
 * For conditions of distribution and use, see the accompanying LEGAL.txt file.
 */
package com.xmlmind.xinclude;

import java.io.IOException;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import org.w3c.dom.Node;
import org.w3c.dom.Text;
import org.w3c.dom.Element;
import org.w3c.dom.Document;
import com.xmlmind.util.ThrowableUtil;
import com.xmlmind.util.URLUtil;
import com.xmlmind.util.XMLText;
import com.xmlmind.util.XMLResolver;
import com.xmlmind.util.Console;
import com.xmlmind.util.SimpleConsole;
import com.xmlmind.domutil.ConsoleHelper;
import com.xmlmind.domutil.DOMUtil;
import com.xmlmind.domutil.LoaderImpl;

/*package*/ abstract class Includer {
    protected ConsoleHelper console;
    protected XMLResolver resolver;

    protected LoadedDocuments docs;
    protected ArrayList<LoadedDocument> docList;
    protected int processedDocCount;
    protected int inclCounter;
    protected boolean processFailed;

    protected static final int MAX_ITERATIONS = 100;

    protected static final String INCL_ID_KEY = "INCL_ID";

    // -----------------------------------------------------------------------

    protected Includer() {
        this(null);
    }

    protected Includer(Console c) {
        setConsole(c);
    }

    public void setConsole(Console c) {
        if (c == null) {
            c = new SimpleConsole();
        }
        console = ((c instanceof ConsoleHelper)? 
                   (ConsoleHelper) c : new ConsoleHelper(c));
    }

    public ConsoleHelper getConsole() {
        return console;
    }

    public void setResolver(XMLResolver resolver) {
        this.resolver = resolver;
    }

    public XMLResolver getResolver() {
        return resolver;
    }

    public boolean process(Document document) 
        throws IOException {
        return process(new Document[] { document });
    }

    public boolean process(Document[] documents) 
        throws IOException {
        docs = new LoadedDocuments(console, resolver);
        docList = new ArrayList<LoadedDocument>();
        processedDocCount = 0;
        processFailed = false;

        for (Document document : documents) {
            URL url = DOMUtil.getDocumentURL(document);
            if (url == null) {
                throw new IOException("document to be processed has no URL");
            }

            LoadedDocument loadedDoc = docs.put(url, document, 
                                                /*isWorkingCopy*/ false);
            docList.add(loadedDoc);
            ++processedDocCount;

            collectIncludes(/*parentInclId*/ null, document, loadedDoc);
        }

        process();

        return !processFailed;
    }

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

    // -----------------------------------------
    // InclusionException
    // -----------------------------------------

    @SuppressWarnings("serial")
    protected static final class InclusionException extends Exception {
        public InclusionException(String message) {
            super(message);
        }
    }

    // -----------------------------------------
    // Incl
    // -----------------------------------------

    protected static abstract class Incl {
        public final Element directiveElement;

        public int[] id;

        public Node[] replacementNodes;

        protected Incl(Element directiveElement) {
            this.directiveElement = directiveElement;
        }
    }

    protected static final Node[] REMOVE_DIRECTIVE_ELEMENT = new Node[0];

    // -----------------------------------------
    // LoadedDocument
    // -----------------------------------------

    protected static final class LoadedDocument {
        public final URL url;
        public final Document document;
        public final boolean isWorkingCopy;
        public final ArrayList<Incl> processList;

        public LoadedDocument(URL url, Document document,
                              boolean isWorkingCopy) {
            this.url = url;
            this.document = document;

            this.isWorkingCopy = isWorkingCopy;
            processList = new ArrayList<Incl>();
        }
    }

    // -----------------------------------------
    // LoadedDocuments
    // -----------------------------------------

    protected static final class LoadedDocuments {
        public final ConsoleHelper console;
        public final XMLResolver resolver;

        private final HashMap<URL,LoadedDocument> docs;

        public LoadedDocuments(ConsoleHelper console, XMLResolver resolver) {
            this.console = console;
            this.resolver = resolver;

            docs = new HashMap<URL,LoadedDocument>();
        }

        public LoadedDocument load(URL url, boolean isWorkingCopy)
            throws IOException {
            if (url.getRef() != null) {
                url = URLUtil.setFragment(url, null);
            }

            LoadedDocument doc = docs.get(url);
            if (doc == null) {
                console.verbose(Msg.msg("loadingDoc", URLUtil.toLabel(url)));

                LoaderImpl loader = new LoaderImpl(/*xincl*/ false, console);
                if (resolver != null) {
                    loader.setEntityResolver(resolver);
                }
                Document loaded = loader.load(url);

                doc = put(url, loaded, isWorkingCopy);
            }

            return doc;
        }

        public LoadedDocument put(URL url, Document loaded,
                                  boolean isWorkingCopy) {
            if (url.getRef() != null) {
                url = URLUtil.setFragment(url, null);
            }

            LoadedDocument doc = 
                new LoadedDocument(url, loaded, isWorkingCopy);
            docs.put(url, doc);

            return doc;
        }

        public LoadedDocument get(URL url) {
            if (url.getRef() != null) {
                url = URLUtil.setFragment(url, null);
            }

            return docs.get(url);
        }
    }

    // -----------------------------------------
    // collectIncludes
    // -----------------------------------------

    protected boolean collectIncludes(int[] parentInclId, Node tree, 
                                      LoadedDocument doc) {
        Node firstChild = tree.getFirstChild();
        if (firstChild == null) {
            // Nothing to do.
            return true;
        }
        Node lastChild = tree.getLastChild();
        return collectIncludes(parentInclId, firstChild, lastChild, doc);
    }

    protected boolean collectIncludes(int[] parentInclId,
                                      Node firstChild, Node lastChild,
                                      LoadedDocument doc) {
        Node child = firstChild;
        while (child != null) {
            if (child.getNodeType() == Node.ELEMENT_NODE) {
                Element childElement = (Element) child;

                Incl incl = null;
                String parseError = null;
                try {
                    incl = detectInclusion(childElement);
                } catch (InclusionException e) {
                    parseError = Msg.msg("cannotParseDirective", 
                                         ThrowableUtil.reason(e));
                }

                if (parseError != null) {
                    processError(childElement, parseError);
                } else {
                    if (incl != null) {
                        int[] inclId = newInclId(parentInclId, childElement);
                        if (inclId == null) {
                            // Inclusion loop.
                            return false;
                        }
                        incl.id = inclId;

                        doc.processList.add(incl);
                    } else {
                        if (!collectIncludes(parentInclId, childElement, doc)) {
                            return false;
                        }
                    }
                }
            }

            child = nextSibling(child, lastChild);
        }

        return true;
    }

    protected void processError(Incl incl, String message) {
        processError(incl.directiveElement, message);
    }

    protected void processError(Element element, String message) {
        console.error(element, message);
        processFailed = true;
    }

    protected abstract Incl detectInclusion(Element element)
        throws InclusionException;

    protected int[] newInclId(int[] parentInclId, Element directive) {
        int id;

        Integer prop = (Integer) directive.getUserData(INCL_ID_KEY);
        if (prop == null) {
            id = inclCounter++;
            directive.setUserData(INCL_ID_KEY, Integer.valueOf(id),
                                  DOMUtil.COPY_USER_DATA);
        } else {
            id = prop.intValue();

            if (parentInclId != null) {
                for (int i = parentInclId.length-1; i >= 0; --i) {
                    if (parentInclId[i] == id) {
                        // Inclusion loop.
                        return null;
                    }
                }
            }
        }

        if (parentInclId == null) {
            return new int[] { id };
        }

        int count = parentInclId.length;
        int[] inclId = new int[count+1];
        System.arraycopy(parentInclId, 0, inclId, 0, count);
        inclId[count] = id;

        return inclId;
    }

    protected static Node nextSibling(Node child, Node lastChild) {
        if (child == lastChild) {
            return null;
        } else {
            return child.getNextSibling();
        }
    }

    // -----------------------------------------
    // process
    // -----------------------------------------

    protected void process()
        throws IOException {
        // MAX_ITERATIONS is just a security: the algorithm does not count on
        // reaching this number of iterations.

        long now = System.currentTimeMillis();
        boolean lastIteration = false;
        int iteration = 0;

        for (; iteration < MAX_ITERATIONS; ++iteration) {
            console.debug(Msg.msg("iteration", iteration));

            int remainDocCount = processedDocCount;
            int initialDocCount = docList.size();
            int changeCount = 0;

            for (int j = 0; j < processedDocCount; ++j) {
                LoadedDocument doc = docList.get(j);

                if (doc.processList.size() > 0) {
                    console.debug(Msg.msg("processingDoc", doc.url, 
                                          doc.processList.size()));

                    if (process(doc, lastIteration)) {
                        ++changeCount;
                    }

                    console.debug(Msg.msg("docProcessed", doc.url, 
                                          doc.processList.size()));
                }

                if (doc.processList.size() == 0) {
                    --remainDocCount;
                }
            }

            if (remainDocCount == 0) {
                // All documents which are not working copies fully processed.
                break;
            }

            int docCount = docList.size();
            for (int j = processedDocCount; j < docCount; ++j) {
                LoadedDocument doc = docList.get(j);

                if (doc.processList.size() > 0) {
                    console.debug(Msg.msg("processingDoc", doc.url, 
                                          doc.processList.size()));

                    if (process(doc, lastIteration)) {
                        ++changeCount;
                    }

                    console.debug(Msg.msg("docProcessed", doc.url, 
                                          doc.processList.size()));
                }
            }

            if (lastIteration) {
                // Stop here.
                break;
            } else {
                if (docList.size() > initialDocCount) {
                    ++changeCount;
                }

                if (changeCount == 0) {
                    lastIteration = true;
                }
            }
        }

        // Clean-up ---

        for (int j = 0; j < processedDocCount; ++j) {
            LoadedDocument doc = docList.get(j);

            DOMUtil.removeUserData(doc.document, INCL_ID_KEY);

            if (doc.processList.size() > 0) {
                processError(doc, Msg.msg("notFullyProcessed", 1+iteration));
            }
        }

        console.debug(Msg.msg("allDocsProcessed", 
                              1+iteration, System.currentTimeMillis()-now));
    }

    protected void processError(LoadedDocument doc, String message) {
        StringBuilder buffer = new StringBuilder(doc.url.toExternalForm());
        buffer.append("::: ");
        buffer.append(message);
        console.error(buffer.toString());

        processFailed = true;
    }

    protected boolean process(LoadedDocument doc, boolean lastIteration) 
        throws IOException {
        int inclCount = doc.processList.size();
        Incl[] incls = new Incl[inclCount];
        doc.processList.toArray(incls);

        doc.processList.clear();

        int removeCount = inclCount;
        int retryCount = 0;

        for (Incl incl : incls) {
            try {
                fetchIncluded(incl);

                if (incl.replacementNodes == null ||
                    (incl.replacementNodes.length == 0 && 
                     incl.replacementNodes != REMOVE_DIRECTIVE_ELEMENT)) {
                    processError(incl, Msg.msg("noReplacementNodes"));
                    incl.replacementNodes = null;
                } else {
                    if (DOMUtil.getParentElement(incl.directiveElement) == null
                        && !isRootReplacement(incl.replacementNodes)) {
                        processError(incl, Msg.msg("notAReplacementForRoot"));
                        incl.replacementNodes = null;
                    }
                }
            } catch (InclusionException e) {
                if (lastIteration) {
                    processError(incl, Msg.msg("cannotFetchIncludedNodes",
                                               ThrowableUtil.reason(e)));
                }

                // Try again during next iteration.
                doc.processList.add(incl);
                --removeCount;
                ++retryCount;

                // Just in case.
                incl.replacementNodes = null;
            }
        }

        // Replacement is a separate step because we don't want to invoke
        // fetchIncluded() on a document we are modifying.

        for (Incl incl : incls) {
            Node[] replacement = incl.replacementNodes;
            incl.replacementNodes = null;

            if (replacement != null) {
                replaceNode(incl.directiveElement, replacement);

                if (replacement.length > 0 &&
                    !collectIncludes(incl.id, replacement[0], 
                                     replacement[replacement.length-1], doc)) {
                    // Restore directive (including INCL_ID_KEY on directive).
                    replaceNodes(replacement, incl.directiveElement);

                    processError(incl, Msg.msg("inclusionLoop"));
                }
            }
        }

        // Has something changed for this Doc?

        int addCount = doc.processList.size() - retryCount;
        return (addCount != 0 || removeCount != 0);
    }

    protected abstract void fetchIncluded(Incl incl)
        throws IOException, InclusionException;

    protected LoadedDocument fetchDoc(URL url) 
        throws IOException {
        LoadedDocument doc = docs.get(url);
        if (doc == null) {
            long now = System.currentTimeMillis();
            String docLocation = URLUtil.toLabel(url);
            console.debug(Msg.msg("cachingDocument", docLocation));

            doc = docs.load(url, /*isWorkingCopy*/ true);
            docList.add(doc);

            console.debug(Msg.msg("documentCached", docLocation, 
                                  (System.currentTimeMillis()-now)));

            collectIncludes(/*parentInclId*/ null, doc.document, doc);
        }

        return doc;
    }

    protected static boolean isRootReplacement(Node[] nodes) {
        int elementCount = 0;

        for (Node node : nodes) {
            switch (node.getNodeType()) {
            case Node.COMMENT_NODE:
            case Node.PROCESSING_INSTRUCTION_NODE:
                break;
            case Node.TEXT_NODE:
                if (!XMLText.isXMLSpace(((Text) node).getData())) {
                    return false;
                }
                break;
            case Node.ELEMENT_NODE:
                ++elementCount;
                break;
            }
        }

        return (elementCount == 1);
    }

    protected static void replaceNode(Node from, Node[] to) {
        Node parent = from.getParentNode();
        Node before = from.getNextSibling();

        parent.removeChild(from);
        for (int i = 0; i < to.length; ++i) {
            parent.insertBefore(to[i], before);
        }
    }

    protected static void replaceNodes(Node[] from, Node to) {
        int count = from.length;
        if (count == 0) {
            return;
        }
        Node parent = from[0].getParentNode();
        Node before = from[count-1].getNextSibling();

        for (int i = 0; i < count; ++i) {
            parent.removeChild(from[i]);
        }
        parent.insertBefore(to, before);
    }
}
