/**
 * Copyright 2009 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.step2.discovery;

import org.openid4java.discovery.Discovery;
import org.openid4java.discovery.DiscoveryException;
import org.openid4java.discovery.DiscoveryInformation;
import org.openid4java.discovery.Identifier;
import org.openid4java.discovery.UrlIdentifier;
import org.openid4java.discovery.html.HtmlResolver;
import org.openid4java.discovery.xri.XriResolver;
import org.openid4java.discovery.yadis.YadisResolver;

import java.net.URI;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Implements Next-Generation OpenID discovery, based on Link-headers,
 * link-elements, and host-meta. There are two different discovery
 * operations available:
 *
 * (1) discover the OP endpoint(s) for a "site", and
 * (2) discover the OP endpoint(s) for an OpenID (aka claimed id, which is the
 *     id of a user)
 *
 * Case (1) is for when users merely indicate the IdP to the RP, case (2) is
 * for when users actually submit their OpenID (claimed id) to the RP, and also
 * used during the validation of an authentication response from an IdP.
 *
 * The strategy for case (1) is as follows:
 *
 * - find the host-meta file for the site.
 * - find a link in the host-meta that points to an XRD(S) metadata
 *   document for the site
 * - follow the links in the XRD(S) to find the OP endpoint.
 *
 * The strategy for case (2) is as follows:
 *
 * - Try host-meta strategy (2a)
 * - If that fails, link-header strategy (2b)
 * - If that fails, try link-element strategy (2c)
 *
 * Strategy (2a) works as follows:
 *
 * - find the host-meta file for the host identified in the claimed id.
 * - in the host-meta file, check whether a Link-Pattern in the host-meta
 *   points to an XRD(S) for the claimed id, and if so, skip the next two steps.
 * - if not, find a Link in the host-meta that points to an XRD(S) metadata
 *   document for the site.
 * - follow the URITemplate link in the site XRD(S) to find the XRD(S) for the
 *   the claimed id.
 * - follow the links in the XRD(S) to find the OP endpoint.
 *
 * Strategy (2b) works as follow:
 *
 * - Find a link in the HTTP headers in the HTTP response that points
 *   to an XRD(S) metadata document for the claimed id.
 * - follow the links in the XRD(S) to find the OP endpoint.
 *
 * Strategy (2c) works as follow:
 *
 * - Find an HTML link element in the document returned from
 *   the claimed id that points to an XRD(S) metadata document for the
 *   claimed id.
 * - follow the links in the XRD(S) to find the OP endpoint.
 *
 *
 * For backwards compatibility, we also provide a generic discover() method,
 * which decides whether to use site-discovery or user-id-discovery based on the
 * type of the provided identifier. This allows us to use this class inside the
 * openid4java library.
 *
 * When calling this class through the legacy generic discover() method, we also
 * employ a "fallback" strategy, i.e. we first try the strategy described above,
 * and then fall back to OpenID 2.0-style discovery, if the strategy above
 * doesn't yield any results.
 */
public class Discovery2 extends Discovery {

  private static final Logger logger =
      Logger.getLogger(Discovery2.class.getName());

  private final HostMetaFetcher hostMetaFetcher;
  private final XrdDiscoveryResolver xrdResolver;
  private final XrdLocationSelector xrdLocationSelector;

  // Strategy for site discovery: First, we try discoverOpEndpointsForSite,
  // and as a fallback try legacy discovery for an identifier derived from
  // the site identifier
  private final FallbackDiscovery<IdpIdentifier> siteFallbackDiscoverer =
    new FallbackDiscovery<IdpIdentifier>() {
      @Override
      public List<SecureDiscoveryInformation> newStyleDiscovery(
          IdpIdentifier idp) throws DiscoveryException {
        // how new-style discovery is performed
        return discoverOpEndpointsForSite(idp);
      }

      @Override
      public Identifier getLegacyIdentifier(IdpIdentifier idp)
          throws DiscoveryException {
        // which identifier is to be used for the fallback old-style discovery
        try {
          String openIdAsString = idp.getIdentifier().trim();

          // since we're going to convert this to a URL anyway, we might as
          // well make sure that the identifier starts with http[s]://
          // Otherwise, the URL classes used inside UrlIdentifier tend to
          // get huffy.
          URI openIdAsUri = URI.create(openIdAsString);
          if (openIdAsUri.getScheme() == null) {
            openIdAsString = "http://" + openIdAsString;
          }

          return new UrlIdentifier(openIdAsString);
        } catch (IllegalArgumentException e) {
          // thrown if the identifier doesn't look like a host name
          throw new DiscoveryException(e);
        }
      }
    };

  // Strategy for claimed_id discovery: first, we'll try
  // discoverOpEndpointsForUser, and if that fails simply perform legacy
  // discovery on the same UrlIdentifier
  private final FallbackDiscovery<UrlIdentifier> userFallbackDiscoverer =
    new FallbackDiscovery<UrlIdentifier>() {
      @Override
      public List<SecureDiscoveryInformation> newStyleDiscovery(
          UrlIdentifier url) throws DiscoveryException {
        // how new-style discovery is performed
        return discoverOpEndpointsForUser(url);
      }

      @Override
      public Identifier getLegacyIdentifier(UrlIdentifier url) {
        // which identifier is to be used for the fallback old-style discovery
        return url;
      }
    };

  public Discovery2(HostMetaFetcher hostMetaFetcher,
      XrdDiscoveryResolver xrdResolver,
      HtmlResolver htmlResolver, YadisResolver yadisResolver,
      XriResolver xriResolver) {
    super();
    setHtmlResolver(htmlResolver);
    setYadisResolver(yadisResolver);
    setXriResolver(xriResolver);
    this.hostMetaFetcher = hostMetaFetcher;
    this.xrdResolver = xrdResolver;
    this.xrdLocationSelector = new XrdLocationSelector();
  }

  /**
   * Returns list of likely OpenID endpoints for a site, ordered by
   * preference as listed by the site. The host-meta points to an XRD(S)
   * document, which contains a (prioritized) list of endpoints, and which
   * will be returned.
   *
   * @param site the {@link IdpIdentifier} identifying the site for which
   *   OpenID endpoints are being sought.
   */
  public List<SecureDiscoveryInformation> discoverOpEndpointsForSite(
      IdpIdentifier site) throws DiscoveryException {

    String host = site.getIdentifier();

    // get host-meta for that host
    HostMeta hostMeta;
    try {
      hostMeta = hostMetaFetcher.getHostMeta(host);
    } catch (HostMetaException e) {
      throw new DiscoveryException("could not get host-meta for " + host, e);
    }

    // Find XRD that host-meta is pointing to. In the case of site-discovery,
    // this will point to the site's XRD.
    URI xrdUri = xrdLocationSelector.findSiteXrdUriForOp(hostMeta,
        xrdResolver.getDiscoveryDocumentType());

    if (xrdUri == null) {
      return Collections.emptyList();
    }

    // now that we have the location of the XRD, perform the actual
    // discovery based on the XRD.
    return xrdResolver.findOpEndpointsForSite(site, xrdUri);
  }

  /**
   * Returns list of OpenID endpoints declared by a claimed id (aka user id).
   * There are a variety of ways to discover OP endpoints from a claimed id.
   * We're doing it in the following order: first, try host-meta-based
   * discovery. Next, try link-header-based discovery. Last, try
   * link-element (in HTML)-based discovery.
   *
   * In the latter two cases, the link will point directly to the XRD(S) of the
   * user (claimed id). In the first case, there will either be a Link-Pattern:
   * pointing to the XRD(S) of the user, or a Link: pointing to a site-wide
   * XRD(S) (which in turn will have URITemplates that point to the XRD(S) of
   * the user).
   *
   * @param claimedId the {@link UrlIdentifier} identifying the user's claimed
   *   id
   */
  public List<SecureDiscoveryInformation> discoverOpEndpointsForUser(
      UrlIdentifier claimedId) throws DiscoveryException {

    List<SecureDiscoveryInformation> result;

    try {
      result = tryHostMetaBasedDiscoveryForUser(claimedId);
    } catch (DiscoveryException e) {
      result = null;
    }

    if (result != null) {
      return result;
    }

    try {
      result = tryLinkHeaderBasedDiscoveryForUser(claimedId);
    } catch (DiscoveryException e) {
      result = null;
    }

    if (result != null) {
      return result;
    }

    return tryLinkElementBasedDiscoveryForUser(claimedId);
  }

  /**
   * Returns list of likely OpenID endpoints for a user, ordered by
   * preference. If there is a link-pattern in the host-meta that points to the
   * user's XRD(S), we base XRD(S) discovery on that document.
   * Otherwise, the host-meta can point to a site's XRD(S) document,
   * which may contain URITemplates, which in turn point to the user's XRD(S).
   * The latter should include a list of OpenID endpoints.
   *
   * @param claimedId the {@link IdpIdentifier} identifying the site for which
   *   OpenID endpoints are being sought.
   */
  /* visible for testing */
  List<SecureDiscoveryInformation> tryHostMetaBasedDiscoveryForUser(
      UrlIdentifier claimedId) throws DiscoveryException {

    // extract the host from the claimed id
    String host = claimedId.getUrl().getHost();

    // get host-meta for that host
    HostMeta hostMeta;
    try {
      hostMeta = hostMetaFetcher.getHostMeta(host);
    } catch (HostMetaException e) {
      throw new DiscoveryException("could not get host-meta for " + host, e);
    }

    // First, let's check whether there are link-patterns in the host-meta that
    // point directly to the user's XRD(S).
    URI xrdUri = xrdLocationSelector.findUserXrdUriForOp(hostMeta,
        xrdResolver.getDiscoveryDocumentType(), claimedId);

    if (xrdUri != null) {

      // xrdUri points to user's XRD
      return xrdResolver.findOpEndpointsForUser(claimedId, xrdUri);
    }

    // There were no link-patterns, i.e.,  we'll have to go with the
    // site-wide XRD(S)
    xrdUri = xrdLocationSelector.findSiteXrdUriForOp(hostMeta,
        xrdResolver.getDiscoveryDocumentType());

    if (xrdUri != null) {

      // xrdUri points to site-wide XRD
      return xrdResolver.findOpEndpointsForUserThroughSiteXrd(claimedId,
          xrdUri);
    }

    // xrdUri == null
    return Collections.emptyList();
  }

  /**
   * Link-element based discovery.
   */
  private List<SecureDiscoveryInformation> tryLinkElementBasedDiscoveryForUser(
      UrlIdentifier claimedId) throws DiscoveryException {
    // TODO: implement this
    throw new DiscoveryException("link-element-based discovery is not " +
        "implemented yet");
  }

  /**
   * Link-header based discovery.
   */
  private List<SecureDiscoveryInformation> tryLinkHeaderBasedDiscoveryForUser(
      UrlIdentifier claimedId) throws DiscoveryException {
    // TODO: implement this
    throw new DiscoveryException("link-header-based discovery is not " +
        "implemented yet");
  }

  /**
   * Legacy generic discovery method. Checks the type of identifier provided,
   * and dispatches to the appropriate discovery method. Also employs a
   * fallback strategy to use 2.0-style discovery in case the new-style
   * discovery doesn't yield any results.
   */
  @Override
  public List<SecureDiscoveryInformation> discover(Identifier identifier)
      throws DiscoveryException {

    /*
     * The old API doesn't distinguish between discovery of an IdP endpoint
     * and discovery of a user id. We introduce a new type of Identifier
     * to be able to distinguish between these two cases.
     */

    if (identifier instanceof IdpIdentifier) {

      IdpIdentifier site = (IdpIdentifier) identifier;
      return siteFallbackDiscoverer.get(site);

    } else if (identifier instanceof UrlIdentifier) {

      UrlIdentifier url = (UrlIdentifier)identifier;
      return userFallbackDiscoverer.get(url);

    } else {

      // for all other types of identifiers, use old-style discovery
      @SuppressWarnings("unchecked")
      List<DiscoveryInformation> result = super.discover(identifier);
      return convertToNewDiscoveryInfo(result);
    }
  }

  /**
   * Converts {@link DiscoveryInformation} objects into
   * {@link SecureDiscoveryInformation} object (which will have the isSecure
   * bit set to false).
   */
  /* visible for testing */
  static List<SecureDiscoveryInformation> convertToNewDiscoveryInfo(
      List<DiscoveryInformation> infos) throws DiscoveryException {

    if (infos == null) {
      return null;
    }

    List<SecureDiscoveryInformation> result = new ArrayList<SecureDiscoveryInformation>(infos.size());

    for (DiscoveryInformation info : infos) {
      result.add(new SecureDiscoveryInformation(info));
    }
    return result;
  }

  /**
   * Implements fallback discovery: First, we try new-style discovery (to
   * be implemented by a subclass), and if that doesn't work, we'll give the
   * implementing subclass a chance to provide a different identifier for the
   * legacy discovery. For example, a subclass implementing discovery for an
   * IdPIdentifier identifying a site (which doesn't exist in 2.0-style
   * discovery) could convert the IdPIdentifier "example.com" into an
   * UrlIdentifier "http://www.example.com/" and have legacy discovery performed
   * on that UrlIdentifier.
   *
   * @param <T> the type of identifier discovery is performed on.
   */
  /* visible for testing */
  abstract class FallbackDiscovery<T extends Identifier> {

    public abstract List<SecureDiscoveryInformation> newStyleDiscovery(T id)
        throws DiscoveryException;

    public abstract Identifier getLegacyIdentifier(T id)
        throws DiscoveryException;

    public List<SecureDiscoveryInformation> get(T id)
        throws DiscoveryException {

      List<SecureDiscoveryInformation> result;

      // first, try new-style discovery
      try {
        result = newStyleDiscovery(id);
        if (result != null && result.size() == 0) {
          logger.log(Level.WARNING, "could not perform new-style discovery on "
              + id.getIdentifier() + ". discovery returned null");
          result = null;
        }
      } catch (DiscoveryException e) {
        logger.log(Level.WARNING, "could not perform new-style discovery on "
            + id.getIdentifier(), e);
        result = null;
      }

      if (result != null) {
        return result;
      }

      // if that doesn't work, try old-style discovery
      return convertToNewDiscoveryInfo(
          oldStyleDiscovery(getLegacyIdentifier(id)));
    }

    @SuppressWarnings("unchecked")
    /* visible for testing */
    List<DiscoveryInformation> oldStyleDiscovery(Identifier id)
        throws DiscoveryException {
      return Discovery2.super.discover(id);
    }
  }
}
