/*
 * Copyright (C) 2017 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.dialer.persistentlog;

import android.content.Context;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
import android.support.annotation.AnyThread;
import android.support.annotation.MainThread;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.WorkerThread;
import android.support.v4.os.UserManagerCompat;
import com.android.dialer.common.LogUtil;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

/**
 * Handles serialization of byte arrays and read/write them to multiple rotating files. If a logText
 * file exceeds {@code fileSizeLimit} after a write, a new file will be used. if the total number of
 * files exceeds {@code fileCountLimit} the oldest ones will be deleted. The logs are stored in the
 * cache but the file index is stored in the data (clearing data will also clear the cache). The
 * logs will be stored under /cache_dir/persistent_log/{@code subfolder}, so multiple independent
 * logs can be created.
 *
 * <p>This class is NOT thread safe. All methods expect the constructor must be called on the same
 * worker thread.
 */
final class PersistentLogFileHandler {

  private static final String LOG_DIRECTORY = "persistent_log";
  private static final String NEXT_FILE_INDEX_PREFIX = "persistent_long_next_file_index_";

  private static final byte[] ENTRY_PREFIX = {'P'};
  private static final byte[] ENTRY_POSTFIX = {'L'};

  private static class LogCorruptionException extends Exception {

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

  private File logDirectory;
  private final String subfolder;
  private final int fileSizeLimit;
  private final int fileCountLimit;

  private SharedPreferences sharedPreferences;

  private File outputFile;
  private Context context;

  @MainThread
  PersistentLogFileHandler(String subfolder, int fileSizeLimit, int fileCountLimit) {
    this.subfolder = subfolder;
    this.fileSizeLimit = fileSizeLimit;
    this.fileCountLimit = fileCountLimit;
  }

  /** Must be called right after the logger thread is created. */
  @WorkerThread
  void initialize(Context context) {
    this.context = context;
    logDirectory = new File(new File(context.getCacheDir(), LOG_DIRECTORY), subfolder);
    initializeSharedPreference(context);
  }

  @WorkerThread
  private boolean initializeSharedPreference(Context context) {
    if (sharedPreferences == null && UserManagerCompat.isUserUnlocked(context)) {
      sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context);
      return true;
    }
    return sharedPreferences != null;
  }

  /**
   * Write the list of byte arrays to the current log file, prefixing each entry with its' length. A
   * new file will only be selected when the batch is completed, so the resulting file might be
   * larger then {@code fileSizeLimit}
   */
  @WorkerThread
  void writeLogs(List<byte[]> logs) throws IOException {
    if (outputFile == null) {
      selectNextFileToWrite();
    }
    outputFile.createNewFile();
    try (DataOutputStream outputStream =
        new DataOutputStream(new FileOutputStream(outputFile, true))) {
      for (byte[] log : logs) {
        outputStream.write(ENTRY_PREFIX);
        outputStream.writeInt(log.length);
        outputStream.write(log);
        outputStream.write(ENTRY_POSTFIX);
      }
      outputStream.close();
      if (outputFile.length() > fileSizeLimit) {
        selectNextFileToWrite();
      }
    }
  }

  void writeRawLogsForTest(byte[] data) throws IOException {
    if (outputFile == null) {
      selectNextFileToWrite();
    }
    outputFile.createNewFile();
    try (DataOutputStream outputStream =
        new DataOutputStream(new FileOutputStream(outputFile, true))) {
      outputStream.write(data);
      outputStream.close();
      if (outputFile.length() > fileSizeLimit) {
        selectNextFileToWrite();
      }
    }
  }

  /** Concatenate all log files in chronicle order and return a byte array. */
  @WorkerThread
  @NonNull
  private byte[] readBlob() throws IOException {
    File[] files = getLogFiles();

    ByteBuffer byteBuffer = ByteBuffer.allocate(getTotalSize(files));
    for (File file : files) {
      byteBuffer.put(readAllBytes(file));
    }
    return byteBuffer.array();
  }

  private static int getTotalSize(File[] files) {
    int sum = 0;
    for (File file : files) {
      sum += (int) file.length();
    }
    return sum;
  }

  /** Parses the content of all files back to individual byte arrays. */
  @WorkerThread
  @NonNull
  List<byte[]> getLogs() throws IOException {
    byte[] blob = readBlob();
    List<byte[]> logs = new ArrayList<>();
    try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(blob))) {
      byte[] log = readLog(input);
      while (log != null) {
        logs.add(log);
        log = readLog(input);
      }
    } catch (LogCorruptionException e) {
      LogUtil.e("PersistentLogFileHandler.getLogs", "logs corrupted, deleting", e);
      deleteLogs();
      return new ArrayList<>();
    }
    return logs;
  }

  private void deleteLogs() throws IOException {
    for (File file : getLogFiles()) {
      file.delete();
    }
    selectNextFileToWrite();
  }

  @WorkerThread
  private void selectNextFileToWrite() throws IOException {
    File[] files = getLogFiles();

    if (files.length == 0 || files[files.length - 1].length() > fileSizeLimit) {
      if (files.length >= fileCountLimit) {
        for (int i = 0; i <= files.length - fileCountLimit; i++) {
          files[i].delete();
        }
      }
      outputFile = new File(logDirectory, String.valueOf(getAndIncrementNextFileIndex()));
    } else {
      outputFile = files[files.length - 1];
    }
  }

  @NonNull
  @WorkerThread
  private File[] getLogFiles() {
    logDirectory.mkdirs();
    File[] files = logDirectory.listFiles();
    if (files == null) {
      files = new File[0];
    }
    Arrays.sort(
        files,
        (File lhs, File rhs) ->
            Long.compare(Long.valueOf(lhs.getName()), Long.valueOf(rhs.getName())));
    return files;
  }

  @Nullable
  @WorkerThread
  private byte[] readLog(DataInputStream inputStream) throws IOException, LogCorruptionException {
    try {
      byte[] prefix = new byte[ENTRY_PREFIX.length];
      if (inputStream.read(prefix) == -1) {
        // EOF
        return null;
      }
      if (!Arrays.equals(prefix, ENTRY_PREFIX)) {
        throw new LogCorruptionException("entry prefix mismatch");
      }
      int dataLength = inputStream.readInt();
      if (dataLength > fileSizeLimit) {
        throw new LogCorruptionException("data length over max size");
      }
      byte[] data = new byte[dataLength];
      inputStream.read(data);

      byte[] postfix = new byte[ENTRY_POSTFIX.length];
      inputStream.read(postfix);
      if (!Arrays.equals(postfix, ENTRY_POSTFIX)) {
        throw new LogCorruptionException("entry postfix mismatch");
      }
      return data;
    } catch (EOFException e) {
      return null;
    }
  }

  @NonNull
  @WorkerThread
  private static byte[] readAllBytes(File file) throws IOException {
    byte[] result = new byte[(int) file.length()];
    try (RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r")) {
      randomAccessFile.readFully(result);
    }
    return result;
  }

  @WorkerThread
  private int getAndIncrementNextFileIndex() throws IOException {
    if (!initializeSharedPreference(context)) {
      throw new IOException("Shared preference is not available");
    }

    int index = sharedPreferences.getInt(getNextFileKey(), 0);
    sharedPreferences.edit().putInt(getNextFileKey(), index + 1).commit();
    return index;
  }

  @AnyThread
  private String getNextFileKey() {
    return NEXT_FILE_INDEX_PREFIX + subfolder;
  }
}
