// Licensed 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.tapestry5.test;

import org.apache.tapestry5.dom.Document;
import org.apache.tapestry5.dom.Element;
import org.apache.tapestry5.dom.Visitor;
import org.apache.tapestry5.http.Link;
import org.apache.tapestry5.http.internal.SingleKeySymbolProvider;
import org.apache.tapestry5.http.internal.TapestryAppInitializer;
import org.apache.tapestry5.http.internal.TapestryHttpInternalConstants;
import org.apache.tapestry5.http.services.ApplicationGlobals;
import org.apache.tapestry5.http.services.RequestHandler;
import org.apache.tapestry5.internal.test.PageTesterContext;
import org.apache.tapestry5.internal.test.PageTesterModule;
import org.apache.tapestry5.internal.test.TestableRequest;
import org.apache.tapestry5.internal.test.TestableResponse;
import org.apache.tapestry5.ioc.Registry;
import org.apache.tapestry5.ioc.def.ModuleDef;
import org.apache.tapestry5.ioc.internal.util.InternalUtils;
import org.apache.tapestry5.ioc.services.SymbolProvider;
import org.apache.tapestry5.modules.TapestryModule;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.util.Locale;
import java.util.Map;

/**
 * This class is used to run a Tapestry app in a single-threaded, in-process testing environment.
 * You can ask it to render a certain page and check the DOM object created. You can also ask it to click on a link
 * element in the DOM object to get the next page. Because no servlet container is required, it is very fast and you
 * can directly debug into your code in your IDE.
 *
 * When using the PageTester in your tests, you should add the {@code org.apache.tapestry:tapestry-test-constants}
 * module as a dependency.
 */
@SuppressWarnings("all")
public class PageTester
{

    private final Registry registry;

    private final TestableRequest request;

    private final TestableResponse response;

    private final RequestHandler requestHandler;

    public static final String DEFAULT_CONTEXT_PATH = "src/main/webapp";

    private static final String DEFAULT_SUBMIT_VALUE_ATTRIBUTE = "Submit Query";

    /**
     * Initializes a PageTester without overriding any services and assuming that the context root
     * is in
     * src/main/webapp.
     *
     * @see #PageTester(String, String, String, Class[])
     */
    public PageTester(String appPackage, String appName)
    {
        this(appPackage, appName, DEFAULT_CONTEXT_PATH);
    }

    /**
     * Initializes a PageTester that acts as a browser and a servlet container to test drive your
     * Tapestry pages.
     *
     * @param appPackage
     *         The same value you would specify using the tapestry.app-package context parameter.
     *         As this
     *         testing environment is not run in a servlet container, you need to specify it.
     * @param appName
     *         The same value you would specify as the filter name. It is used to form the name
     *         of the
     *         module class for your app. If you don't have one, pass an empty string.
     * @param contextPath
     *         The path to the context root so that Tapestry can find the templates (if they're
     *         put
     *         there).
     * @param moduleClasses
     *         Classes of additional modules to load
     */
    public PageTester(String appPackage, String appName, String contextPath, Class... moduleClasses)
    {
        assert InternalUtils.isNonBlank(appPackage);
        assert appName != null;
        assert InternalUtils.isNonBlank(contextPath);

        SymbolProvider provider = new SingleKeySymbolProvider(TapestryHttpInternalConstants.TAPESTRY_APP_PACKAGE_PARAM, appPackage);

        TapestryAppInitializer initializer = new TapestryAppInitializer(LoggerFactory.getLogger(PageTester.class), provider, appName,
                null);

        initializer.addModules(TapestryModule.class);
        initializer.addModules(PageTesterModule.class);
        initializer.addModules(moduleClasses);
        initializer.addModules(provideExtraModuleDefs());

        registry = initializer.createRegistry();

        request = registry.getService(TestableRequest.class);
        response = registry.getService(TestableResponse.class);

        ApplicationGlobals globals = registry.getObject(ApplicationGlobals.class, null);

        globals.storeContext(new PageTesterContext(contextPath));

        registry.performRegistryStartup();

        requestHandler = registry.getService("RequestHandler", RequestHandler.class);

        request.setLocale(Locale.ENGLISH);
        initializer.announceStartup();
    }

    /**
     * Overridden in subclasses to provide additional module definitions beyond those normally
     * located. This
     * implementation returns an empty array.
     */
    protected ModuleDef[] provideExtraModuleDefs()
    {
        return new ModuleDef[0];
    }

    /**
     * Invoke this method when done using the PageTester; it shuts down the internal
     * {@link org.apache.tapestry5.ioc.Registry} used by the tester.
     */
    public void shutdown()
    {
        registry.cleanupThread();

        registry.shutdown();
    }

    /**
     * Returns the Registry that was created for the application.
     */
    public Registry getRegistry()
    {
        return registry;
    }

    /**
     * Allows a service to be retrieved via its service interface. Use {@link #getRegistry()} for
     * more complicated
     * queries.
     *
     * @param serviceInterface
     *         used to select the service
     */
    public <T> T getService(Class<T> serviceInterface)
    {
        return registry.getService(serviceInterface);
    }

    /**
     * Renders a page specified by its name.
     *
     * @param pageName
     *         The name of the page to be rendered.
     * @return The DOM created. Typically you will assert against it.
     */
    public Document renderPage(String pageName)
    {

        renderPageAndReturnResponse(pageName);

        Document result = response.getRenderedDocument();

        if (result == null)
            throw new RuntimeException(String.format("Render of page '%s' did not result in a Document.",
                    pageName));

        return result;

    }

    /**
     * Renders a page specified by its name and returns the response.
     *
     * @param pageName
     *         The name of the page to be rendered.
     * @return The response object to assert against
     * @since 5.2.3
     */
    public TestableResponse renderPageAndReturnResponse(String pageName)
    {
        request.clear().setPath("/" + pageName);

        while (true)
        {
            try
            {
                response.clear();

                boolean handled = requestHandler.service(request, response);

                if (!handled)
                {
                    throw new RuntimeException(String.format(
                            "Request was not handled: '%s' may not be a valid page name.", pageName));
                }

                Link link = response.getRedirectLink();

                if (link != null)
                {
                    setupRequestFromLink(link);
                    continue;
                }

                return response;

            } catch (IOException ex)
            {
                throw new RuntimeException(ex);
            } finally
            {
                registry.cleanupThread();
            }
        }

    }

    /**
     * Simulates a click on a link.
     *
     * @param linkElement
     *         The Link object to be "clicked" on.
     * @return The DOM created. Typically you will assert against it.
     */
    public Document clickLink(Element linkElement)
    {
        clickLinkAndReturnResponse(linkElement);

        return getDocumentFromResponse();
    }

    /**
     * Simulates a click on a link.
     *
     * @param linkElement
     *         The Link object to be "clicked" on.
     * @return The response object to assert against
     * @since 5.2.3
     */
    public TestableResponse clickLinkAndReturnResponse(Element linkElement)
    {
        assert linkElement != null;

        validateElementName(linkElement, "a");

        String href = extractNonBlank(linkElement, "href");

        setupRequestFromURI(href);

        return runComponentEventRequest();
    }

    private String extractNonBlank(Element element, String attributeName)
    {
        String result = element.getAttribute(attributeName);

        if (InternalUtils.isBlank(result))
            throw new RuntimeException(String.format("The %s attribute of the <%s> element was blank or missing.",
                    attributeName, element.getName()));

        return result;
    }

    private void validateElementName(Element element, String expectedElementName)
    {
        if (!element.getName().equalsIgnoreCase(expectedElementName))
            throw new RuntimeException(String.format("The element must be type '%s', not '%s'.", expectedElementName,
                    element.getName()));
    }

    private Document getDocumentFromResponse()
    {
        Document result = response.getRenderedDocument();

        if (result == null)
            throw new RuntimeException(String.format("Render request '%s' did not result in a Document.", request.getPath()));

        return result;
    }

    private TestableResponse runComponentEventRequest()
    {
        while (true)
        {
            response.clear();

            try
            {
                boolean handled = requestHandler.service(request, response);

                if (!handled)
                    throw new RuntimeException(String.format("Request for path '%s' was not handled by Tapestry.",
                            request.getPath()));

                Link link = response.getRedirectLink();

                if (link != null)
                {
                    setupRequestFromLink(link);
                    continue;
                }

                return response;
            } catch (IOException ex)
            {
                throw new RuntimeException(ex);
            } finally
            {
                registry.cleanupThread();
            }
        }

    }

    private void setupRequestFromLink(Link link)
    {
        setupRequestFromURI(link.toRedirectURI());
    }

    public void setupRequestFromURI(String URI)
    {
        String linkPath = stripContextFromPath(URI);

        int comma = linkPath.indexOf('?');

        String path = comma < 0 ? linkPath : linkPath.substring(0, comma);

        request.clear().setPath(path);

        if (comma > 0)
            decodeParametersIntoRequest(linkPath.substring(comma + 1));
    }

    private void decodeParametersIntoRequest(String queryString)
    {
        if (InternalUtils.isBlank(queryString))
            return;

        for (String term : queryString.split("&"))
        {
            int eqx = term.indexOf("=");

            String key = term.substring(0, eqx).trim();
            String value = term.substring(eqx + 1).trim();

            request.loadParameter(key, value);
        }
    }

    private String stripContextFromPath(String path)
    {
        String contextPath = request.getContextPath();

        if (contextPath.equals(""))
            return path;

        if (!path.startsWith(contextPath))
            throw new RuntimeException(String.format("Path '%s' does not start with context path '%s'.", path,
                    contextPath));

        return path.substring(contextPath.length());
    }

    /**
     * Simulates a submission of the form specified. The caller can specify values for the form
     * fields, which act as
     * overrides on the values stored inside the elements.
     *
     * @param form
     *         the form to be submitted.
     * @param parameters
     *         the query parameter name/value pairs
     * @return The DOM created. Typically you will assert against it.
     */
    public Document submitForm(Element form, Map<String, String> parameters)
    {
        submitFormAndReturnResponse(form, parameters);

        return getDocumentFromResponse();
    }

    /**
     * Simulates a submission of the form specified. The caller can specify values for the form
     * fields, which act as
     * overrides on the values stored inside the elements.
     *
     * @param form
     *         the form to be submitted.
     * @param parameters
     *         the query parameter name/value pairs
     * @return The response object to assert against.
     * @since 5.2.3
     */
    public TestableResponse submitFormAndReturnResponse(Element form, Map<String, String> parameters)
    {
        assert form != null;

        validateElementName(form, "form");

        request.clear().setPath(stripContextFromPath(extractNonBlank(form, "action")));

        pushFieldValuesIntoRequest(form);

        overrideParameters(parameters);

        // addHiddenFormFields(form);

        // ComponentInvocation invocation = getInvocation(form);

        return runComponentEventRequest();
    }

    private void overrideParameters(Map<String, String> fieldValues)
    {
        for (Map.Entry<String, String> e : fieldValues.entrySet())
        {
            request.overrideParameter(e.getKey(), e.getValue());
        }
    }

    private void pushFieldValuesIntoRequest(Element form)
    {
        Visitor visitor = new Visitor()
        {
            public void visit(Element element)
            {
                if (InternalUtils.isNonBlank(element.getAttribute("disabled")))
                    return;

                String name = element.getName();

                if (name.equals("input"))
                {
                    String type = extractNonBlank(element, "type");

                    if (type.equals("radio") || type.equals("checkbox"))
                    {
                        if (InternalUtils.isBlank(element.getAttribute("checked")))
                            return;
                    }

                    // Assume that, if the element is a button/submit, it wasn't clicked,
                    // and therefore, is not part of the submission.

                    if (type.equals("button") || type.equals("submit"))
                        return;

                    // Handle radio, checkbox, text, radio, hidden
                    String value = element.getAttribute("value");

                    if (InternalUtils.isNonBlank(value))
                        request.loadParameter(extractNonBlank(element, "name"), value);

                    return;
                }

                if (name.equals("option"))
                {
                    String value = element.getAttribute("value");

                    // TODO: If value is blank do we use the content, or is the content only the
                    // label?

                    if (InternalUtils.isNonBlank(element.getAttribute("selected")))
                    {
                        String selectName = extractNonBlank(findAncestor(element, "select"), "name");

                        request.loadParameter(selectName, value);
                    }

                    return;
                }

                if (name.equals("textarea"))
                {
                    String content = element.getChildMarkup();

                    if (InternalUtils.isNonBlank(content))
                        request.loadParameter(extractNonBlank(element, "name"), content);

                    return;
                }
            }
        };

        form.visit(visitor);
    }

    /**
     * Simulates a submission of the form by clicking the specified submit button. The caller can
     * specify values for the
     * form fields.
     *
     * @param submitButton
     *         the submit button to be clicked.
     * @param fieldValues
     *         the field values keyed on field names.
     * @return The DOM created. Typically you will assert against it.
     */
    public Document clickSubmit(Element submitButton, Map<String, String> fieldValues)
    {
        clickSubmitAndReturnResponse(submitButton, fieldValues);

        return getDocumentFromResponse();
    }

    /**
     * Simulates a submission of the form by clicking the specified submit button. The caller can
     * specify values for the
     * form fields.
     *
     * @param submitButton
     *         the submit button to be clicked.
     * @param fieldValues
     *         the field values keyed on field names.
     * @return The response object to assert against.
     * @since 5.2.3
     */
    public TestableResponse clickSubmitAndReturnResponse(Element submitButton, Map<String, String> fieldValues)
    {
        assert submitButton != null;

        assertIsSubmit(submitButton);

        Element form = getFormAncestor(submitButton);

        request.clear().setPath(stripContextFromPath(extractNonBlank(form, "action")));

        pushFieldValuesIntoRequest(form);

        overrideParameters(fieldValues);

        String value = submitButton.getAttribute("value");

        if (value == null)
            value = DEFAULT_SUBMIT_VALUE_ATTRIBUTE;

        request.overrideParameter(extractNonBlank(submitButton, "name"), value);

        return runComponentEventRequest();
    }

    private void assertIsSubmit(Element element)
    {
        if (element.getName().equals("input"))
        {
            String type = element.getAttribute("type");

            if ("submit".equals(type))
                return;
        }

        throw new IllegalArgumentException("The specified element is not a submit button.");
    }

    private Element getFormAncestor(Element element)
    {
        return findAncestor(element, "form");
    }

    private Element findAncestor(Element element, String ancestorName)
    {
        Element e = element;

        while (e != null)
        {
            if (e.getName().equalsIgnoreCase(ancestorName))
                return e;

            e = e.getContainer();
        }

        throw new RuntimeException(String.format("Could not locate an ancestor element of type '%s'.", ancestorName));

    }

    /**
     * Sets the simulated browser's preferred language, i.e., the value returned from
     * {@link org.apache.tapestry5.http.services.Request#getLocale()}.
     *
     * @param preferedLanguage
     *         preferred language setting
     */
    public void setPreferedLanguage(Locale preferedLanguage)
    {
        request.setLocale(preferedLanguage);
    }
}
