/*
 * Copyright (C) 2015 The Android Open Source Project
 *
 * 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.android.voicemail.impl.imap;

import android.content.Context;
import android.net.ConnectivityManager;
import android.net.Network;
import android.net.NetworkInfo;
import android.support.annotation.Nullable;
import android.telecom.PhoneAccountHandle;
import android.util.Base64;
import com.android.voicemail.PinChanger;
import com.android.voicemail.PinChanger.ChangePinResult;
import com.android.voicemail.impl.OmtpConstants;
import com.android.voicemail.impl.OmtpEvents;
import com.android.voicemail.impl.OmtpVvmCarrierConfigHelper;
import com.android.voicemail.impl.VisualVoicemailPreferences;
import com.android.voicemail.impl.Voicemail;
import com.android.voicemail.impl.VoicemailStatus;
import com.android.voicemail.impl.VoicemailStatus.Editor;
import com.android.voicemail.impl.VvmLog;
import com.android.voicemail.impl.fetch.VoicemailFetchedCallback;
import com.android.voicemail.impl.mail.Address;
import com.android.voicemail.impl.mail.Body;
import com.android.voicemail.impl.mail.BodyPart;
import com.android.voicemail.impl.mail.FetchProfile;
import com.android.voicemail.impl.mail.Flag;
import com.android.voicemail.impl.mail.Message;
import com.android.voicemail.impl.mail.MessagingException;
import com.android.voicemail.impl.mail.Multipart;
import com.android.voicemail.impl.mail.TempDirectory;
import com.android.voicemail.impl.mail.internet.MimeMessage;
import com.android.voicemail.impl.mail.store.ImapConnection;
import com.android.voicemail.impl.mail.store.ImapFolder;
import com.android.voicemail.impl.mail.store.ImapFolder.Quota;
import com.android.voicemail.impl.mail.store.ImapStore;
import com.android.voicemail.impl.mail.store.imap.ImapConstants;
import com.android.voicemail.impl.mail.store.imap.ImapResponse;
import com.android.voicemail.impl.mail.utils.LogUtils;
import com.android.voicemail.impl.sync.OmtpVvmSyncService.TranscriptionFetchedCallback;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import org.apache.commons.io.IOUtils;

/** A helper interface to abstract commands sent across IMAP interface for a given account. */
public class ImapHelper implements Closeable {

  private static final String TAG = "ImapHelper";

  private ImapFolder folder;
  private ImapStore imapStore;

  private final Context context;
  private final PhoneAccountHandle phoneAccount;
  private final Network network;
  private final Editor status;

  VisualVoicemailPreferences prefs;

  private final OmtpVvmCarrierConfigHelper config;

  /** InitializingException */
  public static class InitializingException extends Exception {

    public InitializingException(String message) {
      super(message);
    }
  }

  public ImapHelper(
      Context context, PhoneAccountHandle phoneAccount, Network network, Editor status)
      throws InitializingException {
    this(
        context,
        new OmtpVvmCarrierConfigHelper(context, phoneAccount),
        phoneAccount,
        network,
        status);
  }

  public ImapHelper(
      Context context,
      OmtpVvmCarrierConfigHelper config,
      PhoneAccountHandle phoneAccount,
      Network network,
      Editor status)
      throws InitializingException {
    this.context = context;
    this.phoneAccount = phoneAccount;
    this.network = network;
    this.status = status;
    this.config = config;
    prefs = new VisualVoicemailPreferences(context, phoneAccount);

    try {
      TempDirectory.setTempDirectory(context);

      String username = prefs.getString(OmtpConstants.IMAP_USER_NAME, null);
      String password = prefs.getString(OmtpConstants.IMAP_PASSWORD, null);
      String serverName = prefs.getString(OmtpConstants.SERVER_ADDRESS, null);
      int port = Integer.parseInt(prefs.getString(OmtpConstants.IMAP_PORT, null));
      int auth = ImapStore.FLAG_NONE;

      int sslPort = this.config.getSslPort();
      if (sslPort != 0) {
        port = sslPort;
        auth = ImapStore.FLAG_SSL;
      }

      imapStore = new ImapStore(context, this, username, password, port, serverName, auth, network);
    } catch (NumberFormatException e) {
      handleEvent(OmtpEvents.DATA_INVALID_PORT);
      LogUtils.w(TAG, "Could not parse port number");
      throw new InitializingException("cannot initialize ImapHelper:" + e.toString());
    }
  }

  @Override
  public void close() {
    imapStore.closeConnection();
  }

  public boolean isRoaming() {
    ConnectivityManager connectivityManager =
        (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
    NetworkInfo info = connectivityManager.getNetworkInfo(network);
    if (info == null) {
      return false;
    }
    return info.isRoaming();
  }

  public OmtpVvmCarrierConfigHelper getConfig() {
    return config;
  }

  public ImapConnection connect() {
    return imapStore.getConnection();
  }

  /** The caller thread will block until the method returns. */
  public boolean markMessagesAsRead(List<Voicemail> voicemails) {
    return setFlags(voicemails, Flag.SEEN);
  }

  /** The caller thread will block until the method returns. */
  public boolean markMessagesAsDeleted(List<Voicemail> voicemails) {
    return setFlags(voicemails, Flag.DELETED);
  }

  public void handleEvent(OmtpEvents event) {
    config.handleEvent(status, event);
  }

  /**
   * Set flags on the server for a given set of voicemails.
   *
   * @param voicemails The voicemails to set flags for.
   * @param flags The flags to set on the voicemails.
   * @return {@code true} if the operation completes successfully, {@code false} otherwise.
   */
  private boolean setFlags(List<Voicemail> voicemails, String... flags) {
    if (voicemails.size() == 0) {
      return false;
    }
    try {
      folder = openImapFolder(ImapFolder.MODE_READ_WRITE);
      if (folder != null) {
        folder.setFlags(convertToImapMessages(voicemails), flags, true);
        return true;
      }
      return false;
    } catch (MessagingException e) {
      LogUtils.e(TAG, e, "Messaging exception");
      return false;
    } finally {
      closeImapFolder();
    }
  }

  /**
   * Fetch a list of voicemails from the server.
   *
   * @return A list of voicemail objects containing data about voicemails stored on the server.
   */
  public List<Voicemail> fetchAllVoicemails() {
    List<Voicemail> result = new ArrayList<Voicemail>();
    Message[] messages;
    try {
      folder = openImapFolder(ImapFolder.MODE_READ_WRITE);
      if (folder == null) {
        // This means we were unable to successfully open the folder.
        return null;
      }

      // This method retrieves lightweight messages containing only the uid of the message.
      messages = folder.getMessages(null);

      for (Message message : messages) {
        // Get the voicemail details (message structure).
        MessageStructureWrapper messageStructureWrapper = fetchMessageStructure(message);
        if (messageStructureWrapper != null) {
          result.add(getVoicemailFromMessageStructure(messageStructureWrapper));
        }
      }
      return result;
    } catch (MessagingException e) {
      LogUtils.e(TAG, e, "Messaging Exception");
      return null;
    } finally {
      closeImapFolder();
    }
  }

  /**
   * Extract voicemail details from the message structure. Also fetch transcription if a
   * transcription exists.
   */
  private Voicemail getVoicemailFromMessageStructure(
      MessageStructureWrapper messageStructureWrapper) throws MessagingException {
    Message messageDetails = messageStructureWrapper.messageStructure;

    TranscriptionFetchedListener listener = new TranscriptionFetchedListener();
    if (messageStructureWrapper.transcriptionBodyPart != null) {
      FetchProfile fetchProfile = new FetchProfile();
      fetchProfile.add(messageStructureWrapper.transcriptionBodyPart);

      folder.fetch(new Message[] {messageDetails}, fetchProfile, listener);
    }

    // Found an audio attachment, this is a valid voicemail.
    long time = messageDetails.getSentDate().getTime();
    String number = getNumber(messageDetails.getFrom());
    boolean isRead = Arrays.asList(messageDetails.getFlags()).contains(Flag.SEEN);
    Long duration = messageDetails.getDuration();
    Voicemail.Builder builder =
        Voicemail.createForInsertion(time, number)
            .setPhoneAccount(phoneAccount)
            .setSourcePackage(context.getPackageName())
            .setSourceData(messageDetails.getUid())
            .setIsRead(isRead)
            .setTranscription(listener.getVoicemailTranscription());
    if (duration != null) {
      builder.setDuration(duration);
    }
    return builder.build();
  }

  /**
   * The "from" field of a visual voicemail IMAP message is the number of the caller who left the
   * message. Extract this number from the list of "from" addresses.
   *
   * @param fromAddresses A list of addresses that comprise the "from" line.
   * @return The number of the voicemail sender.
   */
  private String getNumber(Address[] fromAddresses) {
    if (fromAddresses != null && fromAddresses.length > 0) {
      if (fromAddresses.length != 1) {
        LogUtils.w(TAG, "More than one from addresses found. Using the first one.");
      }
      String sender = fromAddresses[0].getAddress();
      int atPos = sender.indexOf('@');
      if (atPos != -1) {
        // Strip domain part of the address.
        sender = sender.substring(0, atPos);
      }
      return sender;
    }
    return null;
  }

  /**
   * Fetches the structure of the given message and returns a wrapper containing the message
   * structure and the transcription structure (if applicable).
   *
   * @throws MessagingException if fetching the structure of the message fails
   */
  private MessageStructureWrapper fetchMessageStructure(Message message) throws MessagingException {
    LogUtils.d(TAG, "Fetching message structure for " + message.getUid());

    MessageStructureFetchedListener listener = new MessageStructureFetchedListener();

    FetchProfile fetchProfile = new FetchProfile();
    fetchProfile.addAll(
        Arrays.asList(
            FetchProfile.Item.FLAGS, FetchProfile.Item.ENVELOPE, FetchProfile.Item.STRUCTURE));

    // The IMAP folder fetch method will call "messageRetrieved" on the listener when the
    // message is successfully retrieved.
    folder.fetch(new Message[] {message}, fetchProfile, listener);
    return listener.getMessageStructure();
  }

  public boolean fetchVoicemailPayload(VoicemailFetchedCallback callback, final String uid) {
    try {
      folder = openImapFolder(ImapFolder.MODE_READ_WRITE);
      if (folder == null) {
        // This means we were unable to successfully open the folder.
        return false;
      }
      Message message = folder.getMessage(uid);
      if (message == null) {
        return false;
      }
      VoicemailPayload voicemailPayload = fetchVoicemailPayload(message);
      callback.setVoicemailContent(voicemailPayload);
      return true;
    } catch (MessagingException e) {
    } finally {
      closeImapFolder();
    }
    return false;
  }

  /**
   * Fetches the body of the given message and returns the parsed voicemail payload.
   *
   * @throws MessagingException if fetching the body of the message fails
   */
  private VoicemailPayload fetchVoicemailPayload(Message message) throws MessagingException {
    LogUtils.d(TAG, "Fetching message body for " + message.getUid());

    MessageBodyFetchedListener listener = new MessageBodyFetchedListener();

    FetchProfile fetchProfile = new FetchProfile();
    fetchProfile.add(FetchProfile.Item.BODY);

    folder.fetch(new Message[] {message}, fetchProfile, listener);
    return listener.getVoicemailPayload();
  }

  public boolean fetchTranscription(TranscriptionFetchedCallback callback, String uid) {
    try {
      folder = openImapFolder(ImapFolder.MODE_READ_WRITE);
      if (folder == null) {
        // This means we were unable to successfully open the folder.
        return false;
      }

      Message message = folder.getMessage(uid);
      if (message == null) {
        return false;
      }

      MessageStructureWrapper messageStructureWrapper = fetchMessageStructure(message);
      if (messageStructureWrapper != null) {
        TranscriptionFetchedListener listener = new TranscriptionFetchedListener();
        if (messageStructureWrapper.transcriptionBodyPart != null) {
          FetchProfile fetchProfile = new FetchProfile();
          fetchProfile.add(messageStructureWrapper.transcriptionBodyPart);

          // This method is called synchronously so the transcription will be populated
          // in the listener once the next method is called.
          folder.fetch(new Message[] {message}, fetchProfile, listener);
          callback.setVoicemailTranscription(listener.getVoicemailTranscription());
        }
      }
      return true;
    } catch (MessagingException e) {
      LogUtils.e(TAG, e, "Messaging Exception");
      return false;
    } finally {
      closeImapFolder();
    }
  }

  @ChangePinResult
  public int changePin(String oldPin, String newPin) throws MessagingException {
    ImapConnection connection = imapStore.getConnection();
    try {
      String command =
          getConfig().getProtocol().getCommand(OmtpConstants.IMAP_CHANGE_TUI_PWD_FORMAT);
      connection.sendCommand(String.format(Locale.US, command, newPin, oldPin), true);
      return getChangePinResultFromImapResponse(connection.readResponse());
    } catch (IOException ioe) {
      VvmLog.e(TAG, "changePin: ", ioe);
      return PinChanger.CHANGE_PIN_SYSTEM_ERROR;
    } finally {
      connection.destroyResponses();
    }
  }

  public void changeVoicemailTuiLanguage(String languageCode) throws MessagingException {
    ImapConnection connection = imapStore.getConnection();
    try {
      String command =
          getConfig().getProtocol().getCommand(OmtpConstants.IMAP_CHANGE_VM_LANG_FORMAT);
      connection.sendCommand(String.format(Locale.US, command, languageCode), true);
    } catch (IOException ioe) {
      LogUtils.e(TAG, ioe.toString());
    } finally {
      connection.destroyResponses();
    }
  }

  public void closeNewUserTutorial() throws MessagingException {
    ImapConnection connection = imapStore.getConnection();
    try {
      String command = getConfig().getProtocol().getCommand(OmtpConstants.IMAP_CLOSE_NUT);
      connection.executeSimpleCommand(command, false);
    } catch (IOException ioe) {
      throw new MessagingException(MessagingException.SERVER_ERROR, ioe.toString());
    } finally {
      connection.destroyResponses();
    }
  }

  @ChangePinResult
  private static int getChangePinResultFromImapResponse(ImapResponse response)
      throws MessagingException {
    if (!response.isTagged()) {
      throw new MessagingException(MessagingException.SERVER_ERROR, "tagged response expected");
    }
    if (!response.isOk()) {
      String message = response.getStringOrEmpty(1).getString();
      LogUtils.d(TAG, "change PIN failed: " + message);
      if (OmtpConstants.RESPONSE_CHANGE_PIN_TOO_SHORT.equals(message)) {
        return PinChanger.CHANGE_PIN_TOO_SHORT;
      }
      if (OmtpConstants.RESPONSE_CHANGE_PIN_TOO_LONG.equals(message)) {
        return PinChanger.CHANGE_PIN_TOO_LONG;
      }
      if (OmtpConstants.RESPONSE_CHANGE_PIN_TOO_WEAK.equals(message)) {
        return PinChanger.CHANGE_PIN_TOO_WEAK;
      }
      if (OmtpConstants.RESPONSE_CHANGE_PIN_MISMATCH.equals(message)) {
        return PinChanger.CHANGE_PIN_MISMATCH;
      }
      if (OmtpConstants.RESPONSE_CHANGE_PIN_INVALID_CHARACTER.equals(message)) {
        return PinChanger.CHANGE_PIN_INVALID_CHARACTER;
      }
      return PinChanger.CHANGE_PIN_SYSTEM_ERROR;
    }
    LogUtils.d(TAG, "change PIN succeeded");
    return PinChanger.CHANGE_PIN_SUCCESS;
  }

  public void updateQuota() {
    try {
      folder = openImapFolder(ImapFolder.MODE_READ_WRITE);
      if (folder == null) {
        // This means we were unable to successfully open the folder.
        return;
      }
      updateQuota(folder);
    } catch (MessagingException e) {
      LogUtils.e(TAG, e, "Messaging Exception");
    } finally {
      closeImapFolder();
    }
  }

  @Nullable
  public Quota getQuota() {
    try {
      folder = openImapFolder(ImapFolder.MODE_READ_ONLY);
      if (folder == null) {
        // This means we were unable to successfully open the folder.
        LogUtils.e(TAG, "Unable to open folder");
        return null;
      }
      return folder.getQuota();
    } catch (MessagingException e) {
      LogUtils.e(TAG, e, "Messaging Exception");
      return null;
    } finally {
      closeImapFolder();
    }
  }

  private void updateQuota(ImapFolder folder) throws MessagingException {
    setQuota(folder.getQuota());
  }

  private void setQuota(ImapFolder.Quota quota) {
    if (quota == null) {
      LogUtils.i(TAG, "quota was null");
      return;
    }

    LogUtils.i(
        TAG,
        "Updating Voicemail status table with"
            + " quota occupied: "
            + quota.occupied
            + " new quota total:"
            + quota.total);
    VoicemailStatus.edit(context, phoneAccount).setQuota(quota.occupied, quota.total).apply();
    LogUtils.i(TAG, "Updated quota occupied and total");
  }

  /**
   * A wrapper to hold a message with its header details and the structure for transcriptions (so
   * they can be fetched in the future).
   */
  public static class MessageStructureWrapper {

    public Message messageStructure;
    public BodyPart transcriptionBodyPart;

    public MessageStructureWrapper() {}
  }

  /** Listener for the message structure being fetched. */
  private final class MessageStructureFetchedListener
      implements ImapFolder.MessageRetrievalListener {

    private MessageStructureWrapper messageStructure;

    public MessageStructureFetchedListener() {}

    public MessageStructureWrapper getMessageStructure() {
      return messageStructure;
    }

    @Override
    public void messageRetrieved(Message message) {
      LogUtils.d(TAG, "Fetched message structure for " + message.getUid());
      LogUtils.d(TAG, "Message retrieved: " + message);
      try {
        messageStructure = getMessageOrNull(message);
        if (messageStructure == null) {
          LogUtils.d(TAG, "This voicemail does not have an attachment...");
          return;
        }
      } catch (MessagingException e) {
        LogUtils.e(TAG, e, "Messaging Exception");
        closeImapFolder();
      }
    }

    /**
     * Check if this IMAP message is a valid voicemail and whether it contains a transcription.
     *
     * @param message The IMAP message.
     * @return The MessageStructureWrapper object corresponding to an IMAP message and
     *     transcription.
     */
    private MessageStructureWrapper getMessageOrNull(Message message) throws MessagingException {
      if (!message.getMimeType().startsWith("multipart/")) {
        LogUtils.w(TAG, "Ignored non multi-part message");
        return null;
      }

      MessageStructureWrapper messageStructureWrapper = new MessageStructureWrapper();

      Multipart multipart = (Multipart) message.getBody();
      for (int i = 0; i < multipart.getCount(); ++i) {
        BodyPart bodyPart = multipart.getBodyPart(i);
        String bodyPartMimeType = bodyPart.getMimeType().toLowerCase();
        LogUtils.d(TAG, "bodyPart mime type: " + bodyPartMimeType);

        if (bodyPartMimeType.startsWith("audio/")) {
          messageStructureWrapper.messageStructure = message;
        } else if (!config.ignoreTranscription() && bodyPartMimeType.startsWith("text/")) {
          messageStructureWrapper.transcriptionBodyPart = bodyPart;
        } else {
          VvmLog.v(TAG, "Unknown bodyPart MIME: " + bodyPartMimeType);
        }
      }

      if (messageStructureWrapper.messageStructure != null) {
        return messageStructureWrapper;
      }

      // No attachment found, this is not a voicemail.
      return null;
    }
  }

  /** Listener for the message body being fetched. */
  private final class MessageBodyFetchedListener implements ImapFolder.MessageRetrievalListener {

    private VoicemailPayload voicemailPayload;

    /** Returns the fetch voicemail payload. */
    public VoicemailPayload getVoicemailPayload() {
      return voicemailPayload;
    }

    @Override
    public void messageRetrieved(Message message) {
      LogUtils.d(TAG, "Fetched message body for " + message.getUid());
      LogUtils.d(TAG, "Message retrieved: " + message);
      try {
        voicemailPayload = getVoicemailPayloadFromMessage(message);
      } catch (MessagingException e) {
        LogUtils.e(TAG, "Messaging Exception:", e);
      } catch (IOException e) {
        LogUtils.e(TAG, "IO Exception:", e);
      }
    }

    private VoicemailPayload getVoicemailPayloadFromMessage(Message message)
        throws MessagingException, IOException {
      Multipart multipart = (Multipart) message.getBody();
      List<String> mimeTypes = new ArrayList<>();
      for (int i = 0; i < multipart.getCount(); ++i) {
        BodyPart bodyPart = multipart.getBodyPart(i);
        String bodyPartMimeType = bodyPart.getMimeType().toLowerCase();
        mimeTypes.add(bodyPartMimeType);
        if (bodyPartMimeType.startsWith("audio/")) {
          byte[] bytes = getDataFromBody(bodyPart.getBody());
          LogUtils.d(TAG, String.format("Fetched %s bytes of data", bytes.length));
          return new VoicemailPayload(bodyPartMimeType, bytes);
        }
      }
      LogUtils.e(TAG, "No audio attachment found on this voicemail, mimeTypes:" + mimeTypes);
      return null;
    }
  }

  /** Listener for the transcription being fetched. */
  private final class TranscriptionFetchedListener implements ImapFolder.MessageRetrievalListener {

    private String voicemailTranscription;

    /** Returns the fetched voicemail transcription. */
    public String getVoicemailTranscription() {
      return voicemailTranscription;
    }

    @Override
    public void messageRetrieved(Message message) {
      LogUtils.d(TAG, "Fetched transcription for " + message.getUid());
      try {
        voicemailTranscription = new String(getDataFromBody(message.getBody()));
      } catch (MessagingException e) {
        LogUtils.e(TAG, "Messaging Exception:", e);
      } catch (IOException e) {
        LogUtils.e(TAG, "IO Exception:", e);
      }
    }
  }

  private ImapFolder openImapFolder(String modeReadWrite) {
    try {
      if (imapStore == null) {
        return null;
      }
      ImapFolder folder = new ImapFolder(imapStore, ImapConstants.INBOX);
      folder.open(modeReadWrite);
      return folder;
    } catch (MessagingException e) {
      LogUtils.e(TAG, e, "Messaging Exception");
    }
    return null;
  }

  private Message[] convertToImapMessages(List<Voicemail> voicemails) {
    Message[] messages = new Message[voicemails.size()];
    for (int i = 0; i < voicemails.size(); ++i) {
      messages[i] = new MimeMessage();
      messages[i].setUid(voicemails.get(i).getSourceData());
    }
    return messages;
  }

  private void closeImapFolder() {
    if (folder != null) {
      folder.close(true);
    }
  }

  private byte[] getDataFromBody(Body body) throws IOException, MessagingException {
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    BufferedOutputStream bufferedOut = new BufferedOutputStream(out);
    try {
      body.writeTo(bufferedOut);
      return Base64.decode(out.toByteArray(), Base64.DEFAULT);
    } finally {
      IOUtils.closeQuietly(bufferedOut);
      IOUtils.closeQuietly(out);
    }
  }
}
