/*
 * Copyright 2018 The gRPC Authors
 *
 * Licensed 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 io.grpc.internal;

import android.annotation.SuppressLint;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Verify;
import io.grpc.internal.DnsNameResolver.ResourceResolver;
import io.grpc.internal.DnsNameResolver.SrvRecord;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Hashtable;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import javax.annotation.Nullable;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.directory.Attribute;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement;

/**
 * {@link JndiResourceResolverFactory} resolves additional records for the DnsNameResolver.
 */
final class JndiResourceResolverFactory implements DnsNameResolver.ResourceResolverFactory {

  @Nullable
  @SuppressWarnings("StaticAssignmentOfThrowable")
  private static final Throwable JNDI_UNAVAILABILITY_CAUSE = initJndi();

  // @UsedReflectively
  public JndiResourceResolverFactory() {}

  /**
   * Returns whether the JNDI DNS resolver is available.  This is accomplished by looking up a
   * particular class.  It is believed to be the default (only?) DNS resolver that will actually be
   * used.  It is provided by the OpenJDK, but unlikely Android.  Actual resolution will be done by
   * using a service provider when a hostname query is present, so the {@code DnsContextFactory}
   * may not actually be used to perform the query.  This is believed to be "okay."
   */
  @Nullable
  private static Throwable initJndi() {
    try {
      Class.forName("javax.naming.directory.InitialDirContext");
      Class.forName("com.sun.jndi.dns.DnsContextFactory");
    } catch (ClassNotFoundException e) {
      return e;
    } catch (RuntimeException e) {
      return e;
    } catch (Error e) {
      return e;
    }
    return null;
  }

  @Nullable
  @Override
  public ResourceResolver newResourceResolver() {
    if (unavailabilityCause() != null) {
      return null;
    }
    return new JndiResourceResolver(new JndiRecordFetcher());
  }

  @Nullable
  @Override
  public Throwable unavailabilityCause() {
    return JNDI_UNAVAILABILITY_CAUSE;
  }

  @VisibleForTesting
  interface RecordFetcher {
    List<String> getAllRecords(String recordType, String name) throws NamingException;
  }

  @VisibleForTesting
  static final class JndiResourceResolver implements DnsNameResolver.ResourceResolver {
    private static final Logger logger =
        Logger.getLogger(JndiResourceResolver.class.getName());

    private static final Pattern whitespace = Pattern.compile("\\s+");

    private final RecordFetcher recordFetcher;

    public JndiResourceResolver(RecordFetcher recordFetcher) {
      this.recordFetcher = recordFetcher;
    }

    @Override
    public List<String> resolveTxt(String serviceConfigHostname) throws NamingException {
      if (logger.isLoggable(Level.FINER)) {
        logger.log(
            Level.FINER, "About to query TXT records for {0}", new Object[]{serviceConfigHostname});
      }
      List<String> serviceConfigRawTxtRecords =
          recordFetcher.getAllRecords("TXT", "dns:///" + serviceConfigHostname);
      if (logger.isLoggable(Level.FINER)) {
        logger.log(
            Level.FINER, "Found {0} TXT records", new Object[]{serviceConfigRawTxtRecords.size()});
      }
      List<String> serviceConfigTxtRecords =
          new ArrayList<>(serviceConfigRawTxtRecords.size());
      for (String serviceConfigRawTxtRecord : serviceConfigRawTxtRecords) {
        serviceConfigTxtRecords.add(unquote(serviceConfigRawTxtRecord));
      }
      return Collections.unmodifiableList(serviceConfigTxtRecords);
    }

    @Override
    public List<SrvRecord> resolveSrv(String host) throws Exception {
      if (logger.isLoggable(Level.FINER)) {
        logger.log(
            Level.FINER, "About to query SRV records for {0}", new Object[]{host});
      }
      List<String> rawSrvRecords =
          recordFetcher.getAllRecords("SRV", "dns:///" + host);
      if (logger.isLoggable(Level.FINER)) {
        logger.log(
            Level.FINER, "Found {0} SRV records", new Object[]{rawSrvRecords.size()});
      }
      List<SrvRecord> srvRecords = new ArrayList<>(rawSrvRecords.size());
      Exception first = null;
      Level level = Level.WARNING;
      for (String rawSrv : rawSrvRecords) {
        try {
          String[] parts = whitespace.split(rawSrv, 5);
          Verify.verify(parts.length == 4, "Bad SRV Record: %s", rawSrv);
          // SRV requires the host name to be absolute
          if (!parts[3].endsWith(".")) {
            throw new RuntimeException("Returned SRV host does not end in period: " + parts[3]);
          }
          srvRecords.add(new SrvRecord(parts[3], Integer.parseInt(parts[2])));
        } catch (RuntimeException e) {
          logger.log(level, "Failed to construct SRV record " + rawSrv, e);
          if (first == null) {
            first = e;
            level = Level.FINE;
          }
        }
      }
      if (srvRecords.isEmpty() && first != null) {
        throw first;
      }
      return Collections.unmodifiableList(srvRecords);
    }

    /**
     * Undo the quoting done in {@link com.sun.jndi.dns.ResourceRecord#decodeTxt}.
     */
    @VisibleForTesting
    static String unquote(String txtRecord) {
      StringBuilder sb = new StringBuilder(txtRecord.length());
      boolean inquote = false;
      for (int i = 0; i < txtRecord.length(); i++) {
        char c = txtRecord.charAt(i);
        if (!inquote) {
          if (c == ' ') {
            continue;
          } else if (c == '"') {
            inquote = true;
            continue;
          }
        } else {
          if (c == '"') {
            inquote = false;
            continue;
          } else if (c == '\\') {
            c = txtRecord.charAt(++i);
            assert c == '"' || c == '\\';
          }
        }
        sb.append(c);
      }
      return sb.toString();
    }
  }

  @VisibleForTesting
  @IgnoreJRERequirement
  @SuppressWarnings({"JdkObsolete", "BanJNDI"})
  // javax.naming.* is only loaded reflectively and is never loaded for Android
  // The lint issue id is supposed to be "InvalidPackage" but it doesn't work, don't know why.
  // Use "all" as the lint issue id to suppress all types of lint error.
  @SuppressLint("all")
  static final class JndiRecordFetcher implements RecordFetcher {
    @Override
    public List<String> getAllRecords(String recordType, String name) throws NamingException {
      checkAvailable();
      String[] rrType = new String[]{recordType};
      List<String> records = new ArrayList<>();

      Hashtable<String, String> env = new Hashtable<>();
      env.put("com.sun.jndi.ldap.connect.timeout", "5000");
      env.put("com.sun.jndi.ldap.read.timeout", "5000");
      DirContext dirContext = new InitialDirContext(env);

      try {
        javax.naming.directory.Attributes attrs = dirContext.getAttributes(name, rrType);
        NamingEnumeration<? extends Attribute> rrGroups = attrs.getAll();

        try {
          while (rrGroups.hasMore()) {
            Attribute rrEntry = rrGroups.next();
            assert Arrays.asList(rrType).contains(rrEntry.getID());
            NamingEnumeration<?> rrValues = rrEntry.getAll();
            try {
              while (rrValues.hasMore()) {
                records.add(String.valueOf(rrValues.next()));
              }
            } catch (NamingException ne) {
              closeThenThrow(rrValues, ne);
            }
            rrValues.close();
          }
        } catch (NamingException ne) {
          closeThenThrow(rrGroups, ne);
        }
        rrGroups.close();
      } catch (NamingException ne) {
        closeThenThrow(dirContext, ne);
      }
      dirContext.close();

      return records;
    }

    private static void closeThenThrow(NamingEnumeration<?> namingEnumeration, NamingException e)
        throws NamingException {
      try {
        namingEnumeration.close();
      } catch (NamingException ignored) {
        // ignore
      }
      throw e;
    }

    private static void closeThenThrow(DirContext ctx, NamingException e) throws NamingException {
      try {
        ctx.close();
      } catch (NamingException ignored) {
        // ignore
      }
      throw e;
    }

    private static void checkAvailable() {
      if (JNDI_UNAVAILABILITY_CAUSE != null) {
        throw new UnsupportedOperationException(
            "JNDI is not currently available", JNDI_UNAVAILABILITY_CAUSE);
      }
    }
  }
}
