/*
 * 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 org.apache.uima.ruta.engine;

import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.text.StringEscapeUtils;
import org.apache.uima.UimaContext;
import org.apache.uima.analysis_engine.AnalysisEngineProcessException;
import org.apache.uima.cas.CAS;
import org.apache.uima.cas.CASException;
import org.apache.uima.cas.FSIterator;
import org.apache.uima.cas.Feature;
import org.apache.uima.cas.Type;
import org.apache.uima.cas.TypeSystem;
import org.apache.uima.cas.text.AnnotationIndex;
import org.apache.uima.fit.component.JCasAnnotator_ImplBase;
import org.apache.uima.fit.descriptor.ConfigurationParameter;
import org.apache.uima.jcas.JCas;
import org.apache.uima.jcas.tcas.Annotation;
import org.apache.uima.resource.ResourceInitializationException;
import org.apache.uima.util.CasCopier;
import org.apache.uima.util.Level;
import org.htmlparser.Parser;
import org.htmlparser.util.NodeList;
import org.htmlparser.util.ParserException;

/**
 * This Analysis Engine is able to convert html content from a source view into a plain string
 * representation stored in an output view. Especially, the Analysis Engine transfers annotations
 * under consideration of the changed document text and annotation offsets in the new view. The copy
 * process also sets features, however, features of type annotation are currently not supported.
 * Note that if an annotation would have the same start and end positions in the new view, i.e., if
 * it would be mapped to an annotation of length 0, it is not moved to the new view.
 * 
 * The HTML Converter also supports heuristic and explicit conversion patterns which default to
 * html4 decoding, e.g., "{@literal &nbsp;}", "{@literal &lt;}", etc. Concepts like tables or lists
 * are not supported.
 * 
 * Note that in general it is suggested to run an html cleaner before any further processing to
 * avoid problems with malformed html.
 * 
 * A descriptor file for this Analysis Engine is located in the folder <code>descriptor/utils</code>
 * of a UIMA Ruta project.
 * 
 */
public class HtmlConverter extends JCasAnnotator_ImplBase {

  public static final String NAMESPACE = "org.apache.uima.ruta.type.html.";

  public static final String DEFAULT_MODIFIED_VIEW = "plaintext";

  public static final String LINEBREAK = "\n";

  /**
   * This string parameter specifies the name of the new view. The default value is
   * <code>plaintext</code>.
   */
  public static final String PARAM_OUTPUT_VIEW = "outputView";

  @ConfigurationParameter(name = PARAM_OUTPUT_VIEW, mandatory = true, defaultValue = DEFAULT_MODIFIED_VIEW)
  private String modifiedViewName;

  /**
   * This string parameter can optionally be set to specify the name of the input view.
   */
  public static final String PARAM_INPUT_VIEW = "inputView";

  @ConfigurationParameter(name = PARAM_INPUT_VIEW, mandatory = false)
  private String inputViewName;

  /**
   * This boolean parameter determines if linebreaks inside the text nodes are kept or removed. The
   * default behavior is <code>true</code>.
   */
  public static final String PARAM_REPLACE_LINEBREAKS = "replaceLinebreaks";

  @ConfigurationParameter(name = PARAM_REPLACE_LINEBREAKS, mandatory = false, defaultValue = "true")
  private boolean replaceLinebreaks;

  /**
   * This boolean parameter determines if the converter should skip whitespaces. Html documents
   * often contains whitespaces for indentation and formatting, which should not be reproduced in
   * the converted plain text document. If the parameter is set to false, then the whitespces are
   * not removed. This behavior is useful, if not Html documents are converted, but XMl files. The
   * default value is true.
   */
  public static final String PARAM_SKIP_WHITESPACES = "skipWhitespaces";

  @ConfigurationParameter(name = PARAM_SKIP_WHITESPACES, mandatory = false, defaultValue = "true")
  private boolean skipWhitespaces;

  /**
   * If this boolean parameter is set to true, then the tags of the complete document is processed
   * and not only those within the body tag.
   */
  public static final String PARAM_PROCESS_ALL = "processAll";

  @ConfigurationParameter(name = PARAM_PROCESS_ALL, mandatory = true, defaultValue = "false")
  private boolean processAll;

  /**
   * If this boolean parameter is set to true, then zero-length annotation will not be dropped, but
   * they will be assigned to the offset of the "nearest" annotation. In that case, a boolean
   * feature names offsetsExpanded will be set to true if available.
   */
  public static final String PARAM_EXPAND_OFFSETS = "expandOffsets";

  @ConfigurationParameter(name = PARAM_EXPAND_OFFSETS, mandatory = true, defaultValue = "false")
  private boolean expandOffsets;

  /**
   * This string parameter determines the character sequence that replaces a linebreak. The default
   * behavior is the empty string.
   */
  public static final String PARAM_LINEBREAK_REPLACEMENT = "linebreakReplacement";

  @ConfigurationParameter(name = PARAM_LINEBREAK_REPLACEMENT, mandatory = false, defaultValue = "")
  private String linebreakReplacement;

  /**
   * This string array parameter sets the names of the html tags that create linebreaks in the
   * output view. The default is <code>br, p, div, ul, ol, dl, li, h1, ..., h6, blockquote</code>.
   */
  public static final String PARAM_NEWLINE_INDUCING_TAGS = "newlineInducingTags";

  @ConfigurationParameter(name = PARAM_NEWLINE_INDUCING_TAGS, mandatory = false, defaultValue = {
      "br", "p", "div", "ul", "ol", "dl", "li", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote" })
  private String[] newlineInducingTags;

  /**
   * This string parameter contains a regular expression for HTML/XML elements. If the pattern
   * matches, then the element will introduce a new line break similar to the element of the
   * parameter <code>newlineInducingTags</code>.
   */
  public static final String PARAM_NEWLINE_INDUCING_TAG_REGEXP = "newlineInducingTagRegExp";

  @ConfigurationParameter(name = PARAM_NEWLINE_INDUCING_TAG_REGEXP, mandatory = false)
  private String newlineInducingTagRegExp;

  /**
   * This string array parameter sets the names of the html tags that create additional text in the
   * output view. The actual string of the gap is defined by the parameter <code>gapText</code>.
   */
  public static final String PARAM_GAP_INDUCING_TAGS = "gapInducingTags";

  @ConfigurationParameter(name = PARAM_GAP_INDUCING_TAGS, mandatory = true, defaultValue = {})
  private String[] gapInducingTags;

  /**
   * This string parameter determines the character sequence that is introduced by the html tags
   * specified in the <code>gapInducingTags</code>.
   */
  public static final String PARAM_GAP_TEXT = "gapText";

  @ConfigurationParameter(name = PARAM_GAP_TEXT, mandatory = true, defaultValue = "")
  private String gapText;

  /**
   * This boolean parameter sets the value of the parameter <code>gapText</code> to a single space.
   */
  public static final String PARAM_USE_SPACE_GAP = "useSpaceGap";

  @ConfigurationParameter(name = PARAM_USE_SPACE_GAP, mandatory = true, defaultValue = "false")
  private boolean useSpaceGap;

  /**
   * This string array parameter can be used to apply custom conversions. It defaults to a list of
   * commonly used codes, e.g., {@literal &nbsp;}, which are converted using html 4 entity
   * unescaping. However, explicit conversion strings can also be passed via the parameter
   * <code>conversionReplacements</code>. Remember to enable explicit conversion via
   * <code>conversionPolicy</code> first.
   */
  public static final String PARAM_CONVERSION_PATTERNS = "conversionPatterns";

  @ConfigurationParameter(name = PARAM_CONVERSION_PATTERNS, mandatory = false, defaultValue = {
      "&nbsp;", "&laquo;", "&raquo;", "&quot;", "&amp;", "&lt;", "&gt;", "&apos;", "&sect;",
      "&uml;", "&copy;", "&trade;", "&reg;", "&ouml;", "&auml;", "&uuml;", "&#160;" })
  private String[] conversionPatterns;

  /**
   * This string parameter determines the conversion policy used, either "heuristic", "explicit", or
   * "none". When the value is "explicit", the parameters <code>conversionPatterns</code> and
   * optionally <code>conversionReplacements</code> are considered. The "heuristic" conversion
   * policy uses simple regular expressions to decode html4 entities such as "{@literal &nbsp;}".
   * The default behavior is "heuristic".
   */
  public static final String PARAM_CONVERSION_POLICY = "conversionPolicy";

  @ConfigurationParameter(name = PARAM_CONVERSION_POLICY, mandatory = true, defaultValue = "heuristic")
  private String conversionPolicy;

  /**
   * This string array parameter corresponds to <code>conversionPatterns</code> such that
   * <code>conversionPatterns[i]</code> will be replaced by <code>conversionReplacements[i]</code>;
   * replacements should be shorter than the source pattern. Per default, the replacement strings
   * are computed using Html4 decoding. Remember to enable explicit conversion via
   * <code>conversionPolicy</code> first.
   */
  public static final String PARAM_CONVERSION_REPLACEMENTS = "conversionReplacements";

  @ConfigurationParameter(name = PARAM_CONVERSION_REPLACEMENTS, mandatory = false)
  private String[] conversionReplacements;

  private int[] map;

  @Override
  public void initialize(UimaContext aContext) throws ResourceInitializationException {
    super.initialize(aContext);
    linebreakReplacement = (String) aContext.getConfigParameterValue(PARAM_LINEBREAK_REPLACEMENT);
    linebreakReplacement = linebreakReplacement == null ? "" : linebreakReplacement;
    String conversionPolicy = (String) aContext.getConfigParameterValue(PARAM_CONVERSION_POLICY);
    if (StringUtils.isBlank(conversionPolicy) || conversionPolicy.equals("heuristic")) {
      conversionPolicy = "heuristic";
    } else if (conversionPolicy.equals("explicit")) {
    } else if (conversionPolicy.equals("none")) {
    } else {
      throw new ResourceInitializationException("illegal conversionPolicy parameter value",
              new Object[0]);
    }
    // check assertions
    if (modifiedViewName.equals(inputViewName)) {
      throw new ResourceInitializationException("input and output view names must differ!",
              new Object[0]);
    }
    conversionReplacements = (String[]) aContext
            .getConfigParameterValue(PARAM_CONVERSION_REPLACEMENTS);
    if (conversionReplacements == null) {
      conversionReplacements = new String[conversionPatterns.length];
      for (int i = 0; i < conversionPatterns.length; i++) {
        String c = conversionPatterns[i];
        String rep = StringEscapeUtils.unescapeHtml4(c);
        conversionReplacements[i] = rep;
      }
    }

    if (useSpaceGap) {
      gapText = " ";
    }

  }

  @Override
  public void process(JCas jcaz) throws AnalysisEngineProcessException {
    JCas jcas;
    try {
      if (inputViewName != null) {
        jcas = jcaz.getView(inputViewName);
      } else {
        jcas = jcaz;
      }
    } catch (CASException e1) {
      throw new AnalysisEngineProcessException(e1.getCause());
    }
    // init:
    String documentText = jcas.getDocumentText();
    String splitSeq = documentText.contains("\r\n") ? "\r\n" : "\n";
    map = new int[documentText.length() + 1];
    JCas modview = null;
    try {
      // check if view already exists:
      Iterator<JCas> viewIterator = jcas.getViewIterator();
      while (viewIterator.hasNext()) {
        JCas jCas2 = viewIterator.next();
        if (jCas2.getViewName().equals(modifiedViewName)) {
          modview = jCas2;
          getContext().getLogger().log(Level.WARNING,
                  "view with name \"" + modifiedViewName + "\" already exists.");
        }
      }
      if (modview == null) {
        modview = jcas.createView(modifiedViewName);
      }
    } catch (CASException e) {
      e.printStackTrace();
      return;
    }
    SortedSet<HtmlConverterPSpan> visibleSpansSoFar = new TreeSet<HtmlConverterPSpan>();
    SortedSet<HtmlConverterPSpan> linebreaksFromHtmlTags = new TreeSet<HtmlConverterPSpan>();
    SortedSet<HtmlConverterPSpan> gapsFromHtmlTags = new TreeSet<HtmlConverterPSpan>();

    // process
    try {
      Parser parser = new Parser(documentText);
      NodeList list = parser.parse(null);
      HtmlConverterVisitor visitor = new HtmlConverterVisitor(newlineInducingTags,
              newlineInducingTagRegExp, gapInducingTags, gapText, skipWhitespaces, processAll);
      list.visitAllNodesWith(visitor);
      visibleSpansSoFar = visitor.getTextSpans();
      linebreaksFromHtmlTags = visitor.getLinebreaksFromHtmlTags();
      gapsFromHtmlTags = visitor.getGapsFromHtmlTags();
    } catch (ParserException e) {
      throw new AnalysisEngineProcessException(e);
    }
    if (replaceLinebreaks) {
      visibleSpansSoFar = this.handleLinebreaksInDocumentText(visibleSpansSoFar, splitSeq);
    }
    if (conversionPolicy.equals("heuristic")) {
      visibleSpansSoFar = this.htmlDecoding(visibleSpansSoFar);
    } else if (conversionPolicy.equals("explicit")) {
      for (int i = 0; i < conversionPatterns.length; i++) {
        String pat = conversionPatterns[i];
        String rep = conversionReplacements[i];
        visibleSpansSoFar = this.handleConversion(visibleSpansSoFar, pat, rep);
      }
    }
    visibleSpansSoFar.addAll(linebreaksFromHtmlTags);
    visibleSpansSoFar.addAll(gapsFromHtmlTags);

    // create new doc-text and the map from deletions and visible-text-spans:
    StringBuffer sbu = new StringBuffer(documentText.length());
    int originalOffsetI = 0;
    int outOffset = 0;
    for (HtmlConverterPSpan vis : visibleSpansSoFar) {
      final int begin = vis.getBegin();
      final int end = vis.getEnd();

      // map text before annotation:
      while (originalOffsetI < begin) {
        map[originalOffsetI++] = outOffset;
      }

      // get and map text/replacement:
      String s = "";
      if (vis instanceof HtmlConverterPSpanReplacement) {
        // conversion/replacement:
        s = vis.getTxt();
        // asserts that s is shorter than the original source
        while (originalOffsetI < begin + s.length()) {
          map[originalOffsetI++] = outOffset++;
        }
        while (originalOffsetI < end) {
          map[originalOffsetI++] = outOffset;
        }
      } else {
        // simple annotation:
        s = documentText.substring(begin, end);
        while (originalOffsetI < end) {
          map[originalOffsetI++] = outOffset++;
        }
      }
      sbu.append(s);
    }
    while (originalOffsetI < documentText.length()) {
      map[originalOffsetI++] = outOffset;
    }
    map[documentText.length()] = outOffset + 1; // handle doc end separately
    String modTxt = sbu.toString();
    modview.setDocumentText(modTxt);

    // copy annotations using the 'map':
    try {
      mapAnnotations(jcas, map, modifiedViewName);
    } catch (CASException e) {
      e.printStackTrace();
    }
  }

  private void mapAnnotations(JCas fromJcas, int[] map, String toView) throws CASException {
    JCas modview = fromJcas.getView(toView);

    Set<Annotation> indexedFs = new HashSet<Annotation>();
    Set<Annotation> toExpand = new HashSet<Annotation>();
    AnnotationIndex<Annotation> annotationIndex = fromJcas.getAnnotationIndex();
    TypeSystem typeSystem = fromJcas.getTypeSystem();
    Type docType = typeSystem.getType(CAS.TYPE_NAME_DOCUMENT_ANNOTATION);
    CasCopier casCopier = new CasCopier(fromJcas.getCas(), modview.getCas());
    for (Annotation annotation : annotationIndex) {
      // TODO be careful here, because some people inherit from DocumentAnnotation
      if (typeSystem.subsumes(docType, annotation.getType())) {
        continue;
      }
      Annotation clone = (Annotation) casCopier.copyFs(annotation);
      // change the view/sofa of the new annotation...
      // see: http://osdir.com/ml/apache.uima.general/2007-09/msg00107.html
      clone.setFeatureValue(
              modview.getTypeSystem().getFeatureByFullName(CAS.FEATURE_FULL_NAME_SOFA),
              modview.getSofa());
      final int mappedBegin = map[clone.getBegin()];
      final int mappedEnd = map[clone.getEnd()];
      if (mappedBegin < mappedEnd) {
        if (mappedEnd > fromJcas.getCas().getDocumentAnnotation().getEnd()) {
          getContext().getLogger().log(Level.WARNING, "illegal annotation offset mapping");
        } else {
          int max = modview.getCas().getDocumentAnnotation().getEnd();
          if (mappedBegin < max && mappedEnd <= max && mappedBegin >= 0 && mappedEnd > 0) {
            clone.setBegin(mappedBegin);
            clone.setEnd(mappedEnd);
            // TODO handle nested annotation features
            modview.addFsToIndexes(clone);
            indexedFs.add(clone);
          } else {
            getContext().getLogger().log(Level.WARNING, "illegal annotation offset mapping");
          }
        }
      } else if (expandOffsets) {
        clone.setBegin(mappedBegin);
        clone.setEnd(mappedEnd);
        toExpand.add(clone);
      }
    }

    for (Annotation each : toExpand) {
      Annotation nextBestAnnotation = getNextBestAnnotation(each, modview);
      if (nextBestAnnotation != null) {
        each.setBegin(nextBestAnnotation.getBegin());
        each.setEnd(nextBestAnnotation.getEnd());
        Feature expandedOffsetsFeature = each.getType().getFeatureByBaseName("expandedOffsets");
        if (expandedOffsetsFeature != null) {
          each.setBooleanValue(expandedOffsetsFeature, true);
        }
        modview.addFsToIndexes(each);
      }
    }
  }

  private Annotation getNextBestAnnotation(Annotation source, JCas jcas) {

    FSIterator<Annotation> iterator = jcas.getAnnotationIndex().iterator(source);
    Annotation best = null;
    if (iterator.isValid()) {
      Annotation annotation = iterator.get();
      best = annotation;
    } else {
      Annotation dummy = new Annotation(jcas, source.getBegin(), source.getBegin() + 1);
      iterator = jcas.getAnnotationIndex().iterator(dummy);
      if (!iterator.isValid()) {
        if ((jcas.getDocumentText().length() / 2) > source.getBegin()) {
          iterator.moveToFirst();
          if (iterator.isValid()) {
            Annotation annotation = iterator.get();
            best = annotation;
          }
        } else {
          iterator.moveToLast();
          if (iterator.isValid()) {
            Annotation annotation = iterator.get();
            best = annotation;
          }
        }
      }
    }

    return best;
  }

  private SortedSet<HtmlConverterPSpan> handleLinebreaksInDocumentText(
          SortedSet<HtmlConverterPSpan> visibleSpansSoFar, String splitSeq) {
    return this.handleConversion(visibleSpansSoFar, splitSeq, linebreakReplacement);
  }

  private SortedSet<HtmlConverterPSpan> htmlDecoding(
          SortedSet<HtmlConverterPSpan> visibleSpansSoFar) {
    TreeSet<HtmlConverterPSpan> copy = new TreeSet<HtmlConverterPSpan>(visibleSpansSoFar);

    Pattern patt = Pattern.compile("(&[a-zA-Z0-9]{2,6};)|(&#\\d{2,5};)");

    for (HtmlConverterPSpan pSpan : visibleSpansSoFar) {
      String spanTxt = pSpan.getTxt();
      Matcher matcher = patt.matcher(spanTxt);

      if (matcher.find()) {
        copy.remove(pSpan);
        int pSpanBegin = pSpan.getBegin();
        int ioff = pSpan.getBegin();
        do {
          String sourceString = matcher.group();
          String replacement = StringEscapeUtils.unescapeHtml4(sourceString);
          HtmlConverterPSpanReplacement replacementSpan = new HtmlConverterPSpanReplacement(
                  pSpanBegin + matcher.start(), pSpanBegin + matcher.end(), replacement);
          copy.add(replacementSpan);

          int replacementLength = sourceString.length();
          if (pSpanBegin + matcher.end() > ioff + replacementLength) {
            int ib = ioff;
            int ie = pSpanBegin + matcher.start();
            String newTxt = spanTxt.substring(ib - pSpanBegin, ie - pSpanBegin);
            copy.add(new HtmlConverterPSpan(ib, ie, newTxt));
            ioff = ie;
          }
          ioff += replacementLength; //
        } while (matcher.find());
        if (ioff < pSpan.getEnd()) {
          int ib = ioff;
          int ie = pSpan.getEnd();
          String newTxt = spanTxt.substring(ib - pSpanBegin, ie - pSpanBegin);
          copy.add(new HtmlConverterPSpan(ioff, pSpan.getEnd(), newTxt));
        }
      }
    }
    return copy;
  }

  private SortedSet<HtmlConverterPSpan> handleConversion(
          SortedSet<HtmlConverterPSpan> visibleSpansSoFar, String patternString,
          String replacement) {
    TreeSet<HtmlConverterPSpan> copy = new TreeSet<HtmlConverterPSpan>(visibleSpansSoFar);

    Pattern patt = Pattern.compile(patternString);
    int replacementLength = patternString.length();

    for (HtmlConverterPSpan pSpan : visibleSpansSoFar) {
      String spanTxt = pSpan.getTxt();
      Matcher matcher = patt.matcher(spanTxt);

      if (matcher.find()) {
        copy.remove(pSpan);
        int pSpanBegin = pSpan.getBegin();
        int ioff = pSpan.getBegin();
        do {
          if (!StringUtils.isEmpty(replacement)) {
            HtmlConverterPSpanReplacement replacementSpan = new HtmlConverterPSpanReplacement(
                    pSpanBegin + matcher.start(), pSpanBegin + matcher.end(), replacement);
            copy.add(replacementSpan);
          }
          if (pSpanBegin + matcher.end() > ioff + replacementLength) {
            int ib = ioff;
            int ie = pSpanBegin + matcher.start();
            String newTxt = spanTxt.substring(ib - pSpanBegin, ie - pSpanBegin);
            copy.add(new HtmlConverterPSpan(ib, ie, newTxt));
            ioff = ie;
          }
          ioff += replacementLength; //
        } while (matcher.find());
        if (ioff < pSpan.getEnd()) {
          int ib = ioff;
          int ie = pSpan.getEnd();
          String newTxt = spanTxt.substring(ib - pSpanBegin, ie - pSpanBegin);
          copy.add(new HtmlConverterPSpan(ioff, pSpan.getEnd(), newTxt));
        }
      }
    }
    return copy;
  }

}
