/*
 * 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.jasper.compiler;

import java.io.ByteArrayInputStream;
import java.io.CharArrayWriter;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;

import javax.servlet.jsp.tagext.PageData;

import org.apache.jasper.JasperException;
import org.apache.tomcat.util.security.Escape;
import org.xml.sax.Attributes;
import org.xml.sax.helpers.AttributesImpl;

/**
 * An implementation of <code>javax.servlet.jsp.tagext.PageData</code> which
 * builds the XML view of a given page.
 *
 * The XML view is built in two passes:
 *
 * During the first pass, the FirstPassVisitor collects the attributes of the
 * top-level jsp:root and those of the jsp:root elements of any included
 * pages, and adds them to the jsp:root element of the XML view.
 * In addition, any taglib directives are converted into xmlns: attributes and
 * added to the jsp:root element of the XML view.
 * This pass ignores any nodes other than JspRoot and TaglibDirective.
 *
 * During the second pass, the SecondPassVisitor produces the XML view, using
 * the combined jsp:root attributes determined in the first pass and any
 * remaining pages nodes (this pass ignores any JspRoot and TaglibDirective
 * nodes).
 *
 * @author Jan Luehe
 */
class PageDataImpl extends PageData implements TagConstants {

    private static final String JSP_VERSION = "2.0";
    private static final String CDATA_START_SECTION = "<![CDATA[\n";
    private static final String CDATA_END_SECTION = "]]>\n";

    // string buffer used to build XML view
    private final StringBuilder buf;

    /**
     * @param page the page nodes from which to generate the XML view
     * @param compiler The compiler for this page
     *
     * @throws JasperException If an error occurs
     */
    PageDataImpl(Node.Nodes page, Compiler compiler)
                throws JasperException {

        // First pass
        FirstPassVisitor firstPass = new FirstPassVisitor(page.getRoot(),
                                                          compiler.getPageInfo());
        page.visit(firstPass);

        // Second pass
        buf = new StringBuilder();
        SecondPassVisitor secondPass
            = new SecondPassVisitor(page.getRoot(), buf, compiler,
                                    firstPass.getJspIdPrefix());
        page.visit(secondPass);
    }

    /**
     * Returns the input stream of the XML view.
     *
     * @return the input stream of the XML view
     */
    @Override
    public InputStream getInputStream() {
        return new ByteArrayInputStream(
                buf.toString().getBytes(StandardCharsets.UTF_8));
    }

    /*
     * First-pass Visitor for JspRoot nodes (representing jsp:root elements)
     * and TablibDirective nodes, ignoring any other nodes.
     *
     * The purpose of this Visitor is to collect the attributes of the
     * top-level jsp:root and those of the jsp:root elements of any included
     * pages, and add them to the jsp:root element of the XML view.
     * In addition, this Visitor converts any taglib directives into xmlns:
     * attributes and adds them to the jsp:root element of the XML view.
     */
    private static class FirstPassVisitor
                extends Node.Visitor implements TagConstants {

        private final Node.Root root;
        private final AttributesImpl rootAttrs;
        private final PageInfo pageInfo;

        // Prefix for the 'id' attribute
        private String jspIdPrefix;

        /*
         * Constructor
         */
        FirstPassVisitor(Node.Root root, PageInfo pageInfo) {
            this.root = root;
            this.pageInfo = pageInfo;
            this.rootAttrs = new AttributesImpl();
            this.rootAttrs.addAttribute("", "", "version", "CDATA",
                                        JSP_VERSION);
            this.jspIdPrefix = "jsp";
        }

        @Override
        public void visit(Node.Root n) throws JasperException {
            visitBody(n);
            if (n == root) {
                /*
                 * Top-level page.
                 *
                 * Add
                 *   xmlns:jsp="http://java.sun.com/JSP/Page"
                 * attribute only if not already present.
                 */
                if (!JSP_URI.equals(rootAttrs.getValue("xmlns:jsp"))) {
                    rootAttrs.addAttribute("", "", "xmlns:jsp", "CDATA",
                                           JSP_URI);
                }

                if (pageInfo.isJspPrefixHijacked()) {
                    /*
                     * 'jsp' prefix has been hijacked, that is, bound to a
                     * namespace other than the JSP namespace. This means that
                     * when adding an 'id' attribute to each element, we can't
                     * use the 'jsp' prefix. Therefore, create a new prefix
                     * (one that is unique across the translation unit) for use
                     * by the 'id' attribute, and bind it to the JSP namespace
                     */
                    jspIdPrefix += "jsp";
                    while (pageInfo.containsPrefix(jspIdPrefix)) {
                        jspIdPrefix += "jsp";
                    }
                    rootAttrs.addAttribute("", "", "xmlns:" + jspIdPrefix,
                                           "CDATA", JSP_URI);
                }

                root.setAttributes(rootAttrs);
            }
        }

        @Override
        public void visit(Node.JspRoot n) throws JasperException {
            addAttributes(n.getTaglibAttributes());
            addAttributes(n.getNonTaglibXmlnsAttributes());
            addAttributes(n.getAttributes());

            visitBody(n);
        }

        /*
         * Converts taglib directive into "xmlns:..." attribute of jsp:root
         * element.
         */
        @Override
        public void visit(Node.TaglibDirective n) throws JasperException {
            Attributes attrs = n.getAttributes();
            if (attrs != null) {
                String qName = "xmlns:" + attrs.getValue("prefix");
                /*
                 * According to javadocs of org.xml.sax.helpers.AttributesImpl,
                 * the addAttribute method does not check to see if the
                 * specified attribute is already contained in the list: This
                 * is the application's responsibility!
                 */
                if (rootAttrs.getIndex(qName) == -1) {
                    String location = attrs.getValue("uri");
                    if (location != null) {
                        if (location.startsWith("/")) {
                            location = URN_JSPTLD + location;
                        }
                        rootAttrs.addAttribute("", "", qName, "CDATA",
                                               location);
                    } else {
                        location = attrs.getValue("tagdir");
                        rootAttrs.addAttribute("", "", qName, "CDATA",
                                               URN_JSPTAGDIR + location);
                    }
                }
            }
        }

        public String getJspIdPrefix() {
            return jspIdPrefix;
        }

        private void addAttributes(Attributes attrs) {
            if (attrs != null) {
                int len = attrs.getLength();

                for (int i=0; i<len; i++) {
                    String qName = attrs.getQName(i);
                    if ("version".equals(qName)) {
                        continue;
                    }

                    // Bugzilla 35252: https://bz.apache.org/bugzilla/show_bug.cgi?id=35252
                    if(rootAttrs.getIndex(qName) == -1) {
                        rootAttrs.addAttribute(attrs.getURI(i),
                                               attrs.getLocalName(i),
                                               qName,
                                               attrs.getType(i),
                                               attrs.getValue(i));
                    }
                }
            }
        }
    }


    /*
     * Second-pass Visitor responsible for producing XML view and assigning
     * each element a unique jsp:id attribute.
     */
    private static class SecondPassVisitor extends Node.Visitor
                implements TagConstants {

        private final Node.Root root;
        private final StringBuilder buf;
        private final Compiler compiler;
        private final String jspIdPrefix;
        private boolean resetDefaultNS = false;

        // Current value of jsp:id attribute
        private int jspId;

        /*
         * Constructor
         */
        SecondPassVisitor(Node.Root root, StringBuilder buf,
                                 Compiler compiler, String jspIdPrefix) {
            this.root = root;
            this.buf = buf;
            this.compiler = compiler;
            this.jspIdPrefix = jspIdPrefix;
        }

        /*
         * Visits root node.
         */
        @Override
        public void visit(Node.Root n) throws JasperException {
            if (n == this.root) {
                // top-level page
                appendXmlProlog();
                appendTag(n);
            } else {
                boolean resetDefaultNSSave = resetDefaultNS;
                if (n.isXmlSyntax()) {
                    resetDefaultNS = true;
                }
                visitBody(n);
                resetDefaultNS = resetDefaultNSSave;
            }
        }

        /*
         * Visits jsp:root element of JSP page in XML syntax.
         *
         * Any nested jsp:root elements (from pages included via an
         * include directive) are ignored.
         */
        @Override
        public void visit(Node.JspRoot n) throws JasperException {
            visitBody(n);
        }

        @Override
        public void visit(Node.PageDirective n) throws JasperException {
            appendPageDirective(n);
        }

        @Override
        public void visit(Node.IncludeDirective n) throws JasperException {
            // expand in place
            visitBody(n);
        }

        @Override
        public void visit(Node.Comment n) throws JasperException {
            // Comments are ignored in XML view
        }

        @Override
        public void visit(Node.Declaration n) throws JasperException {
            appendTag(n);
        }

        @Override
        public void visit(Node.Expression n) throws JasperException {
            appendTag(n);
        }

        @Override
        public void visit(Node.Scriptlet n) throws JasperException {
            appendTag(n);
        }

        @Override
        public void visit(Node.JspElement n) throws JasperException {
            appendTag(n);
        }

        @Override
        public void visit(Node.ELExpression n) throws JasperException {
            if (!n.getRoot().isXmlSyntax()) {
                buf.append('<').append(JSP_TEXT_ACTION);
                buf.append(' ');
                buf.append(jspIdPrefix);
                buf.append(":id=\"");
                buf.append(jspId++).append("\">");
            }
            buf.append("${");
            buf.append(Escape.xml(n.getText()));
            buf.append('}');
            if (!n.getRoot().isXmlSyntax()) {
                buf.append(JSP_TEXT_ACTION_END);
            }
            buf.append("\n");
        }

        @Override
        public void visit(Node.IncludeAction n) throws JasperException {
            appendTag(n);
        }

        @Override
        public void visit(Node.ForwardAction n) throws JasperException {
            appendTag(n);
        }

        @Override
        public void visit(Node.GetProperty n) throws JasperException {
            appendTag(n);
        }

        @Override
        public void visit(Node.SetProperty n) throws JasperException {
            appendTag(n);
        }

        @Override
        public void visit(Node.ParamAction n) throws JasperException {
            appendTag(n);
        }

        @Override
        public void visit(Node.ParamsAction n) throws JasperException {
            appendTag(n);
        }

        @Override
        public void visit(Node.FallBackAction n) throws JasperException {
            appendTag(n);
        }

        @Override
        public void visit(Node.UseBean n) throws JasperException {
            appendTag(n);
        }

        @Override
        public void visit(Node.PlugIn n) throws JasperException {
            appendTag(n);
        }

        @Override
        public void visit(Node.NamedAttribute n) throws JasperException {
            appendTag(n);
        }

        @Override
        public void visit(Node.JspBody n) throws JasperException {
            appendTag(n);
        }

        @Override
        public void visit(Node.CustomTag n) throws JasperException {
            boolean resetDefaultNSSave = resetDefaultNS;
            appendTag(n, resetDefaultNS);
            resetDefaultNS = resetDefaultNSSave;
        }

        @Override
        public void visit(Node.UninterpretedTag n) throws JasperException {
            boolean resetDefaultNSSave = resetDefaultNS;
            appendTag(n, resetDefaultNS);
            resetDefaultNS = resetDefaultNSSave;
        }

        @Override
        public void visit(Node.JspText n) throws JasperException {
            appendTag(n);
        }

        @Override
        public void visit(Node.DoBodyAction n) throws JasperException {
            appendTag(n);
        }

        @Override
        public void visit(Node.InvokeAction n) throws JasperException {
            appendTag(n);
        }

        @Override
        public void visit(Node.TagDirective n) throws JasperException {
            appendTagDirective(n);
        }

        @Override
        public void visit(Node.AttributeDirective n) throws JasperException {
            appendTag(n);
        }

        @Override
        public void visit(Node.VariableDirective n) throws JasperException {
            appendTag(n);
        }

        @Override
        public void visit(Node.TemplateText n) throws JasperException {
            /*
             * If the template text came from a JSP page written in JSP syntax,
             * create a jsp:text element for it (JSP 5.3.2).
             */
            appendText(n.getText(), !n.getRoot().isXmlSyntax());
        }

        /*
         * Appends the given tag, including its body, to the XML view.
         */
        private void appendTag(Node n) throws JasperException {
            appendTag(n, false);
        }

        /*
         * Appends the given tag, including its body, to the XML view,
         * and optionally reset default namespace to "", if none specified.
         */
        private void appendTag(Node n, boolean addDefaultNS)
                throws JasperException {

            Node.Nodes body = n.getBody();
            String text = n.getText();

            buf.append('<').append(n.getQName());
            buf.append("\n");

            printAttributes(n, addDefaultNS);
            buf.append("  ").append(jspIdPrefix).append(":id").append("=\"");
            buf.append(jspId++).append("\"\n");

            if (ROOT_ACTION.equals(n.getLocalName()) || body != null
                        || text != null) {
                buf.append(">\n");
                if (ROOT_ACTION.equals(n.getLocalName())) {
                    if (compiler.getCompilationContext().isTagFile()) {
                        appendTagDirective();
                    } else {
                        appendPageDirective();
                    }
                }
                if (body != null) {
                    body.visit(this);
                } else {
                    appendText(text, false);
                }
                buf.append("</" + n.getQName() + ">\n");
            } else {
                buf.append("/>\n");
            }
        }

        /*
         * Appends the page directive with the given attributes to the XML
         * view.
         *
         * Since the import attribute of the page directive is the only page
         * attribute that is allowed to appear multiple times within the same
         * document, and since XML allows only single-value attributes,
         * the values of multiple import attributes must be combined into one,
         * separated by comma.
         *
         * If the given page directive contains just 'contentType' and/or
         * 'pageEncoding' attributes, we ignore it, as we've already appended
         * a page directive containing just these two attributes.
         */
        private void appendPageDirective(Node.PageDirective n) {
            boolean append = false;
            Attributes attrs = n.getAttributes();
            int len = (attrs == null) ? 0 : attrs.getLength();
            for (int i=0; i<len; i++) {
                @SuppressWarnings("null")  // If attrs==null, len == 0
                String attrName = attrs.getQName(i);
                if (!"pageEncoding".equals(attrName)
                        && !"contentType".equals(attrName)) {
                    append = true;
                    break;
                }
            }
            if (!append) {
                return;
            }

            buf.append('<').append(n.getQName());
            buf.append("\n");

            // append jsp:id
            buf.append("  ").append(jspIdPrefix).append(":id").append("=\"");
            buf.append(jspId++).append("\"\n");

            // append remaining attributes
            for (int i=0; i<len; i++) {
                @SuppressWarnings("null")  // If attrs==null, len == 0
                String attrName = attrs.getQName(i);
                if ("import".equals(attrName) || "contentType".equals(attrName)
                        || "pageEncoding".equals(attrName)) {
                    /*
                     * Page directive's 'import' attribute is considered
                     * further down, and its 'pageEncoding' and 'contentType'
                     * attributes are ignored, since we've already appended
                     * a new page directive containing just these two
                     * attributes
                     */
                    continue;
                }
                String value = attrs.getValue(i);
                buf.append("  ").append(attrName).append("=\"");
                buf.append(JspUtil.getExprInXml(value)).append("\"\n");
            }
            if (n.getImports().size() > 0) {
                // Concatenate names of imported classes/packages
                boolean first = true;
                for (String i : n.getImports()) {
                    if (first) {
                        first = false;
                        buf.append("  import=\"");
                    } else {
                        buf.append(',');
                    }
                    buf.append(JspUtil.getExprInXml(i));
                }
                buf.append("\"\n");
            }
            buf.append("/>\n");
        }

        /*
         * Appends a page directive with 'pageEncoding' and 'contentType'
         * attributes.
         *
         * The value of the 'pageEncoding' attribute is hard-coded
         * to UTF-8, whereas the value of the 'contentType' attribute, which
         * is identical to what the container will pass to
         * ServletResponse.setContentType(), is derived from the pageInfo.
         */
        private void appendPageDirective() {
            buf.append('<').append(JSP_PAGE_DIRECTIVE_ACTION);
            buf.append("\n");

            // append jsp:id
            buf.append("  ").append(jspIdPrefix).append(":id").append("=\"");
            buf.append(jspId++).append("\"\n");
            buf.append("  ").append("pageEncoding").append("=\"UTF-8\"\n");
            buf.append("  ").append("contentType").append("=\"");
            buf.append(compiler.getPageInfo().getContentType()).append("\"\n");
            buf.append("/>\n");
        }

        /*
         * Appends the tag directive with the given attributes to the XML
         * view.
         *
         * If the given tag directive contains just a 'pageEncoding'
         * attributes, we ignore it, as we've already appended
         * a tag directive containing just this attributes.
         */
        private void appendTagDirective(Node.TagDirective n)
                throws JasperException {

            boolean append = false;
            Attributes attrs = n.getAttributes();
            int len = (attrs == null) ? 0 : attrs.getLength();
            for (int i=0; i<len; i++) {
                @SuppressWarnings("null")  // If attrs==null, len == 0
                String attrName = attrs.getQName(i);
                if (!"pageEncoding".equals(attrName)) {
                    append = true;
                    break;
                }
            }
            if (!append) {
                return;
            }

            appendTag(n);
        }

        /*
         * Appends a tag directive containing a single 'pageEncoding'
         * attribute whose value is hard-coded to UTF-8.
         */
        private void appendTagDirective() {
            buf.append('<').append(JSP_TAG_DIRECTIVE_ACTION);
            buf.append("\n");

            // append jsp:id
            buf.append("  ").append(jspIdPrefix).append(":id").append("=\"");
            buf.append(jspId++).append("\"\n");
            buf.append("  ").append("pageEncoding").append("=\"UTF-8\"\n");
            buf.append("/>\n");
        }

        private void appendText(String text, boolean createJspTextElement) {
            if (createJspTextElement) {
                buf.append('<').append(JSP_TEXT_ACTION);
                buf.append("\n");

                // append jsp:id
                buf.append("  ").append(jspIdPrefix).append(":id").append("=\"");
                buf.append(jspId++).append("\"\n");
                buf.append(">\n");

                appendCDATA(text);
                buf.append(JSP_TEXT_ACTION_END);
                buf.append("\n");
            } else {
                appendCDATA(text);
            }
        }

        /*
         * Appends the given text as a CDATA section to the XML view, unless
         * the text has already been marked as CDATA.
         */
        private void appendCDATA(String text) {
            buf.append(CDATA_START_SECTION);
            buf.append(escapeCDATA(text));
            buf.append(CDATA_END_SECTION);
        }

        /*
         * Escapes any occurrences of "]]>" (by replacing them with "]]&gt;")
         * within the given text, so it can be included in a CDATA section.
         */
        private String escapeCDATA(String text) {
            if( text==null ) {
                return "";
            }
            int len = text.length();
            CharArrayWriter result = new CharArrayWriter(len);
            for (int i=0; i<len; i++) {
                if (((i+2) < len)
                        && (text.charAt(i) == ']')
                        && (text.charAt(i+1) == ']')
                        && (text.charAt(i+2) == '>')) {
                    // match found
                    result.write(']');
                    result.write(']');
                    result.write('&');
                    result.write('g');
                    result.write('t');
                    result.write(';');
                    i += 2;
                } else {
                    result.write(text.charAt(i));
                }
            }
            return result.toString();
        }

        /*
         * Appends the attributes of the given Node to the XML view.
         */
        private void printAttributes(Node n, boolean addDefaultNS) {

            /*
             * Append "xmlns" attributes that represent tag libraries
             */
            Attributes attrs = n.getTaglibAttributes();
            int len = (attrs == null) ? 0 : attrs.getLength();
            for (int i=0; i<len; i++) {
                @SuppressWarnings("null")  // If attrs==null, len == 0
                String name = attrs.getQName(i);
                String value = attrs.getValue(i);
                buf.append("  ").append(name).append("=\"").append(value).append("\"\n");
            }

            /*
             * Append "xmlns" attributes that do not represent tag libraries
             */
            attrs = n.getNonTaglibXmlnsAttributes();
            len = (attrs == null) ? 0 : attrs.getLength();
            boolean defaultNSSeen = false;
            for (int i=0; i<len; i++) {
                @SuppressWarnings("null")  // If attrs==null, len == 0
                String name = attrs.getQName(i);
                String value = attrs.getValue(i);
                buf.append("  ").append(name).append("=\"").append(value).append("\"\n");
                defaultNSSeen |= "xmlns".equals(name);
            }
            if (addDefaultNS && !defaultNSSeen) {
                buf.append("  xmlns=\"\"\n");
            }
            resetDefaultNS = false;

            /*
             * Append all other attributes
             */
            attrs = n.getAttributes();
            len = (attrs == null) ? 0 : attrs.getLength();
            for (int i=0; i<len; i++) {
                @SuppressWarnings("null")  // If attrs==null, len == 0
                String name = attrs.getQName(i);
                String value = attrs.getValue(i);
                buf.append("  ").append(name).append("=\"");
                buf.append(JspUtil.getExprInXml(value)).append("\"\n");
            }
        }

        /*
         * Appends XML prolog with encoding declaration.
         */
        private void appendXmlProlog() {
            buf.append("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n");
        }
    }
}

