/*
    GNU GENERAL PUBLIC LICENSE
    Copyright (C) 2006 The Lobo Project

    This program is free software; you can redistribute it and/or
    modify it under the terms of the GNU General Public
    License as published by the Free Software Foundation; either
    verion 2 of the License, or (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
    General Public License for more details.

    You should have received a copy of the GNU General Public
    License along with this library; if not, write to the Free Software
    Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA

    Contact info: lobochief@users.sourceforge.net
 */
/*
 * Created on May 14, 2005
 */
package org.lobobrowser.primary.clientlets.html;

import java.awt.event.ActionEvent;
import java.io.InputStream;
import java.net.URL;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.logging.Level;
import java.util.logging.Logger;

import javax.swing.SwingUtilities;

import org.eclipse.jdt.annotation.NonNull;
import org.lobobrowser.clientlet.Clientlet;
import org.lobobrowser.clientlet.ClientletContext;
import org.lobobrowser.clientlet.ClientletException;
import org.lobobrowser.clientlet.ClientletResponse;
import org.lobobrowser.html.HtmlRendererContext;
import org.lobobrowser.html.domimpl.DocumentNotificationListener;
import org.lobobrowser.html.domimpl.HTMLDocumentImpl;
import org.lobobrowser.html.domimpl.NodeImpl;
import org.lobobrowser.html.gui.HtmlPanel;
import org.lobobrowser.html.parser.DocumentBuilderImpl;
import org.lobobrowser.html.parser.InputSourceImpl;
import org.lobobrowser.ua.NavigatorFrame;
import org.lobobrowser.ua.RequestType;
import org.lobobrowser.util.Strings;
import org.lobobrowser.util.Urls;
import org.lobobrowser.util.io.RecordedInputStream;
import org.w3c.dom.Element;
import org.w3c.dom.html.HTMLElement;

/**
 * @author J. H. S.
 */
public final class HtmlClientlet implements Clientlet {
  private static final Logger logger = Logger.getLogger(HtmlClientlet.class.getName());
  private static final Set<String> NON_VISIBLE_ELEMENTS = new HashSet<>();

  // Maximum buffer size required to determine if a reload due
  // to Http-Equiv is necessary.
  private static final int MAX_IS_BUFFER_SIZE = 1024 * 100;

  public HtmlClientlet() {
    super();
  }

  /*
   * (non-Javadoc)
   *
   * @see org.xamjwg.clientlet.Clientlet#parse(org.xamjwg.dom.XDocument)
   */
  public void process(final ClientletContext cc) throws ClientletException {
    this.processImpl(cc, null, null);
  }

  private void processImpl(final ClientletContext cc, final Map<String, String> httpEquivData, RecordedInputStream rin)
      throws ClientletException {
    // This method may be executed twice, depending on http-equiv meta elements.
    try {
      final ClientletResponse response = cc.getResponse();
      final boolean charsetProvided = response.isCharsetProvided();
      final String contentLanguage = response.getHeader("Content-Language");
      Set<Locale> locales = contentLanguage == null ? null : HtmlClientlet.extractLocales(contentLanguage);
      RefreshInfo refresh = null;
      final Iterator<String> hi = response.getHeaderNames();
      // TODO: What is the behavior if you have
      // a Refresh header and also a Refresh HTTP-EQUIV?
      while (hi.hasNext()) {
        final String headerName = hi.next();
        final String[] headerValues = response.getHeaders(headerName);
        if ((headerValues != null) && (headerValues.length > 0)) {
          if ("refresh".equalsIgnoreCase(headerName)) {
            refresh = HtmlClientlet.extractRefresh(headerValues[headerValues.length - 1]);
          }
        }
      }
      String httpEquivCharset = null;
      if (httpEquivData != null) {
        final Iterator<Map.Entry<String, String>> i = httpEquivData.entrySet().iterator();
        while (i.hasNext()) {
          final Map.Entry<String, String> entry = i.next();
          final String httpEquiv = entry.getKey();
          final String content = entry.getValue();
          if (content != null) {
            if ("content-type".equalsIgnoreCase(httpEquiv)) {
              httpEquivCharset = HtmlClientlet.extractCharset(response.getResponseURL(), content);
            } else if ("refresh".equalsIgnoreCase(httpEquiv)) {
              refresh = HtmlClientlet.extractRefresh(content);
            } else if ("content-language".equalsIgnoreCase(httpEquiv)) {
              locales = HtmlClientlet.extractLocales(content);
            }
          }
        }
      }
      final HtmlRendererContextImpl rcontext = HtmlRendererContextImpl.getHtmlRendererContext(cc.getNavigatorFrame());
      final DocumentBuilderImpl builder = new DocumentBuilderImpl(rcontext.getUserAgentContext(), rcontext);
      if (rin == null) {
        final InputStream in = response.getInputStream();
        rin = in instanceof RecordedInputStream ? (RecordedInputStream) in : new RecordedInputStream(in, MAX_IS_BUFFER_SIZE);
        rin.mark(Short.MAX_VALUE);
      } else {
        rin.reset();
      }
      final URL responseURL = response.getResponseURL();
      final String uri = responseURL.toExternalForm();
      String charset;
      if (!charsetProvided) {
        charset = httpEquivCharset;
      } else {
        // See bug # 2051468. A charset provided
        // in headers takes precendence.
        charset = response.getCharset();
      }
      if (charset == null) {
        // charset = "ISO-8859-1";
        charset = "utf-8";
      }
      if (logger.isLoggable(Level.INFO)) {
        logger.info("process(): charset=" + charset + " for URI=[" + uri + "]");
      }
      final InputSourceImpl is = new InputSourceImpl(rin, uri, charset);
      final HTMLDocumentImpl document = (HTMLDocumentImpl) builder.createDocument(is, response.getContentType());
      document.setLocales(locales);
      final String referrer = cc.getRequest().getReferrer();
      document.setReferrer(referrer == null ? "" : referrer);
      final HtmlPanel panel = rcontext.getHtmlPanel();
      // Create a listener that will switch to rendering when appropriate.
      final HtmlContent content = new HtmlContent(document, panel, rin, charset);
      final LocalDocumentNotificationListener listener = new LocalDocumentNotificationListener(document, panel, rcontext, cc, content,
          httpEquivData == null);
      document.addDocumentNotificationListener(listener);
      // Set resulting content before parsing
      // to enable incremental rendering.
      // The load() call starts parsing.
      try {
        document.load(false);
      } catch (final HttpEquivRetryException retry) {
        if (logger.isLoggable(Level.INFO)) {
          logger.info("processImpl(): Resetting due to META http-equiv: " + uri);
        }
        // This is a recursive call, but it doesn't go further
        // than one level deep.
        this.processImpl(cc, retry.getHttpEquivData(), rin);
        return;
      }
      // We're done parsing, but let's make sure
      // the listener actually renderered the document.
      listener.ensureSwitchedToRendering();

      // Scroll to see anchor.
      /* This is now disabled, because we now layout lazily. There is no point in scrolling until the layout is done.
      final String ref = responseURL.getRef();
      if ((ref != null) && (ref.length() != 0)) {
        panel.scrollToElement(ref);
      }*/

      if (refresh != null) {
        final String destUri = refresh.destinationUrl;
        final java.net.URL currentURL = response.getResponseURL();
        @NonNull URL destURL;
        if (destUri == null) {
          destURL = currentURL;
        } else {
          destURL = Urls.createURL(currentURL, destUri);
        }
        final java.awt.event.ActionListener action = new java.awt.event.ActionListener() {
          public void actionPerformed(final ActionEvent e) {
            final NavigatorFrame frame = cc.getNavigatorFrame();
            if (frame.getComponentContent() == content) {
              // Navigate only if the original document is there.
              // TODO: Address bar shouldn't change if it's being edited.
              // TODO: A nagivation action should cancel this altogether.
              frame.navigate(destURL, RequestType.PROGRAMMATIC);
            }
          }
        };
        int waitMillis = refresh.waitSeconds * 1000;
        if (waitMillis <= 0) {
          waitMillis = 1;
        }
        final javax.swing.Timer timer = new javax.swing.Timer(waitMillis, action);
        timer.setRepeats(false);
        timer.start();
      }
    } catch (final Exception err) {
      throw new ClientletException(err);
    }
  }

  private static String extractCharset(final java.net.URL responseURL, final String contentType) {
    final StringTokenizer tok = new StringTokenizer(contentType, ";");
    if (tok.hasMoreTokens()) {
      tok.nextToken();
      while (tok.hasMoreTokens()) {
        final String assignment = tok.nextToken().trim();
        final int eqIdx = assignment.indexOf('=');
        if (eqIdx != -1) {
          final String varName = assignment.substring(0, eqIdx).trim();
          if ("charset".equalsIgnoreCase(varName)) {
            final String varValue = assignment.substring(eqIdx + 1);
            return Strings.unquote(varValue.trim());
          }
        }
      }
    }
    return null;
  }

  private static Set<Locale> extractLocales(final String contentLanguage) {
    final Set<Locale> locales = new HashSet<>(3);
    final StringTokenizer tok = new StringTokenizer(contentLanguage, ",");
    while (tok.hasMoreTokens()) {
      final String lang = tok.nextToken().trim();
      locales.add(new Locale(lang));
    }
    return locales;
  }

  private static String getDefaultCharset(final URL url) {
    if (Urls.isLocalFile(url)) {
      final String charset = System.getProperty("file.encoding");
      return charset == null ? "ISO-8859-1" : charset;
    } else {
      return "ISO-8859-1";
    }
  }

  private final static RefreshInfo extractRefresh(final String refresh) {
    String delayText = null;
    String urlText = null;
    final StringTokenizer tok = new StringTokenizer(refresh, ";");
    if (tok.hasMoreTokens()) {
      delayText = tok.nextToken().trim();
      while (tok.hasMoreTokens()) {
        final String assignment = tok.nextToken().trim();
        final int eqIdx = assignment.indexOf('=');
        if (eqIdx != -1) {
          final String varName = assignment.substring(0, eqIdx).trim();
          if ("url".equalsIgnoreCase(varName)) {
            final String varValue = assignment.substring(eqIdx + 1);
            urlText = Strings.unquote(varValue.trim());
          }
        } else {
          urlText = Strings.unquote(assignment);
        }
      }
    }
    int delay;
    try {
      delay = Integer.parseInt(delayText);
    } catch (final NumberFormatException nfe) {
      logger.warning("extractRefresh(): Bad META refresh delay: " + delayText + ".");
      delay = 0;
    }
    return new RefreshInfo(delay, urlText);
  }

  private static class LocalDocumentNotificationListener implements DocumentNotificationListener {
    private static final int MAX_WAIT = 7000;
    private final HTMLDocumentImpl document;
    private final HtmlPanel htmlPanel;
    private final long startTimestamp;
    private final HtmlRendererContext rcontext;
    private final ClientletContext ccontext;
    private final HtmlContent content;
    private final boolean detectHttpEquiv;
    private boolean hasVisibleElements = false;
    private boolean hasSwitchedToRendering = false;
    private Collection<HTMLElement> httpEquivElements;

    public LocalDocumentNotificationListener(final HTMLDocumentImpl doc, final HtmlPanel panel, final HtmlRendererContext rcontext,
        final ClientletContext cc,
        final HtmlContent content, final boolean detectHttpEquiv) {
      this.document = doc;
      this.startTimestamp = System.currentTimeMillis();
      this.htmlPanel = panel;
      this.rcontext = rcontext;
      this.ccontext = cc;
      this.content = content;
      this.detectHttpEquiv = detectHttpEquiv;
    }

    public void allInvalidated() {
    }

    public void externalScriptLoading(final NodeImpl node) {
      // We can expect this to occur only in the parser thread.
      if (this.hasVisibleElements) {
        this.ensureSwitchedToRendering();
      }
    }

    public void invalidated(final NodeImpl node) {
    }

    public void lookInvalidated(final NodeImpl node) {
    }

    private void addHttpEquivElement(final HTMLElement element) {
      Collection<HTMLElement> httpEquivElements = this.httpEquivElements;
      if (httpEquivElements == null) {
        httpEquivElements = new LinkedList<>();
        this.httpEquivElements = httpEquivElements;
      }
      httpEquivElements.add(element);
    }

    public void nodeLoaded(final NodeImpl node) {
      // We can expect this to occur only in the parser thread.
      if (this.detectHttpEquiv) {
        if (node instanceof HTMLElement) {
          final HTMLElement element = (HTMLElement) node;
          final String tagName = element.getTagName();
          if ("meta".equalsIgnoreCase(tagName)) {
            final String httpEquiv = element.getAttribute("http-equiv");
            if (httpEquiv != null) {
              this.addHttpEquivElement(element);
            }
          }
          if ("head".equalsIgnoreCase(tagName) || "script".equalsIgnoreCase(tagName) || "html".equalsIgnoreCase(tagName)) {
            // Note: SCRIPT is checked as an optimization. We do not want
            // scripts to be processed twice. HTML is checked because
            // sometimes sites don't put http-equiv in HEAD, e.g.
            // http://baidu.com.
            final Map<String, String> httpEquiv = this.getHttpEquivData();
            if ((httpEquiv != null) && (httpEquiv.size() > 0)) {
              throw new HttpEquivRetryException(httpEquiv);
            }
          }
        }
      }
      if (!this.hasVisibleElements) {
        if (LocalDocumentNotificationListener.mayBeVisibleElement(node)) {
          this.hasVisibleElements = true;
        }
      }
      if (this.hasVisibleElements && ((System.currentTimeMillis() - this.startTimestamp) > MAX_WAIT)) {
        this.ensureSwitchedToRendering();
      }
    }

    public void positionInvalidated(final NodeImpl node) {
    }

    public void sizeInvalidated(final NodeImpl node) {
    }

    public void structureInvalidated(final NodeImpl node) {
    }

    private final static boolean mayBeVisibleElement(final NodeImpl node) {
      if (node instanceof HTMLElement) {
        final HTMLElement element = (HTMLElement) node;
        final boolean visible = !NON_VISIBLE_ELEMENTS.contains(element.getTagName().toLowerCase());
        if (visible && logger.isLoggable(Level.INFO)) {
          logger.info("mayBeVisibleElement(): Found possibly visible element: " + element.getTagName());
        }
        return visible;
      } else {
        return false;
      }
    }

    public void ensureSwitchedToRendering() {
      synchronized (this) {
        if (this.hasSwitchedToRendering) {
          return;
        }
        this.hasSwitchedToRendering = true;
      }
      final HTMLDocumentImpl document = this.document;
      document.removeDocumentNotificationListener(this);
      SwingUtilities.invokeLater(() -> {
        // Should have nicer effect (less flicker) in GUI thread.
        htmlPanel.setDocument(document, rcontext);
        ccontext.setResultingContent(content);
      });
    }

    private Map<String, String> getHttpEquivData() {
      final Collection<HTMLElement> httpEquivElements = this.httpEquivElements;
      if (httpEquivElements == null) {
        return null;
      }
      final Map<String, String> httpEquivData = new HashMap<>(0);
      for (final Element element : httpEquivElements) {
        final String httpEquiv = element.getAttribute("http-equiv");
        if (httpEquiv != null) {
          final String content = element.getAttribute("content");
          httpEquivData.put(httpEquiv, content);
        }
      }
      return httpEquivData;
    }
  }

  private static class RefreshInfo {
    public final int waitSeconds;
    public final String destinationUrl;

    public RefreshInfo(final int waitSeconds, final String destinationUrl) {
      super();
      this.waitSeconds = waitSeconds;
      this.destinationUrl = destinationUrl;
    }
  }

  private static class HttpEquivRetryException extends RuntimeException {
    private static final long serialVersionUID = 3943944816518046414L;
    private final Map<String, String> httpEquivData;

    public HttpEquivRetryException(final Map<String, String> httpEquiv) {
      super();
      this.httpEquivData = httpEquiv;
    }

    public Map<String, String> getHttpEquivData() {
      return httpEquivData;
    }
  }
}
