/**
 * 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.hadoop.nfs;

import java.net.InetAddress;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;

import org.apache.commons.net.util.SubnetUtils;
import org.apache.commons.net.util.SubnetUtils.SubnetInfo;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.CommonConfigurationKeys;
import org.apache.hadoop.nfs.nfs3.Nfs3Constant;
import org.apache.hadoop.util.LightWeightCache;
import org.apache.hadoop.util.LightWeightGSet;
import org.apache.hadoop.util.LightWeightGSet.LinkedElement;
import org.apache.hadoop.util.StringUtils;

import com.google.common.base.Preconditions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * This class provides functionality for loading and checking the mapping 
 * between client hosts and their access privileges.
 */
public class NfsExports {
  
  private static NfsExports exports = null;
  
  public static synchronized NfsExports getInstance(Configuration conf) {
    if (exports == null) {
      String matchHosts = conf.get(
          CommonConfigurationKeys.NFS_EXPORTS_ALLOWED_HOSTS_KEY,
          CommonConfigurationKeys.NFS_EXPORTS_ALLOWED_HOSTS_KEY_DEFAULT);
      int cacheSize = conf.getInt(Nfs3Constant.NFS_EXPORTS_CACHE_SIZE_KEY,
          Nfs3Constant.NFS_EXPORTS_CACHE_SIZE_DEFAULT);
      long expirationPeriodNano = conf.getLong(
          Nfs3Constant.NFS_EXPORTS_CACHE_EXPIRYTIME_MILLIS_KEY,
          Nfs3Constant.NFS_EXPORTS_CACHE_EXPIRYTIME_MILLIS_DEFAULT) * 1000 * 1000;
      try {
        exports = new NfsExports(cacheSize, expirationPeriodNano, matchHosts);
      } catch (IllegalArgumentException e) {
        LOG.error("Invalid NFS Exports provided: ", e);
        return exports;
      }
    }
    return exports;
  }
  
  public static final Logger LOG = LoggerFactory.getLogger(NfsExports.class);
  
  // only support IPv4 now
  private static final String IP_ADDRESS = 
      "(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})";
  private static final String SLASH_FORMAT_SHORT = IP_ADDRESS + "/(\\d{1,3})";
  private static final String SLASH_FORMAT_LONG = IP_ADDRESS + "/" + IP_ADDRESS;
  
  private static final Pattern CIDR_FORMAT_SHORT = 
      Pattern.compile(SLASH_FORMAT_SHORT);
  
  private static final Pattern CIDR_FORMAT_LONG = 
      Pattern.compile(SLASH_FORMAT_LONG);

  // Hostnames are composed of series of 'labels' concatenated with dots.
  // Labels can be between 1-63 characters long, and can only take
  // letters, digits & hyphens. They cannot start and end with hyphens. For
  // more details, refer RFC-1123 & http://en.wikipedia.org/wiki/Hostname
  private static final String LABEL_FORMAT =
      "[a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?";
  private static final Pattern HOSTNAME_FORMAT =
      Pattern.compile("^(" + LABEL_FORMAT + "\\.)*" + LABEL_FORMAT + "$");

  static class AccessCacheEntry implements LightWeightCache.Entry{
    private final String hostAddr;
    private AccessPrivilege access;
    private final long expirationTime; 
    
    private LightWeightGSet.LinkedElement next;
    
    AccessCacheEntry(String hostAddr, AccessPrivilege access,
        long expirationTime) {
      Preconditions.checkArgument(hostAddr != null);
      this.hostAddr = hostAddr;
      this.access = access;
      this.expirationTime = expirationTime;
    }
    
    @Override
    public int hashCode() {
      return hostAddr.hashCode();
    }
    
    @Override
    public boolean equals(Object obj) {
      if (this == obj) {
        return true;
      }
      if (obj instanceof AccessCacheEntry) {
        AccessCacheEntry entry = (AccessCacheEntry) obj;
        return this.hostAddr.equals(entry.hostAddr);
      }
      return false;
    }
    
    @Override
    public void setNext(LinkedElement next) {
      this.next = next;
    }

    @Override
    public LinkedElement getNext() {
      return this.next;
    }

    @Override
    public void setExpirationTime(long timeNano) {
      // we set expiration time in the constructor, and the expiration time 
      // does not change
    }

    @Override
    public long getExpirationTime() {
      return this.expirationTime;
    }
  }

  private final List<Match> mMatches;
  
  private final LightWeightCache<AccessCacheEntry, AccessCacheEntry> accessCache;
  private final long cacheExpirationPeriod;

  /**
   * Constructor.
   * @param cacheSize The size of the access privilege cache.
   * @param expirationPeriodNano The period 
   * @param matchingHosts A string specifying one or multiple matchers. 
   */
  NfsExports(int cacheSize, long expirationPeriodNano, String matchHosts) {
    this.cacheExpirationPeriod = expirationPeriodNano;
    accessCache = new LightWeightCache<AccessCacheEntry, AccessCacheEntry>(
        cacheSize, cacheSize, expirationPeriodNano, 0);        
    String[] matchStrings = matchHosts.split(
        CommonConfigurationKeys.NFS_EXPORTS_ALLOWED_HOSTS_SEPARATOR);
    mMatches = new ArrayList<Match>(matchStrings.length);
    for(String mStr : matchStrings) {
      if (LOG.isDebugEnabled()) {
        LOG.debug("Processing match string '" + mStr + "'");
      }
      mStr = mStr.trim();
      if(!mStr.isEmpty()) {
        mMatches.add(getMatch(mStr));
      }
    }
  }
  
  /**
   * Return the configured group list
   * @return host group list
   */
  public String[] getHostGroupList() {
    int listSize = mMatches.size();
    String[] hostGroups = new String[listSize];

    for (int i = 0; i < mMatches.size(); i++) {
      hostGroups[i] = mMatches.get(i).getHostGroup();
    }
    return hostGroups;
  }
  
  public AccessPrivilege getAccessPrivilege(InetAddress addr) {
    return getAccessPrivilege(addr.getHostAddress(),
        addr.getCanonicalHostName());
  }
  
  AccessPrivilege getAccessPrivilege(String address, String hostname) {
    long now = System.nanoTime();
    AccessCacheEntry newEntry = new AccessCacheEntry(address,
        AccessPrivilege.NONE, now + this.cacheExpirationPeriod);
    // check if there is a cache entry for the given address
    AccessCacheEntry cachedEntry = accessCache.get(newEntry);
    if (cachedEntry != null && now < cachedEntry.expirationTime) {
      // get a non-expired cache entry, use it
      return cachedEntry.access;
    } else {
      for(Match match : mMatches) {
        if(match.isIncluded(address, hostname)) {
          if (match.accessPrivilege == AccessPrivilege.READ_ONLY) {
            newEntry.access = AccessPrivilege.READ_ONLY;
            break;
          } else if (match.accessPrivilege == AccessPrivilege.READ_WRITE) {
            newEntry.access = AccessPrivilege.READ_WRITE;
          }
        }
      }
      accessCache.put(newEntry);
      return newEntry.access;
    }
  }

  private static abstract class Match {
    private final AccessPrivilege accessPrivilege;

    private Match(AccessPrivilege accessPrivilege) {
      this.accessPrivilege = accessPrivilege;
    }

    public abstract boolean isIncluded(String address, String hostname);
    public abstract String getHostGroup();
  }
  
  /**
   * Matcher covering all client hosts (specified by "*")
   */
  private static class AnonymousMatch extends Match {
    private AnonymousMatch(AccessPrivilege accessPrivilege) {
      super(accessPrivilege);
    }
  
    @Override
    public boolean isIncluded(String address, String hostname) {
      return true;
    }

    @Override
    public String getHostGroup() {
      return "*";
    }
  }
  
  /**
   * Matcher using CIDR for client host matching
   */
  private static class CIDRMatch extends Match {
    private final SubnetInfo subnetInfo;
    
    private CIDRMatch(AccessPrivilege accessPrivilege, SubnetInfo subnetInfo) {
      super(accessPrivilege);
      this.subnetInfo = subnetInfo;
    }
    
    @Override
    public boolean isIncluded(String address, String hostname) {
      if(subnetInfo.isInRange(address)) {
        if(LOG.isDebugEnabled()) {
          LOG.debug("CIDRNMatcher low = " + subnetInfo.getLowAddress() +
              ", high = " + subnetInfo.getHighAddress() +
              ", allowing client '" + address + "', '" + hostname + "'");
        }
        return true;
      }
      if(LOG.isDebugEnabled()) {
        LOG.debug("CIDRNMatcher low = " + subnetInfo.getLowAddress() +
            ", high = " + subnetInfo.getHighAddress() +
            ", denying client '" + address + "', '" + hostname + "'");
      }
      return false;
    }

    @Override
    public String getHostGroup() {
      return subnetInfo.getAddress() + "/" + subnetInfo.getNetmask();
    }
  }
  
  /**
   * Matcher requiring exact string match for client host
   */
  private static class ExactMatch extends Match {
    private final String ipOrHost;
    
    private ExactMatch(AccessPrivilege accessPrivilege, String ipOrHost) {
      super(accessPrivilege);
      this.ipOrHost = ipOrHost;
    }
    
    @Override
    public boolean isIncluded(String address, String hostname) {
      if(ipOrHost.equalsIgnoreCase(address) ||
          ipOrHost.equalsIgnoreCase(hostname)) {
        if(LOG.isDebugEnabled()) {
          LOG.debug("ExactMatcher '" + ipOrHost + "', allowing client " +
              "'" + address + "', '" + hostname + "'");
        }
        return true;
      }
      if(LOG.isDebugEnabled()) {
        LOG.debug("ExactMatcher '" + ipOrHost + "', denying client " +
            "'" + address + "', '" + hostname + "'");
      }
      return false;
    }

    @Override
    public String getHostGroup() {
      return ipOrHost;
    }
  }

  /**
   * Matcher where client hosts are specified by regular expression
   */
  private static class RegexMatch extends Match {
    private final Pattern pattern;

    private RegexMatch(AccessPrivilege accessPrivilege, String wildcard) {
      super(accessPrivilege);
      this.pattern = Pattern.compile(wildcard, Pattern.CASE_INSENSITIVE);
    }

    @Override
    public boolean isIncluded(String address, String hostname) {
      if (pattern.matcher(address).matches()
          || pattern.matcher(hostname).matches()) {
        if (LOG.isDebugEnabled()) {
          LOG.debug("RegexMatcher '" + pattern.pattern()
              + "', allowing client '" + address + "', '" + hostname + "'");
        }
        return true;
      }
      if (LOG.isDebugEnabled()) {
        LOG.debug("RegexMatcher '" + pattern.pattern()
            + "', denying client '" + address + "', '" + hostname + "'");
      }
      return false;
    }

    @Override
    public String getHostGroup() {
      return pattern.toString();
    }
  }

  /**
   * Loading a matcher from a string. The default access privilege is read-only.
   * The string contains 1 or 2 parts, separated by whitespace characters, where
   * the first part specifies the client hosts, and the second part (if 
   * existent) specifies the access privilege of the client hosts. I.e.,
   * 
   * "client-hosts [access-privilege]"
   */
  private static Match getMatch(String line) {
    String[] parts = line.split("\\s+");
    final String host;
    AccessPrivilege privilege = AccessPrivilege.READ_ONLY;
    switch (parts.length) {
    case 1:
      host = StringUtils.toLowerCase(parts[0]).trim();
      break;
    case 2:
      host = StringUtils.toLowerCase(parts[0]).trim();
      String option = parts[1].trim();
      if ("rw".equalsIgnoreCase(option)) {
        privilege = AccessPrivilege.READ_WRITE;
      }
      break;
    default:
      throw new IllegalArgumentException("Incorrectly formatted line '" + line
          + "'");
    }
    if (host.equals("*")) {
      if (LOG.isDebugEnabled()) {
        LOG.debug("Using match all for '" + host + "' and " + privilege);
      }
      return new AnonymousMatch(privilege);
    } else if (CIDR_FORMAT_SHORT.matcher(host).matches()) {
      if (LOG.isDebugEnabled()) {
        LOG.debug("Using CIDR match for '" + host + "' and " + privilege);
      }
      return new CIDRMatch(privilege, new SubnetUtils(host).getInfo());
    } else if (CIDR_FORMAT_LONG.matcher(host).matches()) {
      if (LOG.isDebugEnabled()) {
        LOG.debug("Using CIDR match for '" + host + "' and " + privilege);
      }
      String[] pair = host.split("/");
      return new CIDRMatch(privilege,
          new SubnetUtils(pair[0], pair[1]).getInfo());
    } else if (host.contains("*") || host.contains("?") || host.contains("[")
        || host.contains("]") || host.contains("(") || host.contains(")")) {
      if (LOG.isDebugEnabled()) {
        LOG.debug("Using Regex match for '" + host + "' and " + privilege);
      }
      return new RegexMatch(privilege, host);
    } else if (HOSTNAME_FORMAT.matcher(host).matches()) {
      if (LOG.isDebugEnabled()) {
        LOG.debug("Using exact match for '" + host + "' and " + privilege);
      }
      return new ExactMatch(privilege, host);
    } else {
      throw new IllegalArgumentException("Invalid hostname provided '" + host
          + "'");
    }
  }
}
