package hirondelle.web4j.ui.tag;

import static hirondelle.web4j.util.Consts.DOUBLE_QUOTE;
import static hirondelle.web4j.util.Consts.EMPTY_STRING;
import hirondelle.web4j.request.Formats;
import hirondelle.web4j.ui.tag.Populate.Style;
import hirondelle.web4j.util.EscapeChars;
import hirondelle.web4j.util.Regex;
import hirondelle.web4j.util.Util;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.servlet.jsp.JspException;

/**
 Helper class for the custom tag {@link Populate}.

 <P>Performs most of the work of <tt>Populate</tt>, by editing the tag's body, as needed.
*/
final class PopulateHelper {
  
  /**
   Context of a given HTTP request, from which information regarding 
   request parameters and available Model Objects may be extracted.
  
   <P>This interface exists only to allow testing of {@link PopulateHelper} outside of a 
   servlet context, where a {@link Populate} object is not available. During testing,
   fake implementations of this interface are created to simulate the context 
   of a given HTTP request.
    
   <P>Note that the Populate class does not implement this interface, since that would 
   pollute its public API with internal details. Instead, Populate uses an internal wrapper class
   to provide the desired 'view'.
  */
  interface Context {
    
    /**
     Return the value of the request parameter corresponding to <tt>aParamName</tt>.
    
     <P>If the param is absent, or if its value is null, then return an empty String.
     
     <P>HTTP POSTs "missing" items inconsistently:
    <ul>
     <li>empty text/password: param posted, but value is empty <tt>String</tt>
     <li>unselected radio/checkbox: no param is posted at all (null)
     <li>unselected SELECT tags: param is posted using first item as value
    </ul>
    */
    String getReqParamValue(String aParamName);
    
    /** Returns <tt>true</tt> only if the request includes a parameter of the given name.   */
    boolean hasRequestParamNamed(String aParamName);
    
    /**
     Return the value of a multi-valued request parameter corresponding to
     <tt>aParamName</tt>. Intended for use with multi-valued parameters. 
    
     @return unmodifiable <tt>Collection</tt> of <tt>Strings</tt>; if the parameter is
     missing from the request, then return an empty <tt>Collection</tt>.
    */
    Collection<String> getReqParamValues(String aParamName);
    
    /** Return <tt>true</tt> only if the <tt>using</tt> Model Object is present in some scope.    */
    boolean isModelObjectPresent();
    
    /** Return the <tt>using</tt> Model Object.    */
    Object getModelObject();
    
    /** Return the {@link hirondelle.web4j.request.Formats} needed to render objects.  */
    Formats getFormats();    
  }
  
  /**
   Constructor. 
   @param aContext source of information regarding request parameters, 
   any '<tt>using</tt>' Model Object, and formatting styles.
   @param aOriginalBody the HTML content of the <tt>Populate</tt> tag, after
   any JSP processing, but before any prepopulation.
   @param aUseCaseStyle defines the high level use case. 
  */
  PopulateHelper(Context aContext, String aOriginalBody, Style aUseCaseStyle){
    fContext = aContext;
    fOriginalBody = aOriginalBody; 
    fUseCaseStyle = aUseCaseStyle;
  }

  /** Replace supported form controls with edited versions, as needed, reflecting any prepopulation.  */
  String getEditedBody() throws JspException {
    StringBuffer result = new StringBuffer();
    Matcher control = CONTROL_PATTERN.matcher(fOriginalBody);
    while ( control.find() ) {
      control.appendReplacement(result, getReplacement(control));
    }
    control.appendTail(result);
    return result.toString();
  }

  // PRIVATE 

  private final Context fContext;
  private final String fOriginalBody;
  private final Style fUseCaseStyle;
  
  /*
   All of these fields hold dynamic data, and are updated for each control 
   found in the tag body. Not all of these items apply to all controls.
  */
  private String fControl; //entire text of the supported control, as extracted from body
  private String fControlFlavor; //distinguishes INPUT, SELECT, OPTION, and TEXTAREA
  private String fNameAttr; //required in all supported controls
  private String fTypeAttr; //required in all INPUT tags only
  private String fValueAttr; //optional for INPUT and OPTION tags
  
  /* The significant values of fControlFlavor.  */
  private static final String SELECT = "select";
  private static final String INPUT = "input";
  private static final String TEXT_AREA = "textarea";
  
  /* The significant values of the fTypeAttr.  */
  private static final String RADIO = "radio";
  private static final String CHECKBOX = "checkbox";
  private static final String TEXT = "text";
  private static final String PASSWORD = "password";
  private static final String HIDDEN = "hidden";
  
  /* HTML5 types (non date-time) */
  private static final String SEARCH = "search"; //same as text
  private static final String EMAIL = "email"; //text, but can be multi-valued!
  private static final String URL = "url"; //same as text
  private static final String TELEPHONE = "tel"; //same as text
  private static final String NUMBER = "number"; //can have a fractional part, floating point; same as Decimal? or BigDecimal? or both?
  
  /*HTML controls which are always submitted, and never null, according to the HTML5 draft specification. */ 
  private static final String RANGE = "range"; //can have a decimal
  private static final String COLOR = "color";
  
  /* HTML5 date-time types - not yet supported; browser support is poor, and inconsistencies lead to errors.  */
  private static final String DATE = "date"; //no offset; 2012-05-01
  private static final String TIME = "time"; //no offset; 01:23|01:23:45|01:23:45.123
  private static final String DATETIME_LOCAL = "datetime-local"; //no offset 2012-05-31T00:01:05.123 (trailing parts optional)
  private static final String MONTH = "month"; //no offset; 2012-05 actually includes year-and-month
  private static final String WEEK = "week"; //no offset; 2012-W53
  private static final String DATETIME = "datetime"; //WITH offset DO I SUPPORT THIS? For DateTime, no. For general text, yes.
  
  /* Patterns which retrieve attribute values.  */
  private static final Pattern NAME_PATTERN = getPatternForAttrValue("name");
  private static final Pattern TYPE_PATTERN = getPatternForAttrValue("type");
  private static final Pattern VALUE_PATTERN = getPatternForAttrValue("value");
  
  /** Returns value of the attribute as group 1.  */
  private static final Pattern getPatternForAttrValue(String aAttrName){
    String regex = " " + aAttrName + "=" + Regex.QUOTED_ATTR;
    return Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
  }

  /** If match found, then the type attribute is supported by this class.  */
  private static final Pattern SUPPORTED_TYPE_PATTERN = Pattern.compile( "(" + 
      RADIO + "|" + CHECKBOX + "|" + TEXT + "|" + HIDDEN + "|" +  PASSWORD + "|" + 
      SEARCH + "|" + EMAIL + "|" + URL + "|" + TELEPHONE + "|"  + NUMBER +  "|" + COLOR + "|" + RANGE  + "|" +  
    ")", 
    Pattern.CASE_INSENSITIVE
   );
  
  private static final Pattern CONTROL_PATTERN = Pattern.compile(
    "(?:" + "<(input) " + Regex.ALL_BUT_END_OF_TAG + ">" + 
    "|" +
    "<(textarea) " + Regex.ALL_BUT_END_OF_TAG +  ">" + Regex.WS +
       Regex.ALL_BUT_START_OF_TAG + 
    "</textarea>" +
    "|" +
    "<(select)" + Regex.ALL_BUT_END_OF_TAG + ">" + Regex.WS +
    "(?:<option" + Regex.ALL_BUT_END_OF_TAG + ">" + Regex.ALL_BUT_START_OF_TAG + 
    "</option>" + Regex.WS + ")+" + 
    "</select>)",
    Pattern.CASE_INSENSITIVE | Pattern.DOTALL
  );
  
  private static final Pattern FLAVOR_PATTERN = Pattern.compile(
    Regex.START_TAG + "(input|select|option|textarea)", Pattern.CASE_INSENSITIVE
  );

  /** If match found, then item contains the 'checked' attribute.  */
  private static final Pattern CHECKED_PATTERN = Pattern.compile(
    "( checked)", Pattern.CASE_INSENSITIVE
  );

  /** If match found, then item contains the 'selected' attribute.   */
  private static final Pattern SELECTED_PATTERN = Pattern.compile(
    "( selected)", Pattern.CASE_INSENSITIVE
  );
  
  /**
   Fetches an entire OPTION tag.
   Group 1 is the value attr, group 2 is the trimmed text of the option tag's body.
  */
  private static final Pattern OPTION_PATTERN = Pattern.compile(
   Regex.START_TAG + "option" +
    "(?: selected" + 
    "| value=" + Regex.QUOTED_ATTR +"|"+ Regex.ALL_BUT_END_OF_TAG + ")*" + 
    Regex.END_TAG + 
    Regex.TRIMMED_TEXT + "</option>",
    Pattern.CASE_INSENSITIVE
  );

  /** The body of the textarea tag, plus the bounding start and end tags.  */
  private static final Pattern TEXT_AREA_BODY_PATTERN = Pattern.compile(
    Regex.END_TAG + Regex.ALL_BUT_START_OF_TAG + Regex.START_TAG,
    Pattern.CASE_INSENSITIVE
  );
  
  private static final Class[] NO_ARGS = new Class[]{};
  private static final Object[] NO_PARAMS = null;
  private static final Logger fLogger = Util.getLogger(PopulateHelper.class);  

  /** Get a possible replacement for the given control. */
  private String getReplacement(Matcher aMatchForControl) throws JspException {
    setControl(aMatchForControl);
    setControlFlavor();
    setOptionalItems();
    String result = isSupportedControl() ? EscapeChars.forRegex(getPossiblyEditedControl()) : fControl;
    return result;
  }
  
  private void setControl(Matcher aMatchForControl){
    fControl = aMatchForControl.group(Regex.ENTIRE_MATCH);
    fLogger.finer("Found supported control: " + fControl);
  }
  
  private void setControlFlavor() throws JspException {
    Matcher matchForFlavor = FLAVOR_PATTERN.matcher(fControl);
    if ( matchForFlavor.find() ) {
      fControlFlavor = matchForFlavor.group(Regex.FIRST_GROUP);
      fLogger.finer("Flavor of supported control: " + fControlFlavor);
    }
    else {
      throw new JspException("No flavor found for supported control.");
    }
  }
  
  /**
   This method serves as a defensive measure. It ensures that items which do 
   not apply to every control are set to null, and thus no "mixing" of data between 
   controls is possible. As well, erroneous use of an item which has not been 
   populated for that control will result in an immediate NullPointerException.
  */
  private void setOptionalItems(){
    fNameAttr = null;
    fTypeAttr = null;
    fValueAttr = null;
  }
  
  /** Some input controls are explicitly NOT supported: file, reset, submit. */
  private boolean isSupportedControl() throws JspException {
    boolean result = true;
    if ( isInputTag() ){
      fTypeAttr = getCompulsoryAttrValue(TYPE_PATTERN);
      fLogger.finer("Type attr: " + fTypeAttr);
      result = Util.matches(SUPPORTED_TYPE_PATTERN, fTypeAttr);
    }
    return result;
  }

  
  private String getPossiblyEditedControl() throws JspException {
    String result = "";
    fNameAttr = getCompulsoryAttrValue(NAME_PATTERN);
    fLogger.finer("Control name: " + Util.quote(fNameAttr));
    if( fUseCaseStyle == Style.USE_MODEL_OBJECT || fUseCaseStyle == Style.MUST_RECYCLE_PARAMS ) {
      result = getEditedControl();
    }
    else if(fUseCaseStyle == Style.RECYCLE_PARAM_IF_PRESENT){
      if( fContext.hasRequestParamNamed(fNameAttr) ) {
        result = getEditedControl();
      }
      else {
        result = getUneditedControl();        
      }
    }
    return result;
  }

  /** Return the control without any editing - just a simple echo of the tag's content. */
  private String getUneditedControl(){
    return fControl;
  }
  
  /** Perform the core task of editing a control.   */
  private String getEditedControl() throws JspException {
    String result = null;
    if ( isInputTag() ){
      if ( isTextLike() ) {
        //single-valued
        fLogger.finer("Editing 'value' attr for text pr text-like field");
        result = editTextBox(EscapeChars.forHTML(getPrepopValue()));
      }
      else if ( isRadioButton() ){
        //single-valued
        fLogger.finer("Editing 'checked' attr for radio field");
        result = editRadioButton(getPrepopValue());
      }
      else if ( isCheckbox() ){
        //may be single- or multi-valued
        fLogger.finer("Editing 'checked' attr for checkbox field");
        result = editCheckBox(getPrepopValues());
      }
    }
    else if ( isSelectTag() ) {
      //may or may not be multi-valued, depending on the 
      //presence of the 'multiple' attribute
      fLogger.finer("Editing 'selected' attr for option fields");
      result = editOption(getPrepopValues());
    }
    else if ( isTextAreaTag() ) {
      //single-valued
      fLogger.finer("Editing tag body for textarea field");
      result = editTextArea(EscapeChars.forHTML(getPrepopValue()));
    }
    else {
      throw new AssertionError("Unexpected control flavor: " + fControlFlavor);
    }
    fLogger.finest("Edited control: " + Util.quote(result));
    return result;
  }
  
  private boolean isInputTag(){
    return fControlFlavor.equalsIgnoreCase(INPUT);
  }
  
  private boolean isSelectTag(){
    return fControlFlavor.equalsIgnoreCase(SELECT);
  }
  
  private boolean isTextAreaTag() {
    return fControlFlavor.equalsIgnoreCase(TEXT_AREA);
  }

  /** That is, is NOT a radio or check-box. Such controls use 'value', not 'checked'. */
  private boolean isTextLike(){
    return 
      fTypeAttr.equalsIgnoreCase(TEXT) || 
      fTypeAttr.equalsIgnoreCase(PASSWORD) || 
      fTypeAttr.equalsIgnoreCase(HIDDEN) ||
      fTypeAttr.equalsIgnoreCase(SEARCH) ||
      fTypeAttr.equalsIgnoreCase(EMAIL) ||
      fTypeAttr.equalsIgnoreCase(URL) ||
      fTypeAttr.equalsIgnoreCase(TELEPHONE) ||
      fTypeAttr.equalsIgnoreCase(NUMBER) || 
      fTypeAttr.equalsIgnoreCase(COLOR) || 
      fTypeAttr.equalsIgnoreCase(RANGE) 
    ;
  }
  
  private boolean isRadioButton(){
    return fTypeAttr.equalsIgnoreCase(RADIO);
  }
  
  private boolean isCheckbox(){
    return fTypeAttr.equalsIgnoreCase(CHECKBOX);
  }
  
  /** Return the first group of the first match found in <tt>fControl</tt>.   */
  private String getCompulsoryAttrValue(Pattern aPattern) throws JspException {
    String result = null;
    Matcher matcher = aPattern.matcher(fControl);
    if ( matcher.find() ) {
      result = matcher.group(Regex.FIRST_GROUP);
    }
    else {
      throw new JspException(
        "Cannot prepopulate, since does not contain expected attribute: " + fControl + " using Pattern " + aPattern
      );
    }
    return result;
  }
  
  /** <tt>aPrepopValue</tt> is possibly-null.   */
  private String editTextBox(String aPrepopValue){
    String result = null;
    String target = getTargetValueAttr(aPrepopValue);
    Matcher matchForValue = VALUE_PATTERN.matcher(fControl);
    if ( matchForValue.find() ) {
      fLogger.finest("Replacing value attribute using target : " + Util.quote(target));
      result = matchForValue.replaceAll(target);
    }
    else {
      result = addAttribute(target);
    }
    return result;
  }
  
  private String getTargetValueAttr(String aPrepopValue){
    String result = " value=" + DOUBLE_QUOTE + aPrepopValue + DOUBLE_QUOTE;
    return EscapeChars.forRegex(result);
  }

  /** Add an attribute immediately after the start of the tag.   */
  private String addAttribute(String aTarget){
    fLogger.finest("Adding attribute, with target :" + aTarget);
    Matcher matchForFlavor = FLAVOR_PATTERN.matcher(fControl);
    return matchForFlavor.replaceAll("$0" + aTarget);
  }
  
  /** <tt>aPrepopValue</tt> may be null.   */
  private String editRadioButton(String aPrepopValue) throws JspException {
    String result = fControl; //used if no edits needed
    fValueAttr = getCompulsoryAttrValue(VALUE_PATTERN);
    Matcher matchForChecked = CHECKED_PATTERN.matcher(fControl);
    if ( valueMatchesPrepop(aPrepopValue) ) {
      if ( ! matchForChecked.find() ) {
        result = addAttribute(" checked");
      }
    }
    else {
      if (  matchForChecked.find() ) {
        result = matchForChecked.replaceAll(EMPTY_STRING);
      }
    }
    return result;
  }
  
  private String editCheckBox(Collection<String> aPrepopValues) throws JspException {
    String result = fControl; //used if no edits needed
    fValueAttr = getCompulsoryAttrValue(VALUE_PATTERN);
    Matcher matchForChecked = CHECKED_PATTERN.matcher(fControl);
    if ( valueMatchesPrepop(aPrepopValues) ) {
      if ( ! matchForChecked.find() ) {
        result = addAttribute(" checked");
      }
    }
    else {
      if (  matchForChecked.find() ) {
        result = matchForChecked.replaceAll(EMPTY_STRING);
      }
    }
    return result;
  }
  
  /** <tt>aPrepopValue</tt> is possibly-null.   */
  private boolean valueMatchesPrepop(String aPrepopValue){
    return fValueAttr.equals(aPrepopValue);
  }
  
  private boolean valueMatchesPrepop(Collection<String> aPrepopValues){
    return aPrepopValues.contains(fValueAttr);
  }
  
  private String editTextArea(String aPrepopValue){
    String result = null;
    Matcher matchForTextBody = TEXT_AREA_BODY_PATTERN.matcher(fControl);
    if ( matchForTextBody.find() ) {
      result = matchForTextBody.replaceAll( getTargetTextBody(aPrepopValue) );
    }
    return result;
  }
  
  private String getTargetTextBody(String aPrepopValue){
    String result = ">" + aPrepopValue + "<";
    return EscapeChars.forRegex(result);
  }
  
  private String editOption(Collection<String> aPrepopValues){
    String result = fControl; //used if no edits needed
    Matcher matchForOption = OPTION_PATTERN.matcher(fControl);
    while ( matchForOption.find() ){
      String option = matchForOption.group(Regex.ENTIRE_MATCH);
      fLogger.finer("Found option :" + option);
      Matcher matchForSelected = SELECTED_PATTERN.matcher(option);
      String valueAttr = getValueAttrForOption(matchForOption);
      fLogger.finest("Value attr: " + valueAttr);
      if ( aPrepopValues.contains(valueAttr) ) {
        String editedOption = ensureSelected(matchForSelected, option);
        result = result.replaceAll(EscapeChars.forRegex(option), editedOption);
      }
      else {
        String editedOption = ensureUnselected(matchForSelected, option);
        result = result.replaceAll(EscapeChars.forRegex(option), editedOption);
      }
    }
    fLogger.finest("Edited select tag: " + result);
    return result;
  }

  private String ensureSelected(Matcher aMatchForSelected, String aOption){
    String result = aOption; //use if no edit needed
    if ( ! aMatchForSelected.find() ){
      Matcher matchForFlavor = FLAVOR_PATTERN.matcher(aOption);
      result = matchForFlavor.replaceAll("$0" + " selected");
    }
    fLogger.finest("Edited option: " + result);
    return result;
  }
  
  private String ensureUnselected(Matcher aMatchForSelected, String aOption) {
    String result = aOption; //use if no edits needed
    if ( aMatchForSelected.find() ){
      result = aMatchForSelected.replaceAll(EMPTY_STRING);
    }
    fLogger.finest("Edited option: " + result);
    return result;
  }
  
  private String getValueAttrForOption(Matcher aOptionMatcher){
    String value = aOptionMatcher.group(Regex.FIRST_GROUP);
    String text = aOptionMatcher.group(Regex.SECOND_GROUP);
    return Util.textHasContent(value) ? value : text;
  }
  
  /*
  
   The remaining methods below relate to extracting data from the HTTP request context.
  
   Note that SELECT items are always modeled as Collections of Strings. In the common case 
   of not allowing multiple selections, the Collection will contain only one element.
  
   Several methods come in pairs, one of which is simply the plural form of another,
   with a final 's' at the end. The 'plural' methods are called only for SELECT tags.
  */

  /** Returns possibly-null String.   */
  private String getPrepopValue() throws JspException {
    String result = null;
    if ( fContext.isModelObjectPresent() ) {
      fLogger.finer("Getting prepop from model object.");
      result = getPropertyValue();
    }
    else{
      fLogger.finer("Getting prepop from params.");
      result = fContext.getReqParamValue(fNameAttr);
    }
    fLogger.finest("Prepop value: " + result);
    return result;
  }
  
  /**
   Returns an immutable Collection of Strings (may have no elements).
  
   This method is called only for CHECKBOX and SELECT elements, which may allow multiple values.
   In the common case when the SELECT does not include the 'multiple' attr, there 
   will be only one element in the returned value.
  */
  private Collection<String> getPrepopValues() throws JspException {
    Collection<String> result = null;
    if ( fContext.isModelObjectPresent() ) {
      fLogger.finer("Getting prepops from model object.");
      result = getPropertyValues();
    }
    else{
      fLogger.finer("Getting prepops from params.");
      result = fContext.getReqParamValues(fNameAttr);
    }
    fLogger.finest("Prepop values: " + result);
    return result;
  }

  /**
   Return getXXX value as string, after applying {@link Formats#objectToText}.
  */
  private String getPropertyValue() throws JspException {
    return fContext.getFormats().objectToText(getReturnValue());
  }
  
  /**
   Return an non-empty unmodifiable Collection of Strings. 
  
   <P>If the corresponding getXXX does not return a Collection:
  <ul>
   <li>call {@link Formats#objectToText} on the return value of getXXX
   <li>return that single item wrapped in a Collection.
  </ul>
  
   <P>If the corresponding getXXX method returns a Collection, then 
  <ul>
   <li>call {@link Formats#objectToText} on each item in the Collection
   <li>add each string to the result of this method
  </ul>
  */
  private Collection<String> getPropertyValues() throws JspException {
    Collection<String> result = new ArrayList<String>();
    Object returnValue = getReturnValue();
    if ( returnValue instanceof Collection) {
      Collection returnValueCollection = (Collection) getReturnValue(); //OK
      Iterator iter = returnValueCollection.iterator();
      while ( iter.hasNext() ) {
        Object value = iter.next();
        result.add( fContext.getFormats().objectToText(value) );
      }
    }
    else {
      result.add( fContext.getFormats().objectToText(returnValue) );
    }
    return Collections.unmodifiableCollection(result);
  }
  
  /**  Returns possibly-null <tt>Object</tt>.  */
  private Object getReturnValue() throws JspException {
    Object result = null;
    Class modelObjectClass = fContext.getModelObject().getClass();
    try {
      String methodName = "get" + Util.withNoSpaces(Util.withInitialCapital(fNameAttr));
      Method getMethod = modelObjectClass.getMethod(methodName, NO_ARGS); 
      /* 
       Implementation Note
       Here, the Reflection API wraps any primitive return value by using the 
       corresponding wrapper class. Thus, the underlying model object is not constrained 
       to use wrappers instead of primitives.
      */
      result = getMethod.invoke(fContext.getModelObject(), NO_PARAMS);
    }
    catch (NoSuchMethodException ex){
      throw new JspException(
        "Missing expected public method : get" + fNameAttr + " in " + modelObjectClass
      );
    }
    catch (IllegalAccessException ex ){
      throw new JspException("Illegal Access for method : get" + fNameAttr);
    }
    catch (InvocationTargetException ex){
      throw new JspException(
        "Method through an exception : get" + fNameAttr + " named " + ex.getCause()
      );
    }
    return result;
  }
}