// Copyright 2016 The Bazel Authors. All rights reserved.
//
// 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 com.google.devtools.build.android;

import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Strings.emptyToNull;

import com.android.SdkConstants;
import com.android.annotations.VisibleForTesting;
import com.android.ide.common.resources.configuration.FolderConfiguration;
import com.android.ide.common.resources.configuration.ResourceQualifier;
import com.android.resources.ResourceFolderType;
import com.android.resources.ResourceType;
import com.google.common.base.Ascii;
import com.google.common.base.Joiner;
import com.google.common.base.MoreObjects;
import com.google.common.base.Preconditions;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterators;
import com.google.common.collect.PeekingIterator;
import com.google.devtools.build.android.proto.SerializeFormat;
import com.google.devtools.build.android.xml.ResourcesAttribute;
import com.google.errorprone.annotations.CheckReturnValue;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.concurrent.Immutable;

/**
 * Represents a fully qualified name for an android resource.
 *
 * <p>Each resource name consists of the resource package, name, type, and qualifiers.
 */
// TODO(b/146498565): remove and/or replace.  For normal resources this can just be a ResourceName
// with Configuration, and the latter can come from aapt2 directly.  tools:* attributes should be a
// separate DataKey; as FullyQualifiedName they all have the same package, same singleton
// VirtualType, and same empty qualifiers.
@Immutable
public class FullyQualifiedName implements DataKey {
  public static final String DEFAULT_PACKAGE = "res-auto";
  private static final Joiner DASH_JOINER = Joiner.on('-');
  // To save on memory, always return one instance for each FullyQualifiedName.
  // Using a HashMap to deduplicate the instances -- the key retrieves a single instance.
  private static final ConcurrentMap<FullyQualifiedName, FullyQualifiedName> instanceCache =
      new ConcurrentHashMap<>();

  private static final AtomicInteger cacheHit = new AtomicInteger(0);
  private final String pkg;
  // TODO(b/146498565): use com.android.aapt.ConfigurationOuterClass.Configuration.
  private final ImmutableList<String> qualifiers;
  private final Type type;
  private final String name;

  private FullyQualifiedName(String pkg, ImmutableList<String> qualifiers, Type type, String name) {
    Preconditions.checkArgument(!pkg.isEmpty());
    this.pkg = pkg;
    this.qualifiers = qualifiers;
    this.type = type;
    this.name = name;
  }

  private static Type createTypeFrom(String rawType) {
    ResourceType resourceType = ResourceType.getEnum(rawType);
    VirtualType virtualType = VirtualType.getEnum(rawType);
    if (resourceType != null) {
      return new ResourceTypeWrapper(resourceType);
    } else if (virtualType != null) {
      return virtualType;
    }
    return null;
  }

  /**
   * Creates a new FullyQualifiedName with normalized qualifiers.
   *
   * @param rawPkg The resource package of the name. If unknown the default should be "res-auto"
   * @param qualifiers The resource qualifiers of the name, such as "en" or "xhdpi".
   * @param type The type of the name.
   * @param name The name of the name.
   * @return A new FullyQualifiedName.
   */
  public static FullyQualifiedName of(
      String rawPkg, List<String> qualifiers, Type type, String name) {
    checkNotNull(rawPkg);
    checkNotNull(qualifiers);
    checkNotNull(type);
    checkNotNull(name);
    ImmutableList<String> immutableQualifiers = ImmutableList.copyOf(qualifiers);
    String pkg = rawPkg.isEmpty() ? DEFAULT_PACKAGE : rawPkg;
    // TODO(corysmith): Address the GC thrash this creates by managing a simplified, mutable key to
    // do the instance check.
    FullyQualifiedName fqn = new FullyQualifiedName(pkg, immutableQualifiers, type, name);
    // Use putIfAbsent to get the canonical instance, if there. If it isn't, putIfAbsent will
    // return null, and we should return the current instance.
    FullyQualifiedName cached = instanceCache.putIfAbsent(fqn, fqn);
    if (cached == null) {
      return fqn;
    } else {
      cacheHit.incrementAndGet();
      return cached;
    }
  }

  /**
   * Creates a new FullyQualifiedName with normalized qualifiers.
   *
   * @param pkg The resource package of the name. If unknown the default should be "res-auto"
   * @param qualifiers The resource qualifiers of the name, such as "en" or "xhdpi".
   * @param type The resource type of the name.
   * @param name The name of the name.
   * @return A new FullyQualifiedName.
   */
  static FullyQualifiedName of(
      String pkg, List<String> qualifiers, ResourceType type, String name) {
    return of(pkg, qualifiers, new ResourceTypeWrapper(type), name);
  }

  public static FullyQualifiedName fromProto(SerializeFormat.DataKey protoKey) {
    return of(
        protoKey.getKeyPackage(),
        protoKey.getQualifiersList(),
        createTypeFrom(protoKey.getResourceType()),
        protoKey.getKeyValue());
  }

  // Note that while "$" is not allowed in source code, it's used in generated names (precisely to
  // avoid colliding with source code).
  static final Pattern QUALIFIED_REFERENCE =
      Pattern.compile("^((?<package>[^:]+):)?(?<type>\\w+)/(?<name>[A-Za-z0-9_.$]+)$");

  public static FullyQualifiedName fromReference(
      String qualifiedReference, Optional<String> packageName) {
    final Matcher matcher = QUALIFIED_REFERENCE.matcher(qualifiedReference);
    Preconditions.checkArgument(
        matcher.matches(),
        "%s is not a reference. Expected %s",
        qualifiedReference,
        QUALIFIED_REFERENCE.pattern());
    return of(
        Optional.ofNullable(emptyToNull(matcher.group("package")))
            .orElse(packageName.orElse(DEFAULT_PACKAGE)),
        ImmutableList.of(),
        ResourceType.getEnum(matcher.group("type")),
        matcher.group("name"));
  }

  public static void logCacheUsage(Logger logger) {
    logger.fine(
        String.format(
            "Total FullyQualifiedName instance cache hits %s out of %s",
            cacheHit.intValue(), instanceCache.size()));
  }

  /**
   * Returns a string path representation of the FullyQualifiedName.
   *
   * <p>Non-values Android Resource have a well defined file layout: From the resource directory,
   * they reside in &lt;resource type&gt;[-&lt;qualifier&gt;]/&lt;resource name&gt;[.extension]
   *
   * @param source The original source of the file-based resource's FullyQualifiedName
   * @return A string representation of the FullyQualifiedName with the provided extension.
   */
  public String toPathString(Path source) {
    String sourceExtension = FullyQualifiedName.Factory.getSourceExtension(source);
    return Paths.get(
            DASH_JOINER.join(
                ImmutableList.<String>builder().add(type.getName()).addAll(qualifiers).build()),
            name + sourceExtension)
        .toString();
  }

  @Override
  public String toPrettyString() {
    // TODO(corysmith): Add package when we start tracking it.
    return String.format(
        "%s%s/%s",
        DEFAULT_PACKAGE.equals(pkg) ? "" : pkg + ':',
        DASH_JOINER.join(
            ImmutableList.<String>builder().add(type.getName()).addAll(qualifiers).build()),
        name);
  }

  /**
   * Returns the string path representation of the values directory and qualifiers.
   *
   * <p>Certain resource types live in the "values" directory. This will calculate the directory and
   * ensure the qualifiers are represented.
   */
  // TODO(corysmith): Combine this with toPathString to clean up the interface of FullyQualifiedName
  // logically, the FullyQualifiedName should just be able to provide the relative path string for
  // the resource.
  public String valuesPath() {
    return Paths.get(
            DASH_JOINER.join(
                ImmutableList.<String>builder().add("values").addAll(qualifiers).build()),
            "values.xml")
        .toString();
  }

  @VisibleForTesting
  public String asUnqualifedName() {
    return String.format(
        "%s/%s",
        DASH_JOINER.join(
            ImmutableList.<String>builder().add(type.getName()).addAll(qualifiers).build()),
        name);
  }

  public String name() {
    return name;
  }

  public boolean isInPackage(String packageName) {
    return pkg.equals(packageName);
  }

  /** Provides the name qualified by the package it belongs to. */
  public String qualifiedName() {
    return (pkg.equals(DEFAULT_PACKAGE) ? "" : pkg + ":") + name;
  }

  public String asQualifiedReference() {
    return String.format(
        "%s%s/%s", (pkg.equals(DEFAULT_PACKAGE) ? "" : pkg + ":"), type.getName(), name);
  }

  public ResourceType type() {
    if (type instanceof ResourceTypeWrapper) {
      return ((ResourceTypeWrapper) type).resourceType;
    }
    return null;
  }

  public boolean isOverwritable() {
    return type.isOverwritable(this);
  }

  /** Creates a FullyQualifiedName from this one with a different package. */
  @CheckReturnValue
  public FullyQualifiedName replacePackage(String newPackage) {
    if (pkg.equals(newPackage)) {
      return this;
    }
    return of(newPackage, qualifiers, type, name);
  }

  @Override
  public int hashCode() {
    return Objects.hash(pkg, qualifiers, type, name);
  }

  @Override
  public boolean equals(Object obj) {
    if (!(obj instanceof FullyQualifiedName)) {
      return false;
    }

    FullyQualifiedName other = getClass().cast(obj);
    return Objects.equals(pkg, other.pkg)
        && Objects.equals(type, other.type)
        && Objects.equals(name, other.name)
        && Objects.equals(qualifiers, other.qualifiers);
  }

  @Override
  public String toString() {
    return MoreObjects.toStringHelper(getClass())
        .add("pkg", pkg)
        .add("qualifiers", qualifiers)
        .add("type", type)
        .add("name", name)
        .toString();
  }

  @Override
  public int compareTo(DataKey otherKey) {
    if (!(otherKey instanceof FullyQualifiedName)) {
      return getKeyType().compareTo(otherKey.getKeyType());
    }
    FullyQualifiedName other = (FullyQualifiedName) otherKey;
    if (!pkg.equals(other.pkg)) {
      return pkg.compareTo(other.pkg);
    }
    if (!type.equals(other.type)) {
      return type.compareTo(other.type);
    }
    if (!name.equals(other.name)) {
      return name.compareTo(other.name);
    }
    if (!qualifiers.equals(other.qualifiers)) {
      if (qualifiers.size() != other.qualifiers.size()) {
        return qualifiers.size() - other.qualifiers.size();
      }
      // This works because the qualifiers are always in an ordered sequence.
      return qualifiers.toString().compareTo(other.qualifiers.toString());
    }
    return 0;
  }

  @Override
  public KeyType getKeyType() {
    return KeyType.FULL_QUALIFIED_NAME;
  }

  @Override
  public boolean shouldDetectConflicts() {
    // Ignore conflicts among pseudolocales.
    return qualifiers.stream()
        .noneMatch(q -> Ascii.equalsIgnoreCase(q, "en-rXA") || Ascii.equalsIgnoreCase(q, "ar-rXB"));
  }

  @Override
  public void serializeTo(OutputStream out, int valueSize) throws IOException {
    toSerializedBuilder().setValueSize(valueSize).build().writeDelimitedTo(out);
  }

  public SerializeFormat.DataKey.Builder toSerializedBuilder() {
    return SerializeFormat.DataKey.newBuilder()
        .setKeyPackage(pkg)
        .setResourceType(type.getName())
        .addAllQualifiers(qualifiers)
        .setKeyValue(name);
  }

  /** The non-resource {@link Type}s of a {@link FullyQualifiedName}. */
  public enum VirtualType implements Type {
    RESOURCES_ATTRIBUTE("<resources>", "Resources Attribute");

    private final String name;
    private final String displayName;

    private VirtualType(String name, String displayName) {
      this.name = name;
      this.displayName = displayName;
    }

    /** Returns the enum represented by the {@code name}. */
    public static VirtualType getEnum(String name) {
      for (VirtualType type : values()) {
        if (type.name.equals(name)) {
          return type;
        }
      }
      return null;
    }

    /** Returns an array with all the names defined by this enum. */
    public static String[] getNames() {
      VirtualType[] values = values();
      String[] names = new String[values.length];
      for (int i = values.length - 1; i >= 0; --i) {
        names[i] = values[i].getName();
      }
      return names;
    }

    /** Returns the resource type name. */
    @Override
    public String getName() {
      return name;
    }

    /** Returns a translated display name for the resource type. */
    public String getDisplayName() {
      return displayName;
    }

    @Override
    public ConcreteType getType() {
      return ConcreteType.VIRTUAL_TYPE;
    }

    @Override
    public boolean isOverwritable(FullyQualifiedName fqn) {
      if (this == RESOURCES_ATTRIBUTE) {
        return !ResourcesAttribute.AttributeType.from(fqn.name()).isCombining();
      }
      return true;
    }

    @Override
    public int compareTo(Type other) {
      if (!(other instanceof VirtualType)) {
        return getType().compareTo(other.getType());
      }
      return compareTo(((VirtualType) other));
    }

    @Override
    public String toString() {
      return getName();
    }
  }

  /** Represents the type of a {@link FullyQualifiedName}. */
  public interface Type {
    String getName();

    ConcreteType getType();

    boolean isOverwritable(FullyQualifiedName fqn);

    int compareTo(Type other);

    @Override
    boolean equals(Object obj);

    @Override
    int hashCode();

    @Override
    String toString();

    /**
     * The category of type that a {@link Type} can be.
     *
     * <p><em>Note:</em> used for strict ordering of {@link FullyQualifiedName}s.
     */
    enum ConcreteType {
      RESOURCE_TYPE,
      VIRTUAL_TYPE;
    }
  }

  private static class ResourceTypeWrapper implements Type {
    private final ResourceType resourceType;

    public ResourceTypeWrapper(ResourceType resourceType) {
      this.resourceType = resourceType;
    }

    @Override
    public String getName() {
      return resourceType.getName();
    }

    @Override
    public ConcreteType getType() {
      return ConcreteType.RESOURCE_TYPE;
    }

    @Override
    public boolean isOverwritable(FullyQualifiedName fqn) {
      return !(resourceType == ResourceType.ID
          || resourceType == ResourceType.PUBLIC
          || resourceType == ResourceType.STYLEABLE);
    }

    @Override
    public int compareTo(Type other) {
      if (!(other instanceof ResourceTypeWrapper)) {
        return getType().compareTo(other.getType());
      }
      return resourceType.compareTo(((ResourceTypeWrapper) other).resourceType);
    }

    @Override
    public boolean equals(Object obj) {
      if (!(obj instanceof ResourceTypeWrapper)) {
        return false;
      }
      ResourceTypeWrapper other = (ResourceTypeWrapper) obj;
      return Objects.equals(resourceType, other.resourceType);
    }

    @Override
    public int hashCode() {
      return Objects.hashCode(resourceType);
    }

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

  /** Represents the configuration qualifiers in a resource directory. */
  public static class Qualifiers {

    private static final Qualifiers EMPTY_QUALIFIERS =
        new Qualifiers(null, ImmutableList.of(), false);

    // Qualifiers are reasonably expensive to create, so cache them on directory names.
    private static final ConcurrentMap<String, Qualifiers> qualifierCache =
        new ConcurrentHashMap<>();

    public static final String INVALID_QUALIFIERS = "%s contains invalid qualifiers.";
    private final ResourceFolderType folderType;
    private final ImmutableList<String> qualifiers;
    private boolean defaultLocale;

    private Qualifiers(
        ResourceFolderType folderType, ImmutableList<String> qualifiers, boolean defaultLocale) {
      this.folderType = folderType;
      this.qualifiers = qualifiers;
      this.defaultLocale = defaultLocale;
    }

    public static Qualifiers parseFrom(String directoryName) {
      return qualifierCache.computeIfAbsent(
          directoryName, d -> getQualifiers(Splitter.on(SdkConstants.RES_QUALIFIER_SEP).split(d)));
    }

    private static Qualifiers getQualifiers(String... dirNameAndQualifiers) {
      return getQualifiers(Arrays.asList(dirNameAndQualifiers));
    }

    private static Qualifiers getQualifiers(Iterable<String> dirNameAndQualifiers) {
      PeekingIterator<String> rawQualifiers =
          Iterators.peekingIterator(dirNameAndQualifiers.iterator());
      // Remove directory name
      final ResourceFolderType folderType = ResourceFolderType.getTypeByName(rawQualifiers.next());

      // If there is no folder type, there are no qualifiers to parse.
      if (folderType == null) {
        return EMPTY_QUALIFIERS;
      }

      List<String> handledQualifiers = new ArrayList<>();
      // Do some substitution of language/region qualifiers.
      while (rawQualifiers.hasNext()) {
        handledQualifiers.add(rawQualifiers.next());
      }
      // Create a configuration
      FolderConfiguration config = FolderConfiguration.getConfigFromQualifiers(handledQualifiers);
      // FolderConfiguration returns an unhelpful null when it considers the qualifiers to be
      // invalid.
      if (config == null) {
        throw new IllegalArgumentException(
            String.format(INVALID_QUALIFIERS, DASH_JOINER.join(dirNameAndQualifiers)));
      }
      config.normalize();

      ImmutableList.Builder<String> builder = ImmutableList.<String>builder();
      // index 3 is past the country code, network code, and locale indices.
      for (int i = 0; i < FolderConfiguration.getQualifierCount(); ++i) {
        addIfNotNull(config.getQualifier(i), builder);
      }
      return new Qualifiers(folderType, builder.build(), config.getLocaleQualifier() == null);
    }

    private static void addIfNotNull(
        ResourceQualifier qualifier, ImmutableList.Builder<String> builder) {
      if (qualifier != null) {
        builder.add(qualifier.getFolderSegment());
      }
    }

    /** Returns the qualifiers as a list of strings. */
    public List<String> asList() {
      return qualifiers;
    }

    public ResourceFolderType asFolderType() {
      return folderType;
    }

    /** Creates a Qualifiers assuming that they are in the values directory. */
    @VisibleForTesting
    public static Qualifiers forValuesFolderFrom(List<String> qualifiers) {
      return Qualifiers.getQualifiers(
          ImmutableList.builder().add("values").addAll(qualifiers).build().toArray(new String[0]));
    }

    public boolean containDefaultLocale() {
      return defaultLocale;
    }
  }

  /** A factory for parsing an generating FullyQualified names with qualifiers and package. */
  public static class Factory {

    public static final String INVALID_QUALIFIED_NAME_MESSAGE_NO_MATCH =
        String.format(
            "%%s is not a valid qualified name. "
                + "It should be in the pattern [package:]{%s}/name",
            Joiner.on(",")
                .join(
                    ImmutableList.<String>builder()
                        .add(ResourceType.getNames())
                        .add(VirtualType.getNames())
                        .build()));
    public static final String INVALID_QUALIFIED_NAME_MESSAGE_NO_TYPE_OR_NAME =
        String.format(
            "Could not find either resource type (%%s) or name (%%s) in %%s. "
                + "It should be in the pattern [package:]{%s}/name",
            Joiner.on(",")
                .join(
                    ImmutableList.<String>builder()
                        .add(ResourceType.getNames())
                        .add(VirtualType.getNames())
                        .build()));
    private static final Pattern PARSING_REGEX =
        Pattern.compile(
            "(?:(?<package>[^:]+):){0,1}(?<type>[^-/]+)(?:[^/]*)/(?:(?:(?<namespace>\\{[^}]+\\}))"
                + "|(?:(?<misplacedPackage>[^:]+):)){0,1}(?<name>.+)");
    // private final ImmutableList<String> qualifiers;

    private final String pkg;

    private final Qualifiers qs;

    private Factory(Qualifiers qualifiers, String pkg) {
      // this.qualifiers = qualifiers;
      this.pkg = pkg;
      this.qs = qualifiers;
    }

    /** Creates a factory with default package from a directory name split on '-'. */
    @VisibleForTesting
    public static Factory fromDirectoryName(String... dirNameAndQualifiers) {
      return using(Qualifiers.getQualifiers(dirNameAndQualifiers), DEFAULT_PACKAGE);
    }

    /** Creates a factory with default package from a directory with '-' separating qualifiers. */
    public static Factory fromDirectoryName(String dirNameAndQualifiers) {
      return using(Qualifiers.parseFrom(dirNameAndQualifiers), DEFAULT_PACKAGE);
    }

    @VisibleForTesting
    public static Factory from(List<String> qualifiers, String pkg) {
      return using(Qualifiers.forValuesFolderFrom(qualifiers), pkg);
    }

    @VisibleForTesting
    public static Factory from(List<String> qualifiers) {
      return from(qualifiers, DEFAULT_PACKAGE);
    }

    /** Creates a factory with the qualifiers and package. */
    public static Factory using(Qualifiers qualifiers) {
      return using(qualifiers, DEFAULT_PACKAGE);
    }

    /** Creates a factory with the qualifiers and package. */
    public static Factory using(Qualifiers qualifiers, String pkg) {
      return new Factory(qualifiers, pkg.isEmpty() ? DEFAULT_PACKAGE : pkg);
    }

    private static String deriveRawFullyQualifiedName(Path source) {
      if (source.getNameCount() < 2) {
        throw new IllegalArgumentException(
            String.format(
                "The resource path %s is too short. "
                    + "The path is expected to be <resource type>/<file name>.",
                source));
      }
      // Compose the `pathWithExtension` manually to ensure it uses a forward slash.
      // Using Path.subpath would return a backslash-using path on Windows.
      String pathWithExtension = source.getParent().getFileName() + "/" + source.getFileName();
      int extensionStart = pathWithExtension.indexOf('.');
      if (extensionStart > 0) {
        return pathWithExtension.substring(0, extensionStart);
      }
      return pathWithExtension;
    }

    // Grabs the extension portion of the path removed by deriveRawFullyQualifiedName.
    private static String getSourceExtension(Path source) {
      // TODO(corysmith): Find out if there is a filename parser utility.
      String fileName = source.getFileName().toString();
      int extensionStart = fileName.indexOf('.');
      if (extensionStart > 0) {
        return fileName.substring(extensionStart);
      }
      return "";
    }

    public FullyQualifiedName create(Type type, String name, String pkg) {
      return FullyQualifiedName.of(pkg, qs.asList(), type, name);
    }

    public FullyQualifiedName create(ResourceType type, String name) {
      return create(new ResourceTypeWrapper(type), name, pkg);
    }

    public FullyQualifiedName create(ResourceType type, String name, String pkg) {
      return create(new ResourceTypeWrapper(type), name, pkg);
    }

    public FullyQualifiedName create(VirtualType type, String name) {
      return create(type, name, pkg);
    }

    /**
     * Parses a FullyQualifiedName from a string.
     *
     * @param raw A string in the expected format from
     *     [&lt;package&gt;:]&lt;ResourceType.name&gt;/&lt;resource name&gt;.
     * @throws IllegalArgumentException when the raw string is not valid qualified name.
     */
    public FullyQualifiedName parse(String raw) {
      Matcher matcher = PARSING_REGEX.matcher(raw);
      if (!matcher.matches()) {
        throw new IllegalArgumentException(
            String.format(INVALID_QUALIFIED_NAME_MESSAGE_NO_MATCH, raw));
      }
      String parsedPackage =
          firstNonNull(matcher.group("package"), matcher.group("misplacedPackage"), pkg);

      Type type = createTypeFrom(matcher.group("type"));
      String name =
          matcher.group("namespace") != null
              ? matcher.group("namespace") + matcher.group("name")
              : matcher.group("name");

      if (type == null || name == null) {
        throw new IllegalArgumentException(
            String.format(INVALID_QUALIFIED_NAME_MESSAGE_NO_TYPE_OR_NAME, type, name, raw));
      }

      return FullyQualifiedName.of(parsedPackage, qs.asList(), type, name);
    }

    /**
     * Generates a FullyQualifiedName for a file-based resource given the source Path.
     *
     * @param sourcePath the path of the file-based resource.
     * @throws IllegalArgumentException if the file-based resource has an invalid filename
     */
    public FullyQualifiedName parse(Path sourcePath) {
      return parse(deriveRawFullyQualifiedName(sourcePath));
    }

    private String firstNonNull(String... values) {
      for (String value : values) {
        if (value != null) {
          return value;
        }
      }
      throw new NullPointerException("Expected a nonnull value.");
    }
  }
}
