/*
 *  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 groovy.text;

import groovy.lang.Binding;
import groovy.lang.Closure;
import groovy.lang.GroovyRuntimeException;
import groovy.lang.GroovyShell;
import groovy.lang.Script;
import groovy.lang.Writable;
import groovy.namespace.QName;
import groovy.util.IndentPrinter;
import groovy.util.Node;
import groovy.xml.XmlNodePrinter;
import groovy.xml.XmlParser;
import org.apache.groovy.io.StringBuilderWriter;
import org.codehaus.groovy.control.CompilationFailedException;
import org.codehaus.groovy.runtime.FormatHelper;
import org.codehaus.groovy.runtime.InvokerHelper;
import org.xml.sax.SAXException;

import javax.xml.parsers.ParserConfigurationException;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.Reader;
import java.io.Writer;
import java.lang.ref.WeakReference;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Template engine for use in templating scenarios where both the template
 * source and the expected output are intended to be XML.
 * <p>
 * Templates may use the normal '${expression}' and '$variable' notations
 * to insert an arbitrary expression into the template.
 * In addition, support is also provided for special tags:
 * &lt;gsp:scriptlet&gt; (for inserting code fragments) and
 * &lt;gsp:expression&gt; (for code fragments which produce output).
 * <p>
 * Comments and processing instructions
 * will be removed as part of processing and special XML characters such as
 * &lt;, &gt;, &quot; and &apos; <em>in the text of the template</em> will be escaped
 * using the respective XML notation.
 * The output will also be indented using standard XML pretty printing.
 * <p>
 * That escaping is applied to the template as it is read, and deliberately not to the
 * <code>${expression}</code> and <code>$variable</code> insertions, which have to survive
 * as Groovy for the value to be computed at all. Whatever such an insertion produces is
 * written as it stands: a value carrying <code>&lt;</code> or <code>&amp;</code> reaches
 * the output as markup rather than as text, and can leave it neither well-formed nor what
 * the template intended. Escape a value that may contain either, with
 * {@link groovy.xml.XmlUtil#escapeXml(String)} or otherwise, before binding it &mdash;
 * always for a value that came from somewhere you do not control.
 * <p>
 * The xmlns namespace definition for <code>gsp:</code> tags will be removed
 * but other namespace definitions will be preserved (but may change to an
 * equivalent position within the XML tree).
 * <p>
 * Normally, the template source will be in a file but here is a simple
 * example providing the XML template as a string:
 * <pre>
 * def binding = [firstname:"Jochen", lastname:"Theodorou",
 *                nickname:"blackdrag", salutation:"Dear"]
 * def engine = new groovy.text.XmlTemplateEngine()
 * def text = '''\
 * &lt;?xml version="1.0" encoding="UTF-8"?&gt;
 * &lt;document xmlns:gsp='http://groovy.codehaus.org/2005/gsp' xmlns:foo='baz' type='letter'&gt;
 *   &lt;gsp:scriptlet&gt;def greeting = "${salutation}est"&lt;/gsp:scriptlet&gt;
 *   &lt;gsp:expression&gt;greeting&lt;/gsp:expression&gt;
 *   &lt;foo:to&gt;$firstname "$nickname" $lastname&lt;/foo:to&gt;
 *   How are you today?
 * &lt;/document&gt;
 * '''
 * def template = engine.createTemplate(text).make(binding)
 * println template.toString()
 * </pre>
 * This example will produce this output:
 * <pre>
 * &lt;document type='letter'&gt;
 * Dearest
 * &lt;foo:to xmlns:foo='baz'&gt;
 *   Jochen &amp;quot;blackdrag&amp;quot; Theodorou
 * &lt;/foo:to&gt;
 * How are you today?
 * &lt;/document&gt;
 * </pre>
 * The XML template engine can also be used as the engine for {@code groovy.servlet.TemplateServlet} by placing the
 * following in your web.xml file (plus a corresponding servlet-mapping element):
 * <pre>
 * &lt;servlet&gt;
 *   &lt;servlet-name&gt;XmlTemplate&lt;/servlet-name&gt;
 *   &lt;servlet-class&gt;groovy.servlet.TemplateServlet&lt;/servlet-class&gt;
 *   &lt;init-param&gt;
 *     &lt;param-name&gt;template.engine&lt;/param-name&gt;
 *     &lt;param-value&gt;groovy.text.XmlTemplateEngine&lt;/param-value&gt;
 *   &lt;/init-param&gt;
 * &lt;/servlet&gt;
 * </pre>
 */
public class XmlTemplateEngine extends TemplateEngine {

    private static AtomicInteger counter = new AtomicInteger(0);

    private Closure configurePrinter = null;

    /**
     * Closure that can be used to configure the printer.
     * The printer is passed as a parameter to the closure.
     * <pre>
     * new XmlTemplateEngine(configurePrinter: { it.preserveWhitespace = true })
     * </pre>
     */
    public void setConfigurePrinter(Closure configurePrinter) {
        this.configurePrinter = configurePrinter;
    }

    private static class GspPrinter extends XmlNodePrinter {

        /**
         * Creates a printer using a {@link PrintWriter} and indentation string.
         *
         * @param out target writer
         * @param indent indentation sequence
         */
        GspPrinter(PrintWriter out, String indent) {
            this(new IndentPrinter(out, indent));
        }

        /**
         * Creates a printer using the supplied {@link IndentPrinter}.
         *
         * @param out indent printer used to emit XML template script text
         */
        GspPrinter(IndentPrinter out) {
            super(out, "\\\"");
            setQuote("'");
        }

        /**
         * Emits Groovy Server Pages helper tags as template script statements or expressions.
         *
         * @param tag local GSP tag name
         * @param text tag body text
         */
        protected void printGroovyTag(String tag, String text) {
            if ("scriptlet".equals(tag)) {
                out.print(text);
                out.print("\n");
                return;
            }
            if ("expression".equals(tag)) {
                printLineBegin();
                out.print("${");
                out.print(text);
                out.print("}");
                printLineEnd();
                return;
            }
            throw new RuntimeException("Unsupported 'gsp:' tag named \"" + tag + "\".");
        }

        /**
         * {@inheritDoc}
         */
        @Override
        protected void printName(Node node, NamespaceContext ctx, boolean begin, boolean preserve) {
            if (node == null || node.name() == null) {
                super.printName(node, ctx, begin, preserve);
            }
            printLineBegin();
            out.print("<");
            if (!begin) {
                out.print("/");
            }
            out.print(getName(node));
            if (ctx != null) {
                printNamespace(node, ctx);
            }
            if (begin) {
                printNameAttributes(node.attributes(), ctx);
            }
            out.print(">");
            printLineEnd();
        }

        /**
         * {@inheritDoc}
         */
        @Override
        protected void printSimpleItem(Object value) {
            this.printLineBegin();
            out.print(escapeSpecialChars(FormatHelper.toString(value)));
            printLineEnd();
        }

        private String escapeSpecialChars(String s) {
            StringBuilder sb = new StringBuilder();
            boolean inGString = false;
            for (int i = 0; i < s.length(); i++) {
                final char c = s.charAt(i);
                switch (c) {
                    case '$':
                        sb.append("$");
                        if (i < s.length() - 1 && s.charAt(i + 1) == '{') inGString = true;
                        break;
                    case '<':
                        append(sb, c, "&lt;", inGString);
                        break;
                    case '>':
                        append(sb, c, "&gt;", inGString);
                        break;
                    case '"':
                        append(sb, c, "&quot;", inGString);
                        break;
                    case '\'':
                        append(sb, c, "&apos;", inGString);
                        break;
                    case '}':
                        sb.append(c);
                        inGString = false;
                        break;
                    default:
                        sb.append(c);
                }
            }
            return sb.toString();
        }

        private void append(StringBuilder sb, char plainChar, String xmlString, boolean inGString) {
            if (inGString) {
                sb.append(plainChar);
            } else {
                sb.append(xmlString);
            }
        }

        /**
         * {@inheritDoc}
         */
        @Override
        protected void printLineBegin() {
            out.print("out.print(\"\"\"");
            if (!isPreserveWhitespace()) out.printIndent();
        }

        /**
         * {@inheritDoc}
         */
        @Override
        protected void printLineEnd(String comment) {
            if (!isPreserveWhitespace()) out.print("\\n");
            out.print("\"\"\");");
            if (comment != null) {
                out.print(" // ");
                out.print(comment);
            }
            out.print("\n");
        }

        /**
         * {@inheritDoc}
         */
        @Override
        protected boolean printSpecialNode(Node node) {
            Object name = node.name();
            if (name instanceof QName qn) {
                // check uri and for legacy cases just check prefix name (not recommended)
                if ("http://groovy.codehaus.org/2005/gsp".equals(qn.getNamespaceURI()) || "gsp".equals(qn.getPrefix())) {
                    String s = qn.getLocalPart();
                    if (s.isEmpty()) {
                        throw new RuntimeException("No local part after 'gsp:' given in node " + node);
                    }
                    printGroovyTag(s, node.text());
                    return true;
                }
            }
            return false;
        }
    }

    private static class XmlTemplate implements Template {

        private final Script script;

        /**
         * Creates a compiled XML template wrapper around the supplied script.
         *
         * @param script generated Groovy script backing this template
         */
        XmlTemplate(Script script) {
            this.script = script;
        }

        /**
         * Creates a writable view using an empty binding.
         *
         * @return a writable XML template
         */
        @Override
        public Writable make() {
            return make(new HashMap());
        }

        /**
         * Creates a writable view using the supplied binding.
         *
         * @param map binding values made available to the template
         * @return a writable XML template
         */
        @Override
        public Writable make(Map map) {
            if (map == null) {
                throw new IllegalArgumentException("map must not be null");
            }
            return new XmlWritable(script, new Binding(map));
        }
    }

    private static class XmlWritable implements Writable {

        private final Binding binding;
        private final Script script;
        private WeakReference result;

        /**
         * Creates a writable XML renderer backed by the supplied script and binding.
         *
         * @param script generated Groovy script backing this writable
         * @param binding binding values visible during rendering
         */
        XmlWritable(Script script, Binding binding) {
            this.script = script;
            this.binding = binding;
            this.result = new WeakReference<>(null);
        }

        /**
         * Renders the template into the supplied writer.
         *
         * @param out target writer
         * @return the supplied writer
         */
        @Override
        public Writer writeTo(Writer out) {
            Script scriptObject = InvokerHelper.createScript(script.getClass(), binding);
            PrintWriter pw = new PrintWriter(out);
            scriptObject.setProperty("out", pw);
            scriptObject.run();
            pw.flush();
            return out;
        }

        /**
         * Renders the template to a string, reusing a cached value while it remains strongly reachable.
         *
         * @return rendered XML output
         */
        @Override
        public String toString() {
            Object o = result.get();
            if (o != null) {
                return o.toString();
            }
            String string = writeTo(new StringBuilderWriter(1024)).toString();
            result = new WeakReference<>(string);
            return string;
        }
    }

    /**
     * Default indentation sequence used for pretty-printed XML output.
     */
    public static final String DEFAULT_INDENTATION = "  ";

    private final GroovyShell groovyShell;
    private final XmlParser xmlParser;
    private String indentation;

    /**
     * Creates an XML template engine using the default indentation and with XML validation disabled.
     *
     * @throws SAXException if the underlying parser cannot be configured
     * @throws ParserConfigurationException if the XML parser cannot be created
     */
    public XmlTemplateEngine() throws SAXException, ParserConfigurationException {
        this(DEFAULT_INDENTATION, false);
    }

    /**
     * Creates an XML template engine with custom indentation and validation settings.
     *
     * @param indentation indentation string used when rendering nested XML nodes
     * @param validating {@code true} to enable XML validation while parsing templates
     * @throws SAXException if the underlying parser cannot be configured
     * @throws ParserConfigurationException if the XML parser cannot be created
     */
    public XmlTemplateEngine(String indentation, boolean validating) throws SAXException, ParserConfigurationException {
        this(new XmlParser(validating, true), new GroovyShell());
        this.xmlParser.setTrimWhitespace(true);
        setIndentation(indentation);
    }

    /**
     * Creates an XML template engine backed by the supplied parser and a {@link GroovyShell} built from the parent loader.
     *
     * @param xmlParser parser used to read template XML
     * @param parentLoader class loader used to compile generated template scripts
     */
    public XmlTemplateEngine(XmlParser xmlParser, ClassLoader parentLoader) {
        this(xmlParser, new GroovyShell(parentLoader));
    }

    /**
     * Creates an XML template engine backed by the supplied parser and shell.
     *
     * @param xmlParser parser used to read template XML
     * @param groovyShell shell used to compile generated template scripts
     */
    public XmlTemplateEngine(XmlParser xmlParser, GroovyShell groovyShell) {
        this.groovyShell = groovyShell;
        this.xmlParser = xmlParser;
        setIndentation(DEFAULT_INDENTATION);
    }

    /**
     * Parses XML template source from the supplied reader and compiles it into a template.
     *
     * @param reader XML template source
     * @return a compiled XML template
     * @throws CompilationFailedException if Groovy fails to compile the generated template script
     * @throws ClassNotFoundException if the generated template class cannot be instantiated
     * @throws IOException if reading the XML template fails
     */
    @Override
    public Template createTemplate(Reader reader) throws CompilationFailedException, ClassNotFoundException, IOException {
        Node root ;
        try {
            root = xmlParser.parse(reader);
        } catch (SAXException e) {
            throw new RuntimeException("Parsing XML source failed.", e);
        }

        if (root == null) {
            throw new IOException("Parsing XML source failed: root node is null.");
        }

        StringBuilderWriter writer = new StringBuilderWriter(1024);
        writer.write("/* Generated by XmlTemplateEngine */\n");
        GspPrinter printer = new GspPrinter(new PrintWriter(writer), indentation);
        if (configurePrinter != null) {
            configurePrinter.call(printer);
        }
        printer.print(root);

        Script script;
        try {
            script = groovyShell.parse(writer.toString(), "XmlTemplateScript" + counter.incrementAndGet() + ".groovy");
        } catch (Exception e) {
            throw new GroovyRuntimeException("Failed to parse template script (your template may contain an error or be trying to use expressions not currently supported): " + e.getMessage());
        }
        return new XmlTemplate(script);
    }

    /**
     * Returns the indentation string used for pretty-printed XML output.
     *
     * @return the indentation sequence, never {@code null}
     */
    public String getIndentation() {
        return indentation;
    }

    /**
     * Sets the indentation string used for pretty-printed XML output.
     *
     * @param indentation indentation sequence to use; {@code null} restores {@link #DEFAULT_INDENTATION}
     */
    public void setIndentation(String indentation) {
        if (indentation == null) {
            indentation = DEFAULT_INDENTATION;
        }
        this.indentation = indentation;
    }

    /**
     * Returns a concise engine name for diagnostics.
     *
     * @return the literal engine name
     */
    @Override
    public String toString() {
        return "XmlTemplateEngine";
    }

}
