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

import groovy.lang.Binding;
import groovy.xml.MarkupBuilder;
import org.codehaus.groovy.GroovyBugError;
import org.codehaus.groovy.runtime.MethodClosure;

import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.WriteListener;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.io.Writer;
import java.lang.reflect.Constructor;
import java.util.Enumeration;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;

/**
 * Servlet-specific binding extension to lazy load the writer or the output
 * stream from the response.
 * <p>
 * <h3>Eager variables</h3>
 * <ul>
 * <li><tt>"request"</tt> : the <code>HttpServletRequest</code> object</li>
 * <li><tt>"response"</tt> : the <code>HttpServletRequest</code> object</li>
 * <li><tt>"context"</tt> : the <code>ServletContext</code> object</li>
 * <li><tt>"application"</tt> : same as context</li>
 * <li><tt>"session"</tt> : shorthand for <code>request.getSession(<tt>false</tt>)</code> - can be null!</li>
 * <li><tt>"params"</tt> : map of all form parameters - can be empty</li>
 * <li><tt>"headers"</tt> : map of all <tt>request</tt> header fields</li>
 * </ul>
 * <p>
 * <h3>Lazy variables</h3>
 * <ul>
 * <li><tt>"out"</tt> : <code>response.getWriter()</code></li>
 * <li><tt>"sout"</tt> : <code>response.getOutputStream()</code></li>
 * <li><tt>"html"</tt> : <code>new MarkupBuilder(response.getWriter())</code> - <code>expandEmptyElements</code> flag is set to true</li>
 * <li><tt>"json"</tt> : <code>new JsonBuilder()</code></li>
 * </ul>
 * As per the Servlet specification, a call to <code>response.getWriter()</code> should not be
 * done if a call to <code>response.getOutputStream()</code> has already occurred or the other way
 * around. You may wonder then how the above lazy variables can possibly be provided - since
 * setting them up would involve calling both of the above methods. The trick is catered for
 * behind the scenes using lazy variables. Lazy bound variables can be requested without side
 * effects; under the covers the writer and stream are wrapped. That means
 * <code>response.getWriter()</code> is never directly called until some output is done using
 * 'out' or 'html'. Once a write method call is done using either of these variable, then an attempt
 * to write using 'sout' will cause an <code>IllegalStateException</code>. Similarly, if a write method
 * call on 'sout' has been done already, then any further write method call on 'out' or 'html' will cause an
 * <code>IllegalStateException</code>.
 * <p>
 * <h3>Reserved internal variable names (see "Methods" below)</h3>
 * <ul>
 * <li><tt>"forward"</tt></li>
 * <li><tt>"include"</tt></li>
 * <li><tt>"redirect"</tt></li>
 * </ul>
 *
 * If <code>response.getWriter()</code> is called directly (without using out), then a write method
 * call on 'sout' will not cause the <code>IllegalStateException</code>, but it will still be invalid.
 * It is the responsibility of the user of this class to not mix these different usage
 * styles. The same applies to calling <code>response.getOutputStream()</code> and using 'out' or 'html'.
 *
 * <h3>Methods</h3>
 * <ul>
 * <li><tt>"forward(String path)"</tt> : <code>request.getRequestDispatcher(path).forward(request, response)</code></li>
 * <li><tt>"include(String path)"</tt> : <code>request.getRequestDispatcher(path).include(request, response)</code></li>
 * <li><tt>"redirect(String location)"</tt> : <code>response.sendRedirect(location)</code></li>
 * </ul>
 */
public class ServletBinding extends Binding {

    /**
     * A OutputStream dummy that will throw a GroovyBugError for any
     * write method call to it.
     */
    private static final class InvalidOutputStream extends OutputStream {
        /**
         * Will always throw a GroovyBugError
         * @see java.io.OutputStream#write(int)
         */
        @Override
        public void write(int b) {
            throw new GroovyBugError("Any write calls to this stream are invalid!");
        }
    }
    /**
     * A class to manage the response output stream and writer.
     * If the stream have been 'used', then using the writer will cause
     * a IllegalStateException. If the writer have been 'used', then
     * using the stream will cause a IllegalStateException. 'used' means
     * any write method has been called. Simply requesting the objects will
     * not cause an exception.
     */
    private static class ServletOutput {
        private final HttpServletResponse response;
        private ServletOutputStream outputStream;
        private PrintWriter writer;

        ServletOutput(HttpServletResponse response) {
            this.response = response;
        }
        private ServletOutputStream getResponseStream() throws IOException {
            if (writer != null) throw new IllegalStateException("The variable 'out' or 'html' have been used already. Use either out/html or sout, not both.");
            if (outputStream == null) outputStream = response.getOutputStream();
            return outputStream;
        }
        public ServletOutputStream getOutputStream() {
            return new ServletOutputStream() {
                @Override
                public boolean isReady() {
                    try {
                        return getResponseStream().isReady();
                    } catch (IOException e) {
                        throw new RuntimeException(e);
                    }
                }

                @Override
                public void setWriteListener(WriteListener writeListener) {
                    try {
                        getResponseStream().setWriteListener(writeListener);
                    } catch (IOException e) {
                        throw new RuntimeException(e);
                    }
                }

                @Override
                public void write(int b) throws IOException {
                    getResponseStream().write(b);
                }
                @Override
                public void close() throws IOException {
                    getResponseStream().close();
                }
                @Override
                public void flush() throws IOException {
                    getResponseStream().flush();
                }
                @Override
                public void write(byte[] b) throws IOException {
                    getResponseStream().write(b);
                }
                @Override
                public void write(byte[] b, int off, int len) throws IOException {
                    getResponseStream().write(b, off, len);
                }
            };
        }
        private PrintWriter getResponseWriter() {
            if (outputStream != null) throw new IllegalStateException("The variable 'sout' have been used already. Use either out/html or sout, not both.");
            if (writer == null) {
                try {
                    writer = response.getWriter();
                } catch (IOException ioe) {
                    writer = new PrintWriter(new ByteArrayOutputStream());
                    throw new IllegalStateException("unable to get response writer",ioe);
                }
            }
            return writer;
        }
        public PrintWriter getWriter() {
            return new PrintWriter(new InvalidOutputStream()) {
                @Override
                public boolean checkError() {
                    return getResponseWriter().checkError();
                }
                @Override
                public void close() {
                    getResponseWriter().close();
                }
                @Override
                public void flush() {
                    getResponseWriter().flush();
                }
                @Override
                public void write(char[] buf) {
                    getResponseWriter().write(buf);
                }
                @Override
                public void write(char[] buf, int off, int len) {
                    getResponseWriter().write(buf, off, len);
                }
                @Override
                public void write(int c) {
                    getResponseWriter().write(c);
                }
                @Override
                public void write(String s, int off, int len) {
                    getResponseWriter().write(s, off, len);
                }
                @Override
                public void println() {
                    getResponseWriter().println();
                }
                @Override
                public PrintWriter format(String format, Object... args) {
                    getResponseWriter().format(format, args);
                    return this;
                }
                @Override
                public PrintWriter format(Locale l, String format, Object... args) {
                    getResponseWriter().format(l, format, args);
                    return this;
                }
            };
        }
    }

    private boolean initialized;

    /**
     * Initializes a servlet binding.
     *
     * @param request  the HttpServletRequest object
     * @param response the HttpServletRequest object
     * @param context  the ServletContext object
     */
    public ServletBinding(HttpServletRequest request, HttpServletResponse response, ServletContext context) {
        /*
         * Bind the default variables.
         */
        super.setVariable("request", request);
        super.setVariable("response", response);
        super.setVariable("context", context);
        super.setVariable("application", context);

        /*
         * Bind the HTTP session object - if there is one.
         * Note: we don't create one here!
         */
        super.setVariable("session", request.getSession(false));

        /*
         * Bind form parameter key-value hash map.
         *
         * If there are multiple, they are passed as an array.
         */
        Map params = collectParams(request);
        super.setVariable("params", params);

        /*
         * Bind request header key-value hash map.
         */
        Map<String, String> headers = new LinkedHashMap<String, String>();
        for (Enumeration names = request.getHeaderNames(); names.hasMoreElements();) {
            String headerName = (String) names.nextElement();
            String headerValue = request.getHeader(headerName);
            headers.put(headerName, headerValue);
        }
        super.setVariable("headers", headers);
    }

    @SuppressWarnings("unchecked")
    private Map collectParams(HttpServletRequest request) {
        Map params = new LinkedHashMap();
        for (Enumeration names = request.getParameterNames(); names.hasMoreElements();) {
            String name = (String) names.nextElement();
            if (!super.getVariables().containsKey(name)) {
                String[] values = request.getParameterValues(name);
                if (values.length == 1) {
                    params.put(name, values[0]);
                } else {
                    params.put(name, values);
                }
            }
        }
        return params;
    }

    /**
     * Binds a servlet variable while protecting the reserved lazy helper names.
     *
     * @param name the variable name to bind
     * @param value the value to expose through the binding
     */
    @Override
    public void setVariable(String name, Object value) {
        lazyInit();
        validateArgs(name, "Can't bind variable to");
        excludeReservedName(name, "out");
        excludeReservedName(name, "sout");
        excludeReservedName(name, "html");
		excludeReservedName(name, "json");
        excludeReservedName(name, "forward");
        excludeReservedName(name, "include");
        excludeReservedName(name, "redirect");
        super.setVariable(name, value);
    }

    /**
     * Returns the current binding variables after initializing the lazy servlet helpers.
     *
     * @return the binding variables map
     */
    @Override
    public Map getVariables() {
        lazyInit();
        return super.getVariables();
    }

    /**
     * @return a writer, an output stream, a markup builder or another requested object
     */
    @Override
    public Object getVariable(String name) {
        lazyInit();
        validateArgs(name, "No variable with");
        return super.getVariable(name);
    }

    private void lazyInit() {
        if (initialized) return;
        initialized = true;

        HttpServletResponse response = (HttpServletResponse) super.getVariable("response");
        ServletOutput output = new ServletOutput(response);
        super.setVariable("out", output.getWriter());
        super.setVariable("sout", output.getOutputStream());

        MarkupBuilder builder = new MarkupBuilder(output.getWriter());
        builder.setExpandEmptyElements(true);
        super.setVariable("html", builder);

        try {
            // load using reflection to avoid needing a hard requirement on groovy-json for those not needing JSON support
            Class jsonBuilderClass = this.getClass().getClassLoader().loadClass("groovy.json.StreamingJsonBuilder");
            Constructor writerConstructor = getWriterConstructor(jsonBuilderClass);
            super.setVariable("json", writerConstructor.newInstance(output.getWriter()));
        } catch (Throwable t) {
            t.printStackTrace();
        }

        // bind forward method
        MethodClosure c = new MethodClosure(this, "forward");
        super.setVariable("forward", c);

        // bind include method
        c = new MethodClosure(this, "include");
        super.setVariable("include", c);

        // bind redirect method
        c = new MethodClosure(this, "redirect");
        super.setVariable("redirect", c);
    }

    @SuppressWarnings("unchecked")
    private Constructor getWriterConstructor(Class jsonBuilderClass) throws NoSuchMethodException {
        return jsonBuilderClass.getConstructor(Writer.class);
    }

    private static void validateArgs(String name, String message) {
        if (name == null) {
            throw new IllegalArgumentException(message + " null key.");
        }
        if (name.isEmpty()) {
            throw new IllegalArgumentException(message + " blank key name. [length=0]");
        }
    }

    private static void excludeReservedName(String name, String reservedName) {
        if (reservedName.equals(name)) {
            throw new IllegalArgumentException("Can't bind variable to key named '" + name + "'.");
        }
    }

    /**
     * Forwards the current request to another resource using the same request and response.
     *
     * @param path the dispatcher path to forward to
     * @throws ServletException if the target resource fails
     * @throws IOException if request dispatching fails
     */
    public void forward(String path) throws ServletException, IOException {
        HttpServletRequest request = (HttpServletRequest) super.getVariable("request");
        HttpServletResponse response = (HttpServletResponse) super.getVariable("response");
        RequestDispatcher dispatcher = request.getRequestDispatcher(path);
        dispatcher.forward(request, response);
    }

    /**
     * Includes another resource in the current response.
     *
     * @param path the dispatcher path to include
     * @throws ServletException if the target resource fails
     * @throws IOException if request dispatching fails
     */
    public void include(String path) throws ServletException, IOException {
        HttpServletRequest request = (HttpServletRequest) super.getVariable("request");
        HttpServletResponse response = (HttpServletResponse) super.getVariable("response");
        RequestDispatcher dispatcher = request.getRequestDispatcher(path);
        dispatcher.include(request, response);
    }

    /**
     * Sends a redirect response to the supplied location.
     *
     * @param location the redirect target
     * @throws IOException if the redirect cannot be sent
     */
    public void redirect(String location) throws IOException {
        HttpServletResponse response = (HttpServletResponse) super.getVariable("response");
        response.sendRedirect(location);
    }
}
