/*
 * Copyright 2008 Google Inc.
 *
 * 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.gwt.dev.resource.impl;

import com.google.gwt.core.ext.TreeLogger;
import com.google.gwt.dev.cfg.ResourceLoader;
import com.google.gwt.dev.cfg.ResourceLoaders;
import com.google.gwt.dev.resource.Resource;
import com.google.gwt.dev.util.log.perf.SimpleEvent;
import com.google.gwt.dev.util.msg.Message0;
import com.google.gwt.dev.util.msg.Message1String;
import com.google.gwt.thirdparty.guava.common.collect.HashMultimap;
import com.google.gwt.thirdparty.guava.common.collect.MapMaker;
import com.google.gwt.thirdparty.guava.common.collect.SetMultimap;
import com.google.gwt.thirdparty.guava.common.collect.Sets;
import com.google.gwt.thirdparty.guava.common.io.Files;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;

/**
 * The normal implementation of {@code ResourceOracle}.
 */
public class ResourceOracleImpl extends AbstractResourceOracle {

  private static class Messages {
    static final Message1String EXAMINING_PATH_ROOT = new Message1String(TreeLogger.DEBUG,
        "Searching for resources within $0");
    static final Message1String IGNORING_SHADOWED_RESOURCE =
        new Message1String(
            TreeLogger.DEBUG,
            "Resource '$0' is being shadowed by another resource higher in the classpath having the same name; this one will not be used");
    static final Message0 REFRESHING_RESOURCES = new Message0(TreeLogger.TRACE,
        "Refreshing resources");
  }

  /**
   * Wrapper object around a resource to change its path when it is rerooted.
   */
  private static class RerootedResource extends AbstractResource {
    private final String path;
    private final AbstractResource resource;

    public RerootedResource(AbstractResource resource, PathPrefix pathPrefix) {
      this.path = pathPrefix.getRerootedPath(resource.getPath());
      this.resource = resource;
    }

    @Override
    public long getLastModified() {
      return resource.getLastModified();
    }

    @Override
    public String getLocation() {
      return resource.getLocation();
    }

    @Override
    public String getPath() {
      return path;
    }

    @Override
    public String getPathPrefix() {
      int fullPathLen = resource.getPath().length();
      return resource.getPath().substring(0, fullPathLen - path.length());
    }

    @Override
    public InputStream openContents() throws IOException {
      return resource.openContents();
    }

    @Override
    public boolean wasRerooted() {
      return true;
    }
  }

  private static class ResourceDescription implements Comparable<ResourceDescription> {
    public final PathPrefix pathPrefix;
    public final AbstractResource resource;

    public ResourceDescription(AbstractResource resource, PathPrefix pathPrefix) {
      this.resource =
          pathPrefix.shouldReroot() ? new RerootedResource(resource, pathPrefix) : resource;
      this.pathPrefix = pathPrefix;
    }

    public boolean isPreferredOver(ResourceDescription that) {
      return this.compareTo(that) > 0;
    }

    @Override
    public int compareTo(ResourceDescription other) {
      // Rerooted takes precedence over not rerooted.
      if (this.resource.wasRerooted() != other.resource.wasRerooted()) {
        return this.resource.wasRerooted() ? 1 : -1;
      }
      // Compare priorities of the path prefixes, high number == high priority.
      return this.pathPrefix.getPriority() - other.pathPrefix.getPriority();
    }

    @Override
    public boolean equals(Object o) {
      if (!(o instanceof ResourceDescription)) {
        return false;
      }
      ResourceDescription other = (ResourceDescription) o;
      return this.pathPrefix.getPriority() == other.pathPrefix.getPriority()
          && this.resource.wasRerooted() == other.resource.wasRerooted();
    }

    @Override
    public int hashCode() {
      return (pathPrefix.getPriority() << 1) + (resource.wasRerooted() ? 1 : 0);
    }

    @Override
    public String toString() {
      return "{" + resource + "," + pathPrefix + "}";
    }
  }

  private static final Map<ResourceLoader, List<ClassPathEntry>> classPathCache =
      new MapMaker().weakKeys().makeMap();

  /**
   * A mapping from resource paths to the name of the module that
   * created the PathPrefix (usually because of a <source> entry) that made
   * the resource path live.
   * <p>
   * For example com/google/gwt/user/client/DOM.java was made live by the
   * com.google.gwt.user.User module.
   */
  private SetMultimap<String, String> sourceModulesByTypeSourceName = HashMultimap.create();

  public static void clearCache() {
    classPathCache.clear();
  }

  public static ClassPathEntry createEntryForUrl(TreeLogger logger, URL url)
      throws URISyntaxException, IOException {
    if (url.getProtocol().equals("file")) {
      File f = new File(url.toURI());
      String lowerCaseFileName = f.getName().toLowerCase(Locale.ROOT);
      if (f.isDirectory()) {
        return new DirectoryClassPathEntry(f);
      } else if (f.isFile() && lowerCaseFileName.endsWith(".jar")) {
        return ZipFileClassPathEntry.get(f);
      } else if (f.isFile() && lowerCaseFileName.endsWith(".zip")) {
        return ZipFileClassPathEntry.get(f);
      } else {
        // It's a file ending in neither jar nor zip, speculatively try to
        // open as jar/zip anyway.
        try {
          return ZipFileClassPathEntry.get(f);
        } catch (Exception ignored) {
        }
        if (logger.isLoggable(TreeLogger.TRACE)) {
          logger.log(TreeLogger.TRACE, "Unexpected entry in classpath; " + f
              + " is neither a directory nor an archive (.jar or .zip)");
        }
        return null;
      }
    } else {
      logger.log(TreeLogger.WARN, "Unknown URL type for " + url, null);
      return null;
    }
  }

  /**
   * Preinitializes the classpath from the thread default {@link ClassLoader}.
   */
  public static void preload(TreeLogger logger) {
    preload(logger, ResourceLoaders.fromContextClassLoader());
  }

  /**
   * Preinitializes the classpath for a given {@link ResourceLoader}.
   */
  public static void preload(TreeLogger logger, ResourceLoader resources) {
    try (SimpleEvent ignored = new SimpleEvent("ResourceOracle preload")) {
      List<ClassPathEntry> entries = getAllClassPathEntries(logger, resources);
      for (ClassPathEntry entry : entries) {
        // We only handle pre-indexing jars, the file system could change.
        if (entry instanceof ZipFileClassPathEntry) {
          ZipFileClassPathEntry zpe = (ZipFileClassPathEntry) entry;
          zpe.index(logger);
        }
      }
    }
  }

  /**
   * Returns a mapping from resource paths to the set of names of modules that created PathPrefixes
   * (usually because of a <source> entry) that made the resource path live.
   * <p>
   * For example com/google/gwt/user/client/DOM.java was made live by the com.google.gwt.user.User
   * module.
   */
  public SetMultimap<String, String> getSourceModulesByTypeSourceName() {
    return sourceModulesByTypeSourceName;
  }

  /**
   * Scans the associated paths to recompute the available resources.
   *
   * @param logger status and error details are written here
   */
  public synchronized void scanResources(TreeLogger logger) {
    try (SimpleEvent ignored = new SimpleEvent("ResourceOracle refresh")) {

      TreeLogger refreshBranch = Messages.REFRESHING_RESOURCES.branch(logger, null);

      Map<String, ResourceDescription> resourceDescriptionsByPath =
          new LinkedHashMap<String, ResourceDescription>();

      for (ClassPathEntry classPathEntry : classPathEntries) {
        TreeLogger branchForClassPathEntry =
            Messages.EXAMINING_PATH_ROOT.branch(refreshBranch, classPathEntry.getLocation(), null);

        Map<AbstractResource, ResourceResolution> prefixesByResource =
            classPathEntry.findApplicableResources(branchForClassPathEntry, pathPrefixSet);
        for (Entry<AbstractResource, ResourceResolution> entry : prefixesByResource.entrySet()) {
          AbstractResource resource = entry.getKey();
          ResourceResolution resourceResolution = entry.getValue();
          ResourceDescription resourceDescription =
              new ResourceDescription(resource, resourceResolution.getPathPrefix());
          String resourcePath = resourceDescription.resource.getPath();
          maybeRecordTypeForModule(resourceResolution, resourcePath);

          // In case of collision.
          if (resourceDescriptionsByPath.containsKey(resourcePath)) {
            ResourceDescription oldResourceDescription =
                resourceDescriptionsByPath.get(resourcePath);
            if (resourceDescription.isPreferredOver(oldResourceDescription)) {
              resourceDescriptionsByPath.put(resourcePath, resourceDescription);
            } else {
              Messages.IGNORING_SHADOWED_RESOURCE
                  .log(branchForClassPathEntry, resourcePath, null);
            }
          } else {
            resourceDescriptionsByPath.put(resourcePath, resourceDescription);
          }
        }
      }

      Map<String, Resource> resourcesByPath = new HashMap<String, Resource>();
      for (Entry<String, ResourceDescription> entry : resourceDescriptionsByPath.entrySet()) {
        resourcesByPath.put(entry.getKey(), entry.getValue().resource);
      }

      // Update exposed collections with new (unmodifiable) data structures.
      exposedResources = Collections.unmodifiableSet(Sets.newHashSet(resourcesByPath.values()));
      exposedResourceMap = Collections.unmodifiableMap(resourcesByPath);
      exposedPathNames = Collections.unmodifiableSet(resourcesByPath.keySet());
    }
  }

  private void maybeRecordTypeForModule(ResourceResolution resourceResolution,
      String resourcePath) {
    // If PathPrefix->Module associations are inaccurate because PathPrefixes have been merged.
    if (pathPrefixSet.mergePathPrefixes()) {
      // Then don't record any Type<->Module associations since they won't be accurate;
      return;
    }

    sourceModulesByTypeSourceName.putAll(asTypeSourceName(resourcePath),
        resourceResolution.getSourceModuleNames());
  }

  private String asTypeSourceName(String resourcePath) {
    return resourcePath.replace(".java", "").replace("/", ".");
  }

  private static void addAllClassPathEntries(TreeLogger logger, ResourceLoader loader,
      List<ClassPathEntry> classPath) {
    // URL is expensive in collections, so we use URI instead
    // See:
    // http://michaelscharf.blogspot.com/2006/11/javaneturlequals-and-hashcode-make.html
    Set<URI> seenEntries = new HashSet<URI>();

    for (URL url : loader.getClassPath()) {
      URI uri;
      try {
        uri = url.toURI();
      } catch (URISyntaxException e) {
        logger.log(TreeLogger.WARN, "Error processing classpath URL '" + url + "'", e);
        continue;
      }
      if (seenEntries.contains(uri)) {
        continue;
      }
      seenEntries.add(uri);
      Throwable caught;
      try {
        ClassPathEntry entry = createEntryForUrl(logger, url);
        if (entry != null) {
          classPath.add(entry);
        }
        continue;
      } catch (URISyntaxException | IOException e) {
        caught = e;
      }
      logger.log(TreeLogger.WARN, "Error processing classpath URL '" + url + "'", caught);
    }
  }

  private static synchronized List<ClassPathEntry> getAllClassPathEntries(TreeLogger logger,
      ResourceLoader resources) {
    List<ClassPathEntry> classPath = classPathCache.get(resources);
    if (classPath == null) {
      classPath = new ArrayList<ClassPathEntry>();
      addAllClassPathEntries(logger, resources, classPath);
      classPathCache.put(resources, classPath);
    }
    return classPath;
  }

  private final List<ClassPathEntry> classPathEntries;

  private Set<String> exposedPathNames = Collections.emptySet();

  private Map<String, Resource> exposedResourceMap = Collections.emptyMap();

  private Set<Resource> exposedResources = Collections.emptySet();

  private PathPrefixSet pathPrefixSet = new PathPrefixSet();

  /**
   * Constructs a {@link ResourceOracleImpl} from a set of
   * {@link ClassPathEntry ClassPathEntries}. The list is held by reference and
   * must not be modified.
   */
  public ResourceOracleImpl(List<ClassPathEntry> classPathEntries) {
    this.classPathEntries = classPathEntries;
  }

  /**
   * Constructs a {@link ResourceOracleImpl} from the thread's default
   * {@link ClassLoader}.
   */
  public ResourceOracleImpl(TreeLogger logger) {
    this(logger, ResourceLoaders.fromContextClassLoader());
  }

  public ResourceOracleImpl(TreeLogger logger, ResourceLoader resources) {
    this(getAllClassPathEntries(logger, resources));
  }

  @Override
  public void clear() {
    sourceModulesByTypeSourceName.clear();
    exposedPathNames = Collections.emptySet();
    exposedResourceMap = Collections.emptyMap();
    exposedResources = Collections.emptySet();
  }

  @Override
  public Set<String> getPathNames() {
    return exposedPathNames;
  }

  public PathPrefixSet getPathPrefixes() {
    return pathPrefixSet;
  }

  @Override
  public Set<Resource> getResources() {
    return exposedResources;
  }

  public void setPathPrefixes(PathPrefixSet pathPrefixSet) {
    this.pathPrefixSet = pathPrefixSet;
  }

  // @VisibleForTesting
  List<ClassPathEntry> getClassPathEntries() {
    return classPathEntries;
  }

  @Override
  public Resource getResource(String pathName) {
    pathName = Files.simplifyPath(pathName);
    return exposedResourceMap.get(pathName);
  }
}
