/*
 * This file is part of the DITA Open Toolkit project.
 * See the accompanying license.txt file for applicable licenses.
 */

/*
 * (c) Copyright IBM Corp. 2007 All Rights Reserved.
 */
package org.dita.dost.reader;

import static org.dita.dost.util.FileUtils.*;
import static org.apache.commons.io.FilenameUtils.*;
import static org.dita.dost.util.Constants.*;
import static org.dita.dost.util.StringUtils.join;
import static org.dita.dost.util.URLUtils.toURI;
import static org.dita.dost.util.XMLUtils.close;
import static org.dita.dost.writer.AbstractChunkTopicParser.getElementNode;
import static org.dita.dost.writer.AbstractChunkTopicParser.getText;
import static java.util.Arrays.*;

import java.io.*;
import java.net.URI;
import java.util.*;

import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamWriter;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;

import org.dita.dost.exception.DITAOTException;
import org.dita.dost.log.MessageUtils;
import org.dita.dost.module.ChunkModule.ChunkFilenameGeneratorFactory;
import org.dita.dost.module.ChunkModule.ChunkFilenameGenerator;
import org.dita.dost.util.*;
import org.dita.dost.writer.AbstractDomFilter;
import org.dita.dost.writer.ChunkTopicParser;
import org.dita.dost.writer.SeparateChunkTopicParser;
import org.w3c.dom.*;
import org.xml.sax.SAXException;

/**
 * ChunkMapReader class, read and filter ditamap file for chunking.
 * 
 */
public final class ChunkMapReader extends AbstractDomFilter {

    public static final String FILE_NAME_STUB_DITAMAP = "stub.ditamap";
    public static final String FILE_EXTENSION_CHUNK = ".chunk";
    public static final String ATTR_XTRF_VALUE_GENERATED = "generated_by_chunk";

    public static final String CHUNK_SELECT_BRANCH = "select-branch";
    public static final String CHUNK_SELECT_TOPIC = "select-topic";
    public static final String CHUNK_SELECT_DOCUMENT = "select-document";
    private static final String CHUNK_BY_DOCUMENT = "by-document";
    private static final String CHUNK_BY_TOPIC = "by-topic";
    public static final String CHUNK_TO_CONTENT = "to-content";
    public static final String CHUNK_TO_NAVIGATION = "to-navigation";

    private Collection<String> rootChunkOverride;
    private String defaultChunkByToken;

    /** Input file's parent directory */
    private File fileDir = null;
    // ChunkTopicParser assumes keys and values are chimera paths, i.e. systems paths with fragments.
    private final LinkedHashMap<String, String> changeTable = new LinkedHashMap<>(128);

    private final Map<String, String> conflictTable = new HashMap<>(128);

    private boolean supportToNavigation;

    private ProcessingInstruction workdir = null;
    private ProcessingInstruction workdirUrl = null;
    private ProcessingInstruction path2proj = null;
    private ProcessingInstruction path2projUrl = null;

    private final ChunkFilenameGenerator chunkFilenameGenerator = ChunkFilenameGeneratorFactory.newInstance();
    private Job job;

    /**
     * Constructor.
     */
    public ChunkMapReader() {
        super();
    }

    public void setJob(final Job job) {
        this.job = job;
    }

    public void setRootChunkOverride(final String chunkValue) {
        rootChunkOverride = split(chunkValue);
    }

    private File inputFile;
    
    /**
     * read input file.
     * 
     * @param inputFile filename
     */
    @Override
    public void read(final File inputFile) {
        this.inputFile = inputFile;
        fileDir = inputFile.getParentFile();

        super.read(inputFile);
    }

    @Override
    public Document process(final Document doc) {
        readProcessingInstructions(doc);

        final Element root = doc.getDocumentElement();
        if (rootChunkOverride != null) {
            final String c = join(rootChunkOverride, " ");
            logger.debug("Use override root chunk \"" + c + "\"");
            root.setAttribute(ATTRIBUTE_NAME_CHUNK, c);
        }
        final Collection<String> rootChunkValue =  split(root.getAttribute(ATTRIBUTE_NAME_CHUNK));
        defaultChunkByToken = getChunkByToken(rootChunkValue, "by-", CHUNK_BY_DOCUMENT);
        // chunk value = "to-content"
        // When @chunk="to-content" is specified on "map" element,
        // chunk module will change its @class attribute to "topicref"
        // and process it as if it were a normal topicref wich
        // @chunk="to-content"
        if (rootChunkValue.contains(CHUNK_TO_CONTENT)) {
            chunkMap(root);
        } else {
            // if to-content is not specified on map element
            // process the map element's immediate child node(s)
            // get the immediate child nodes
            final NodeList list = root.getChildNodes();
            for (int i = 0; i < list.getLength(); i++) {
                final Node node = list.item(i);
                if (node.getNodeType() == Node.ELEMENT_NODE) {
                    final Element currentElem = (Element) node;
                    if (MAP_RELTABLE.matches(currentElem)) {
                        updateReltable(currentElem);
                    } else if (MAPGROUP_D_TOPICGROUP.matches(currentElem)) {
                    	processChildTopicref(currentElem);
                    } else if (MAP_TOPICREF.matches(currentElem)) {
                        processTopicref(currentElem);
                    }

                }
            }
        }

        return buildOutputDocument(root);
    }

    public static String getChunkByToken(final Collection<String> chunkValue, final String category, final String defaultToken) {
        if (chunkValue.isEmpty()) {
            return defaultToken;
        }
        for (final String token: chunkValue) {
            if (token.startsWith(category)) {
                return token;
            }
        }
        return defaultToken;
    }

    /**
     * Process map when "to-content" is specified on map element.
     *
     * TODO: Instead of reclassing map element to be a topicref, add a topicref
     * into the map root and move all map content into that topicref.
     */
    private void chunkMap(final Element root) {
        // create the reference to the new file on root element.
        String newFilename = replaceExtension(inputFile.getName(), FILE_EXTENSION_DITA);
        File newFile = new File(inputFile.getParentFile().getAbsolutePath(), newFilename);
        if (newFile.exists()) {
            newFilename = chunkFilenameGenerator.generateFilename("Chunk", FILE_EXTENSION_DITA);
            final String oldpath = newFile.getAbsolutePath();
            newFile = resolve(inputFile.getParentFile().getAbsolutePath(), newFilename);
            // Mark up the possible name changing, in case that references might be updated.
            conflictTable.put(newFile.getAbsolutePath(), normalize(oldpath));
        }
        changeTable.put(newFile.getAbsolutePath(), newFile.getAbsolutePath());

        // change the class attribute to "topicref"
        final String originClassValue = root.getAttribute(ATTRIBUTE_NAME_CLASS);
        root.setAttribute(ATTRIBUTE_NAME_CLASS, originClassValue + MAP_TOPICREF.matcher);
        root.setAttribute(ATTRIBUTE_NAME_HREF, toURI(newFilename).toString());

        createTopicStump(newFile);

        // process chunk
        processTopicref(root);

        // restore original root element
        if (originClassValue != null) {
            root.setAttribute(ATTRIBUTE_NAME_CLASS, originClassValue);
        }
        root.removeAttribute(ATTRIBUTE_NAME_HREF);
    }

    /**
     * Create the new topic stump.
     */
    private void createTopicStump(final File newFile) {
        OutputStream newFileWriter = null;
        try {
            newFileWriter = new FileOutputStream(newFile);
            final XMLStreamWriter o = XMLOutputFactory.newInstance().createXMLStreamWriter(newFileWriter, UTF8);
            o.writeStartDocument();
            o.writeProcessingInstruction(PI_WORKDIR_TARGET, UNIX_SEPARATOR + newFile.getParentFile().getAbsolutePath());
            o.writeProcessingInstruction(PI_WORKDIR_TARGET_URI, newFile.getParentFile().toURI().toString());
            o.writeStartElement(ELEMENT_NAME_DITA);
            o.writeEndElement();
            o.writeEndDocument();
            o.close();
            newFileWriter.flush();
        } catch (final RuntimeException e) {
            throw e;
        } catch (final Exception e) {
            logger.error(e.getMessage(), e);
        } finally {
            try {
                if (newFileWriter != null) {
                    newFileWriter.close();
                }
            } catch (final Exception e) {
                logger.error(e.getMessage(), e);
            }
        }
    }

    /**
     * Read processing metadata from processing instructions.
     */
    private void readProcessingInstructions(final Document doc) {
        final NodeList docNodes = doc.getChildNodes();
        for (int i = 0; i < docNodes.getLength(); i++) {
            final Node node = docNodes.item(i);
            if (node.getNodeType() == Node.PROCESSING_INSTRUCTION_NODE) {
                final ProcessingInstruction pi = (ProcessingInstruction) node;
                if (pi.getNodeName().equals(PI_WORKDIR_TARGET)) {
                    workdir = pi;
                } else if (pi.getNodeName().equals(PI_WORKDIR_TARGET_URI)) {
                    workdirUrl = pi;
                } else if (pi.getNodeName().equals(PI_PATH2PROJ_TARGET)) {
                    path2proj = pi;
                } else if (pi.getNodeName().equals(PI_PATH2PROJ_TARGET_URI)) {
                    path2projUrl = pi;
                }
            }
        }
    }

    private void outputMapFile(final File file, final Document doc) {
        StreamResult result = null;
        try {
            final Transformer t = TransformerFactory.newInstance().newTransformer();
            result = new StreamResult(new FileOutputStream(file));
            t.transform(new DOMSource(doc), result);
        } catch (final RuntimeException e) {
            throw e;
        } catch (final Exception e) {
            logger.error(e.getMessage(), e);
        } finally {
            try {
                close(result);
            } catch (final Exception e) {
                logger.error(e.getMessage(), e);
            }
        }
    }

    private Document buildOutputDocument(final Element root) {
        final Document doc = XMLUtils.getDocumentBuilder().newDocument();
        if (workdir != null) {
            doc.appendChild(doc.importNode(workdir, true));
        }
        if (workdirUrl != null) {
            doc.appendChild(doc.importNode(workdirUrl, true));
        }
        if (path2proj != null) {
            doc.appendChild(doc.importNode(path2proj, true));
        }
        if (path2projUrl != null) {
            doc.appendChild(doc.importNode(path2projUrl, true));
        }
        doc.appendChild(doc.importNode(root, true));
        return doc;
    } 

    public static Collection<String> split(final String value) {
        if (value == null) {
            return Collections.EMPTY_LIST;
        }
        final String[] tokens = value.trim().split("\\s+");
        return asList(tokens);
    }

    private void processTopicref(final Element topicref) {
        final String xtrfValue = getValue(topicref, ATTRIBUTE_NAME_XTRF);
        if (xtrfValue != null && xtrfValue.contains(ATTR_XTRF_VALUE_GENERATED)) {
            return;
        }

        final Collection<String> chunkValue = split(getValue(topicref, ATTRIBUTE_NAME_CHUNK));

        if (topicref.getAttributeNode(ATTRIBUTE_NAME_HREF) == null && chunkValue.contains(CHUNK_TO_CONTENT)) {
            generateStumpTopic(topicref);
        }

        final URI hrefValue = toURI(getValue(topicref,ATTRIBUTE_NAME_HREF));
        final URI copytoValue = toURI(getValue(topicref, ATTRIBUTE_NAME_COPY_TO));
        final String scopeValue = getCascadeValue(topicref, ATTRIBUTE_NAME_SCOPE);
        final String chunkByToken = getChunkByToken(chunkValue, "by-", defaultChunkByToken);

        if (ATTR_SCOPE_VALUE_EXTERNAL.equals(scopeValue)
                || (hrefValue != null && !resolve(fileDir, hrefValue.toString()).exists())
                || (chunkValue.isEmpty() && hrefValue == null)) {
            processChildTopicref(topicref);
        } else if (chunkValue.contains(CHUNK_TO_CONTENT)
                && (hrefValue != null || copytoValue != null || topicref.hasChildNodes())) {
            if (chunkValue.contains(CHUNK_BY_TOPIC)) {
                logger.warn(MessageUtils.getInstance().getMessage("DOTJ064W").setLocation(topicref).toString());
            }
            processChunk(topicref, false);
        } else if (chunkValue.contains(CHUNK_TO_NAVIGATION)
                && supportToNavigation) {
            processChildTopicref(topicref);
            processNavitation(topicref);
        } else if (chunkByToken.equals(CHUNK_BY_TOPIC)) {
            processChunk(topicref, true);
            processChildTopicref(topicref);
        } else { // chunkByToken.equals(CHUNK_BY_DOCUMENT)
            String currentPath = null;
            if (copytoValue != null) {
                currentPath = resolve(fileDir, copytoValue).getPath();
            } else if (hrefValue != null) {
                currentPath = resolve(fileDir, hrefValue).getPath();
            }
            if (currentPath != null) {
                if (changeTable.containsKey(currentPath)) {
                    changeTable.remove(currentPath);
                }
                final String processingRole = getCascadeValue(topicref, ATTRIBUTE_NAME_PROCESSING_ROLE);
                if (!ATTR_PROCESSING_ROLE_VALUE_RESOURCE_ONLY.equals(processingRole)) {
                    changeTable.put(currentPath, currentPath);
                }
            }
            processChildTopicref(topicref);
        }
    }

    /**
     * Create new map and refer to it with navref.
     * @param topicref
     */
    private void processNavitation(final Element topicref) {
        // create new map's root element
        final Element root = (Element) topicref.getOwnerDocument().getDocumentElement().cloneNode(false);
        // create navref element
        final Element navref = topicref.getOwnerDocument().createElement(MAP_NAVREF.localName);
        final String newMapFile = chunkFilenameGenerator.generateFilename("MAPCHUNK", FILE_EXTENSION_DITAMAP);
        navref.setAttribute(ATTRIBUTE_NAME_MAPREF, newMapFile);
        navref.setAttribute(ATTRIBUTE_NAME_CLASS, MAP_NAVREF.toString());
        // replace topicref with navref
        topicref.getParentNode().replaceChild(navref, topicref);
        root.appendChild(topicref);
        // generate new file
        final File navmap = resolve(fileDir, newMapFile);
        changeTable.put(navmap.getPath(), navmap.getPath());
        outputMapFile(navmap, buildOutputDocument(root));
    }

    /**
     * Generate file name.
     *
     * @return generated file name
     */
    private String generateFilename() {
        return chunkFilenameGenerator.generateFilename("XChunk", FILE_EXTENSION_DITA);
    }

    /**
     * Generate stump topic for to-content content.
     * @param topicref topicref without href to generate stump topic for
     */
    private void generateStumpTopic(final Element topicref) {
        logger.info("generateStumpTopic: "+ topicref.toString());
        final URI copytoValue = toURI(getValue(topicref, ATTRIBUTE_NAME_COPY_TO));
        final String idValue = getValue(topicref, ATTRIBUTE_NAME_ID);

        File outputFileName;
        if (copytoValue != null) {
            outputFileName = resolve(fileDir, copytoValue.toString());
        } else if (idValue != null) {
            outputFileName = resolve(fileDir, idValue + FILE_EXTENSION_DITA);
        } else {
            do {
                outputFileName = resolve(fileDir, generateFilename());
            } while (outputFileName.exists());
        }

        final String id = getBaseName(outputFileName.getName());
        String navtitleValue = getChildElementValueOfTopicmeta(topicref, TOPIC_NAVTITLE);
        if (navtitleValue == null) {
            navtitleValue = getValue(topicref, ATTRIBUTE_NAME_NAVTITLE);
        }
        if (navtitleValue == null) {
            navtitleValue = id;
        }
        final String shortDescValue = getChildElementValueOfTopicmeta(topicref, MAP_SHORTDESC);

        OutputStream output = null;
        try {
            output = new FileOutputStream(outputFileName);
            final XMLSerializer serializer = XMLSerializer.newInstance(output);
            serializer.writeStartDocument();
            serializer.writeStartElement(TOPIC_TOPIC.localName);
            serializer.writeAttribute(DITA_NAMESPACE, ATTRIBUTE_PREFIX_DITAARCHVERSION + ":" + ATTRIBUTE_NAME_DITAARCHVERSION, "1.2");
            serializer.writeAttribute(ATTRIBUTE_NAME_ID, id);
            serializer.writeAttribute(ATTRIBUTE_NAME_CLASS, TOPIC_TOPIC.toString());
            serializer.writeAttribute(ATTRIBUTE_NAME_DOMAINS, "");
            serializer.writeStartElement(TOPIC_TITLE.localName);
            serializer.writeAttribute(ATTRIBUTE_NAME_CLASS, TOPIC_TITLE.toString());
            serializer.writeCharacters(navtitleValue);
            serializer.writeEndElement(); // title
            if (shortDescValue != null) {
                serializer.writeStartElement(TOPIC_SHORTDESC.localName);
                serializer.writeAttribute(ATTRIBUTE_NAME_CLASS, TOPIC_SHORTDESC.toString());
                serializer.writeCharacters(shortDescValue);
                serializer.writeEndElement(); // shortdesc
            }
            serializer.writeEndElement(); // topic
            serializer.writeEndDocument();
            serializer.close();
        } catch (final IOException | SAXException e) {
            logger.error("Failed to write generated chunk: " + e.getMessage(), e);
        } finally {
            if (output != null) {
                try {
                    output.close();
                } catch (IOException e) {
                    logger.error("Failed to close output stream: " + e.getMessage(), e);
                }
            }
        }

        // update current element's @href value
        final URI relativePath = toURI(getRelativePath(new File(fileDir, FILE_NAME_STUB_DITAMAP), outputFileName));
        topicref.setAttribute(ATTRIBUTE_NAME_HREF, relativePath.toString());

        final URI relativeToBase = URLUtils.getRelativePath(job.tempDir.toURI().resolve("dummy"), outputFileName.toURI());
        job.add(new Job.FileInfo.Builder().uri(relativeToBase).format(ATTR_FORMAT_VALUE_DITA).build());
    }

    /**
     * get topicmeta's child(e.g navtitle, shortdesc) tag's value(text-only).
     * @param element input element
     * @return text value
     */
    private String getChildElementValueOfTopicmeta(final Element element, final DitaClass classValue) {
        if (element.hasChildNodes()) {
            final Element topicMeta = getElementNode(element, MAP_TOPICMETA);
            if (topicMeta != null) {
                final Element elem = getElementNode(topicMeta, classValue);
                if (elem != null) {
                    return getText(elem);
                }
            }
        }
        return null;
    }

    public static String getValue(final Element elem, final String attrName) {
        final Attr attr = elem.getAttributeNode(attrName);
        if (attr != null && !attr.getValue().isEmpty()) {
            return attr.getValue();
        }
        return null;
    }

    public static String getCascadeValue(final Element elem, final String attrName) {
        Element current = elem;
        while (current != null) {
            final Attr attr = current.getAttributeNode(attrName);
            if (attr != null) {
                return attr.getValue();
            }
            final Node parent = current.getParentNode();
            if (parent != null && parent.getNodeType() == Node.ELEMENT_NODE) {
                current = (Element) parent;
            } else {
                break;
            }
        }
        return null;
    }

    private void processChildTopicref(final Element node) {
        final NodeList children = node.getChildNodes();
        for (int i = 0; i < children.getLength(); i++) {
            final Node current = children.item(i);
            if (current.getNodeType() == Node.ELEMENT_NODE) {
                final Element currentElem = (Element) current;
                final String classValue = currentElem.getAttribute(ATTRIBUTE_NAME_CLASS);
                if (MAP_TOPICREF.matches(classValue)) {
                    final String hrefValue = currentElem.getAttribute(ATTRIBUTE_NAME_HREF);
                    final String xtrfValue = currentElem.getAttribute(ATTRIBUTE_NAME_XTRF);
                    if (hrefValue.length() == 0) {
                        processTopicref(currentElem);
                    } else if (!ATTR_XTRF_VALUE_GENERATED.equals(xtrfValue)
                            && !resolve(fileDir, hrefValue).getPath().equals(changeTable.get(resolve(fileDir, hrefValue).getPath()))) {
                        processTopicref(currentElem);
                    }
                }
            }
        }
    }

    private void processChunk(final Element topicref, final boolean separate) {
        try {
            if (separate) {
                final SeparateChunkTopicParser chunkParser = new SeparateChunkTopicParser();
                chunkParser.setLogger(logger);
                chunkParser.setJob(job);
                chunkParser.setup(changeTable, conflictTable, topicref, chunkFilenameGenerator);
                chunkParser.write(fileDir);
            } else {
                final ChunkTopicParser chunkParser = new ChunkTopicParser();
                chunkParser.setLogger(logger);
                chunkParser.setJob(job);
                chunkParser.setup(changeTable, conflictTable, topicref, chunkFilenameGenerator);
                chunkParser.write(fileDir);
            }
        } catch (final DITAOTException e) {
            logger.error("Failed to process chunk: " + e.getMessage(), e);
        }
    }

    private void updateReltable(final Element elem) {
        final String hrefValue = elem.getAttribute(ATTRIBUTE_NAME_HREF);
        if (hrefValue.length() != 0) {
            if (changeTable.containsKey(resolve(fileDir, hrefValue).getPath())) {
                String resulthrefValue = getRelativeUnixPath(fileDir + UNIX_SEPARATOR + FILE_NAME_STUB_DITAMAP,
                                                             resolve(fileDir, hrefValue).getPath());
                final String fragment = getFragment(hrefValue);
                if (fragment != null) {
                    resulthrefValue = resulthrefValue + fragment;
                }
                elem.setAttribute(ATTRIBUTE_NAME_HREF, resulthrefValue);
            }
        }
        final NodeList children = elem.getChildNodes();
        for (int i = 0; i < children.getLength(); i++) {
            final Node current = children.item(i);
            if (current.getNodeType() == Node.ELEMENT_NODE) {
                final Element currentElem = (Element) current;
                final String classValue = currentElem.getAttribute(ATTRIBUTE_NAME_CLASS);
                if (MAP_TOPICREF.matches(classValue)) {
                    // FIXME: What should happen here?
                }
            }
        }
    }

    /**
     * Get changed files table.
     * 
     * @return map of changed files
     */
    public Map<String, String> getChangeTable() {
        for (final Map.Entry<String, String> e: changeTable.entrySet()) {
            assert new File(e.getKey()).isAbsolute();
            assert new File(e.getValue()).isAbsolute();
        }
        return Collections.unmodifiableMap(changeTable);
    }

    /**
     * get conflict table.
     * 
     * @return conflict table
     */
    public Map<String, String> getConflicTable() {
        for (final Map.Entry<String, String> e: conflictTable.entrySet()) {
            assert new File(e.getKey()).isAbsolute();
            assert new File(e.getValue()).isAbsolute();
        }
        return conflictTable;
    }

    /**
     * Support chunk token to-navigation.
     * 
     * @param supportToNavigation flag to enable to-navigation support
     */
    public void supportToNavigation(final boolean supportToNavigation) {
        this.supportToNavigation = supportToNavigation;
    }

}
