/*
 *  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
 *
 *    https://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 grails.doc

import java.nio.charset.StandardCharsets

import groovy.ant.AntBuilder
import groovy.io.FileType
import groovy.text.Template

import org.apache.commons.logging.LogFactory
import org.radeox.api.engine.WikiRenderEngine
import org.radeox.engine.context.BaseInitialRenderContext
import org.radeox.engine.context.BaseRenderContext
import org.yaml.snakeyaml.LoaderOptions
import org.yaml.snakeyaml.Yaml
import org.yaml.snakeyaml.constructor.SafeConstructor

import grails.doc.asciidoc.AsciiDocEngine
import grails.doc.internal.FileResourceChecker
import grails.doc.internal.StringEscapeCategory
import grails.doc.internal.UserGuideNode
import grails.doc.internal.YamlTocStrategy

/**
 * Coordinated the DocEngine the produce documentation based on the gdoc format.
 *
 * @see DocEngine
 *
 * @author Graeme Rocher
 * @since 1.2
 */
class DocPublisher {

    static final String TOC_FILENAME = 'toc.yml'

    /** The source directory of the documentation */
    File src
    /** The target directory to publish to */
    File target
    /** The temporary work directory */
    File workDir
    /** Directory containing the project's API documentation. */
    File apiDir
    /** The directory containing any images to use (will override defaults) **/
    File images
    /** The directory containing any CSS to use (will override defaults) **/
    File css
    /** The directory containing any fonts to use (will override defaults) **/
    File fonts
    /** The directory containing any Javascript to use (will override defaults) **/
    File js
    /** The directory cotnaining any templates to use (will override defaults) **/
    File style
    /**
     * The properties fie to populate the engine properties from
     */
    File propertiesFile
    /** The AntBuilder instance to use */
    AntBuilder ant
    /** The language we're generating for (gets its own sub-directory). Defaults to '' */
    String language = ''
    /** The encoding to use (default is UTF-8) */
    String encoding = 'UTF-8'
    /** The title of the documentation */
    String title
    /** The subtitle of the documentation */
    String subtitle = ''
    /** The version of the documentation */
    String version
    /** The authors of the documentation */
    String authors = ''
    /** The translators of the documentation (if any) */
    String translators = ''
    /** The documentation license */
    String license = ''
    /** The copyright message */
    String copyright = ''
    /** The footer to include */
    String footer = ''
    /** HTML markup that renders the left logo */
    String logo
    /** HTML markup that renders the right logo */
    String sponsorLogo
    /**
     * The source repository
     */
    String sourceRepo

    /** Properties used to configure the DocEngine */
    Properties engineProperties

    boolean asciidoc = false

    def output
    private BaseRenderContext context
    private WikiRenderEngine engine
    private customMacros = []

    DocPublisher() {
        this(null, null)
    }

    DocPublisher(File src, File target, out = LogFactory.getLog(DocPublisher)) {
        this.src = src
        this.target = target
        this.output = out

        try {
            engineProperties.load(getClass().classLoader.getResourceAsStream('grails/doc/doc.properties'))
        }
        catch (e) {
            // ignore
        }
    }

    /** Returns the engine properties. */
    Properties getEngineProperties() { engineProperties }

    /** Sets the engine properties. Allows clients to override the defaults. */
    void setEngineProperties(Properties p) {
        engineProperties = p
    }

    /**
     * Registers a custom Radeox macro. If the macro has an 'initialContext'
     * property, it is set to the render context before first use.
     */
    void registerMacro(macro) {
        customMacros << macro
    }

    void publish() {
        // Adds encodeAsUrlPath(), encodeAsUrlFragment() and encodeAsHtml()
        // methods to String.
        use(StringEscapeCategory) {
            catPublish()
        }
    }

    private void catPublish() {
        initialize()
        if (!src?.exists()) {
            return
        }

        // unpack documentation resources
        String docResources = "${workDir}/doc-resources"
        ant.mkdir(dir: docResources)
        unpack(dest: docResources, src: 'grails-doc-files.jar')

        def refDocsDir = calculateLanguageDir(target?.absolutePath ?: './docs')
        def refGuideDir = new File(refDocsDir, 'guide')
        def refPagesDir = "$refGuideDir/pages"

        ant.mkdir(dir: refDocsDir)
        ant.mkdir(dir: refGuideDir)
        ant.mkdir(dir: refPagesDir)
        ant.mkdir(dir: "$refDocsDir/ref")

        String imgsDir = new File(refDocsDir, calculatePathToResources('img')).path
        File fontsDir = new File(refDocsDir, calculatePathToResources('fonts'))
        ant.mkdir(dir: imgsDir)
        ant.mkdir(dir: fontsDir)
        String cssDir = new File(refDocsDir, calculatePathToResources('css')).path
        ant.mkdir(dir: cssDir)
        String jsDir = new File(refDocsDir, calculatePathToResources('js')).path
        ant.mkdir(dir: jsDir)
        ant.mkdir(dir: "${refDocsDir}/ref")

        ant.copy(todir: imgsDir, overwrite: true) {
            fileset(dir: "${docResources}/img")
        }

        if (images && images.exists()) {
            ant.copy(todir: imgsDir, overwrite: true, failonerror: false) {
                fileset(dir: images)
            }
        }

        ant.copy(todir: cssDir, overwrite: true) {
            fileset(dir: "${docResources}/css")
        }
        ant.copy(todir: fontsDir, overwrite: true) {
            fileset(dir: "${docResources}/fonts")
        }

        if (css && css.exists()) {
            ant.copy(todir: cssDir, overwrite: true, failonerror: false) {
                fileset(dir: css)
            }
        }
        if (fonts && fonts.exists()) {
            ant.copy(todir: fontsDir, overwrite: true, failonerror: false) {
                fileset(dir: fonts)
            }
        }
        ant.copy(todir: jsDir, overwrite: true) {
            fileset(dir: "${docResources}/js")
        }
        if (js && js.exists()) {
            ant.copy(todir: jsDir, overwrite: true, failonerror: false) {
                fileset(dir: js)
            }
        }
        if (style && style.exists()) {
            ant.copy(todir: "${docResources}/style", overwrite: true, failonerror: false) {
                fileset(dir: style)
            }
        }

        // Build the table of contents as a tree of nodes. We currently support
        // two strategies for this:
        //
        //  1. From a toc.yml file
        //  2. From the gdoc filenames
        //
        // The first strategy is used if the TOC file exists, otherwise we call
        // back to the old way of doing it, which means putting the section
        // numbers in the gdoc filenames.
        def guideSrcDir = new File(src, 'guide')
        def yamlTocFile = new File(guideSrcDir, TOC_FILENAME)
        def guide
        def ext = asciidoc ? '.adoc' : '.gdoc'

        if (yamlTocFile.exists()) {
            def tocStrategy = new YamlTocStrategy(new FileResourceChecker(guideSrcDir), ext)
            guide = tocStrategy.generateToc(yamlTocFile)

            // A set of all gdoc files.
            def files = []
            def pattern = asciidoc ? ~/^.+\.adoc$/ : ~/^.+\.gdoc$/
            guideSrcDir.traverse(type: FileType.FILES, nameFilter: pattern) {
                // We need relative file paths with '/' separators, since those
                // are what are stored in the UserGuideNodes.
                files << (it.absolutePath - guideSrcDir.absolutePath)[1..-1].
                        replace(File.separator as char, '/' as char)
            }

            def errors = verifyToc(guideSrcDir, files, guide)
            if (errors) {
                throw new RuntimeException("Encountered the following errors while building table of contents. Aborting.\n${errors.join('\n')}")
            }

            for (ch in guide.children) {
                overrideAliasesFromToc(ch)
            }
        }
        else {
            throw new RuntimeException("Legacy TOC is no longer supported. Please add a ${TOC_FILENAME}")
        }

        // When migrating from the old style docs to the new style, existing
        // external links that use URL fragment identifiers will break. To
        // mitigate against this problem, the user can provide a list of mappings
        // from the new fragment identifiers to the old ones. The docs will then
        // include both.
        def legacyLinksFile = new File(guideSrcDir, 'links.yml')
        def legacyLinks = [:]
        if (legacyLinksFile.exists()) {
            legacyLinksFile.withInputStream { input ->
                legacyLinks = new Yaml(new SafeConstructor(new LoaderOptions())).load(input)
            }
        }

        def templateEngine = new groovy.text.SimpleTemplateEngine()

        // Reference menu items.
        def sectionFilter = { it.directory && !it.name.startsWith('.') } as FileFilter
        def files = new File(src, 'ref').listFiles(sectionFilter)?.toList()?.sort() ?: []
        def refCategories = files.collect { f ->
            new Expando(
                    name: f.name,
                    usage: new File("${src}/ref/${f.name}$ext"),
                    sections: f.listFiles().findAll { it.name.endsWith(ext) }.sort())
        }

        def fullToc = new StringBuilder()

        def pathToRoot = '..'
        Map vars = new LinkedHashMap(engineProperties)
        vars.putAll(
            encoding: encoding,
            title: title,
            docTitle: title,
            subtitle: subtitle,
            footer: footer, // TODO - add a way to specify footer
            authors: authors,
            translators: translators,
            version: version,
            refMenu: refCategories,
            toc: guide,
            copyright: copyright,
            logo: injectPath(logo, pathToRoot),
            sponsorLogo: injectPath(sponsorLogo, pathToRoot),
            single: false,
            path: pathToRoot,
            resourcesPath: calculatePathToResources(pathToRoot),
            prev: null,
            next: null,
            legacyLinks: legacyLinks,
            sourceRepo: sourceRepo,
        )

        if (engine instanceof AsciiDocEngine) {
            // pass attributes to asciidoc
            ((AsciiDocEngine) engine).attributes.putAll(
                    version: version,
                    apiDocs: "https://grails.apache.org/docs/${version}/api/",
                    sourceRepo: sourceRepo
            )
            ((AsciiDocEngine) engine).attributes.putAll(
                    engineProperties
            )
        }

        // Build the user guide sections first.
        def template = templateEngine.createTemplate(new File("${docResources}/style/guideItem.html").newReader(encoding))
        def sectionTemplate = templateEngine.createTemplate(new File("${docResources}/style/section.html").newReader(encoding))
        def fullContents = new StringBuilder()

        def chapterVars
        def chapters = guide.children
        chapters.eachWithIndex { chapter, i ->
            chapterVars = [*:vars, chapterNumber: i + 1]
            if (i != 0) {
                chapterVars['prev'] = chapters[i - 1]
            }
            if (i != (chapters.size() - 1)) {
                chapterVars['next'] = chapters[i + 1]
            }
            chapterVars.sectionNumber = (i + 1).toString()
            writeChapter(chapter, template, sectionTemplate, guideSrcDir, refGuideDir.path, fullContents, chapterVars)
        }

        files = new File("${src}/ref").listFiles()?.toList()?.sort() ?: []
        def reference = [:]
        template = templateEngine.createTemplate(new File("${docResources}/style/referenceItem.html").newReader(encoding))

        pathToRoot = '../..'
        vars.logo = injectPath(logo, pathToRoot)
        vars.sponsorLogo = injectPath(sponsorLogo, pathToRoot)
        vars.path = pathToRoot
        vars.resourcesPath = calculatePathToResources(pathToRoot)

        // Generate the reference section of the guide.
        for (f in files) {
            if (f.directory && !f.name.startsWith('.')) {
                def section = f.name
                vars.section = section

                new File("${refDocsDir}/ref/${section}").mkdirs()
                def textiles = f.listFiles().findAll { it.name.endsWith(ext) }.sort()
                def usageFile = new File("${src}/ref/${section}${ext}")
                if (usageFile.exists()) {
                    def data = usageFile.getText(StandardCharsets.UTF_8.name())
                    context.set(DocEngine.SOURCE_FILE, usageFile)
                    context.set(DocEngine.CONTEXT_PATH, pathToRoot)
                    context.set(DocEngine.API_CONTEXT_PATH, vars.resourcesPath)
                    output.warn("Rendering document file $usageFile.name")
                    vars.content = engine.render(data, context)
                    vars.sourcePath = "ref/$usageFile.name"
                    new File("${refDocsDir}/ref/${section}/Usage.html").withWriter(encoding) { out ->
                        template.make(vars).writeTo(out)
                    }
                }
                for (txt in textiles) {
                    def name = txt.name[0..-6]
                    def data = txt.getText(StandardCharsets.UTF_8.name())
                    context.set(DocEngine.SOURCE_FILE, txt.name)
                    context.set(DocEngine.CONTEXT_PATH, pathToRoot)
                    context.set(DocEngine.API_CONTEXT_PATH, vars.resourcesPath)
                    output.warn("Rendering document file $txt.name")
                    vars.content = engine.render(data, context)
                    vars.sourcePath = "ref/${section}/$txt.name"
                    new File("${refDocsDir}/ref/${section}/${name}.html").withWriter(encoding) { out ->
                        template.make(vars).writeTo(out)
                    }
                }
            }
        }

        vars.remove('section')
        vars.content = fullContents.toString()
        vars.single = true

        pathToRoot = '..'
        vars.logo = injectPath(logo, pathToRoot)
        vars.sponsorLogo = injectPath(sponsorLogo, pathToRoot)
        vars.path = pathToRoot
        vars.resourcesPath = calculatePathToResources(pathToRoot)

        template = templateEngine.createTemplate(new File("${docResources}/style/layout.html").newReader(encoding))
        new File("${refGuideDir}/single.html").withWriter(encoding) { out ->
            template.make(vars).writeTo(out)
        }

        vars.content = ''
        vars.single = false
        new File("${refGuideDir}/index.html").withWriter(encoding) { out ->
            template.make(vars).writeTo(out)
        }

        pathToRoot = '.'
        vars.logo = injectPath(logo, pathToRoot)
        vars.sponsorLogo = injectPath(sponsorLogo, pathToRoot)
        vars.path = pathToRoot
        vars.resourcesPath = calculatePathToResources(pathToRoot)

        new File("${refDocsDir}/index.html").withWriter(encoding) { out ->
            template.make(vars).writeTo(out)
        }

        ant.echo("Built user manual at ${refDocsDir}/index.html")
    }

    void writeChapter(
            section,
            Template layoutTemplate,
            Template sectionTemplate,
            File guideSrcDir,
            String targetDir,
            fullContents,
            vars) {
        fullContents << writePage(section, layoutTemplate, sectionTemplate, guideSrcDir, targetDir, '', '..', 0, vars)
    }

    String writePage(
            section,
            Template layoutTemplate,
            Template sectionTemplate,
            File guideSrcDir,
            String targetDir,
            String subDir,
            path,
            level,
            vars) {
        def sourceFile = new File(guideSrcDir, section.file)
        context.set(DocEngine.SOURCE_FILE, sourceFile)
        context.set(DocEngine.CONTEXT_PATH, path)

        def varsCopy = [*:vars]
        varsCopy.putAll(engineProperties)
        varsCopy.name = section.name
        varsCopy.title = section.title
        varsCopy.path = path
        varsCopy.level = level
        varsCopy.sectionToc = section.children
        varsCopy.sourcePath = section.file
        output.warn("Rendering document file $sourceFile.name")
        varsCopy.content = engine.render(sourceFile.getText(StandardCharsets.UTF_8.name()), context)

        // First create the section content, which usually consists of a header
        // and the translated gdoc content.
        def sectionContent = new StringWriter()
        sectionTemplate.make(varsCopy).writeTo(sectionContent)

        // Aggregate the section content and sub-sections.
        def accumulatedContent = new StringBuilder()
        accumulatedContent << sectionContent.toString()

        // Create the sub-section pages.
        level++
        final sectionNumber = varsCopy.sectionNumber
        int subSectionNumber = 1
        for (s in section.children) {
            varsCopy.sectionNumber = "$sectionNumber.$subSectionNumber"
            accumulatedContent << writePage(s, layoutTemplate, sectionTemplate, guideSrcDir, targetDir, 'pages', path, level, varsCopy)
            subSectionNumber++
        }

        // Reset the section number in the template vars.
        varsCopy.sectionNumber = sectionNumber

        // TODO PAL - I don't see why these pages are necessary, plus there seems
        // to be no way to get embedded images to display properly (since the path
        // passed to the Wiki rendering engine is wrong for pages written to a
        // 'pages' subdirectory). Keeping them in case someone, somewhere depends
        // on them.
        //
        // Create the HTML page for this section, which includes the content
        // from all the sub-sections too.
        if (subDir) {
            if (subDir.endsWith('/')) subDir = subDir[0..-2]
            targetDir = "$targetDir/$subDir"

            varsCopy.path = "../${path}"
            varsCopy.logo = injectPath(logo, varsCopy.path)
            varsCopy.sponsorLogo = injectPath(sponsorLogo, varsCopy.path)
        }

        new File("${targetDir}/${section.name}.html").withWriter(encoding) { writer ->
            varsCopy.content = accumulatedContent.toString()
            layoutTemplate.make(varsCopy).writeTo(writer)
        }

        return varsCopy.content
    }

    protected void initialize() {
        if (language) {
            src = new File(src, language)
        }

        if (!workDir) {
            workDir = new File(System.getProperty('java.io.tmpdir'))
        }
        if (!apiDir) {
            apiDir = target
        }
        if (!ant) {
            ant = new AntBuilder()
        }
        def metaProps = DocPublisher.metaClass.properties
        Properties props
        if (engineProperties != null) {
            props = engineProperties
        }
        else {
            props = new Properties()
            engineProperties = props
        }

        if (propertiesFile?.exists()) {
            if (propertiesFile.name.endsWith('.properties')) {
                propertiesFile.withInputStream {
                    props.load(it)
                }
            }
            else if (propertiesFile.name.endsWith('.yml')) {
                propertiesFile.withInputStream { input ->
                    def ymls = new Yaml(new SafeConstructor(new LoaderOptions())).loadAll(input)
                    for (yml in ymls) {
                        if (yml instanceof Map) {
                            def config = yml.grails?.doc
                            if (config instanceof Map) {
                                flattenKeys(props, (Map) config, [], true)
                            }
                        }
                    }
                }

            }

        }

        for (MetaProperty mp in metaProps) {
            if (mp.type == String) {
                def value = props[mp.name]
                if (value) {
                    this[mp.name] = value
                }
            }
        }

        context = new BaseInitialRenderContext()
        initContext(context, '..')

        if (asciidoc) {
            engine = new AsciiDocEngine(context)
        }
        else {
            engine = new DocEngine(context)
        }

        engine.engineProperties = props
        context.renderEngine = engine

        // Add any custom macros registered with this publisher to the engine.
        for (m in customMacros) {
            if (m.metaClass.hasProperty(m, 'initialContext')) {
                m.initialContext = context
            }
            engine.addMacro(m)
        }
    }

    private void flattenKeys(Map<String, Object> flatConfig, Map currentMap, List<String> path, boolean forceStrings) {
        currentMap.each { key, value ->
            String stringKey = String.valueOf(key)
            if (value != null) {
                if (value instanceof Map) {
                    flattenKeys(flatConfig, (Map) value, ((path + [stringKey]) as List<String>).asImmutable(), forceStrings)
                } else {
                    String fullKey
                    if (path) {
                        fullKey = path.join('.') + '.' + stringKey
                    } else {
                        fullKey = stringKey
                    }
                    if (value instanceof Collection) {
                        if (forceStrings) {
                            flatConfig.put(fullKey, ((Collection) value).join(','))
                        } else {
                            flatConfig.put(fullKey, value)
                        }
                        int index = 0
                        for (Object item: (Collection) value) {
                            String collectionKey = "${fullKey}[${index}]".toString()
                            flatConfig.put(collectionKey, forceStrings ? String.valueOf(item) : item)
                            index++
                        }
                    } else {
                        flatConfig.put(fullKey, forceStrings ? String.valueOf(value) : value)
                    }
                }
            }
        }
    }

    /**
     * Checks the table of contents (a tree of {@link UserGuideNode}s) for
     * duplicate section/alias names and invalid file paths.
     * @return <code>false</code> if any errors are detected.
     */
    protected List<String> verifyToc(File baseDir, gdocFiles, toc) {
        List<String> errors = []
        def sectionsFound = [] as Set
        def gdocsNotInToc = gdocFiles as Set

        // Defensive copy
        if (gdocsNotInToc.is(gdocFiles)) gdocsNotInToc = new HashSet(gdocFiles)

        for (ch in toc.children) {
            List<String> internalErrors = verifyTocInternal(baseDir, ch, sectionsFound, gdocsNotInToc, [])
            if (internalErrors) {
                errors.addAll(internalErrors)
            }
        }

        if (gdocsNotInToc) {
            for (gdoc in gdocsNotInToc) {
                output.warn("No TOC entry found for '${gdoc}'")
            }
        }

        return errors
    }

    private List<String> verifyTocInternal(File baseDir, section, existing, gdocFiles, pathElements) {
        List<String> errors = []
        def fullName = pathElements ? "${pathElements.join('/')}/${section.name}" : section.name

        // Has this section name already been used?
        if (section.name in existing) {
            def duplicateError = "Duplicate section name: ${fullName}" as String
            errors << duplicateError
            output.error(duplicateError)
        }

        // Does the file path for the gdoc exist?
        if (!section.file || !new File(baseDir, section.file).exists()) {
            def noFileError = "No file found for '${fullName}'" as String
            errors << noFileError
            output.error(noFileError)
        }
        else {
            // Found this gdoc file in the TOC.
            gdocFiles.remove(section.file)
        }

        existing << section.name

        for (s in section.children) {
            List<String> internalErrors = verifyTocInternal(baseDir, s, existing, gdocFiles, pathElements + section.name)
            if (internalErrors) {
                errors.addAll(internalErrors)
            }
        }

        return errors
    }

    private String calculateLanguageDir(startPath, endPath = '') {
        def elements = [startPath, language, endPath]
        elements = elements.findAll { it }
        return elements.join('/')
    }

    private String injectPath(String source, String path) {
        if (!source) return source

        def templateEngine = new groovy.text.SimpleTemplateEngine()
        def out = new StringWriter()
        templateEngine.createTemplate(source).make(path: calculatePathToResources(path)).writeTo(out)
        return out.toString()
    }

    private String calculatePathToResources(String pathToRoot) {
        return language ? '../' + pathToRoot : pathToRoot
    }

    private initContext(context, path) {
        context.set(DocEngine.CONTEXT_PATH, path)
        context.set(DocEngine.BASE_DIR, src.absolutePath)
        context.set(DocEngine.API_BASE_PATH, apiDir.absolutePath)
        context.set(DocEngine.API_CONTEXT_PATH, calculatePathToResources(path))
        context.set(DocEngine.RESOURCES_CONTEXT_PATH, calculatePathToResources(path))
        return context
    }

    private unpack(Map args) {

        def dir = args['dest'] ?: '.'
        def src = args['src']
        def overwriteOption = args['overwrite'] == null ? true : args['overwrite']

        // Can't unjar a file from within a JAR, so we copy it to
        // the destination directory first.
        try {
            URL url = getClass().getClassLoader().getResource(src)
            if (url) {
                url.withInputStream { InputStream input ->
                    new File("$dir/$src").withOutputStream { out ->
                        def buffer = new byte[1024]
                        int len
                        while ((len = input.read(buffer)) != -1) {
                            out.write(buffer, 0, len)
                        }
                    }
                }
            }
            // Now unjar it, excluding the META-INF directory.
            ant.unjar(dest: dir, src: "${dir}/${src}", overwrite: overwriteOption) {
                patternset {
                    exclude(name: 'META-INF/**')
                }
            }
        }
        finally {
            // Don't need the JAR file any more, so remove it.
            ant.delete(file: "${dir}/${src}", failonerror: false)
        }
    }

    private overrideAliasesFromToc(node) {
        engine.engineProperties.setProperty("alias.${node.name}", node.file - '.gdoc')

        for (section in node.children) {
            overrideAliasesFromToc(section)
        }
    }
}
