package org.signal.core.util.logging;

import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Looper;

import androidx.annotation.AnyThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.PrintStream;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicReference;

@SuppressLint("LogNotSignal")
public final class PersistentLogger extends Log.Logger {

  private static final String TAG     = PersistentLogger.class.getSimpleName();

  private static final String LOG_V   = "V";
  private static final String LOG_D   = "D";
  private static final String LOG_I   = "I";
  private static final String LOG_W   = "W";
  private static final String LOG_E   = "E";
  private static final String LOG_WTF = "A";

  private static final String           LOG_DIRECTORY   = "log";
  private static final String           FILENAME_PREFIX = "log-";
  private static final SimpleDateFormat DATE_FORMAT     = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS zzz", Locale.US);

  private final Context  context;
  private final Executor executor;
  private final byte[]   secret;
  private final String   logTag;
  private final int      maxLogFiles;
  private final long     maxLogFileSize;

  private LogFile.Writer writer;

  private final ThreadLocal<String> cachedThreadString;

  public PersistentLogger(@NonNull Context context, @NonNull byte[] secret, @NonNull String logTag, int maxLogFiles, long maxLogFileSize) {
    this.context            = context.getApplicationContext();
    this.secret             = secret;
    this.logTag             = logTag;
    this.maxLogFiles        = maxLogFiles;
    this.maxLogFileSize     = maxLogFileSize;
    this.cachedThreadString = new ThreadLocal<>();
    this.executor           = Executors.newSingleThreadExecutor(r -> {
      Thread thread = new Thread(r, "signal-PersistentLogger");
      thread.setPriority(Thread.MIN_PRIORITY);
      return thread;
    });

    executor.execute(this::initializeWriter);
  }

  @Override
  public void v(String tag, String message, Throwable t) {
    write(LOG_V, tag, message, t);
  }

  @Override
  public void d(String tag, String message, Throwable t) {
    write(LOG_D, tag, message, t);
  }

  @Override
  public void i(String tag, String message, Throwable t) {
    write(LOG_I, tag, message, t);
  }

  @Override
  public void w(String tag, String message, Throwable t) {
    write(LOG_W, tag, message, t);
  }

  @Override
  public void e(String tag, String message, Throwable t) {
    write(LOG_E, tag, message, t);
  }

  @Override
  public void wtf(String tag, String message, Throwable t) {
    write(LOG_WTF, tag, message, t);
  }

  @Override
  public void blockUntilAllWritesFinished() {
    CountDownLatch latch = new CountDownLatch(1);

    executor.execute(latch::countDown);

    try {
      latch.await();
    } catch (InterruptedException e) {
      android.util.Log.w(TAG, "Failed to wait for all writes.");
    }
  }

  @WorkerThread
  public @Nullable CharSequence getLogs() {
    CountDownLatch                latch = new CountDownLatch(1);
    AtomicReference<CharSequence> logs  = new AtomicReference<>();

    executor.execute(() -> {
      StringBuilder builder = new StringBuilder();

      try {
        File[] logFiles = getSortedLogFiles();
        for (int i = logFiles.length - 1; i >= 0; i--) {
          try {
            LogFile.Reader reader = new LogFile.Reader(secret, logFiles[i]);
            builder.append(reader.readAll());
          } catch (IOException e) {
            android.util.Log.w(TAG, "Failed to read log at index " + i + ". Removing reference.");
            logFiles[i].delete();
          }
        }

        logs.set(builder);
      } catch (IOException e) {
        logs.set(null);
      }

      latch.countDown();
    });

    try {
      latch.await();
      return logs.get();
    } catch (InterruptedException e) {
      android.util.Log.w(TAG, "Failed to wait for logs to be retrieved.");
      return null;
    }
  }

  @WorkerThread
  private void initializeWriter() {
    try {
      writer = new LogFile.Writer(secret, getOrCreateActiveLogFile());
    } catch (IOException e) {
      android.util.Log.e(TAG, "Failed to initialize writer.", e);
    }
  }

  @AnyThread
  private void write(String level, String tag, String message, Throwable t) {
    String threadString = cachedThreadString.get();

    if (cachedThreadString.get() == null) {
      if (Looper.myLooper() == Looper.getMainLooper()) {
        threadString = "main ";
      } else {
        threadString = String.format("%-5s", Thread.currentThread().getId());
      }

      cachedThreadString.set(threadString);
    }

    final String finalThreadString = threadString;

    executor.execute(() -> {
      try {
        if (writer == null) {
          return;
        }

        if (writer.getLogSize() >= maxLogFileSize) {
          writer.close();
          writer = new LogFile.Writer(secret, createNewLogFile());
          trimLogFilesOverMax();
        }

        for (String entry : buildLogEntries(level, tag, message, t, finalThreadString)) {
          writer.writeEntry(entry);
        }

      } catch (IOException e) {
        android.util.Log.w(TAG, "Failed to write line. Deleting all logs and starting over.");
        deleteAllLogs();
        initializeWriter();
      }
    });
  }

  private void trimLogFilesOverMax() throws IOException {
    File[] logs = getSortedLogFiles();
    if (logs.length > maxLogFiles) {
      for (int i = maxLogFiles; i < logs.length; i++) {
        logs[i].delete();
      }
    }
  }

  private void deleteAllLogs() {
    try {
      File[] logs = getSortedLogFiles();
      for (File log : logs) {
        log.delete();
      }
    } catch (IOException e) {
      android.util.Log.w(TAG, "Was unable to delete logs.", e);
    }
  }

  private File getOrCreateActiveLogFile() throws IOException {
    File[] logs = getSortedLogFiles();
    if (logs.length > 0) {
      return logs[0];
    }

    return createNewLogFile();
  }

  private File createNewLogFile() throws IOException {
    return new File(getOrCreateLogDirectory(), FILENAME_PREFIX + System.currentTimeMillis());
  }

  private File[] getSortedLogFiles() throws IOException {
    File[] logs = getOrCreateLogDirectory().listFiles();
    if (logs != null) {
      Arrays.sort(logs, (o1, o2) -> o2.getName().compareTo(o1.getName()));
      return logs;
    }
    return new File[0];
  }

  private File getOrCreateLogDirectory() throws IOException {
    File logDir = new File(context.getCacheDir(), LOG_DIRECTORY);
    if (!logDir.exists() && !logDir.mkdir()) {
      throw new IOException("Unable to create log directory.");
    }

    return logDir;
  }

  private List<String> buildLogEntries(String level, String tag, String message, Throwable t, String threadString) {
    List<String> entries = new LinkedList<>();
    Date         date    = new Date();

    entries.add(buildEntry(level, tag, message, date, threadString));

    if (t != null) {
      ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
      t.printStackTrace(new PrintStream(outputStream));

      String   trace = new String(outputStream.toByteArray());
      String[] lines = trace.split("\\n");

      for (String line : lines) {
        entries.add(buildEntry(level, tag, line, date, threadString));
      }
    }

    return entries;
  }

  private String buildEntry(String level, String tag, String message, Date date, String threadString) {
    return '[' + logTag + "] [" + threadString + "] " + DATE_FORMAT.format(date) + ' ' + level + ' ' + tag + ": " + message;
  }
}
