// Copyright 2008, 2009, 2010, 2011 The Apache Software Foundation
//
// 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.corelib.components;

import java.util.Collections;
import java.util.Iterator;

import org.apache.tapestry5.*;
import org.apache.tapestry5.annotations.Environmental;
import org.apache.tapestry5.annotations.Events;
import org.apache.tapestry5.annotations.InjectComponent;
import org.apache.tapestry5.annotations.Log;
import org.apache.tapestry5.annotations.Parameter;
import org.apache.tapestry5.annotations.Property;
import org.apache.tapestry5.corelib.internal.AjaxFormLoopContext;
import org.apache.tapestry5.internal.services.PageRenderQueue;
import org.apache.tapestry5.ioc.annotations.Inject;
import org.apache.tapestry5.ioc.internal.util.InternalUtils;
import org.apache.tapestry5.ioc.services.TypeCoercer;
import org.apache.tapestry5.json.JSONArray;
import org.apache.tapestry5.json.JSONObject;
import org.apache.tapestry5.services.ComponentDefaultProvider;
import org.apache.tapestry5.services.Environment;
import org.apache.tapestry5.services.FormSupport;
import org.apache.tapestry5.services.Heartbeat;
import org.apache.tapestry5.services.PartialMarkupRenderer;
import org.apache.tapestry5.services.PartialMarkupRendererFilter;
import org.apache.tapestry5.services.javascript.JavaScriptSupport;

/**
 * A special form of the {@link org.apache.tapestry5.corelib.components.Loop}
 * component that adds Ajax support to handle adding new rows and removing
 * existing rows dynamically.
 * <p/>
 * This component expects that the values being iterated over are entities that
 * can be identified via a {@link org.apache.tapestry5.ValueEncoder}, therefore
 * you must either bind the "encoder" parameter to a ValueEncoder or use an
 * entity type for the "value" parameter for which Tapestry can provide a
 * ValueEncoder automatically.
 * <p/>
 * Works with {@link org.apache.tapestry5.corelib.components.AddRowLink} and
 * {@link org.apache.tapestry5.corelib.components.RemoveRowLink} components.
 * <p/>
 * The addRow event will receive the context specified by the context parameter.
 * <p/>
 * The removeRow event will receive the client-side value for the row being iterated.
 * 
 * @see EventConstants#ADD_ROW
 * @see EventConstants#REMOVE_ROW
 * @tapestrydoc
 * @see AddRowLink
 * @see RemoveRowLink
 * @see Loop
 * @see FormInjector
 */
@Events(
{ EventConstants.ADD_ROW, EventConstants.REMOVE_ROW })
public class AjaxFormLoop
{
    /**
     * The element to render for each iteration of the loop. The default comes from the template, or "div" if the
     * template did not specify an element.
     */
    @Parameter(defaultPrefix = BindingConstants.LITERAL)
    @Property(write = false)
    private String element;

    /**
     * The objects to iterate over (passed to the internal Loop component).
     */
    @Parameter(required = true, autoconnect = true)
    private Iterable source;

    /**
     * The current value from the source.
     */
    @Parameter(required = true)
    private Object value;

    /**
     * Name of a function on the client-side Tapestry.ElementEffect object that is invoked to make added content
     * visible. This is used with the {@link FormInjector} component, when adding a new row to the loop. Leaving as
     * null uses the default function, "highlight".
     */
    @Parameter(defaultPrefix = BindingConstants.LITERAL)
    private String show;

    /**
     * The context for the form loop (optional parameter). This list of values will be converted into strings and
     * included in the URI. The strings will be coerced back to whatever their values are and made available to event
     * handler methods.
     */
    @Parameter
    private Object[] context;

    /**
     * A block to render after the loop as the body of the {@link org.apache.tapestry5.corelib.components.FormInjector}.
     * This typically contains a {@link org.apache.tapestry5.corelib.components.AddRowLink}.
     */
    @Parameter(value = "block:defaultAddRow", defaultPrefix = BindingConstants.LITERAL)
    @Property(write = false)
    private Block addRow;

    /**
     * The block that contains the form injector (it is rendered last, as the "tail" of the AjaxFormLoop). This, in
     * turn, references the addRow block (from a parameter, or a default).
     */
    @Inject
    private Block tail;

    /**
     * A ValueEncoder used to convert server-side objects (provided by the
     * "source" parameter) into unique client-side strings (typically IDs) and
     * back. Note: this parameter may be OMITTED if Tapestry is configured to
     * provide a ValueEncoder automatically for the type of property bound to
     * the "value" parameter. 
     */
    @Parameter(required = true, allowNull = false)
    private ValueEncoder<Object> encoder;

    @InjectComponent
    private ClientElement rowInjector;

    @InjectComponent
    private FormFragment fragment;

    @Inject
    private Block ajaxResponse;

    @Inject
    private ComponentResources resources;

    @Environmental
    private FormSupport formSupport;

    @Environmental
    private Heartbeat heartbeat;

    @Inject
    private Environment environment;

    @Inject
    private JavaScriptSupport jsSupport;

    private JSONArray addRowTriggers;

    private Iterator iterator;

    @Inject
    private TypeCoercer typeCoercer;

    @Inject
    private ComponentDefaultProvider defaultProvider;

    @Inject
    private PageRenderQueue pageRenderQueue;

    private boolean renderingInjector;

    ValueEncoder defaultEncoder()
    {
        return defaultProvider.defaultValueEncoder("value", resources);
    }

    private final AjaxFormLoopContext formLoopContext = new AjaxFormLoopContext()
    {
        public void addAddRowTrigger(String clientId)
        {
            assert InternalUtils.isNonBlank(clientId);
            addRowTriggers.put(clientId);
        }

        private String currentFragmentId()
        {
            ClientElement element = renderingInjector ? rowInjector : fragment;

            return element.getClientId();
        }

        public void addRemoveRowTrigger(String clientId)
        {
            Link link = resources.createEventLink("triggerRemoveRow", toClientValue());

            String asURI = link.toURI();

            JSONObject spec = new JSONObject();
            spec.put("link", clientId);
            spec.put("fragment", currentFragmentId());
            spec.put("url", asURI);

            jsSupport.addInitializerCall("formLoopRemoveLink", spec);
        }
    };

    String defaultElement()
    {
        return resources.getElementName("div");
    }

    /**
     * Action for synchronizing the current element of the loop by recording its client value.
     */
    static class SyncValue implements ComponentAction<AjaxFormLoop>
    {
        private final String clientValue;

        public SyncValue(String clientValue)
        {
            this.clientValue = clientValue;
        }

        public void execute(AjaxFormLoop component)
        {
            component.syncValue(clientValue);
        }

        @Override
        public String toString()
        {
            return String.format("AjaxFormLoop.SyncValue[%s]", clientValue);
        }
    }

    private static final ComponentAction<AjaxFormLoop> BEGIN_HEARTBEAT = new ComponentAction<AjaxFormLoop>()
    {
        public void execute(AjaxFormLoop component)
        {
            component.beginHeartbeat();
        }

        @Override
        public String toString()
        {
            return "AjaxFormLoop.BeginHeartbeat";
        }
    };

    @Property(write = false)
    private final Renderable beginHeartbeat = new Renderable()
    {
        public void render(MarkupWriter writer)
        {
            formSupport.storeAndExecute(AjaxFormLoop.this, BEGIN_HEARTBEAT);
        }
    };

    private static final ComponentAction<AjaxFormLoop> END_HEARTBEAT = new ComponentAction<AjaxFormLoop>()
    {
        public void execute(AjaxFormLoop component)
        {
            component.endHeartbeat();
        }

        @Override
        public String toString()
        {
            return "AjaxFormLoop.EndHeartbeat";
        }
    };

    @Property(write = false)
    private final Renderable endHeartbeat = new Renderable()
    {
        public void render(MarkupWriter writer)
        {
            formSupport.storeAndExecute(AjaxFormLoop.this, END_HEARTBEAT);
        }
    };

    @Property(write = false)
    private final Renderable beforeBody = new Renderable()
    {
        public void render(MarkupWriter writer)
        {
            beginHeartbeat();
            syncCurrentValue();
        }
    };

    @Property(write = false)
    private final Renderable afterBody = new Renderable()
    {
        public void render(MarkupWriter writer)
        {
            endHeartbeat();
        }
    };

    @SuppressWarnings(
    { "unchecked" })
    @Log
    private void syncValue(String clientValue)
    {
        Object value = encoder.toValue(clientValue);

        if (value == null)
            throw new RuntimeException(String.format(
                    "Unable to convert client value '%s' back into a server-side object.", clientValue));

        this.value = value;
    }

    @Property(write = false)
    private final Renderable syncValue = new Renderable()
    {
        public void render(MarkupWriter writer)
        {
            syncCurrentValue();
        }
    };

    private void syncCurrentValue()
    {
        String id = toClientValue();

        // Add the command that restores value from the value clientValue,
        // when the form is submitted.

        formSupport.store(this, new SyncValue(id));
    }

    /**
     * Uses the {@link org.apache.tapestry5.ValueEncoder} to convert the current server-side value to a client-side
     * value.
     */
    @SuppressWarnings(
    { "unchecked" })
    private String toClientValue()
    {
        return encoder.toClient(value);
    }

    void setupRender()
    {
        addRowTriggers = new JSONArray();

        pushContext();

        iterator = source == null ? Collections.EMPTY_LIST.iterator() : source.iterator();

        renderingInjector = false;
    }

    private void pushContext()
    {
        environment.push(AjaxFormLoopContext.class, formLoopContext);
    }

    boolean beginRender(MarkupWriter writer)
    {
        if (!iterator.hasNext())
            return false;

        value = iterator.next();

        return true; // Render body, etc.
    }

    Object afterRender(MarkupWriter writer)
    {
        // When out of source items to render, switch over to the addRow block (either the default,
        // or from the addRow parameter) before proceeding to cleanup render.

        if (!iterator.hasNext())
        {
            renderingInjector = true;
            return tail;
        }

        // There's more to come, loop back to begin render.

        return false;
    }

    void cleanupRender()
    {
        popContext();

        JSONObject spec = new JSONObject();

        spec.put("rowInjector", rowInjector.getClientId());
        spec.put("addRowTriggers", addRowTriggers);

        jsSupport.addInitializerCall("ajaxFormLoop", spec);
    }

    private void popContext()
    {
        environment.pop(AjaxFormLoopContext.class);
    }

    /**
     * When the action event arrives from the FormInjector, we fire our own event, "addRow" to tell the container to add
     * a new row, and to return that new entity for rendering.
     */
    @Log
    Object onActionFromRowInjector(EventContext context)
    {
        ComponentEventCallback callback = new ComponentEventCallback()
        {
            public boolean handleResult(Object result)
            {
                value = result;

                return true;
            }
        };

        resources.triggerContextEvent(EventConstants.ADD_ROW, context, callback);

        if (value == null)
            throw new IllegalArgumentException(String.format(
                    "Event handler for event 'addRow' from %s should have returned a non-null value.",
                    resources.getCompleteId()));

        renderingInjector = true;

        pageRenderQueue.addPartialMarkupRendererFilter(new PartialMarkupRendererFilter()
        {
            public void renderMarkup(MarkupWriter writer, JSONObject reply, PartialMarkupRenderer renderer)
            {
                pushContext();

                renderer.renderMarkup(writer, reply);

                popContext();
            }
        });

        return ajaxResponse;
    }

    @Log
    Object onTriggerRemoveRow(String rowId)
    {
        Object value = encoder.toValue(rowId);

        resources.triggerEvent(EventConstants.REMOVE_ROW, new Object[]
        { value }, null);

        return new JSONObject();
    }

    private void beginHeartbeat()
    {
        heartbeat.begin();
    }

    private void endHeartbeat()
    {
        heartbeat.end();
    }
}
