/*
 * 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.fs.s3native;

import org.apache.commons.lang.StringUtils;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.FileSystem;
import org.apache.hadoop.fs.Path;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLDecoder;
import java.util.Objects;

import static org.apache.commons.lang.StringUtils.equalsIgnoreCase;

/**
 * Class to aid logging in to S3 endpoints.
 * It is in S3N so that it can be used across all S3 filesystems.
 */
public final class S3xLoginHelper {
  private static final Logger LOG =
      LoggerFactory.getLogger(S3xLoginHelper.class);

  private S3xLoginHelper() {
  }

  public static final String LOGIN_WARNING =
      "The Filesystem URI contains login details."
      +" This is insecure and may be unsupported in future.";

  /**
   * Build the filesystem URI. This can include stripping down of part
   * of the URI.
   * @param uri filesystem uri
   * @return the URI to use as the basis for FS operation and qualifying paths.
   * @throws IllegalArgumentException if the URI is in some way invalid.
   */
  public static URI buildFSURI(URI uri) {
    Objects.requireNonNull(uri, "null uri");
    Objects.requireNonNull(uri.getScheme(), "null uri.getScheme()");
    if (uri.getHost() == null && uri.getAuthority() != null) {
      Objects.requireNonNull(uri.getHost(), "null uri host." +
          " This can be caused by unencoded / in the password string");
    }
    Objects.requireNonNull(uri.getHost(), "null uri host.");
    return URI.create(uri.getScheme() + "://" + uri.getHost());
  }

  /**
   * Create a stripped down string value for error messages.
   * @param pathUri URI
   * @return a shortened schema://host/path value
   */
  public static String toString(URI pathUri) {
    return pathUri != null
        ? String.format("%s://%s/%s",
        pathUri.getScheme(), pathUri.getHost(), pathUri.getPath())
        : "(null URI)";
  }

  /**
   * Extract the login details from a URI, logging a warning if
   * the URI contains these.
   * @param name URI of the filesystem
   * @return a login tuple, possibly empty.
   */
  public static Login extractLoginDetailsWithWarnings(URI name) {
    Login login = extractLoginDetails(name);
    if (login.hasLogin()) {
      LOG.warn(LOGIN_WARNING);
    }
    return login;
  }

  /**
   * Extract the login details from a URI.
   * @param name URI of the filesystem
   * @return a login tuple, possibly empty.
   */
  public static Login extractLoginDetails(URI name) {
    try {
      String authority = name.getAuthority();
      if (authority == null) {
        return Login.EMPTY;
      }
      int loginIndex = authority.indexOf('@');
      if (loginIndex < 0) {
        // no login
        return Login.EMPTY;
      }
      String login = authority.substring(0, loginIndex);
      int loginSplit = login.indexOf(':');
      if (loginSplit > 0) {
        String user = login.substring(0, loginSplit);
        String password = URLDecoder.decode(login.substring(loginSplit + 1),
            "UTF-8");
        return new Login(user, password);
      } else if (loginSplit == 0) {
        // there is no user, just a password. In this case, there's no login
        return Login.EMPTY;
      } else {
        return new Login(login, "");
      }
    } catch (UnsupportedEncodingException e) {
      // this should never happen; translate it if it does.
      throw new RuntimeException(e);
    }
  }

  /**
   * Canonicalize the given URI.
   *
   * This strips out login information.
   *
   * @param uri the URI to canonicalize
   * @param defaultPort default port to use in canonicalized URI if the input
   *     URI has no port and this value is greater than 0
   * @return a new, canonicalized URI.
   */
  public static URI canonicalizeUri(URI uri, int defaultPort) {
    if (uri.getPort() == -1 && defaultPort > 0) {
      // reconstruct the uri with the default port set
      try {
        uri = new URI(uri.getScheme(),
            null,
            uri.getHost(),
            defaultPort,
            uri.getPath(),
            uri.getQuery(),
            uri.getFragment());
      } catch (URISyntaxException e) {
        // Should never happen!
        throw new AssertionError("Valid URI became unparseable: " +
            uri);
      }
    }

    return uri;
  }

  /**
   * Check the path, ignoring authentication details.
   * See {@link FileSystem#checkPath(Path)} for the operation of this.
   *
   * Essentially
   * <ol>
   *   <li>The URI is canonicalized.</li>
   *   <li>If the schemas match, the hosts are compared.</li>
   *   <li>If there is a mismatch between null/non-null host, the default FS
   *   values are used to patch in the host.</li>
   * </ol>
   * That all originates in the core FS; the sole change here being to use
   * {@link URI#getHost()} over {@link URI#getAuthority()}. Some of that
   * code looks a relic of the code anti-pattern of using "hdfs:file.txt"
   * to define the path without declaring the hostname. It's retained
   * for compatibility.
   * @param conf FS configuration
   * @param fsUri the FS URI
   * @param path path to check
   * @param defaultPort default port of FS
   */
  public static void checkPath(Configuration conf,
      URI fsUri,
      Path path,
      int defaultPort) {
    URI pathUri = path.toUri();
    String thatScheme = pathUri.getScheme();
    if (thatScheme == null) {
      // fs is relative
      return;
    }
    URI thisUri = canonicalizeUri(fsUri, defaultPort);
    String thisScheme = thisUri.getScheme();
    //hostname and scheme are not case sensitive in these checks
    if (equalsIgnoreCase(thisScheme, thatScheme)) {// schemes match
      String thisHost = thisUri.getHost();
      String thatHost = pathUri.getHost();
      if (thatHost == null &&                // path's host is null
          thisHost != null) {                // fs has a host
        URI defaultUri = FileSystem.getDefaultUri(conf);
        if (equalsIgnoreCase(thisScheme, defaultUri.getScheme())) {
          pathUri = defaultUri; // schemes match, so use this uri instead
        } else {
          pathUri = null; // can't determine auth of the path
        }
      }
      if (pathUri != null) {
        // canonicalize uri before comparing with this fs
        pathUri = canonicalizeUri(pathUri, defaultPort);
        thatHost = pathUri.getHost();
        if (thisHost == thatHost ||       // hosts match
            (thisHost != null &&
                 equalsIgnoreCase(thisHost, thatHost))) {
          return;
        }
      }
    }
    // make sure the exception strips out any auth details
    throw new IllegalArgumentException(
        "Wrong FS " + S3xLoginHelper.toString(pathUri)
            + " -expected " + fsUri);
  }

  /**
   * Simple tuple of login details.
   */
  public static class Login {
    private final String user;
    private final String password;

    public static final Login EMPTY = new Login();

    /**
     * Create an instance with no login details.
     * Calls to {@link #hasLogin()} return false.
     */
    public Login() {
      this("", "");
    }

    public Login(String user, String password) {
      this.user = user;
      this.password = password;
    }

    /**
     * Predicate to verify login details are defined.
     * @return true if the username is defined (not null, not empty).
     */
    public boolean hasLogin() {
      return StringUtils.isNotEmpty(user);
    }

    /**
     * Equality test matches user and password.
     * @param o other object
     * @return true if the objects are considered equivalent.
     */
    @Override
    public boolean equals(Object o) {
      if (this == o) {
        return true;
      }
      if (o == null || getClass() != o.getClass()) {
        return false;
      }
      Login that = (Login) o;
      return Objects.equals(user, that.user) &&
          Objects.equals(password, that.password);
    }

    @Override
    public int hashCode() {
      return Objects.hash(user, password);
    }

    public String getUser() {
      return user;
    }

    public String getPassword() {
      return password;
    }
  }

}
