/*
 * 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.nutch.net.urlnormalizer.basic;

import java.lang.invoke.MethodHandles;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.net.IDN;
import java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.hadoop.conf.Configuration;
import org.apache.nutch.net.URLNormalizer;
import org.apache.nutch.net.URLNormalizers;
import org.apache.nutch.util.NutchConfiguration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Converts URLs to a normal form:
 * <ul>
 * <li>remove dot segments in path: <code>/./</code> or <code>/../</code></li>
 * <li>remove default ports, e.g. 80 for protocol <code>http://</code></li>
 * <li>normalize <a href=
 * "https://en.wikipedia.org/wiki/Percent-encoding#Percent-encoding_in_a_URI">
 * percent-encoding</a> in URL paths</li>
 * </ul>
 */
public class BasicURLNormalizer implements URLNormalizer {
  private static final Logger LOG = LoggerFactory
      .getLogger(MethodHandles.lookup().lookupClass());

  public final static String NORM_HOST_IDN = "urlnormalizer.basic.host.idn";
  public final static String NORM_HOST_TRIM_TRAILING_DOT = "urlnormalizer.basic.host.trim-trailing-dot";

  /**
   * Pattern to detect whether a URL path could be normalized. Contains one of
   * /. or ./ /.. or ../ //
   */
  private final static Pattern hasNormalizablePathPattern = Pattern
      .compile("/[./]|[.]/");

  /**
   * Nutch 1098 - finds URL encoded parts of the URL
   */
  private final static Pattern unescapeRulePattern = Pattern
      .compile("%([0-9A-Fa-f]{2})");
  
  // charset used for encoding URLs before escaping
  private final static Charset utf8 = StandardCharsets.UTF_8;

  /** look-up table for characters which should not be escaped in URL paths */
  private final static boolean[] unescapedCharacters = new boolean[128];
  static {
    for (int c = 0; c < 128; c++) {
      /* https://tools.ietf.org/html/rfc3986#section-2.2
       * For consistency, percent-encoded octets in the ranges of ALPHA
       * (%41-%5A and %61-%7A), DIGIT (%30-%39), hyphen (%2D), period (%2E),
       * underscore (%5F), or tilde (%7E) should not be created by URI
       * producers and, when found in a URI, should be decoded to their
       * corresponding unreserved characters by URI normalizers.
       */
      if (isAlphaNumeric(c)
        || c == 0x2D || c == 0x2E
        || c == 0x5F || c == 0x7E) {
        unescapedCharacters[c] = true;
      } else {
        unescapedCharacters[c] = false;
      }
    }
  }

  /** look-up table for characters which should not be escaped in URL paths */
  private final static boolean[] escapedCharacters = new boolean[128];
  static {
    for (int c = 0; c < 128; c++) {
      if (unescapedCharacters[c]) {
        escapedCharacters[c] = false;
      } else if (c < 0x21 // control character or space
          || c == 0x22 // "
          || c == 0x3C // <
          || c == 0x3E // >
          || c == 0x5B // [
          || c == 0x5D // ]
          || c == 0x5E // ^
          || c == 0x60 // `
          || c == 0x7B // {
          || c == 0x7C // |
          || c == 0x7D // }
          || c == 0x7F // DEL
          ) {
        escapedCharacters[c] = true;
      } else {
        LOG.debug("Character {} ({}) not handled as escaped or unescaped", c,
            (char) c);
      }
    }
  }

  private static boolean isAlphaNumeric(int c) {
    return (0x41 <= c && c <= 0x5A)
        || (0x61 <= c && c <= 0x7A)
        || (0x30 <= c && c <= 0x39);
  }

  private static boolean isHexCharacter(int c) {
    return (0x41 <= c && c <= 0x46)
        || (0x61 <= c && c <= 0x66)
        || (0x30 <= c && c <= 0x39);
  }

  private static boolean isAscii(String str) {
    char[] chars = str.toCharArray();
    for (char c : chars) {
      if (c > 127) {
        return false;
      }
    }
    return true;
  }

  private Configuration conf;

  private boolean hostIDNtoASCII;
  private boolean hostASCIItoIDN;
  private boolean hostTrimTrailingDot;

  @Override
  public Configuration getConf() {
    return conf;
  }

  @Override
  public void setConf(Configuration conf) {
    this.conf = conf;
    String normIdn = conf.get(NORM_HOST_IDN, "");
    if (normIdn.equalsIgnoreCase("toAscii")) {
      hostIDNtoASCII = true;
    } else if (normIdn.equalsIgnoreCase("toUnicode")) {
      hostASCIItoIDN = true;
    }
    hostTrimTrailingDot = conf.getBoolean(NORM_HOST_TRIM_TRAILING_DOT, false);
  }

  @Override
  public String normalize(String urlString, String scope)
      throws MalformedURLException {
    
    if ("".equals(urlString)) // permit empty
      return urlString;

    urlString = urlString.trim(); // remove extra spaces

    URL url = new URL(urlString);

    String protocol = url.getProtocol();
    String host = url.getHost();
    int port = url.getPort();
    String file = url.getFile();

    boolean changed = false;
    boolean normalizePath = false;

    if (!urlString.startsWith(protocol)) // protocol was lowercased
      changed = true;

    if ("http".equals(protocol) || "https".equals(protocol)
        || "ftp".equals(protocol)) {

      if (host != null && url.getAuthority() != null) {
        String newHost = normalizeHostName(host);
        if (!host.equals(newHost)) {
          host = newHost;
          changed = true;
        } else if (!url.getAuthority().equals(newHost)) {
          // authority (http://<...>/) contains other elements (port, user,
          // etc.) which will likely cause a change if left away
          changed = true;
        }
      } else {
        // no host or authority: recompose the URL from components
        changed = true;
      }

      if (port == url.getDefaultPort()) { // uses default port
        port = -1; // so don't specify it
        changed = true;
      }

      normalizePath = true;
      if (file == null || "".equals(file)) {
        file = "/";
        changed = true;
        normalizePath = false; // no further path normalization required
      } else if (!file.startsWith("/")) {
        file = "/" + file;
        changed = true;
        normalizePath = false; // no further path normalization required
      }

      if (url.getRef() != null) { // remove the ref
        changed = true;
      }

    } else if (protocol.equals("file")) {
      normalizePath = true;
    }

    // properly encode characters in path/file using percent-encoding
    String file2 = unescapePath(file);
    file2 = escapePath(file2);
    if (!file.equals(file2)) {
      changed = true;
      file = file2;
    }

    if (normalizePath) {
      // check for unnecessary use of "/../", "/./", and "//"
      if (changed) {
        URL u = new URL(protocol, host, port, file);
        file2 = getFileWithNormalizedPath(u);
      } else {
        file2 = getFileWithNormalizedPath(url);
      }
      if (!file.equals(file2)) {
        changed = true;
        file = file2;
      }
    }

    if (changed) {
      if (protocol.equals("http") || protocol.equals("https")
          || url.getUserInfo() == null) {
        url = new URL(protocol, host, port, file);
        urlString = url.toString();
      } else {
        /*
         * NUTCH-3087 - userinfo is required for protocols with frequent
         * authentication. Note: need to build the URL string directly, because
         * there is no URL constructor which takes the userinfo as parameter.
         */
        StringBuilder sb = new StringBuilder();
        sb.append(protocol).append("://").append(url.getUserInfo()).append('@')
            .append(host);
        if (port != -1) {
          sb.append(':').append(port);
        }
        sb.append(file);
        urlString = sb.toString();
      }
    }

    return urlString;
  }

  private String getFileWithNormalizedPath(URL url)
      throws MalformedURLException {
    String file;

    if (hasNormalizablePathPattern.matcher(url.getPath()).find()) {
      // only normalize the path if there is something to normalize
      // to avoid needless work
      try {
        file = url.toURI().normalize().toURL().getFile();
        // URI.normalize() does not normalize leading dot segments,
        // see also https://tools.ietf.org/html/rfc3986#section-5.2.4
        int start = 0;
        while (file.startsWith("/..", start)
            && ((start + 3) == file.length() || file.charAt(3) == '/')) {
          start += 3;
        }
        if (start > 0) {
          file = file.substring(start);
        }
      } catch (URISyntaxException e) {
        file = url.getFile();
      }
    } else {
      file = url.getFile();
    }

    // if path is empty return a single slash
    if (file.isEmpty()) {
      file = "/";
    } else if (!file.startsWith("/")) {
      file = "/" + file;
    }

    return file;
  }
  
  /**
   * Remove % encoding from path segment in URL for characters which should be
   * unescaped according to <a
   * href="https://tools.ietf.org/html/rfc3986#section-2.2">RFC3986</a>.
   */
  private String unescapePath(String path) {
    StringBuilder sb = new StringBuilder();
    
    Matcher matcher = unescapeRulePattern.matcher(path);
    
    int end = -1;
    int letter;

    // Traverse over all encoded groups
    while (matcher.find()) {
      // Append everything up to this group
      sb.append(path.substring(end + 1, matcher.start()));
      
      // Get the integer representation of this hexadecimal encoded character
      letter = Integer.valueOf(matcher.group().substring(1), 16);

      if (letter < 128 && unescapedCharacters[letter]) {
        // character should be unescaped in URLs
        sb.append(Character.valueOf((char)letter));
      } else {
        // Append the encoded character as uppercase
        sb.append(matcher.group().toUpperCase(Locale.ROOT));
      }
      
      end = matcher.start() + 2;
    }
    
    letter = path.length();
    
    // Append the rest if there's anything
    if (end <= letter - 1) {
      sb.append(path.substring(end + 1, letter));
    }

    // Ok!
    return sb.toString();
  }

  /**
   * Convert path segment of URL from Unicode to UTF-8 and escape all
   * characters which should be escaped according to <a
   * href="https://tools.ietf.org/html/rfc3986#section-2.2">RFC3986</a>..
   */
  private String escapePath(String path) {
    StringBuilder sb = new StringBuilder(path.length());

    // Traverse over all bytes in this URL
    byte[] bytes = path.getBytes(utf8);
    for (int i = 0; i < bytes.length; i++) {
      byte b = bytes[i];
      // Is this a control character?
      if (b < 0 || escapedCharacters[b]) {
        // Start escape sequence 
        sb.append('%');
        
        // Get this byte's hexadecimal representation 
        String hex = Integer.toHexString(b & 0xFF).toUpperCase(Locale.ROOT);
        
        // Do we need to prepend a zero?
        if (hex.length() % 2 != 0 ) {
          sb.append('0');
          sb.append(hex);
        } else {
          // No, append this hexadecimal representation
          sb.append(hex);
        }
      } else if (b == 0x25) {
        // percent sign (%): read-ahead to check whether a valid escape sequence
        if ((i+2) >= bytes.length) {
          // need at least two more characters
          sb.append("%25");
        } else {
          byte e1 = bytes[i+1];
          byte e2 = bytes[i+2];
          if (isHexCharacter(e1) && isHexCharacter(e2)) {
            // valid percent encoding, output and fast-forward
            i += 2;
            sb.append((char) b);
            sb.append((char) e1);
            sb.append((char) e2);
          } else {
            sb.append("%25");
          }
        }
      } else {
        // No, just append this character as-is
        sb.append((char) b);
      }
    }
    
    return sb.toString();
  }

  private String normalizeHostName(String host) throws MalformedURLException {

    // 1. unescape percent-encoded characters in host name
    if (host.indexOf('%') != -1) {
      try {
        host = URLDecoder.decode(host, StandardCharsets.UTF_8.toString());
      } catch (UnsupportedEncodingException | IllegalArgumentException e) {
        LOG.debug("Failed to convert percent-encoded host name {}: ", host, e);
        throw (MalformedURLException) new MalformedURLException(
            "Invalid percent-encoded host name " + host + ": " + e.getMessage())
                .initCause(e);
      }
    }

    // 2. lowercase host name
    host = host.toLowerCase(Locale.ROOT);

    // 3. if configured: convert between Unicode and ASCII forms
    //    for Internationalized Domain Names (IDNs)
    if (hostIDNtoASCII && !isAscii(host)) {
      try {
        host = IDN.toASCII(host);
      } catch (IllegalArgumentException | IndexOutOfBoundsException e) {
        // IllegalArgumentException: thrown if the input string contains
        // non-convertible Unicode codepoints
        // IndexOutOfBoundsException: thrown (undocumented) if one "label"
        // (non-ASCII dot-separated segment) is longer than 256 characters,
        // cf. https://bugs.openjdk.java.net/browse/JDK-6806873
        LOG.debug("Failed to convert IDN host {}: ", host, e);
        throw (MalformedURLException) new MalformedURLException(
            "Invalid IDN " + host + ": " + e.getMessage()).initCause(e);
      }
    } else if (hostASCIItoIDN && host.contains("xn--")) {
      host = IDN.toUnicode(host);
    }

    // 4. optionally trim a trailing dot
    if (hostTrimTrailingDot) {
      if (host.endsWith(".")) {
        host = host.substring(0, host.length()-1);
      }
    }

    return host;
  }

  public static void main(String args[]) throws IOException {
    BasicURLNormalizer normalizer = new BasicURLNormalizer();
    normalizer.setConf(NutchConfiguration.create());
    String scope = URLNormalizers.SCOPE_DEFAULT;
    if (args.length >= 1) {
      scope = args[0];
      System.out.println("Scope: " + scope);
    }
    String line, normUrl;
    BufferedReader in = new BufferedReader(
        new InputStreamReader(System.in, utf8));
    while ((line = in.readLine()) != null) {
      try {
        normUrl = normalizer.normalize(line, scope);
        System.out.println(normUrl);
      } catch (MalformedURLException e) {
        System.out.println("failed: " + line);
      }
    }
    System.exit(0);
  }

}
