/*
 * Copyright 2011 Google Inc.
 * 
 * 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 com.google.gwt.uibinder.client.impl;

import com.google.gwt.core.client.GWT;
import com.google.gwt.dom.client.Document;
import com.google.gwt.dom.client.Element;
import com.google.gwt.dom.client.EventTarget;
import com.google.gwt.dom.client.NativeEvent;
import com.google.gwt.event.dom.client.DomEvent;
import com.google.gwt.event.shared.HasHandlers;
import com.google.gwt.safehtml.shared.SafeHtml;
import com.google.gwt.safehtml.shared.SafeHtmlUtils;
import com.google.gwt.uibinder.client.UiRenderer;

import java.util.HashMap;

/**
 * Abstract implementation of a safe HTML binder to make implementation of generated rendering
 * simpler.
 */
public abstract class AbstractUiRenderer implements UiRenderer {

  /**
   * Helps handle method dispatch to classes that use UiRenderer.
   * 
   * @param <T> class that can receive events from a UiRenderer implementation
   */
  protected abstract static class UiRendererDispatcher<T> implements HasHandlers {
    private T eventTarget;

    private int methodIndex;

    private Element root;

    /**
     * Maps strings describing event types and field names to methods
     * contained in type {@code T} (which are indexed by an integer).
     */
    private HashMap<String, Integer> table;

    /**
     * Fire an event to the receiver.
     * @param target object that will handle the events
     * @param event event to dispatch
     * @param parentOrRoot root element of a previously rendered DOM structure (or its parent)
     */
    protected void fireEvent(T target, NativeEvent event, Element parentOrRoot) {
      if (target == null) {
        throw new NullPointerException("Null event handler received");
      }
      if (event == null) {
        throw new NullPointerException("Null event object received");
      }
      if (parentOrRoot == null) {
        throw new NullPointerException("Null parent received");
      }

      if (!isParentOrRenderer(parentOrRoot, RENDERED_ATTRIBUTE)) {
        return;
      }
      eventTarget = target;
      root = findRootElementOrNull(parentOrRoot, RENDERED_ATTRIBUTE);
      methodIndex = computeDispatchEvent(table, root, event);
      DomEvent.fireNativeEvent(event, this);
    }

    /**
     * Object that will receive the event.
     */
    protected T getEventTarget() {
      return eventTarget;
    }

    /**
     * Index of the method that will receive the event.
     */
    protected int getMethodIndex() {
      return methodIndex;
    }

    /**
     * Root Element of a previously rendered DOM structure.
     */
    protected Element getRoot() {
      return root;
    }

    /**
     * Initializes the dispatch table if necessary.
     */
    protected void initDispatchTable(String[] keys, Integer[] values) {
      table = buildDispatchMap(keys, values);
    }
  }

  /**
   * Marker attribute for DOM structures previously generated by UiRenderer.
   */
  public static final String RENDERED_ATTRIBUTE = "gwtuirendered";

  /**
   * Field name used to identify the root element while dispatching events.
   */
  public static final String ROOT_FAKE_NAME = "^";

  public static final String UI_ID_SEPARATOR = ":";

  private static final int NO_HANDLER_FOUND = -1;

  /**
   * Build id strings used to identify DOM elements related to ui:fields.
   * 
   * @param fieldName name of the field that identifies the element
   * @param uiId common part of the identifier for all elements in the rendered DOM structure
   */
  protected static String buildInnerId(String fieldName, String uiId) {
    return uiId + UI_ID_SEPARATOR + fieldName;
  }

  /**
   * Retrieves a specific element within a previously rendered element.
   * 
   * @param parent parent element containing the element of interest
   * @param fieldName name of the field to retrieve
   * @param attribute that identifies the root element as such
   * @return the element identified by {@code fieldName}
   *
   * @throws IllegalArgumentException if the {@code parent} does not point to or contains
   *         a previously rendered element. In DevMode also when the root element is not
   *         attached to the DOM
   * @throws IllegalStateException parent does not contain an element matching
   *         {@code filedName}
   *
   * @throws RuntimeException if the root element is not attached to the DOM and not running in
   *         DevMode
   * 
   * @throws NullPointerException if {@code parent} == null
   */
  protected static Element findInnerField(Element parent, String fieldName, String attribute) {
    Element root = findRootElement(parent, attribute);

    if (parent != root && !isRenderedElementSingleChild(root)) {
      throw new IllegalArgumentException(
          "Parent Element of previously rendered element contains more than one child"
          + " while getting \"" + fieldName + "\"");
    }

    String uiId = root.getAttribute(attribute);
    String renderedId = buildInnerId(fieldName, uiId);

    Element elementById = Document.get().getElementById(renderedId);
    if (elementById == null) {
      if (!isAttachedToDom(root)) {
        throw new RuntimeException("UiRendered element is not attached to DOM while getting \""
            + fieldName + "\"");
      } else if (!GWT.isProdMode()) {
        throw new IllegalStateException("\"" + fieldName
            + "\" not found within rendered element");
      } else {
        // In prod mode we do not distinguish between being unattached or not finding the element
        throw new IllegalArgumentException("UiRendered element is not attached to DOM, or \""
            + fieldName + "\" not found within rendered element");
      }
    }
    return elementById;
  }

  /**
   * Retrieves the root of a previously rendered element contained within the {@code parent}.
   * The {@code parent} must either contain the previously rendered DOM structure as its only child,
   * or point directly to the rendered element root.
   * 
   * @param parent element containing, or pointing to, a previously rendered DOM structure
   * @param attribute attribute name that identifies the root of the DOM structure
   * @return the root element of the previously rendered DOM structure
   * 
   * @throws NullPointerException if {@code parent} == null
   * @throws IllegalArgumentException if {@code parent} does not contain a previously rendered
   *         element
   */
  protected static Element findRootElement(Element parent, String attribute) {
    Element root = findRootElementOrNull(parent, attribute);
    if (root == null) {
      throw new IllegalArgumentException(
          "Parent element does not contain a previously rendered element");
    }
    return root;
  }

  /**
   * Inserts an attribute into the first tag found in a {@code safeHtml} template.
   * This method assumes that the {@code safeHtml} template begins with an open HTML tag.
   * {@code SafeHtml} templates produced by UiBinder always meet these conditions.
   * <p>
   * This method does not attempt to ensure {@code atributeName} and {@code attributeValue}
   * contain safe values.
   *
   * @returns the {@code safeHtml} template with "{@code attributeName}={@code attributeValue}"
   *          inserted as an attribute of the first tag found
   */
  protected static SafeHtml stampUiRendererAttribute(SafeHtml safeHtml, String attributeName,
      String attributeValue) {
    String html = safeHtml.asString();
    int endOfFirstTag = html.indexOf(">");

    assert endOfFirstTag > 1 : "Safe html template does not start with an HTML open tag";

    if (html.charAt(endOfFirstTag - 1) == '/') {
      endOfFirstTag--;
    }

    html = html.substring(0, endOfFirstTag) + " " + attributeName + "=\"" + attributeValue + "\""
            + html.substring(endOfFirstTag);
    return SafeHtmlUtils.fromTrustedString(html);
  }

  /**
   * Converts an array of keys and values into a map.
   */
  private static HashMap<String, Integer> buildDispatchMap(String[] keys, Integer[] values) {
    HashMap<String, Integer> result = new HashMap<String, Integer>(keys.length);
    for (int i = 0; i < keys.length; i++) {
      result.put(keys[i], values[i]);
    }
    return result;
  }

  /**
   * Obtains the index of the method that will receive an event.
   * @param table event types and field names indexed by the method that
   *              can handle an event.
   * @param root of a previously rendered DOM structure
   * @param event event to handle
   * @return index of the method that will process the event or NO_HANDLER_FOUND.
   */
  private static int computeDispatchEvent(HashMap<String, Integer> table, Element root,
      NativeEvent event) {
    String uiId = root.getAttribute(RENDERED_ATTRIBUTE);

    EventTarget eventTarget = event.getEventTarget();
    if (!Element.is(eventTarget)) {
      return NO_HANDLER_FOUND;
    }

    Element cursor = Element.as(eventTarget);

    while (cursor != null && cursor != root && cursor.getNodeType() != Element.DOCUMENT_NODE) {
      String fieldName = getFieldName(uiId, cursor);
      if (fieldName == null) {
        cursor = cursor.getParentElement();
        continue;
      }
      String key = event.getType() + UI_ID_SEPARATOR + fieldName;
      if (table.containsKey(key)) {
        return table.get(key);
      }
      cursor = cursor.getParentElement();
    }

    if (cursor == root) {
      String key = event.getType() + UI_ID_SEPARATOR + ROOT_FAKE_NAME;
      if (table.containsKey(key)) {
        return table.get(key);
      }
    }

    return NO_HANDLER_FOUND;
  }

  /**
   * Retrieves the root of a previously rendered element contained within the {@code parent}.
   * The {@code parent} must either contain the previously rendered DOM structure as its only child,
   * or point directly to the rendered element root.
   * 
   * @param parent element containing, or pointing to, a previously rendered DOM structure
   * @param attribute attribute name that identifies the root of the DOM structure
   * @return the root element of the previously rendered DOM structure or <code>null</code>
   *         if {@code parent} does not contain a previously rendered element
   * 
   * @throws NullPointerException if {@code parent} == null
   */
  private static Element findRootElementOrNull(Element parent, String attribute) {
    if (parent == null) {
      throw new NullPointerException("parent argument is null");
    }

    Element rendered;
    if (parent.hasAttribute(attribute)) {
      // The parent is the root
      return parent;
    } else if ((rendered = parent.getFirstChildElement()) != null
        && rendered.hasAttribute(attribute)) {
      // The first child is the root
      return rendered;
    } else {
      return null;
    }
  }

  /**
   * Obtains the field name of a previously rendered DOM Element.
   * @param uiId identifier of the fields contained in a previously rendered DOM structure
   * @param element which may correspond to {@code ui:field}
   * @return the field name or {@code null} if the {@code element} does not have
   *         an id attribute as would be produced by {@link #buildInnerId(String, String)}) with
   *         {@code fieldName} and {@code uiId}
   */
  private static String getFieldName(String uiId, Element element) {
    String id = element.getId();
    if (id == null) {
      return null;
    }
    int split = id.indexOf(UI_ID_SEPARATOR);
    return split != -1 && uiId.length() == split && id.startsWith(uiId)
        ? id.substring(split + 1)
        : null;
  }

  /**
   * In DevMode, walks up the parents of the {@code rendered} element to ascertain that it is
   * attached to the document. Always returns <code>true</code> in ProdMode.
   */
  private static boolean isAttachedToDom(Element rendered) {
    if (GWT.isProdMode()) {
      return true;
    }

    Element body = Document.get().getBody();

    while (rendered != null && rendered.hasParentElement() && !body.equals(rendered)) {
      rendered = rendered.getParentElement();
    }
    return body.equals(rendered);
  }

  /**
   * Implements {@link com.google.gwt.uibinder.client.UiRenderer#isParentOrRenderer(Element)}.
   */
  private static boolean isParentOrRenderer(Element parent, String attribute) {
    if (parent == null) {
      return false;
    }

    Element root = findRootElementOrNull(parent, attribute);
    return root != null && isAttachedToDom(root)
        && isRenderedElementSingleChild(root);
  }

  /**
   * Checks that the parent of {@code rendered} has a single child.
   */
  private static boolean isRenderedElementSingleChild(Element rendered) {
    return GWT.isProdMode() || rendered.getParentElement().getChildCount() == 1;
  }

  /**
   * Holds the part of the id attribute common to all elements being rendered.
   */
  protected String uiId;

  @Override
  public boolean isParentOrRenderer(Element parent) {
    return isParentOrRenderer(parent, RENDERED_ATTRIBUTE);
  }
}
