/*
 * Data Hub Service (DHuS) - For Space data distribution.
 * Copyright (C) 2013,2014,2015 GAEL Systems
 *
 * This file is part of DHuS software sources.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
 */
package fr.gael.dhus.olingo;

import fr.gael.dhus.util.http.HttpAsyncClientProducer;
import fr.gael.dhus.util.http.InterruptibleHttpClient;
import fr.gael.dhus.util.http.Timeouts;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.StringTokenizer;

import org.apache.http.HttpResponse;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.client.config.CookieSpecs;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
import org.apache.http.impl.nio.client.HttpAsyncClients;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import org.apache.olingo.odata2.api.edm.Edm;
import org.apache.olingo.odata2.api.edm.EdmEntitySet;
import org.apache.olingo.odata2.api.edm.EdmException;
import org.apache.olingo.odata2.api.edm.EdmProperty;
import org.apache.olingo.odata2.api.edm.EdmType;
import org.apache.olingo.odata2.api.edm.EdmTypeKind;
import org.apache.olingo.odata2.api.ep.EntityProvider;
import org.apache.olingo.odata2.api.ep.EntityProviderException;
import org.apache.olingo.odata2.api.ep.EntityProviderReadProperties;
import org.apache.olingo.odata2.api.ep.entry.ODataEntry;
import org.apache.olingo.odata2.api.ep.feed.ODataFeed;
import org.apache.olingo.odata2.api.exception.ODataException;
import org.apache.olingo.odata2.api.rt.RuntimeDelegate;
import org.apache.olingo.odata2.api.uri.PathSegment;
import org.apache.olingo.odata2.api.uri.UriInfo;
import org.apache.olingo.odata2.api.uri.UriNotMatchingException;
import org.apache.olingo.odata2.api.uri.UriParser;
import org.apache.olingo.odata2.api.uri.UriSyntaxException;

/**
 * Manages the connection to an OData service.
 */
public class ODataClient
{
   private static final Logger LOGGER = LogManager.getLogger(ODataClient.class);

   private final InterruptibleHttpClient httpClient = new InterruptibleHttpClient(new ClientProducer());
   private final URI serviceRoot;
   private final String username;
   private final String password;
   private final Edm serviceEDM;
   private final UriParser uriParser;
   
   /**
    * Creates an ODataClient for the given service.
    * 
    * @param url an URL to an OData service, 
    *    does not have to be the root service URL.
    *    This parameter must follow this syntax :
    *    {@code odata://hostname:port/path/...}
    * 
    * @throws URISyntaxException when the {@code url} parameter is invalid.
    * @throws IOException when the OdataClient fails to contact the server 
    *    at {@code url}.
    * @throws ODataException when no OData service have been found at the 
    *    given url.
    */
   public ODataClient(String url) throws URISyntaxException, IOException,
      ODataException
   {
      this (url, null, null);
   }
   
   /**
    * Creates an OdataClient for the given service
    * and credentials (HTTP Basic authentication).
    * 
    * @param url an URL to an OData service, 
    *    does not have to be the root service URL.
    *    this parameter must follow this syntax :
    *    {@code odata://hostname:port/path/...}
    * @param username Username
    * @param password Password
    * 
    * @throws URISyntaxException when the {@code url} parameter is invalid.
    * @throws IOException when the OdataClient fails to contact the server 
    *    at {@code url}.
    * @throws ODataException when no OData service have been found at the 
    *    given url.
    */
   public ODataClient(String url, String username, String password)
      throws URISyntaxException, IOException, ODataException
   {
      this.username = username;
      this.password = password;
      
      // Find the service root URL and retrieve the Entity Data Model (EDM).
      URI uri = new URI (url);
      String metadata = "/$metadata";
      
      URI svc = null;
      Edm edm = null;
      
      String[] pathSegments = uri.getPath().split("/");
      StringBuilder sb = new StringBuilder();
      
      // for each possible service root URL.
      for (int i = 1; i < pathSegments.length; i++)
      {
         sb.append ('/').append (pathSegments[i]).append (metadata);
         svc = new URI (uri.getScheme (), uri.getAuthority (),
            sb.toString (), null, null);
         sb.delete (sb.length () - metadata.length (), sb.length ());
         
         // Test if `svc` is the service root URL.
         try
         {
            InputStream content = execute (svc.toString (),
               ContentType.APPLICATION_XML, "GET");
            
            edm = EntityProvider.readMetadata(content, false);
            svc = new URI (uri.getScheme (), uri.getAuthority (),
               sb.toString (), null, null);
            
            break;
         }
         catch (InterruptedException ex)
         {
            break;
         }
         catch (HttpException | EntityProviderException e)
         {
            LOGGER.debug ("URL not root "+svc, e);
         }
      }
      
      // no OData service have been found at the given URL.
      if (svc == null || edm == null)
         throw new ODataException ("No service found at "+url);
      
      this.serviceRoot = svc;
      this.serviceEDM  = edm;
      this.uriParser = RuntimeDelegate.getUriParser (edm);
   }
   
   /**
    * Reads a feed (the content of an EntitySet).
    * 
    * @param resource_path the resource path to the parent of the requested
    *    EntitySet, as defined in {@link #getResourcePath(URI)}.
    * @param query_parameters Query parameters, as defined in {@link URI}.
    * 
    * @return an ODataFeed containing the ODataEntries for the given 
    *    {@code resource_path}.
    * 
    * @throws HttpException if the server emits an HTTP error code.
    * @throws IOException if the connection with the remote service fails.
    * @throws EdmException if the EDM does not contain the given entitySetName.
    * @throws EntityProviderException if reading of data (de-serialization)
    *    fails.
    * @throws UriSyntaxException violation of the OData URI construction rules.
    * @throws UriNotMatchingException URI parsing exception.
    * @throws ODataException encapsulate the OData exceptions described above.
    * @throws InterruptedException if running thread has been interrupted.
    */
   public ODataFeed readFeed(String resource_path,
      Map<String, String> query_parameters) throws IOException, ODataException, InterruptedException
   {
      if (resource_path == null || resource_path.isEmpty ())
         throw new IllegalArgumentException (
            "resource_path must not be null or empty.");
      
      ContentType contentType = ContentType.APPLICATION_ATOM_XML;
      
      String absolutUri = serviceRoot.toString () + '/' + resource_path;
      
      // Builds the query parameters string part of the URL.
      absolutUri = appendQueryParam (absolutUri, query_parameters);
      
      InputStream content = execute (absolutUri, contentType, "GET");
      
      return EntityProvider.readFeed (contentType.type (),
         getEntitySet (resource_path), content,
         EntityProviderReadProperties.init ().build ());
   }
   
   /**
    * Reads an entry (an Entity, a property, a complexType, ...).
    * 
    * @param resource_path the resource path to the parent of the requested
    *    EntitySet, as defined in {@link #getResourcePath(URI)}.
    * @param query_parameters Query parameters, as defined in {@link URI}.
    * 
    * @return an ODataEntry for the given {@code resource_path}.
    * 
    * @throws HttpException if the server emits an HTTP error code.
    * @throws IOException if the connection with the remote service fails.
    * @throws EdmException if the EDM does not contain the given entitySetName.
    * @throws EntityProviderException if reading of data (de-serialization)
    *    fails.
    * @throws UriSyntaxException violation of the OData URI construction rules.
    * @throws UriNotMatchingException URI parsing exception.
    * @throws ODataException encapsulate the OData exceptions described above.
    * @throws InterruptedException if running thread has been interrupted.
    */
   public ODataEntry readEntry(String resource_path,
      Map<String, String> query_parameters) throws IOException, ODataException, InterruptedException
   {
      if (resource_path == null || resource_path.isEmpty ())
         throw new IllegalArgumentException (
            "resource_path must not be null or empty.");
      
      ContentType contentType = ContentType.APPLICATION_ATOM_XML;
      
      String absolutUri = serviceRoot.toString () + '/' + resource_path;
      
      // Builds the query parameters string part of the URL.
      absolutUri = appendQueryParam (absolutUri, query_parameters);
      
      InputStream content = execute (absolutUri, contentType, "GET");
      
      return EntityProvider.readEntry(contentType.type (),
         getEntitySet (resource_path), content,
         EntityProviderReadProperties.init ().build ());
   }
   
   /**
    * Returns the Entity Data Model (EDM) served by this OData service.
    * @return the schema for this OData service.
    */
   public Edm getSchema ()
   {
      // The class `Edm` is immutable.
      return this.serviceEDM;
   }
   
   /**
    * Returns an UriParser configured with this service EDM.
    * @return an UriParser.
    */
   public UriParser getUriParser ()
   {
      return this.uriParser;
   }

   /**
    * Returns the service root URL for this OData service.
    * @return the service root URL.
    */
   public String getServiceRoot ()
   {
      return this.serviceRoot.toString();
   }
   
   /**
    * Returns the resource path relative to this OData root service URL.
    * A resource path is a slash '/' separated list of EntitySets, Entities,
    * Properties, ComplexTypes and Values.<br>
    * 
    * This method works only on the path part of the URI as returned by 
    * {@link URI#getPath()}.<br>
    * 
    * Example: the root service URL is "odata://odata.org/services/address.svc"
    * the passed URI is 
    *    "odata://odata.org/services/address.svc/Contact(33)/PhoneNumber"
    * The result will be "/Contact(33)/PhoneNumber".<br>
    * 
    * As this method only work on the {@code path} part of the URI, the passed
    * URI may just contain a path.
    * Example: "/services/address.svc/Contact(33)/PhoneNumber"
    * 
    * @param uri URI to extract a resource path from.
    * @return the resource path.
    */
   public String getResourcePath (URI uri)
   {
      if (uri == null)
         throw new IllegalArgumentException ("uri must not be null.");
      
      String uri_path = uri.getPath ();
      String svc_path = this.serviceRoot.getPath ();
      if (uri_path.startsWith (svc_path))
      {
         return uri_path.substring (svc_path.length ());
      }
      return null;
   }
   
   /**
    * Gets the EdmEntitySet for the last segment of the given
    * {@code resource_path}.
    * If the last segment is not an EntitySet or a NavigationProperty, it will
    * return the EntitySet of the previous segment.
    * This method navigate through the EDM to resolve the EntitySet, thus it
    * may be slow.
    * 
    * @param resource_path path to a resource on the OData service.
    * @return An instance of EdmEntitySet for the last EntitySet in the
    *    {@code resource_path}.
    * @throws EdmException if the navigation through the EDM failed.
    * @throws UriSyntaxException violation of the OData URI construction rules.
    * @throws UriNotMatchingException URI parsing exception.
    * @throws ODataException encapsulate the OData exceptions described above.
    */
   public EdmEntitySet getEntitySet (String resource_path) throws ODataException
   {
      if (resource_path == null || resource_path.isEmpty ())
         throw new IllegalArgumentException (
            "resource_path must not be null or empty.");
      
      return parseRequest (resource_path, null).getTargetEntitySet ();
   }
   
   /**
    * Creates a UriInfo from a resource path and query parameters.
    * The returned object may be one of UriInfo subclasses.
    * 
    * @param resource_path path to a resource on the OData service.
    * @param query_parameters OData query parameters, can be {@code null}
    * 
    * @return an UriInfo instance exposing informations about each segment of
    *    the resource path and the query parameters.
    * 
    * @throws UriSyntaxException violation of the OData URI construction rules.
    * @throws UriNotMatchingException URI parsing exception.
    * @throws EdmException if a problem occurs while reading the EDM.
    * @throws ODataException encapsulate the OData exceptions described above.
    */
   public UriInfo parseRequest (String resource_path,
      Map<String, String> query_parameters) throws ODataException
   {
      List<PathSegment> path_segments;
      
      if (resource_path != null && !resource_path.isEmpty ())
      {
         path_segments = new ArrayList<> ();
         
         StringTokenizer st = new StringTokenizer (resource_path, "/");
         
         while (st.hasMoreTokens ())
         {
            path_segments.add(UriParser.createPathSegment(st.nextToken(), null));
         }
      }
      else path_segments = Collections.emptyList ();
      
      if (query_parameters == null) query_parameters = Collections.emptyMap ();
      
      return this.uriParser.parse (path_segments, query_parameters);
   }
   
   /**
    * Returns the kind of resource the given URI is addressing.
    * It can be the service root or an entity set or an entity or a simple
    * property or a complex property.
    * 
    * @param uri References an OData resource at this service.
    * 
    * @return the kind of resource the given URI is addressing
    *  
    * @throws UriSyntaxException violation of the OData URI construction rules.
    * @throws UriNotMatchingException URI parsing exception.
    * @throws EdmException if a problem occurs while reading the EDM.
    * @throws ODataException encapsulate the OData exceptions described above.
    */
   public resourceKind whatIs (URI uri) throws ODataException
   {
      if (uri == null)
         throw new IllegalArgumentException ("uri must not be null.");
      
      Map<String, String> query_parameters = null;
      
      if (uri.getQuery () != null)
      {
         query_parameters = new HashMap<> ();
         StringTokenizer st = new StringTokenizer (uri.getQuery (), "&");
         
         while (st.hasMoreTokens ())
         {
            String[] key_val = st.nextToken ().split ("=", 2);
            if (key_val.length != 2)
               throw new UriSyntaxException(UriSyntaxException.URISYNTAX);
            
            query_parameters.put (key_val[0], key_val[1]);
         }
      }
      
      String resource_path = getResourcePath (uri);
      
      UriInfo uri_info = parseRequest (resource_path, query_parameters);
      
      EdmType et = uri_info.getTargetType ();
      if (et == null)
         return resourceKind.SERVICE_ROOT;
      
      EdmTypeKind etk = et.getKind ();
      if (etk == EdmTypeKind.ENTITY)
      {
         if (uri_info.getTargetKeyPredicates ().isEmpty ())
            return resourceKind.ENTITY_SET;
         return resourceKind.ENTITY;
      }
      if (etk == EdmTypeKind.SIMPLE)
         return resourceKind.SIMPLE_PROPERTY;
      if (etk == EdmTypeKind.COMPLEX)
         return resourceKind.COMPLEX_PROPERTY;
      
      return resourceKind.UNKNOWN;
   }
   
   /**
    * Makes the key predicate for the given Entity and EntitySet.
    * 
    * @param entity_set the EntitySet
    * @param entity an entity whose key property values will be used to make
    *    the key predicate.
    * @return a comma separated list of key=value couples.
    * @throws EdmException not likely to happen.
    */
   public String makeKeyPredicate(EdmEntitySet entity_set, ODataEntry entity)
      throws EdmException
   {
      if (entity_set == null)
         throw new IllegalArgumentException ("entity_set must not be null.");

      if (entity == null)
         throw new IllegalArgumentException ("entity must not be null.");
      
      List<EdmProperty> edm_props = entity_set.getEntityType ()
         .getKeyProperties ();
      
      StringBuilder sb = new StringBuilder ();
      
      for (EdmProperty edm_prop: edm_props)
      {
         String key_prop_name = edm_prop.getName ();
         Object key_prop_val = entity.getProperties ().get (key_prop_name);
         
         if (sb.length () > 0) sb.append(',');
         
         sb.append (key_prop_name).append ('=');
         
         if (key_prop_val instanceof String)
            sb.append ('\'').append (key_prop_val).append ('\'');
         else
            sb.append (key_prop_val);
      }
      
      return sb.toString ();
   }
   
   @Override
   public int hashCode ()
   {
      final int prime = 31;
      int result = 1;
      result =
         prime * result + ((password == null) ? 0 : password.hashCode ());
      result =
         prime * result + serviceRoot.hashCode ();
      result =
         prime * result + ((username == null) ? 0 : username.hashCode ());
      return result;
   }
   
   @Override
   public boolean equals (Object obj)
   {
      if (this == obj) return true;
      if (obj == null) return false;
      if (getClass () != obj.getClass ()) return false;
      ODataClient other = (ODataClient) obj;
      if (password == null)
      {
         if (other.password != null) return false;
      }
      else
         if (!password.equals (other.password)) return false;
      if (serviceRoot == null)
      {
         if (other.serviceRoot != null) return false;
      }
      else
         if (!serviceRoot.equals (other.serviceRoot)) return false;
      if (username == null)
      {
         if (other.username != null) return false;
      }
      else
         if (!username.equals (other.username)) return false;
      return true;
   }
   
   /**
    * Builds and appends the query parameter part at the end of the given URL.
    * @param base_url an URL to append query parameters to.
    * @param query_parameters can be {@code null}, see {@link URI}.
    * @return the given URL with its query parameters.
    */
   private String appendQueryParam (String base_url, 
      Map<String, String> query_parameters)
   {
      if (query_parameters != null && !query_parameters.isEmpty ())
      {
         StringBuilder sb = new StringBuilder (base_url).append ('?');
         for (Map.Entry<String, String> entry: query_parameters.entrySet ())
         {
            String value = entry.getValue ().replaceAll (" ", "%20");
            sb.append (entry.getKey ()).append ('=').append (value);
            sb.append ('&');
         }
         sb.deleteCharAt (sb.length () - 1);
         
         return sb.toString ();
      }
      else
      {
         return base_url;
      }
   }
   
   /**
    * Performs the execution of an OData command through HTTP.
    * 
    * @param absolute_uri The not that relative URI to query.
    * @param content_type The content type can be JSON, XML, Atom+XML,
    *    see {@link OdataContentType}.
    * @param http_method {@code "POST", "GET", "PUT", "DELETE", ...}
    * 
    * @return The response as a stream. You may assume it's UTF-8 encoded.
    * 
    * @throws HttpException if the server emits an HTTP error code.
    * @throws IOException if an error occurred connecting to the server.
    * @throws InterruptedException if running thread has been interrupted.
    */
   private InputStream execute (String absolute_uri,
      ContentType content_type, String http_method)
      throws IOException, InterruptedException
   {
      // FIXME: only 'GET' http method is currently supported
      HttpGet get = new HttpGet(absolute_uri);
      // `Accept` for GET, `Content-Type` for POST and PUT.
      get.addHeader("Accept", content_type.type ());

      InterruptibleHttpClient.MemoryIWC mem_iwc = new InterruptibleHttpClient.MemoryIWC();

      HttpResponse resp = httpClient.interruptibleRequest(get, mem_iwc, null);
      int resp_code = resp.getStatusLine().getStatusCode();

      if (resp_code != 200)
      {
         throw new HttpException(resp_code, resp.getStatusLine().getReasonPhrase());
      }

      InputStream content = new ByteArrayInputStream(mem_iwc.getBytes());

      return content;
   }

   /**
    * Signals that an HTTP request failed.
    */
   public static class HttpException extends IOException
   {
      private static final long serialVersionUID = 1L;
      
      private final int statusCode;

      /**
       * Constructs an ODataHttpException with the specified HTTP status code.
       * @param status_code HTTP status code
       *    (eg: 500 for internal server error).
       */
      public HttpException (int status_code)
      {
         this(status_code, null);
      }

      /**
       * Constructs an ODataHttpException with the specified HTTP status code
       * and detail message.
       * @param status_code HTTP status code
       *    (eg: 500 for internal server error).
       * @param message the detail message.
       */
      public HttpException (int status_code, String message)
      {
         super (message);
         this.statusCode = status_code;
      }
      
      /**
       * Gets the HTTP status code.
       * @return the HTTP status code.
       */
      public int getStatusCode ()
      {
         return this.statusCode;
      }
   }

   /** Creates a client producer that produces HTTP Basic auth aware clients. */
   class ClientProducer implements HttpAsyncClientProducer
   {
      @Override
      public CloseableHttpAsyncClient generateClient ()
      {
         CredentialsProvider credsProvider = new BasicCredentialsProvider();
         credsProvider.setCredentials(new AuthScope (AuthScope.ANY),
                  new UsernamePasswordCredentials(username, password));
         RequestConfig rqconf = RequestConfig.custom()
               .setCookieSpec(CookieSpecs.DEFAULT)
               .setSocketTimeout(Timeouts.SOCKET_TIMEOUT)
               .setConnectTimeout(Timeouts.CONNECTION_TIMEOUT)
               .setConnectionRequestTimeout(Timeouts.CONNECTION_REQUEST_TIMEOUT)
               .build();
         CloseableHttpAsyncClient res = HttpAsyncClients.custom ()
               .setDefaultCredentialsProvider (credsProvider)
               .setDefaultRequestConfig(rqconf)
               .build ();
         res.start ();
         return res;
      }
   }

   /**
    * Returned by {@link ODataClient#whatIs(URI)}.
    */
   public static enum resourceKind
   {
      /** Is the service root. */
      SERVICE_ROOT,
      /** Is an entity. */
      ENTITY,
      /** Is an entity set. */
      ENTITY_SET,
      /** Is a simple property. */
      SIMPLE_PROPERTY,
      /** Is a complex property. */
      COMPLEX_PROPERTY,
      /** Unknown, you will probably get an Exception instead of this */
      UNKNOWN;
   }
   
   /**
    * Enumerates the list of OData supported content types.
    */
   private static enum ContentType
   {
      /** JSON Encoded EntitySets and Entities. */
      APPLICATION_JSON("application/json"),
      /** XML schema (Entity Data Model), Entities, messages. */
      APPLICATION_XML ("application/xml"),
      /** Atom+XML Encoded EntitySets (feeds). */
      APPLICATION_ATOM_XML("application/atom+xml"),
      /** Create/Update requests. */
      APPLICATION_FORM ("application/x-www-form-urlencoded");
      
      private final String contentType;
      
      private ContentType (String type)
      {
         this.contentType = type;
      }
      
      /**
       * To specify the {@code Accept} and/or {@code Content-Type}
       * HTTP Header fields.
       * @return the related content type string.
       */
      public String type ()
      {
         return this.contentType;
      }
   }
}