/*
 * Copyright (C) 2003-2013 eXo Platform SAS.
 *
 * 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 org.exoplatform.clouddrive.gdrive;

import com.google.api.client.auth.oauth2.AuthorizationCodeFlow.CredentialCreatedListener;
import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.auth.oauth2.CredentialRefreshListener;
import com.google.api.client.auth.oauth2.StoredCredential;
import com.google.api.client.auth.oauth2.TokenErrorResponse;
import com.google.api.client.auth.oauth2.TokenResponse;
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow;
import com.google.api.client.googleapis.auth.oauth2.GoogleTokenResponse;
import com.google.api.client.googleapis.json.GoogleJsonError;
import com.google.api.client.googleapis.json.GoogleJsonError.ErrorInfo;
import com.google.api.client.googleapis.json.GoogleJsonResponseException;
import com.google.api.client.http.AbstractInputStreamContent;
import com.google.api.client.http.HttpResponseException;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.jackson2.JacksonFactory;
import com.google.api.client.util.store.DataStore;
import com.google.api.client.util.store.DataStoreFactory;
import com.google.api.services.drive.Drive;
import com.google.api.services.drive.Drive.Changes;
import com.google.api.services.drive.Drive.Children;
import com.google.api.services.drive.Drive.Files.Delete;
import com.google.api.services.drive.DriveScopes;
import com.google.api.services.drive.model.About;
import com.google.api.services.drive.model.Change;
import com.google.api.services.drive.model.ChangeList;
import com.google.api.services.drive.model.ChildList;
import com.google.api.services.drive.model.ChildReference;
import com.google.api.services.drive.model.File;
import com.google.api.services.oauth2.Oauth2;
import com.google.api.services.oauth2.Oauth2Scopes;
import com.google.api.services.oauth2.model.Userinfoplus;

import org.exoplatform.clouddrive.CloudDriveAccessException;
import org.exoplatform.clouddrive.CloudDriveException;
import org.exoplatform.clouddrive.NotFoundException;
import org.exoplatform.clouddrive.oauth2.UserToken;
import org.exoplatform.clouddrive.utils.ChunkIterator;
import org.exoplatform.services.log.ExoLogger;
import org.exoplatform.services.log.Log;

import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collection;
import java.util.Date;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Covers calls to Google Drive services and handles related exceptions. <br>
 * Created by The eXo Platform SAS.
 * 
 * @author <a href="mailto:pnedonosko@exoplatform.com">Peter Nedonosko</a>
 * @version $Id: GoogleDriveAPI.java 00000 Jan 5, 2013 pnedonosko $
 */
class GoogleDriveAPI implements DataStoreFactory {

  public static final String       APP_NAME           = "eXo Cloud Drive";

  public static final String       FOLDER_MIMETYPE    = "application/vnd.google-apps.folder";

  public static final List<String> SCOPES             = Arrays.asList(DriveScopes.DRIVE,
                                                                      DriveScopes.DRIVE_FILE,
                                                                      DriveScopes.DRIVE_APPDATA,
                                                                      DriveScopes.DRIVE_SCRIPTS,
                                                                      DriveScopes.DRIVE_APPS_READONLY,
                                                                      Oauth2Scopes.USERINFO_EMAIL,
                                                                      Oauth2Scopes.USERINFO_PROFILE);

  public static final String       SCOPES_STRING      = scopes();

  public static final String       ACCESS_TYPE        = "offline";

  public static final String       APPOVAl_PROMT      = "force";

  public static final String       NO_STATE           = "__no_state_set__";

  protected static final String    USER_ID            = "user_id";

  protected static final String    USER_EMAIL_ADDRESS = "emailAddress";

  protected static final Log       LOG                = ExoLogger.getLogger(GoogleDriveAPI.class);

  class AuthToken extends UserToken implements CredentialRefreshListener, CredentialCreatedListener {

    class Store implements DataStore<StoredCredential> {

      /**
       * {@inheritDoc}
       */
      @Override
      public DataStoreFactory getDataStoreFactory() {
        return GoogleDriveAPI.this;
      }

      /**
       * {@inheritDoc}
       */
      @Override
      public String getId() {
        return GoogleDriveAPI.class.getSimpleName();
      }

      /**
       * {@inheritDoc}
       */
      @Override
      public int size() throws IOException {
        // Only one item possible - current user token
        return isEmpty() ? 0 : 1;
      }

      /**
       * {@inheritDoc}
       */
      @Override
      public boolean isEmpty() throws IOException {
        return getAccessToken() == null || getRefreshToken() == null;
      }

      /**
       * {@inheritDoc}
       */
      @Override
      public boolean containsKey(String userId) throws IOException {
        return USER_ID.equals(userId);
      }

      /**
       * {@inheritDoc}
       */
      @Override
      public boolean containsValue(StoredCredential value) throws IOException {
        return value.getAccessToken().equals(getAccessToken())
            && value.getRefreshToken().equals(getRefreshToken())
            && value.getExpirationTimeMilliseconds() == getExpirationTime();
      }

      /**
       * {@inheritDoc}
       */
      @Override
      public Set<String> keySet() throws IOException {
        Set<String> keys = new HashSet<String>();
        keys.add(USER_ID);
        return keys;
      }

      /**
       * {@inheritDoc}
       */
      @Override
      public Collection<StoredCredential> values() throws IOException {
        StoredCredential[] single = new StoredCredential[] { get(USER_ID) };
        return Arrays.asList(single);
      }

      /**
       * {@inheritDoc}
       */
      @Override
      public StoredCredential get(String userId) throws IOException {
        // Need return StoredCredential...
        StoredCredential stored = new StoredCredential();
        stored.setAccessToken(getAccessToken());
        stored.setRefreshToken(getRefreshToken());
        stored.setExpirationTimeMilliseconds(getExpirationTime());
        return stored;
      }

      /**
       * {@inheritDoc}
       */
      @Override
      public DataStore<StoredCredential> set(String userId, StoredCredential value) throws IOException {
        store(value);
        return this;
      }

      /**
       * {@inheritDoc}
       */
      @Override
      public DataStore<StoredCredential> clear() throws IOException {
        // TODO clear the token keys
        return this;
      }

      /**
       * {@inheritDoc}
       */
      @Override
      public DataStore<StoredCredential> delete(String userId) throws IOException {
        // TODO clear the token keys
        return this;
      }
    }

    /**
     * Facade-implementation of {@link DataStore} to actual value stored in enclosing {@link UserToken}.
     */
    final Store store = new Store();

    void store(StoredCredential credential) {
      try {
        store(credential.getAccessToken(),
              credential.getRefreshToken(),
              credential.getExpirationTimeMilliseconds());
      } catch (CloudDriveException e) {
        LOG.error("Error storing credential", e);
      }
    }

    void store(Credential credential) {
      try {
        store(credential.getAccessToken(),
              credential.getRefreshToken(),
              credential.getExpirationTimeMilliseconds());
      } catch (CloudDriveException e) {
        LOG.error("Error storing credential", e);
      }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onCredentialCreated(Credential credential, TokenResponse tokenResponse) throws IOException {
      store(credential);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onTokenResponse(Credential credential, TokenResponse tokenResponse) throws IOException {
      store(credential);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onTokenErrorResponse(Credential credential, TokenErrorResponse tokenErrorResponse) throws IOException {
      // TODO clean token keys to let them be re-requested to the user
      String errDescription = tokenErrorResponse.getErrorDescription();
      String errURI = tokenErrorResponse.getErrorUri();
      LOG.error("Error refreshing credentials: " + tokenErrorResponse.getError()
          + (errDescription != null ? " " + errDescription : "")
          + (errURI != null ? ". Error URI: " + errURI : ""));
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void merge(UserToken newToken) throws CloudDriveException {
      super.merge(newToken);

      // explicitly apply token keys to currently used credential
      if (credential != null) {
        credential.setAccessToken(newToken.getAccessToken());
        credential.setExpirationTimeMilliseconds(newToken.getExpirationTime());
        credential.setRefreshToken(newToken.getRefreshToken());
      }
    }
  }

  class ChildIterator extends ChunkIterator<ChildReference> {
    final Children.List request;

    /**
     * @throws GoogleDriveException
     */
    ChildIterator(String fileId) throws GoogleDriveException {
      try {
        this.request = drive.children().list(fileId);
      } catch (IOException e) {
        throw new GoogleDriveException("Error creating request to Children.List service: " + e.getMessage(),
                                       e);
      }

      // fetch first page
      iter = nextChunk();
    }

    @Override
    protected Iterator<ChildReference> nextChunk() throws GoogleDriveException {
      try {
        ChildList children = request.execute();
        request.setPageToken(children.getNextPageToken());
        List<ChildReference> items = children.getItems();

        available(items.size());

        return items.iterator();
      } catch (IOException e) {
        throw new GoogleDriveException("Error requesting Children.List service: " + e.getMessage(), e);
      }
    }

    @Override
    protected boolean hasNextChunk() {
      return request.getPageToken() != null && request.getPageToken().length() > 0;
    }
  }

  class ChangesIterator extends ChunkIterator<Change> {
    final Changes.List request;

    long               largestChangeId;

    /**
     * @throws GoogleDriveException
     */
    ChangesIterator(long startChangeId) throws GoogleDriveException {
      try {
        this.request = drive.changes().list();
        this.request.setIncludeSubscribed(false); // get changes of files only explicitly added to user drive
        this.request.setIncludeDeleted(true);
        this.request.setStartChangeId(startChangeId);
      } catch (IOException e) {
        throw new GoogleDriveException("Error creating request to Changes.List service: " + e.getMessage(), e);
      }

      // fetch first page
      iter = nextChunk();
    }

    @Override
    protected Iterator<Change> nextChunk() throws GoogleDriveException {
      try {
        ChangeList children = request.execute();
        largestChangeId = children.getLargestChangeId();
        request.setPageToken(children.getNextPageToken());
        List<Change> items = children.getItems();

        available(items.size());

        return items.iterator();
      } catch (IOException e) {
        throw new GoogleDriveException("Error requesting Children.List service: " + e.getMessage(), e);
      }
    }

    @Override
    protected boolean hasNextChunk() {
      return request.getPageToken() != null && request.getPageToken().length() > 0;
    }

    long getLargestChangeId() {
      return largestChangeId;
    }
  }

  /**
   * Credentials for request authentication.
   */
  final Credential credential;

  /**
   * Drive services API.
   */
  final Drive      drive;

  final AuthToken  token;

  /**
   * User info API.
   */
  final Oauth2     oauth2;

  /**
   * Timezone regexp pattern for adapting Google's date format to SimpleDateFormatter supported.
   */
  // full date pattern: \\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d\\.\\d+([+-][0-2]\\d:[0-5]\\d|Z)
  final Pattern    tzPattern = Pattern.compile("([+-][0-2]\\d:[0-5]\\d|Z)$");

  /**
   * Create Google Drive API from OAuth2 authentication code.
   * 
   * @param clientId {@link String}
   * @param clientSecret {@link String}
   * @param authCode {@link String}
   * @throws GoogleDriveException if authentication failed for any reason.
   * @throws CloudDriveException if credentials store exception happen
   */
  GoogleDriveAPI(String clientId, String clientSecret, String authCode, String redirectUri) throws GoogleDriveException,
      CloudDriveException {
    // use clean token, it will be populated with actual credentials as CredentialRefreshListener
    this.token = new AuthToken();

    GoogleAuthorizationCodeFlow authFlow;
    try {
      authFlow = createFlow(clientId, clientSecret, token);
    } catch (IOException e) {
      throw new GoogleDriveException("Error creating authentication flow: " + e.getMessage(), e);
    }

    GoogleTokenResponse response;
    try {
      // Exchange an authorization code for OAuth 2.0 credentials.
      response = authFlow.newTokenRequest(authCode).setRedirectUri(redirectUri).execute();
    } catch (IOException e) {
      throw new GoogleDriveException("Error authenticating user code: " + e.getMessage(), e);
    }

    try {
      this.credential = authFlow.createAndStoreCredential(response, USER_ID);
    } catch (IOException e) {
      throw new CloudDriveException("Error storing user credential: " + e.getMessage(), e);
    }

    // XXX .setHttpRequestInitializer(new RequestInitializer() this causes OAuth2 401 Unauthorized
    this.drive = new Drive.Builder(new NetHttpTransport(), new JacksonFactory(), this.credential).setApplicationName(APP_NAME)
                                                                                                 .build();
    this.oauth2 = new Oauth2.Builder(new NetHttpTransport(), new JacksonFactory(), this.credential).setApplicationName(APP_NAME)
                                                                                                   .build();
  }

  /**
   * Create Google Drive API from existing user credentials.
   * 
   * @param clientId {@link String}
   * @param clientSecret {@link String}
   * @param accessToken {@link String}
   * @param refreshToken {@link String}
   * @param expirationTime long, token expiration time on milliseconds
   * @throws CloudDriveException if credentials store exception happen
   */
  GoogleDriveAPI(String clientId,
                 String clientSecret,
                 String accessToken,
                 String refreshToken,
                 long expirationTime) throws CloudDriveException {
    this.token = new AuthToken();
    this.token.load(accessToken, refreshToken, expirationTime);

    GoogleAuthorizationCodeFlow authFlow;
    try {
      authFlow = createFlow(clientId, clientSecret, token);
    } catch (IOException e) {
      throw new GoogleDriveException("Error creating authentication flow: " + e.getMessage(), e);
    }

    try {
      this.credential = authFlow.loadCredential(USER_ID);
    } catch (IOException e) {
      throw new CloudDriveException("Error loading Google user credentials: " + e.getMessage(), e);
    }

    // XXX .setHttpRequestInitializer(new RequestInitializer() this causes OAuth2 401 Unauthorized
    this.drive = new Drive.Builder(new NetHttpTransport(), new JacksonFactory(), credential).setApplicationName(APP_NAME)
                                                                                            .build();
    this.oauth2 = new Oauth2.Builder(new NetHttpTransport(), new JacksonFactory(), credential).setApplicationName(APP_NAME)
                                                                                              .build();
  }

  /**
   * {@inheritDoc}
   */
  @SuppressWarnings("unchecked")
  @Override
  public DataStore<StoredCredential> getDataStore(String id) throws IOException {
    return token.store;
  }

  private static String scopes() {
    StringBuilder s = new StringBuilder();
    for (String scope : SCOPES) {
      s.append(scope);
      s.append(' ');
    }
    return s.toString();
  }

  /**
   * Build an authorization flow optionally using provided {@link AuthToken}, then store it as a static
   * class attribute.
   * 
   * @param clientId
   * @param clientSecret
   * @param tokenStore
   * @return GoogleAuthorizationCodeFlow instance.
   * @throws IOException
   */
  GoogleAuthorizationCodeFlow createFlow(String clientId, String clientSecret, AuthToken storedToken) throws IOException {
    HttpTransport httpTransport = new NetHttpTransport();
    JacksonFactory jsonFactory = new JacksonFactory();

    // (access_type=offline) if application needs to refresh access tokens
    // when the user is not present at the browser.
    // If the value "approval_prompt" is force, then the user sees a
    // consent page even if they have previously given consent to your
    // application for a given set of scopes.
    // was setApprovalPrompt("force")

    GoogleAuthorizationCodeFlow.Builder flow = new GoogleAuthorizationCodeFlow.Builder(httpTransport,
                                                                                       jsonFactory,
                                                                                       clientId,
                                                                                       clientSecret,
                                                                                       SCOPES);

    flow.setAccessType(ACCESS_TYPE).setApprovalPrompt(APPOVAl_PROMT);
    if (storedToken != null) {
      flow.setCredentialDataStore(storedToken.store);
      flow.setCredentialCreatedListener(storedToken);
      flow.addRefreshListener(storedToken);
    }

    return flow.build();
  }

  /**
   * Return Userinfo service.
   * 
   * @return {@link Userinfoplus}
   * @throws GoogleDriveException on error from OAuth2 service
   * @throws CloudDriveException if no Userinfo found
   */
  Userinfoplus userInfo() throws GoogleDriveException, CloudDriveException {
    Userinfoplus userInfo;
    try {
      userInfo = oauth2.userinfo().get().execute();
    } catch (GoogleJsonResponseException e) {
      GoogleJsonError error = e.getDetails();
      // More error information can be retrieved with error.getErrors().
      throw new GoogleDriveException("Error getting userinfo: " + error.getMessage() + " (" + error.getCode()
          + ").", e);
    } catch (HttpResponseException e) {
      // No Json body was returned by the API.
      throw new GoogleDriveException("Error handling userinfo response: " + e.getMessage() + " ("
          + e.getStatusCode() + ").", e);
    } catch (IOException e) {
      throw new GoogleDriveException("Error requesting userinfo: " + e.getMessage(), e);
    }
    if (userInfo != null && userInfo.getId() != null) {
      return userInfo;
    } else {
      throw new CloudDriveException("User ID cannot be retrieved.");
    }
  }

  /**
   * Return About service.
   * 
   * @return {@link About}
   * @throws GoogleDriveException
   * @throws CloudDriveAccessException
   */
  About about() throws GoogleDriveException, CloudDriveAccessException {
    try {
      return drive.about().get().execute();
    } catch (GoogleJsonResponseException e) {
      if (e.getStatusCode() == 403) {
        throw new CloudDriveAccessException("Error accessing About service: " + e.getMessage(), e);
      } else {
        throw new GoogleDriveException("Error reading About service: " + e.getMessage(), e);
      }
    } catch (IOException e) {
      throw new GoogleDriveException("Error requesting About service: " + e.getMessage(), e);
    }
  }

  ChildIterator children(String fileId) throws GoogleDriveException {
    return new ChildIterator(fileId);
  }

  ChangesIterator changes(long startChangeId) throws GoogleDriveException {
    return new ChangesIterator(startChangeId);
  }

  /**
   * Read file from Files service.
   * 
   * @param fileId {@link String}
   * @return {@link File}
   * @throws GoogleDriveException
   * @throws NotFoundException
   */
  File file(String fileId) throws GoogleDriveException, NotFoundException {
    try {
      return drive.files().get(fileId).execute();
    } catch (GoogleJsonResponseException e) {
      if (e.getStatusCode() == 404) {
        throw new NotFoundException("Cloud file not found: " + fileId, e);
      } else {
        throw new GoogleDriveException("Error getting file from Files service: " + e.getMessage(), e);
      }
    } catch (IOException e) {
      throw new GoogleDriveException("Error requesting file from Files service: " + e.getMessage(), e);
    }
  }

  /**
   * Insert a new file to Files service and upload its content.
   * 
   * @param file {@link File} file metadata
   * @param file {@link AbstractInputStreamContent} file content
   * @return {@link File} resulting file
   * @throws GoogleDriveException
   * @throws CloudDriveAccessException
   */
  File insert(File file, AbstractInputStreamContent content) throws GoogleDriveException,
                                                            CloudDriveAccessException {
    try {
      return drive.files().insert(file, content).execute();
    } catch (GoogleJsonResponseException e) {
      if (isInsufficientPermissions(e)) {
        throw new CloudDriveAccessException("Insufficient permissions to inserting file with content in Files service. "
            + e.getStatusMessage() + " (" + e.getStatusCode() + ")");
      } else {
        throw new GoogleDriveException("Error inserting file with content to Files service: "
            + e.getMessage(), e);
      }
    } catch (IOException e) {
      throw new GoogleDriveException("Error inserting file with content to Files service: " + e.getMessage(),
                                     e);
    }
  }

  /**
   * Insert a new file to Files service. This method will create an empty file or a folder (if given file
   * object has such mimetype).
   * 
   * @param file {@link File} file metadata
   * @return {@link File} resulting file
   * @throws GoogleDriveException
   * @throws CloudDriveAccessException
   */
  File insert(File file) throws GoogleDriveException, CloudDriveAccessException {
    try {
      return drive.files().insert(file).execute();
    } catch (GoogleJsonResponseException e) {
      if (isInsufficientPermissions(e)) {
        throw new CloudDriveAccessException("Insufficient permissions to insert file to Files service. "
            + e.getStatusMessage() + " (" + e.getStatusCode() + ")");
      } else {
        throw new GoogleDriveException("Error inserting file to Files service: " + e.getMessage(), e);
      }
    } catch (IOException e) {
      throw new GoogleDriveException("Error inserting file to Files service: " + e.getMessage(), e);
    }
  }

  /**
   * Update a file metadata in Files service and upload its new content.
   * 
   * @param file {@link File} file metadata
   * @param file {@link AbstractInputStreamContent} file content
   * @return {@link File} resulting file
   * @throws GoogleDriveException
   * @throws NotFoundException
   * @throws CloudDriveAccessException
   */
  void update(File file, AbstractInputStreamContent content) throws GoogleDriveException,
                                                            NotFoundException,
                                                            CloudDriveAccessException {
    // TODO use If-Match with local ETag to esnure consistency
    // http://stackoverflow.com/questions/15723284/google-drive-sdk-check-etag-when-uploading-synchronizing
    String fileId = file.getId();
    try {
      // file id update not assumed in this context
      drive.files().update(fileId, file, content).execute();
    } catch (GoogleJsonResponseException e) {
      if (isInsufficientPermissions(e)) {
        throw new CloudDriveAccessException("Insufficient permissions to update file in Files service. "
            + e.getStatusMessage() + " (" + e.getStatusCode() + ")");
      } else if (e.getStatusCode() == 404) {
        throw new NotFoundException("Cloud file not found for updating: " + fileId, e);
      } else {
        throw new GoogleDriveException("Error updating file in Files service: " + e.getMessage(), e);
      }
    } catch (IOException e) {
      throw new GoogleDriveException("Error upating file with content in Files service: " + e.getMessage(), e);
    }
  }

  /**
   * Update a file metadata in Files service.
   * 
   * @param file {@link File} file metadata
   * @return {@link File} resulting file
   * @throws GoogleDriveException
   * @throws NotFoundException
   * @throws CloudDriveAccessException
   */
  void update(File file) throws GoogleDriveException, NotFoundException, CloudDriveAccessException {
    // TODO use If-Match with local ETag to esnure consistency
    // http://stackoverflow.com/questions/15723284/google-drive-sdk-check-etag-when-uploading-synchronizing
    String fileId = file.getId();
    try {
      // file id update not assumed in this context
      drive.files().update(fileId, file).execute();
    } catch (GoogleJsonResponseException e) {
      if (isInsufficientPermissions(e)) {
        throw new CloudDriveAccessException("Insufficient permissions to update file in Files service. "
            + e.getStatusMessage() + " (" + e.getStatusCode() + ")");
      } else if (e.getStatusCode() == 404) {
        throw new NotFoundException("Cloud file not found for updating: " + fileId, e);
      } else {
        throw new GoogleDriveException("Error updating file in Files service: " + e.getMessage(), e);
      }
    } catch (IOException e) {
      throw new GoogleDriveException("Error upating file metadata in Files service: " + e.getMessage(), e);
    }
  }

  /**
   * Copy a file in Files service.
   * 
   * @param file {@link File} file metadata
   * @return {@link File} resulting file
   * @throws GoogleDriveException
   * @throws NotFoundException
   * @throws CloudDriveAccessException
   */
  File copy(File file) throws GoogleDriveException, NotFoundException, CloudDriveAccessException {
    // TODO use If-Match with local ETag to esnure consistency
    // http://stackoverflow.com/questions/15723284/google-drive-sdk-check-etag-when-uploading-synchronizing
    String fileId = file.getId();
    try {
      return drive.files().copy(fileId, file).execute();
    } catch (GoogleJsonResponseException e) {
      if (isInsufficientPermissions(e)) {
        throw new CloudDriveAccessException("Insufficient permissions to copy file in Files service. "
            + e.getStatusMessage() + " (" + e.getStatusCode() + ")");
      } else if (e.getStatusCode() == 404) {
        throw new NotFoundException("Cloud file not found for copying: " + fileId, e);
      } else {
        throw new GoogleDriveException("Error copying file in Files service: " + e.getMessage(), e);
      }
    } catch (IOException e) {
      throw new GoogleDriveException("Error copying file metadata in Files service: " + e.getMessage(), e);
    }
  }

  /**
   * Delete a file in Files service.
   * 
   * @param fileId {@link String} file id
   * @return {@link Delete} resulting object
   * @throws GoogleDriveException
   * @throws NotFoundException
   * @throws CloudDriveAccessException
   */
  void delete(String fileId) throws GoogleDriveException, NotFoundException, CloudDriveAccessException {
    try {
      drive.files().delete(fileId).execute();
    } catch (GoogleJsonResponseException e) {
      if (isInsufficientPermissions(e)) {
        throw new CloudDriveAccessException("Insufficient permissions to delete file in Files service. "
            + e.getStatusMessage() + " (" + e.getStatusCode() + ")");
      } else if (e.getStatusCode() == 404) {
        throw new NotFoundException("Cloud file not found for deleting: " + fileId, e);
      } else {
        throw new GoogleDriveException("Error deleting file in Files service: " + e.getMessage(), e);
      }
    } catch (IOException e) {
      throw new GoogleDriveException("Error deleting file in Files service: " + e.getMessage(), e);
    }
  }

  /**
   * Move a file to Trash using Files service.
   * 
   * @param fileId {@link String} file id
   * @return {@link File} resulting object
   * @throws GoogleDriveException
   * @throws NotFoundException
   * @throws CloudDriveAccessException
   */
  File trash(String fileId) throws GoogleDriveException, NotFoundException, CloudDriveAccessException {
    try {
      return drive.files().trash(fileId).execute();
    } catch (GoogleJsonResponseException e) {
      if (isInsufficientPermissions(e)) {
        throw new CloudDriveAccessException("Insufficient permissions to trash file in Files service. "
            + e.getStatusMessage() + " (" + e.getStatusCode() + ")");
      } else if (e.getStatusCode() == 404) {
        throw new NotFoundException("Cloud file not found for trashing: " + fileId, e);
      } else {
        throw new GoogleDriveException("Error trashing file in Files service: " + e.getMessage(), e);
      }
    } catch (IOException e) {
      throw new GoogleDriveException("Error trashing file in Files service: " + e.getMessage(), e);
    }
  }

  /**
   * Move a file from Trash to its original place using Files service.
   * 
   * @param fileId {@link String} file id
   * @return {@link File} resulting object
   * @throws GoogleDriveException
   * @throws NotFoundException
   * @throws CloudDriveAccessException
   */
  File untrash(String fileId) throws GoogleDriveException, NotFoundException, CloudDriveAccessException {
    try {
      return drive.files().untrash(fileId).execute();
    } catch (GoogleJsonResponseException e) {
      if (isInsufficientPermissions(e)) {
        throw new CloudDriveAccessException("Insufficient permissions to untrash file in Files service. "
            + e.getStatusMessage() + " (" + e.getStatusCode() + ")");
      } else if (e.getStatusCode() == 404) {
        throw new NotFoundException("Cloud file not found for untrashing: " + fileId, e);
      } else {
        throw new GoogleDriveException("Error untrashing file in Files service: " + e.getMessage(), e);
      }
    } catch (IOException e) {
      throw new GoogleDriveException("Error untrashing file in Files service: " + e.getMessage(), e);
    }
  }

  /**
   * Check credentials isn't expired and refresh them if required.
   * 
   * @throws GoogleDriveException if error during communication with the provider
   */
  void refreshAccess() throws GoogleDriveException {
    Long expirationTime = credential.getExpiresInSeconds();
    if (expirationTime != null && expirationTime < 0) {
      try {
        credential.refreshToken();
      } catch (IOException e) {
        throw new GoogleDriveException("Error refreshing access token: " + e.getMessage(), e);
      }
    }
  }

  /**
   * Update OAuth2 token to a new one.
   * 
   * @param newToken {@link AuthToken}
   * @throws CloudDriveException
   */
  void updateToken(UserToken newToken) throws CloudDriveException {
    this.token.merge(newToken);

  }

  /**
   * Current OAuth2 token associated with this API instance.
   * 
   * @return {@link AuthToken}
   */
  AuthToken getToken() {
    return token;
  }

  // ********** helpers ***********

  boolean isFolder(File file) {
    return file.getMimeType().equals(FOLDER_MIMETYPE);
  }

  /**
   * Parse RFC3339 date format into Calendar type.
   * 
   * @param datestring date in RFC3339 format.
   * @return Calendar.
   */
  Calendar parseDate(String datestring) {
    Date d = new Date();
    Calendar calendar = Calendar.getInstance();
    // if there is no time zone, we don't need to do any special parsing.
    if (datestring.endsWith("Z")) {
      try {
        SimpleDateFormat s = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
        d = s.parse(datestring);
      } catch (ParseException pe) {// try again with optional decimals
        SimpleDateFormat s = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'");
        s.setLenient(true);
        try {
          d = s.parse(datestring);
        } catch (ParseException e) {
          LOG.error("An error occurred: ", e);
        }
      }
      calendar.setTime(d);
      return calendar;
    }
    
    // Google keep dates in form "2014-12-24T13:45:13.620+02:00" - we need convert timezone to RFC 822 form
    Matcher dm = tzPattern.matcher(datestring);
    if (dm.find() && dm.groupCount() >= 1) {
      String tz = dm.group(1);
      datestring = dm.replaceFirst(tz.replace(":", ""));
    }

    SimpleDateFormat s = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ");
    try {
      d = s.parse(datestring);
    } catch (ParseException pe) {
      s = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ");
      s.setLenient(true);
      try {
        d = s.parse(datestring);
      } catch (ParseException e) {
        LOG.error("An error occurred: ", e);
      }
    }
    calendar.setTime(d);
    return calendar;
  }

  // **** internals *****

  private boolean isInsufficientPermissions(GoogleJsonResponseException e) {
    GoogleJsonError details = e.getDetails();
    if (e.getStatusCode() == 403 && details != null) {
      List<ErrorInfo> errors = details.getErrors();
      if (errors != null) {
        for (ErrorInfo ei : errors) {
          if (ei.getReason().equals("insufficientPermissions")) {
            return true;
          }
        }
      }
    }
    return false;
  }
}
