/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.maven.doxia.siterenderer;

import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Reader;
import java.io.StringReader;
import java.io.StringWriter;
import java.io.Writer;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Properties;
import java.util.TimeZone;
import java.util.function.Function;
import java.util.zip.ZipEntry;
import java.util.zip.ZipException;
import java.util.zip.ZipFile;

import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.SystemUtils;
import org.apache.maven.artifact.Artifact;
import org.apache.maven.artifact.versioning.ArtifactVersion;
import org.apache.maven.artifact.versioning.DefaultArtifactVersion;
import org.apache.maven.artifact.versioning.InvalidVersionSpecificationException;
import org.apache.maven.artifact.versioning.Restriction;
import org.apache.maven.artifact.versioning.VersionRange;
import org.apache.maven.doxia.Doxia;
import org.apache.maven.doxia.parser.ParseException;
import org.apache.maven.doxia.parser.Parser;
import org.apache.maven.doxia.parser.manager.ParserNotFoundException;
import org.apache.maven.doxia.parser.module.ParserModule;
import org.apache.maven.doxia.parser.module.ParserModuleManager;
import org.apache.maven.doxia.site.SiteModel;
import org.apache.maven.doxia.site.skin.ResourceCondition;
import org.apache.maven.doxia.site.skin.SkinModel;
import org.apache.maven.doxia.site.skin.io.xpp3.SkinXpp3Reader;
import org.apache.maven.doxia.siterenderer.SiteRenderingContext.SiteDirectory;
import org.apache.maven.doxia.siterenderer.sink.SiteRendererSink;
import org.apache.maven.doxia.util.XmlValidator;
import org.apache.velocity.Template;
import org.apache.velocity.app.Velocity;
import org.apache.velocity.context.Context;
import org.apache.velocity.exception.ParseErrorException;
import org.apache.velocity.exception.ResourceNotFoundException;
import org.apache.velocity.exception.VelocityException;
import org.apache.velocity.tools.Scope;
import org.apache.velocity.tools.ToolManager;
import org.apache.velocity.tools.config.ConfigurationUtils;
import org.apache.velocity.tools.config.EasyFactoryConfiguration;
import org.apache.velocity.tools.config.FactoryConfiguration;
import org.apache.velocity.tools.generic.AlternatorTool;
import org.apache.velocity.tools.generic.ClassTool;
import org.apache.velocity.tools.generic.ComparisonDateTool;
import org.apache.velocity.tools.generic.ContextTool;
import org.apache.velocity.tools.generic.ConversionTool;
import org.apache.velocity.tools.generic.DisplayTool;
import org.apache.velocity.tools.generic.EscapeTool;
import org.apache.velocity.tools.generic.FieldTool;
import org.apache.velocity.tools.generic.LinkTool;
import org.apache.velocity.tools.generic.LoopTool;
import org.apache.velocity.tools.generic.MathTool;
import org.apache.velocity.tools.generic.NumberTool;
import org.apache.velocity.tools.generic.RenderTool;
import org.apache.velocity.tools.generic.ResourceTool;
import org.apache.velocity.tools.generic.SortTool;
import org.apache.velocity.tools.generic.XmlTool;
import org.codehaus.plexus.PlexusContainer;
import org.codehaus.plexus.util.DirectoryScanner;
import org.codehaus.plexus.util.FileUtils;
import org.codehaus.plexus.util.IOUtil;
import org.codehaus.plexus.util.Os;
import org.codehaus.plexus.util.PathTool;
import org.codehaus.plexus.util.ReaderFactory;
import org.codehaus.plexus.util.StringUtils;
import org.codehaus.plexus.util.WriterFactory;
import org.codehaus.plexus.util.xml.pull.XmlPullParserException;
import org.codehaus.plexus.velocity.VelocityComponent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * <p>DefaultSiteRenderer class.</p>
 *
 * @author <a href="mailto:evenisse@codehaus.org">Emmanuel Venisse</a>
 * @author <a href="mailto:vincent.siveton@gmail.com">Vincent Siveton</a>
 * @since 1.0
 */
@Singleton
@Named
public class DefaultSiteRenderer implements Renderer {
    private static final Logger LOGGER = LoggerFactory.getLogger(DefaultSiteRenderer.class);

    // ----------------------------------------------------------------------
    // Requirements
    // ----------------------------------------------------------------------

    @Inject
    private VelocityComponent velocity;

    @Inject
    private ParserModuleManager parserModuleManager;

    @Inject
    private Doxia doxia;

    @Inject
    private PlexusContainer plexus;

    @Inject
    private Map<String, ContextCustomizer> contextCustomizers;

    private static final String SKIN_TEMPLATE_LOCATION = "META-INF/maven/site.vm";

    private static final String TOOLS_LOCATION = "META-INF/maven/site-tools.xml";

    private static final String DOXIA_SITE_RENDERER_VERSION = getSiteRendererVersion();

    public static final String MERMAID_VERSION;

    static {
        try {
            MERMAID_VERSION = getMavenProjectProperties().getProperty("mermaidVersion");
        } catch (IOException e) {
            throw new IllegalStateException("Failed to load mermaid version from properties", e);
        }
    }

    /**
     * Properties contain some resolved properties from this project's Maven model
     * @return some resolved Maven project properties
     * @throws IOException in case the underlying file could not be accessed
     */
    static Properties getMavenProjectProperties() throws IOException {
        final Properties properties = new Properties();
        try (InputStream input = DefaultSiteRenderer.class.getResourceAsStream("/maven-project.properties")) {
            if (input == null) {
                throw new IOException("Could not find \"/maven-project.properties\" in classpath");
            }
            properties.load(input);
        }
        return properties;
    }

    // ----------------------------------------------------------------------
    // SiteRenderer implementation
    // ----------------------------------------------------------------------

    /** {@inheritDoc} */
    public Map<String, DocumentRenderer> locateDocumentFiles(SiteRenderingContext siteRenderingContext)
            throws IOException, RendererException {
        Map<String, DocumentRenderer> files = new LinkedHashMap<>();
        Map<String, String> moduleExcludes = siteRenderingContext.getModuleExcludes();

        // look in every site directory (in general src/site or target/generated-site)
        for (SiteDirectory siteDirectory : siteRenderingContext.getSiteDirectories()) {
            File siteDirectoryPath = siteDirectory.getPath();
            if (siteDirectoryPath.exists()) {
                Collection<ParserModule> modules = parserModuleManager.getParserModules();
                // use every Doxia parser module
                for (ParserModule module : modules) {
                    File moduleBasedir = new File(siteDirectoryPath, module.getSourceDirectory());

                    String excludes = (moduleExcludes == null) ? null : moduleExcludes.get(module.getParserId());

                    addModuleFiles(
                            siteRenderingContext.getRootDirectory(),
                            siteDirectory,
                            moduleBasedir,
                            module,
                            excludes,
                            files);
                }
            }
        }

        return files;
    }

    private List<String> filterExtensionIgnoreCase(List<String> fileNames, String extension) {
        List<String> filtered = new LinkedList<>(fileNames);
        for (Iterator<String> it = filtered.iterator(); it.hasNext(); ) {
            String name = it.next();

            // Take care of extension case
            if (!endsWithIgnoreCase(name, extension)) {
                it.remove();
            }
        }
        return filtered;
    }

    /**
     * Populates the files map with {@link DocumentRenderer}s per output name in parameter {@code files} for all files in the moduleBasedir matching the module extensions,
     * taking care of duplicates if needed.
     *
     * @param siteRootDirectory
     * @param siteDirectory
     * @param moduleBasedir
     * @param module
     * @param excludes
     * @param files
     * @throws IOException
     * @throws RendererException
     */
    private void addModuleFiles(
            File siteRootDirectory,
            SiteDirectory siteDirectory,
            File moduleBasedir,
            ParserModule module,
            String excludes,
            Map<String, DocumentRenderer> files)
            throws IOException, RendererException {
        if (!moduleBasedir.exists() || ArrayUtils.isEmpty(module.getExtensions())) {
            return;
        }

        List<String> allFiles = FileUtils.getFileNames(moduleBasedir, "**/*", excludes, false);

        for (String extension : module.getExtensions()) {
            String fullExtension = "." + extension;

            List<String> docs = filterExtensionIgnoreCase(allFiles, fullExtension);

            // *.<extension>.vm
            List<String> velocityFiles = filterExtensionIgnoreCase(allFiles, fullExtension + ".vm");

            docs.addAll(velocityFiles);

            for (String doc : docs) {
                DocumentRenderingContext docRenderingContext = new DocumentRenderingContext(
                        moduleBasedir,
                        doc,
                        module.getParserId(),
                        extension,
                        siteRootDirectory,
                        siteDirectory.getPath(),
                        siteDirectory.getEditableSourceDirectories());

                // TODO: DOXIA-111: we need a general filter here that knows how to alter the context
                if (endsWithIgnoreCase(doc, ".vm")) {
                    docRenderingContext.setAttribute("velocity", "true");
                }

                if (!checkForDuplicate(docRenderingContext, files, siteDirectory.isSkipDuplicates())) {
                    String key = docRenderingContext.getOutputName();
                    files.put(key, new DoxiaDocumentRenderer(docRenderingContext));
                }
            }
        }
    }

    @FunctionalInterface
    private interface DuplicateCallback {
        /**
         * Callback for handling duplicate files.
         * @param message
         * @return {@code false} if the duplicate should be ignored, {@code true} otherwise
         * @throws RendererException
         */
        boolean onDuplicate(String message) throws RendererException;
    }

    /**
     * Checks if the newDocRenderingContext clashes with an existing document renderer.
     * This check involves checking for duplicates both case-sensitive and case-insensitive.
     * @param newDocRenderingContext the doc rendering context of a new file
     * @param existingDocumentRenderers the map of already existing renderers
     * @return {@code true} if no duplicates were found, {@code false} otherwise
     * @throws RendererException
     */
    private boolean checkForDuplicate(
            DocumentRenderingContext newDocRenderingContext,
            Map<String, DocumentRenderer> existingDocumentRenderers,
            boolean skipDuplicates)
            throws RendererException {
        DuplicateCallback duplicateCallback = (message) -> {
            if (skipDuplicates) {
                LOGGER.debug(message + " (ignored due to flag 'skipDuplicates').");
            } else {
                throw new RendererException(message + ".");
            }
            return true;
        };

        DuplicateCallback caseInsensitiveDuplicateCallback = (message) -> {
            if (Os.isFamily(Os.FAMILY_WINDOWS)) {
                return duplicateCallback.onDuplicate(message);
            } else {
                if (LOGGER.isWarnEnabled()) {
                    LOGGER.warn(message + " in case a case-insensitive filesystem is used.");
                }
                return false;
            }
        };

        if (!checkForDuplicate(newDocRenderingContext, existingDocumentRenderers::get, duplicateCallback)) {
            // also check for case-insensitive duplicates
            return checkForDuplicate(
                    newDocRenderingContext,
                    key -> existingDocumentRenderers.entrySet().stream()
                            .filter(e -> e.getKey().equalsIgnoreCase(key))
                            .findFirst()
                            .map(Entry::getValue)
                            .orElse(null),
                    caseInsensitiveDuplicateCallback);
        }
        return true;
    }

    private boolean checkForDuplicate(
            DocumentRenderingContext newDocRenderingContext,
            Function<String, DocumentRenderer> lookupFunction,
            DuplicateCallback callback)
            throws RendererException {
        DocumentRenderer originalDocRenderer = lookupFunction.apply(newDocRenderingContext.getOutputName());
        if (originalDocRenderer != null) {
            DocumentRenderingContext originalDocRenderingContext = originalDocRenderer.getRenderingContext();

            File originalFile =
                    new File(originalDocRenderingContext.getBasedir(), originalDocRenderingContext.getInputName());

            File newFile = new File(newDocRenderingContext.getBasedir(), newDocRenderingContext.getInputName());
            String message = "File '" + newFile + "' clashes with existing '" + originalFile + "'";
            return callback.onDuplicate(message);
        }
        return false;
    }

    /** {@inheritDoc} */
    public void render(
            Collection<DocumentRenderer> documents, SiteRenderingContext siteRenderingContext, File outputDirectory)
            throws RendererException, IOException {
        for (DocumentRenderer docRenderer : documents) {
            DocumentRenderingContext docRenderingContext = docRenderer.getRenderingContext();

            File outputFile = new File(outputDirectory, docRenderer.getOutputName());

            File inputFile = new File(docRenderingContext.getBasedir(), docRenderingContext.getInputName());

            boolean modified = !outputFile.exists()
                    || (inputFile.lastModified() > outputFile.lastModified())
                    || (siteRenderingContext.getSiteModel().getLastModified() > outputFile.lastModified());

            if (modified || docRenderer.isOverwrite()) {
                if (!outputFile.getParentFile().exists()) {
                    outputFile.getParentFile().mkdirs();
                }

                if (LOGGER.isDebugEnabled()) {
                    LOGGER.debug("Generating " + outputFile);
                }

                Writer writer = null;
                try {
                    if (!docRenderer.isExternalReport()) {
                        writer = WriterFactory.newWriter(outputFile, siteRenderingContext.getOutputEncoding());
                    }
                    docRenderer.renderDocument(writer, this, siteRenderingContext);
                } finally {
                    IOUtil.close(writer);
                }
            } else {
                if (LOGGER.isDebugEnabled()) {
                    LOGGER.debug(inputFile + " unchanged, not regenerating...");
                }
            }
        }
    }

    /** {@inheritDoc} */
    public void renderDocument(
            Writer writer, DocumentRenderingContext docRenderingContext, SiteRenderingContext siteContext)
            throws RendererException {
        SiteRendererSink sink = new SiteRendererSink(
                docRenderingContext,
                siteContext.getSiteModel() != null ? siteContext.getSiteModel().getMermaid() : null);

        File doc = new File(docRenderingContext.getBasedir(), docRenderingContext.getInputName());

        Reader reader = null;
        try {
            String resource = doc.getAbsolutePath();

            Parser parser = doxia.getParser(docRenderingContext.getParserId());
            ParserConfigurator configurator = siteContext.getParserConfigurator();
            boolean isConfigured = false;
            if (configurator != null) {
                isConfigured = configurator.configure(docRenderingContext.getParserId(), doc.toPath(), parser);
            }
            if (!isConfigured) {
                // DOXIASITETOOLS-146 don't render comments from source markup
                parser.setEmitComments(false);
                parser.setEmitAnchorsForIndexableEntries(true);
            }

            // TODO: DOXIA-111: the filter used here must be checked generally.
            if (docRenderingContext.getAttribute("velocity") != null) {
                LOGGER.debug("Processing Velocity for " + docRenderingContext.getDoxiaSourcePath());
                try {
                    Context vc = createDocumentVelocityContext(docRenderingContext, siteContext);

                    StringWriter sw = new StringWriter();

                    velocity.getEngine().mergeTemplate(resource, siteContext.getInputEncoding(), vc, sw);

                    String doxiaContent = sw.toString();

                    if (siteContext.getProcessedContentOutput() != null) {
                        // save Velocity processing result, ie the Doxia content that will be parsed after
                        saveVelocityProcessedContent(docRenderingContext, siteContext, doxiaContent);
                    }

                    reader = new StringReader(doxiaContent);
                } catch (VelocityException e) {
                    throw new RendererException(
                            "Error parsing " + docRenderingContext.getDoxiaSourcePath() + " as a Velocity template", e);
                }

                if (parser.getType() == Parser.XML_TYPE && siteContext.isValidate()) {
                    reader = validate(reader, resource);
                }
            } else {
                switch (parser.getType()) {
                    case Parser.XML_TYPE:
                        reader = ReaderFactory.newXmlReader(doc);
                        if (siteContext.isValidate()) {
                            reader = validate(reader, resource);
                        }
                        break;

                    case Parser.TXT_TYPE:
                    case Parser.UNKNOWN_TYPE:
                    default:
                        reader = ReaderFactory.newReader(doc, siteContext.getInputEncoding());
                }
            }

            doxia.parse(reader, docRenderingContext.getParserId(), sink, docRenderingContext.getDoxiaSourcePath());
        } catch (ParserNotFoundException e) {
            throw new RendererException("Error getting a parser for '" + doc + "'", e);
        } catch (ParseException e) {
            StringBuilder errorMsgBuilder = new StringBuilder();
            errorMsgBuilder.append("Error parsing '").append(doc).append("'");
            if (e.getLineNumber() > 0) {
                errorMsgBuilder.append(", line ").append(e.getLineNumber());
            }
            throw new RendererException(errorMsgBuilder.toString(), e);
        } catch (IOException e) {
            throw new RendererException("Error while processing '" + doc + "'", e);
        } finally {
            sink.flush();

            sink.close();

            IOUtil.close(reader);
        }

        mergeDocumentIntoSite(writer, (DocumentContent) sink, siteContext);
    }

    private void saveVelocityProcessedContent(
            DocumentRenderingContext docRenderingContext, SiteRenderingContext siteContext, String doxiaContent)
            throws IOException {
        if (!siteContext.getProcessedContentOutput().exists()) {
            siteContext.getProcessedContentOutput().mkdirs();
        }

        String inputPath = docRenderingContext.getInputName();
        // Remove .vm suffix
        File outputFile =
                new File(siteContext.getProcessedContentOutput(), inputPath.substring(0, inputPath.length() - 3));

        File outputParent = outputFile.getParentFile();
        if (!outputParent.exists()) {
            outputParent.mkdirs();
        }

        FileUtils.fileWrite(outputFile, siteContext.getInputEncoding(), doxiaContent);
    }

    /**
     * Creates a Velocity Context with all generic tools configured wit the site rendering context.
     *
     * @param siteRenderingContext the site rendering context
     * @return a Velocity tools managed context
     */
    protected Context createToolManagedVelocityContext(SiteRenderingContext siteRenderingContext) {
        Locale locale = siteRenderingContext.getLocale();
        String dateFormat = siteRenderingContext.getSiteModel().getPublishDate().getFormat();
        String timeZoneId = siteRenderingContext.getSiteModel().getPublishDate().getTimezone();
        TimeZone timeZone =
                "system".equalsIgnoreCase(timeZoneId) ? TimeZone.getDefault() : TimeZone.getTimeZone(timeZoneId);

        EasyFactoryConfiguration config = new EasyFactoryConfiguration(false);
        config.property("safeMode", Boolean.FALSE);
        config.toolbox(Scope.REQUEST)
                .tool(ContextTool.class)
                .tool(LinkTool.class)
                .tool(LoopTool.class)
                .tool(RenderTool.class);
        config.toolbox(Scope.APPLICATION)
                .property("locale", locale)
                .tool(AlternatorTool.class)
                .tool(ClassTool.class)
                .tool(ComparisonDateTool.class)
                .property("format", dateFormat)
                .property("timezone", timeZone)
                .tool(ConversionTool.class)
                .property("dateFormat", dateFormat)
                .tool(DisplayTool.class)
                .tool(EscapeTool.class)
                .tool(FieldTool.class)
                .tool(MathTool.class)
                .tool(NumberTool.class)
                .tool(ResourceTool.class)
                .property("bundles", new String[] {"site-renderer"})
                .tool(SortTool.class)
                .tool(XmlTool.class);

        FactoryConfiguration customConfig = ConfigurationUtils.findInClasspath(TOOLS_LOCATION);

        if (customConfig != null) {
            config.addConfiguration(customConfig);
        }

        ToolManager manager = new ToolManager(false, false);
        manager.configure(config);

        return manager.createContext();
    }

    /**
     * Create a Velocity Context for a Doxia document, containing every information about rendered document.
     *
     * @param docRenderingContext the document's rendering context (may be {@code null} in which case the context does not contain document-specific information)
     * @param siteRenderingContext the site rendering context
     * @return a Velocity tools managed context
     */
    protected Context createDocumentVelocityContext(
            DocumentRenderingContext docRenderingContext, SiteRenderingContext siteRenderingContext) {
        Context context = createToolManagedVelocityContext(siteRenderingContext);
        if (docRenderingContext != null) {
            // ----------------------------------------------------------------------
            // Data objects
            // ----------------------------------------------------------------------

            context.put("relativePath", docRenderingContext.getRelativePath());

            String currentFilePath = docRenderingContext.getOutputName();
            context.put("currentFilePath", currentFilePath);
            // TODO Deprecated -- will be removed!
            context.put("currentFileName", currentFilePath);

            String alignedFilePath = PathTool.calculateLink(currentFilePath, docRenderingContext.getRelativePath());
            context.put("alignedFilePath", alignedFilePath);
            // TODO Deprecated -- will be removed!
            context.put("alignedFileName", alignedFilePath);

            for (Map.Entry<String, ContextCustomizer> entry : contextCustomizers.entrySet()) {
                try {
                    LOGGER.debug("Applying Velocity context customizer '" + entry.getKey() + "'");
                    entry.getValue().customizeContext(context, docRenderingContext, siteRenderingContext);
                } catch (Exception e) {
                    LOGGER.warn(
                            "Velocity context customizer '" + entry.getKey()
                                    + "' threw an exception and will be ignored",
                            e);
                }
            }
        }
        context.put("site", siteRenderingContext.getSiteModel());
        // TODO Deprecated -- will be removed!
        context.put("decoration", siteRenderingContext.getSiteModel());

        context.put("locale", siteRenderingContext.getLocale());
        context.put("supportedLocales", Collections.unmodifiableList(siteRenderingContext.getSiteLocales()));

        context.put("publishDate", siteRenderingContext.getPublishDate());

        if (DOXIA_SITE_RENDERER_VERSION != null) {
            context.put("doxiaSiteRendererVersion", DOXIA_SITE_RENDERER_VERSION);
        }

        // Add user properties
        Map<String, ?> templateProperties = siteRenderingContext.getTemplateProperties();

        if (templateProperties != null) {
            for (Map.Entry<String, ?> entry : templateProperties.entrySet()) {
                context.put(entry.getKey(), entry.getValue());
            }
        }

        // ----------------------------------------------------------------------
        // Tools
        // ----------------------------------------------------------------------

        context.put("PathTool", new PathTool());

        context.put("StringUtils", new StringUtils());

        context.put("plexus", plexus);
        return context;
    }

    /**
     * Create a Velocity Context for the site template decorating the document. In addition to all the informations
     * from the document, this context contains data gathered in {@link SiteRendererSink} during document rendering.
     *
     * @param content the document content to be merged into the template
     * @param siteRenderingContext the site rendering context
     * @return a Velocity tools managed context
     */
    protected Context createSiteTemplateVelocityContext(
            DocumentContent content, SiteRenderingContext siteRenderingContext) {
        // first get the context from document
        Context context = createDocumentVelocityContext(content.getRenderingContext(), siteRenderingContext);

        // then add data objects from rendered document

        // Add infos from document
        Collection<String> authors = content.getAuthors();
        if (authors != null && !authors.isEmpty()) {
            context.put("authors", authors);
        } else {
            // use scmModifiedDate (if available) as fallback
            context.put("authors", context.get("scmModifiedAuthor"));
        }

        String shortTitle = content.getTitle();
        context.put("shortTitle", shortTitle);

        String projectTitle = null;
        if (StringUtils.isNotEmpty(siteRenderingContext.getSiteModel().getName())) {
            projectTitle = siteRenderingContext.getSiteModel().getName();
        } else if (StringUtils.isNotEmpty(siteRenderingContext.getDefaultTitle())) {
            projectTitle = siteRenderingContext.getDefaultTitle();
        }

        StringBuilder title = new StringBuilder();
        if (StringUtils.isNotEmpty(shortTitle)) {
            title.append(shortTitle);
        }

        if (title.length() > 0 && StringUtils.isNotEmpty(projectTitle)) {
            title.append(" \u2013 "); // Symbol Name: En Dash
        }

        if (StringUtils.isNotEmpty(projectTitle)) {
            title.append(projectTitle);
        }

        context.put("title", title.length() > 0 ? title.toString() : null);

        context.put("headContent", content.getHead());

        context.put("bodyContent", content.getBody());

        // document date (got from Doxia Sink date() API)
        if (content.getDate() != null) {
            context.put("documentDate", content.getDate());
        } else {
            // use scmModifiedDate (if available) as fallback
            context.put("documentDate", context.get("scmModifiedDate"));
        }

        // document rendering context, to get eventual inputPath
        context.put("docRenderingContext", content.getRenderingContext());

        return context;
    }

    public void generateDocument(Writer writer, SiteRendererSink sink, SiteRenderingContext siteRenderingContext)
            throws RendererException {
        mergeDocumentIntoSite(writer, sink, siteRenderingContext);
    }

    /** {@inheritDoc} */
    public void mergeDocumentIntoSite(Writer writer, DocumentContent content, SiteRenderingContext siteRenderingContext)
            throws RendererException {
        String templateName = siteRenderingContext.getTemplateName();

        LOGGER.debug("Processing Velocity for template " + templateName + " on "
                + content.getRenderingContext().getDoxiaSourcePath());

        Context context = createSiteTemplateVelocityContext(content, siteRenderingContext);

        ClassLoader old = null;

        if (siteRenderingContext.getTemplateClassLoader() != null) {
            // -------------------------------------------------------------------------
            // If no template classloader was set we'll just use the context classloader
            // -------------------------------------------------------------------------

            old = Thread.currentThread().getContextClassLoader();

            Thread.currentThread().setContextClassLoader(siteRenderingContext.getTemplateClassLoader());
        }

        try {
            Template template;
            Artifact skin = siteRenderingContext.getSkin();

            try {
                SkinModel skinModel = siteRenderingContext.getSkinModel();
                String encoding = (skinModel == null) ? null : skinModel.getEncoding();

                template = (encoding == null)
                        ? velocity.getEngine().getTemplate(templateName)
                        : velocity.getEngine().getTemplate(templateName, encoding);
            } catch (ParseErrorException pee) {
                throw new RendererException(
                        "Velocity parsing error while reading the site template " + "from " + skin.getId() + " skin",
                        pee);
            } catch (ResourceNotFoundException rnfe) {
                throw new RendererException(
                        "Could not find the site template " + "from " + skin.getId() + " skin", rnfe);
            }

            try {
                StringWriter sw = new StringWriter();
                template.merge(context, sw);
                writer.write(sw.toString().replaceAll("\r?\n", SystemUtils.LINE_SEPARATOR));
            } catch (VelocityException ve) {
                throw new RendererException("Velocity error while merging site template.", ve);
            } catch (IOException ioe) {
                throw new RendererException("IO exception while merging site template.", ioe);
            }
        } finally {
            IOUtil.close(writer);

            if (old != null) {
                Thread.currentThread().setContextClassLoader(old);
            }
        }
    }

    private SiteRenderingContext createSiteRenderingContext(
            Map<String, ?> attributes, SiteModel siteModel, String defaultTitle, Locale locale) {
        SiteRenderingContext context = new SiteRenderingContext();

        context.setTemplateProperties(attributes);
        context.setLocale(locale);
        context.setSiteModel(siteModel);
        context.setDefaultTitle(defaultTitle);

        return context;
    }

    /** {@inheritDoc} */
    public SiteRenderingContext createContextForSkin(
            Artifact skin, Map<String, ?> attributes, SiteModel siteModel, String defaultTitle, Locale locale)
            throws IOException, RendererException {
        SiteRenderingContext context = createSiteRenderingContext(attributes, siteModel, defaultTitle, locale);

        context.setSkin(skin);

        ZipFile zipFile = getZipFile(skin.getFile());
        InputStream in = null;

        try {
            if (zipFile.getEntry(SKIN_TEMPLATE_LOCATION) == null) {
                throw new RendererException("Skin does not contain template at " + SKIN_TEMPLATE_LOCATION);
            }
            context.setTemplateName(SKIN_TEMPLATE_LOCATION);
            context.setTemplateClassLoader(
                    new URLClassLoader(new URL[] {skin.getFile().toURI().toURL()}));

            ZipEntry skinDescriptorEntry = zipFile.getEntry(SkinModel.SKIN_DESCRIPTOR_LOCATION);
            if (skinDescriptorEntry != null) {
                in = zipFile.getInputStream(skinDescriptorEntry);

                SkinModel skinModel = new SkinXpp3Reader().read(in);
                context.setSkinModel(skinModel);

                String toolsPrerequisite = skinModel.getPrerequisites() == null
                        ? null
                        : skinModel.getPrerequisites().getDoxiaSitetools();

                Package p = DefaultSiteRenderer.class.getPackage();
                String current = (p == null) ? null : p.getImplementationVersion();

                if (StringUtils.isNotBlank(toolsPrerequisite)
                        && (current != null)
                        && !matchVersion(current, toolsPrerequisite)) {
                    throw new RendererException("Your current Doxia Sitetools version " + current
                            + " does not match the requirements of the skin (" + toolsPrerequisite
                            + "). In order to fix this choose a skin version that is "
                            + "compatible with this Doxia Sitetools version.");
                }
            }
        } catch (XmlPullParserException e) {
            throw new RendererException(
                    "Failed to parse " + SkinModel.SKIN_DESCRIPTOR_LOCATION + " skin descriptor from " + skin.getId()
                            + " skin",
                    e);
        } finally {
            IOUtil.close(in);
            closeZipFile(zipFile);
        }

        return context;
    }

    boolean matchVersion(String current, String prerequisite) throws RendererException {
        try {
            ArtifactVersion v = new DefaultArtifactVersion(current);
            VersionRange vr = VersionRange.createFromVersionSpec(prerequisite);

            boolean matched = false;
            ArtifactVersion recommendedVersion = vr.getRecommendedVersion();
            if (recommendedVersion == null) {
                List<Restriction> restrictions = vr.getRestrictions();
                for (Restriction restriction : restrictions) {
                    if (restriction.containsVersion(v)) {
                        matched = true;
                        break;
                    }
                }
            } else {
                // only singular versions ever have a recommendedVersion
                @SuppressWarnings("unchecked")
                int compareTo = recommendedVersion.compareTo(v);
                matched = (compareTo <= 0);
            }

            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug("Skin doxia-sitetools prerequisite: " + prerequisite + ", current: " + current
                        + ", matched = " + matched);
            }

            return matched;
        } catch (InvalidVersionSpecificationException e) {
            throw new RendererException("Invalid skin doxia-sitetools prerequisite: " + prerequisite, e);
        }
    }

    /** {@inheritDoc} */
    public void copyResources(SiteRenderingContext siteRenderingContext, File outputDirectory) throws IOException {
        ZipFile file = getZipFile(siteRenderingContext.getSkin().getFile());

        Context velocityContext = createDocumentVelocityContext(null, siteRenderingContext);
        Map<String, String> resourceConditions = createResourceConditionsMap(siteRenderingContext.getSkinModel());
        try {
            for (Enumeration<? extends ZipEntry> e = file.entries(); e.hasMoreElements(); ) {
                ZipEntry entry = e.nextElement();

                if (!entry.getName().startsWith("META-INF/")) {
                    File destFile = new File(outputDirectory, entry.getName());
                    if (!entry.isDirectory()) {
                        if (destFile.exists()) {
                            // don't override existing content: avoids extra rewrite with same content or extra site
                            // resource
                            continue;
                        }
                        if (!isResourceRelevant(entry.getName(), velocityContext, resourceConditions)) {
                            continue;
                        }
                        destFile.getParentFile().mkdirs();

                        copyFileFromZip(file, entry, destFile);
                    } else {
                        destFile.mkdirs();
                    }
                }
            }
        } finally {
            closeZipFile(file);
        }

        // Copy extra site resources
        for (SiteDirectory siteDirectory : siteRenderingContext.getSiteDirectories()) {
            File resourcesDirectory = new File(siteDirectory.getPath(), "resources");

            if (resourcesDirectory != null && resourcesDirectory.exists()) {
                copyDirectory(resourcesDirectory, outputDirectory);
            }
        }

        // Check for the existence of /css/site.css
        File siteCssFile = new File(outputDirectory, "/css/site.css");
        if (!siteCssFile.exists()) {
            // Create the subdirectory css if it doesn't exist, DOXIA-151
            File cssDirectory = new File(outputDirectory, "/css/");
            boolean created = cssDirectory.mkdirs();
            if (created && LOGGER.isDebugEnabled()) {
                LOGGER.debug("The directory '" + cssDirectory.getAbsolutePath() + "' did not exist. It was created.");
            }

            // If the file is not there - create an empty file, DOXIA-86
            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug(
                        "The file '" + siteCssFile.getAbsolutePath() + "' does not exist. Creating an empty file.");
            }
            Writer writer = null;
            try {
                writer = WriterFactory.newWriter(siteCssFile, siteRenderingContext.getOutputEncoding());
                // DOXIA-290...the file should not be 0 bytes.
                writer.write("/* You can override this file with your own styles */");
            } finally {
                IOUtil.close(writer);
            }
        }

        if (siteRenderingContext.getSiteModel().getMermaid() != null
                && siteRenderingContext.getSiteModel().getMermaid().getExternalJs() == null) {
            final String name;
            if (siteRenderingContext.getSiteModel().getMermaid().isUseTiny()) {
                // use integrated tiny version of mermaid, which is smaller and faster to load, but has some limitations
                // (e.g. no sequence diagrams)
                name = "/js/mermaid-" + MERMAID_VERSION + ".tiny.min.js";
            } else {
                name = "/js/mermaid-" + MERMAID_VERSION + ".min.js";
            }
            copyFileFromResource(name, new File(outputDirectory, name));
        }
    }

    private static void copyFileFromResource(String name, File destFile) throws IOException {
        destFile.getParentFile().mkdirs();
        try (InputStream in = DefaultSiteRenderer.class.getResourceAsStream(name)) {
            if (in == null) {
                throw new IllegalArgumentException("Could not find the resource with name " + name);
            } else {
                try (OutputStream out = new FileOutputStream(destFile)) {
                    IOUtil.copy(in, out);
                }
            }
        }
    }

    private boolean isResourceRelevant(String name, Context velocityContext, Map<String, String> resourceConditions) {
        if (resourceConditions == null || !resourceConditions.containsKey(name)) {
            LOGGER.debug("No condition for resource '{}'", name);
        } else {
            String condition = resourceConditions.get(name);
            LOGGER.debug(
                    "Evaluating condition for resource '{}' with condition '{}'",
                    name,
                    escapeLineBreaksForLogging(condition));
            StringWriter writer = new StringWriter();
            Velocity.evaluate(velocityContext, writer, "conditional-resource-evaluation", condition);
            String result = writer.toString().trim();
            LOGGER.debug("Condition evaluation result: {}", result);
            if (!Boolean.parseBoolean(result)) {
                LOGGER.debug("Excluding resource '{}'", name);
                return false;
            }
        }
        return true;
    }

    private Map<String, String> createResourceConditionsMap(SkinModel skinModel) {
        if (skinModel == null) {
            LOGGER.debug("No skin model provided, so no resource conditions will be applied.");
            return Collections.emptyMap();
        }
        Map<String, String> resourceConditions = new HashMap<>();
        for (ResourceCondition resource : skinModel.getResourceConditions()) {
            if (resource.getVtlCondition() != null
                    && !resource.getVtlCondition().isEmpty()) {
                for (String resourceName : resource.getResourceNames()) {
                    if (resourceConditions.containsKey(resourceName)) {
                        LOGGER.warn(
                                "Multiple conditions found for resource '{}'. Only the first one will be used.",
                                resourceName);
                        continue;
                    }
                    LOGGER.debug(
                            "Adding condition for resource '{}' with condition '{}'",
                            resourceName,
                            escapeLineBreaksForLogging(resource.getVtlCondition()));
                    resourceConditions.put(resourceName, resource.getVtlCondition());
                }
            }
        }
        return resourceConditions;
    }

    private static String escapeLineBreaksForLogging(String input) {
        return input.replaceAll("\\r?\\n", "\\\\n");
    }

    private static void copyFileFromZip(ZipFile file, ZipEntry entry, File destFile) throws IOException {
        FileOutputStream fos = new FileOutputStream(destFile);

        try {
            IOUtil.copy(file.getInputStream(entry), fos);
        } finally {
            IOUtil.close(fos);
        }
    }

    /**
     * Copy the directory
     *
     * @param source      source file to be copied
     * @param destination destination file
     * @throws java.io.IOException if any
     */
    protected void copyDirectory(File source, File destination) throws IOException {
        if (source.exists()) {
            DirectoryScanner scanner = new DirectoryScanner();

            String[] includedResources = {"**/*"};

            scanner.setIncludes(includedResources);

            scanner.addDefaultExcludes();

            scanner.setBasedir(source);

            scanner.scan();

            List<String> includedFiles = Arrays.asList(scanner.getIncludedFiles());

            for (String name : includedFiles) {
                File sourceFile = new File(source, name);

                File destinationFile = new File(destination, name);

                FileUtils.copyFile(sourceFile, destinationFile);
            }
        }
    }

    private Reader validate(Reader source, String resource) throws ParseException, IOException {
        LOGGER.debug("Validating: " + resource);

        try {
            String content = IOUtil.toString(new BufferedReader(source));

            new XmlValidator().validate(content);

            return new StringReader(content);
        } finally {
            IOUtil.close(source);
        }
    }

    // TODO replace with StringUtils.endsWithIgnoreCase() from maven-shared-utils 0.7
    static boolean endsWithIgnoreCase(String str, String searchStr) {
        if (str.length() < searchStr.length()) {
            return false;
        }

        return str.regionMatches(true, str.length() - searchStr.length(), searchStr, 0, searchStr.length());
    }

    private static ZipFile getZipFile(File file) throws IOException {
        if (file == null) {
            throw new IOException("Error opening ZipFile: null");
        }

        try {
            // TODO: plexus-archiver, if it could do the excludes
            return new ZipFile(file);
        } catch (ZipException ex) {
            IOException ioe = new IOException("Error opening ZipFile: " + file.getAbsolutePath());
            ioe.initCause(ex);
            throw ioe;
        }
    }

    private static void closeZipFile(ZipFile zipFile) {
        // TODO: move to plexus utils
        try {
            zipFile.close();
        } catch (IOException e) {
            // ignore
        }
    }

    private static String getSiteRendererVersion() {
        InputStream inputStream = DefaultSiteRenderer.class.getResourceAsStream(
                "/META-INF/" + "maven/org.apache.maven.doxia/doxia-site-renderer/pom.properties");
        if (inputStream == null) {
            LOGGER.debug("pom.properties for doxia-site-renderer not found");
        } else {
            Properties properties = new Properties();
            try (InputStream in = inputStream) {
                properties.load(in);
                return properties.getProperty("version");
            } catch (IOException e) {
                LOGGER.debug("Failed to load pom.properties, so Doxia SiteRenderer version will not be available", e);
            }
        }

        return null;
    }
}
