/*
 * Copyright 2018 Scytl Secure Electronic Voting SA
 *
 * All rights reserved
 *
 * See our extended copyright notice in *file 'Copyright.txt'
 * which is part of this source code package
 */

package com.scytl.slogger;

import static java.nio.file.Files.createTempDirectory;
import static java.nio.file.Files.delete;
import static java.nio.file.Files.newDirectoryStream;
import static java.nio.file.Files.readAllBytes;
import static java.nio.file.Files.readAllLines;
import static java.nio.file.Files.size;
import static java.nio.file.Files.write;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

import com.scytl.slogger.crypto.SecureLoggerCryptographyFactory;
import com.scytl.slogger.crypto.SecureLoggerCryptographyProvider;
import com.scytl.slogger.event.SecureLoggingEvent;
import com.scytl.slogger.event.SecureMessageProperties;
import com.scytl.slogger.event.SecurePatternLayout;
import com.scytl.slogger.policy.LogFileNamePolicies;
import com.scytl.slogger.policy.SequentialFileNamePolicy;
import com.scytl.slogger.util.LogValidator;

import org.apache.log4j.Category;
import org.apache.log4j.LogManager;
import org.apache.log4j.Logger;
import org.apache.log4j.Priority;
import org.apache.log4j.spi.LoggingEvent;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.GeneralSecurityException;
import java.util.Base64;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.StringJoiner;

import javax.crypto.SecretKey;

/**
 * Secure logger integration test.
 *
 * @author aakimov
 */
public class SecureLoggerITest {
  private static final String NAME = "AUDIT";
  private static final String PATTERN = "%d %p - %C (%F:%L) [%t] - %m%n";
  private static final String VERIFY_PATTERN =
      "TIMESTAMP LEVEL - CLASS (FILE:LINE) [THREAD] - MESSAGE";
  private static final int NUMBER_LOG_LINES = 3;
  private static final long TIMER_LOG_MILLISECONDS = 4000;
  private static final String SIGNATURE_PKCS12_PASSWORD = "GXXCNJH48X5F649VRY52";
  private static final String CIPHER_PKCS12_PASSWORD = "649VRY52GXXCNJH48X5F";
  private static final String SIGNATURE_PKCS12_FILE = "signatureKeyStore.p12";
  private static final String CIPHER_PKCS12_FILE = "cipherKeyStore.p12";
  private static final String CERTIFICATE_RESOURCE = "/test.crt";
  private static final int EVENT_COUNT = 3;
  private static final String LOG_FILE_NAME = "Log File Name";
  private static final String NEW_SECRET_KEY_GENERATED = "New Secret Key generated.";
  private static final String BEGIN_SECURE_BLOCK = "# BEGIN SECURE BLOCK";
  private static final String END_SECURE_BLOCK = "# END SECURE BLOCK";
  private static final String BEGIN_INSECURE_BLOCK = "# BEGIN INSECURE BLOCK";
  private static final String END_INSECURE_BLOCK = "# END INSECURE BLOCK";
  private SecureLoggerCryptographyProvider provider;
  private Path folder;
  private Path baseFile;
  private Path okFile;
  private Path warnFile;
  private Path errorFile;
  private List<Path> logFiles;

  private static void assertFileIsEmpty(Path file) throws IOException {
    assertEquals(0, size(file));
  }

  private static byte[] toByteArray(long value) {
    byte[] bytes = new byte[8];
    bytes[0] = (byte) (value >>> 56);
    bytes[1] = (byte) (value >>> 48);
    bytes[2] = (byte) (value >>> 40);
    bytes[3] = (byte) (value >>> 32);
    bytes[4] = (byte) (value >>> 24);
    bytes[5] = (byte) (value >>> 16);
    bytes[6] = (byte) (value >>> 8);
    bytes[7] = (byte) value;
    return bytes;
  }

  @Before
  public void setUp() throws IOException, GeneralSecurityException {
    provider = new SecureLoggerCryptographyFactory().build();
    folder = createTempDirectory("slogger");
    baseFile = folder.resolve("it-audit.log");
    okFile = folder.resolve("ok.log");
    warnFile = folder.resolve("warn.log");
    errorFile = folder.resolve("error.log");
  }

  @After
  public void tearDown() throws IOException {
    try (DirectoryStream<Path> stream = newDirectoryStream(folder)) {
      for (Path file : stream) {
        delete(file);
      }
    }
    delete(folder);
  }

  @Test
  public void testContinued() throws SecureLoggerException, IOException {
    writeLog();
    writeLog();
    validateLog();
    assertContinuedLogFilesAreCorrect();
    assertContinuedOkFileContainsAll();
    assertWarnFileIsEmpty();
    assertErrorFileIsEmpty();
  }

  @Test
  public void testCorrectLog() throws SecureLoggerException, IOException {
    writeLog();
    validateLog();
    assertLogFilesAreCorrect();
    assertOkFileContainsAll();
    assertWarnFileIsEmpty();
    assertErrorFileIsEmpty();
  }

  @Test
  public void testLateEvents() throws SecureLoggerException, IOException, GeneralSecurityException {
    writeLog();
    Checkpoint checkpoint = getLastCheckpoint();
    truncateLog();
    try {
      Thread.sleep(TIMER_LOG_MILLISECONDS + 1);
    } catch (InterruptedException e) {
      Thread.currentThread().interrupt();
      throw new AssertionError(e);
    }
    appendFalseEvents(checkpoint, 1);
    writeLog();
    validateLog();
    assertOkFileContainsFirstBlock();
    assertWarnFileContainsLateFalseBlock();
  }

  @Test
  public void testManipulatedLog() throws SecureLoggerException, IOException {
    writeLog();
    manipulateLog();
    validateLog();
    assertOkFileContainsSecondBlock();
    assertWarnFileContainsFirstBlock();
    assertErrorFileContainsManipulated();
  }

  @Test
  public void testTooManyEvents()
      throws SecureLoggerException, IOException, GeneralSecurityException {
    writeLog();
    Checkpoint checkpoint = getLastCheckpoint();
    truncateLog();
    appendFalseEvents(checkpoint, 3);
    writeLog();
    validateLog();
    assertOkFileContainsFirstBlock();
    assertWarnFileContainsBigFalseBlock();
  }

  @Test
  public void testTruncatedLog() throws SecureLoggerException, IOException {
    writeLog();
    truncateLog();
    validateLog();
    assertOkFileContainsFirstBlock();
    assertWarnFileContainsSecondBlock();
    assertErrorFileContainsTruncatedBlock();
  }

  private void appendFalseEvents(Checkpoint checkpoint, int count)
      throws GeneralSecurityException, IOException {
    byte[] lastHMac = checkpoint.lastHMac;
    Category category = new FakeCategory(NAME);
    SecurePatternLayout layout = new SecurePatternLayout(PATTERN);
    StringJoiner content = new StringJoiner("\r");
    content.add("");
    for (int i = 0; i < count; i++) {
      @SuppressWarnings("deprecation")
      LoggingEvent event =
          new LoggingEvent(getClass().getName(), category, Priority.INFO, "False event " + i, null);
      Map<String, String> properties = new LinkedHashMap<>();

      long timestamp = System.currentTimeMillis();
      properties.put(SecureMessageProperties.TIMESTAMP, Long.toString(timestamp));

      lastHMac = provider.getMac(checkpoint.liberatedSessionKey, lastHMac, toByteArray(timestamp),
          layout.format(event).getBytes(StandardCharsets.UTF_8));
      properties.put(SecureMessageProperties.HMAC, Base64.getEncoder().encodeToString(lastHMac));

      content.add(layout.format(
          SecureLoggingEvent.newClientInstance(event).addProperties(properties), true));
    }
    write(logFiles.get(0), content.toString().getBytes(), StandardOpenOption.APPEND);
  }

  private void assertContinuedLogFilesAreCorrect() throws IOException {
    assertEquals(2, logFiles.size());
    Path logFile = logFiles.get(0);
    List<String> lines = readAllLines(logFile, StandardCharsets.UTF_8);
    assertEquals(7, lines.size());
    assertTrue(lines.get(0).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(1).contains(logFile.toString()));
    assertTrue(lines.get(2).contains(" 0 "));
    assertTrue(lines.get(3).contains(" 1 "));
    assertTrue(lines.get(4).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(5).contains(" 2 "));
    assertTrue(lines.get(6).contains(NEW_SECRET_KEY_GENERATED));

    logFile = logFiles.get(1);
    lines = readAllLines(logFile, StandardCharsets.UTF_8);
    assertEquals(7, lines.size());
    assertTrue(lines.get(0).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(1).contains(logFile.toString()));
    assertTrue(lines.get(2).contains(" 0 "));
    assertTrue(lines.get(3).contains(" 1 "));
    assertTrue(lines.get(4).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(5).contains(" 2 "));
    assertTrue(lines.get(6).contains(NEW_SECRET_KEY_GENERATED));
  }

  private void assertContinuedOkFileContainsAll() throws IOException {
    List<String> lines = readAllLines(okFile, StandardCharsets.UTF_8);
    assertEquals(31, lines.size());
    assertTrue(lines.get(0).contains(BEGIN_SECURE_BLOCK));
    assertTrue(lines.get(1).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(2).contains(END_SECURE_BLOCK));
    assertTrue(lines.get(3).contains(BEGIN_SECURE_BLOCK));
    assertTrue(lines.get(4).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(5).contains(LOG_FILE_NAME));
    assertTrue(lines.get(6).contains(" 0 "));
    assertTrue(lines.get(7).contains(" 1 "));
    assertTrue(lines.get(8).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(9).contains(END_SECURE_BLOCK));
    assertTrue(lines.get(10).contains(BEGIN_SECURE_BLOCK));
    assertTrue(lines.get(11).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(12).contains(" 2 "));
    assertTrue(lines.get(13).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(14).contains(END_SECURE_BLOCK));
    assertTrue(lines.get(15).contains(BEGIN_SECURE_BLOCK));
    assertTrue(lines.get(16).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(17).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(18).contains(END_SECURE_BLOCK));
    assertTrue(lines.get(19).contains(BEGIN_SECURE_BLOCK));
    assertTrue(lines.get(20).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(21).contains(LOG_FILE_NAME));
    assertTrue(lines.get(22).contains(" 0 "));
    assertTrue(lines.get(23).contains(" 1 "));
    assertTrue(lines.get(24).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(25).contains(END_SECURE_BLOCK));
    assertTrue(lines.get(26).contains(BEGIN_SECURE_BLOCK));
    assertTrue(lines.get(27).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(28).contains(" 2 "));
    assertTrue(lines.get(29).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(30).contains(END_SECURE_BLOCK));
  }

  private void assertErrorFileContainsManipulated() throws IOException {
    List<String> lines = readAllLines(errorFile);
    assertEquals(1, lines.size());
    assertTrue(lines.get(0).contains(" 1 "));
  }

  private void assertErrorFileContainsTruncatedBlock() throws IOException {
    List<String> lines = readAllLines(errorFile);
    assertEquals(2, lines.size());
    assertTrue(lines.get(0).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(1).contains(" 2 "));
  }

  private void assertErrorFileIsEmpty() throws IOException {
    assertFileIsEmpty(errorFile);
  }

  private void assertLogFilesAreCorrect() throws IOException {
    assertEquals(1, logFiles.size());
    Path logFile = logFiles.get(0);
    List<String> lines = readAllLines(logFile, StandardCharsets.UTF_8);
    assertEquals(7, lines.size());
    assertTrue(lines.get(0).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(1).contains(logFile.toString()));
    assertTrue(lines.get(2).contains(" 0 "));
    assertTrue(lines.get(3).contains(" 1 "));
    assertTrue(lines.get(4).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(5).contains(" 2 "));
    assertTrue(lines.get(6).contains(NEW_SECRET_KEY_GENERATED));
  }

  private void assertOkFileContainsAll() throws IOException {
    List<String> lines = readAllLines(okFile, StandardCharsets.UTF_8);
    assertEquals(15, lines.size());
    assertTrue(lines.get(0).contains(BEGIN_SECURE_BLOCK));
    assertTrue(lines.get(1).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(2).contains(END_SECURE_BLOCK));
    assertTrue(lines.get(3).contains(BEGIN_SECURE_BLOCK));
    assertTrue(lines.get(4).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(5).contains(LOG_FILE_NAME));
    assertTrue(lines.get(6).contains(" 0 "));
    assertTrue(lines.get(7).contains(" 1 "));
    assertTrue(lines.get(8).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(9).contains(END_SECURE_BLOCK));
    assertTrue(lines.get(10).contains(BEGIN_SECURE_BLOCK));
    assertTrue(lines.get(11).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(12).contains(" 2 "));
    assertTrue(lines.get(13).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(14).contains(END_SECURE_BLOCK));
  }

  private void assertOkFileContainsFirstBlock() throws IOException {
    List<String> lines = readAllLines(okFile);
    assertTrue(lines.size() >= 10);
    assertTrue(lines.get(0).contains(BEGIN_SECURE_BLOCK));
    assertTrue(lines.get(1).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(2).contains(END_SECURE_BLOCK));
    assertTrue(lines.get(3).contains(BEGIN_SECURE_BLOCK));
    assertTrue(lines.get(4).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(5).contains(LOG_FILE_NAME));
    assertTrue(lines.get(6).contains(" 0 "));
    assertTrue(lines.get(7).contains(" 1 "));
    assertTrue(lines.get(8).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(9).contains(END_SECURE_BLOCK));
  }

  private void assertOkFileContainsSecondBlock() throws IOException {
    List<String> lines = readAllLines(okFile);
    assertEquals(8, lines.size());
    assertTrue(lines.get(0).contains(BEGIN_SECURE_BLOCK));
    assertTrue(lines.get(1).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(2).contains(END_SECURE_BLOCK));
    assertTrue(lines.get(3).contains(BEGIN_SECURE_BLOCK));
    assertTrue(lines.get(4).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(5).contains(" 2 "));
    assertTrue(lines.get(6).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(7).contains(END_SECURE_BLOCK));
  }

  private void assertWarnFileContainsBigFalseBlock() throws IOException {
    List<String> lines = readAllLines(warnFile, StandardCharsets.UTF_8);
    assertTrue(lines.size() >= 8);
    assertTrue(lines.get(0).contains(BEGIN_INSECURE_BLOCK));
    assertTrue(lines.get(1).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(2).contains(" 2 "));
    assertTrue(lines.get(3).contains(" False event 0 "));
    assertTrue(lines.get(4).contains(" False event 1 "));
    assertTrue(lines.get(5).contains(" False event 2 "));
    assertTrue(lines.get(6).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(7).contains(END_INSECURE_BLOCK));
  }

  private void assertWarnFileContainsFirstBlock() throws IOException {
    List<String> lines = readAllLines(warnFile);
    assertEquals(7, lines.size());
    assertTrue(lines.get(0).contains(BEGIN_INSECURE_BLOCK));
    assertTrue(lines.get(1).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(2).contains(LOG_FILE_NAME));
    assertTrue(lines.get(3).contains(" 1 "));
    assertTrue(lines.get(4).contains(" 1 "));
    assertTrue(lines.get(5).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(6).contains(END_INSECURE_BLOCK));
  }

  private void assertWarnFileContainsLateFalseBlock() throws IOException {
    List<String> lines = readAllLines(warnFile, StandardCharsets.UTF_8);
    assertTrue(lines.size() >= 6);
    assertTrue(lines.get(0).contains(BEGIN_INSECURE_BLOCK));
    assertTrue(lines.get(1).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(2).contains(" 2 "));
    assertTrue(lines.get(3).contains(" False event 0 "));
    assertTrue(lines.get(4).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(5).contains(END_INSECURE_BLOCK));
  }

  private void assertWarnFileContainsSecondBlock() throws IOException {
    List<String> lines = readAllLines(warnFile);
    assertEquals(4, lines.size());
    assertTrue(lines.get(0).contains(BEGIN_INSECURE_BLOCK));
    assertTrue(lines.get(1).contains(NEW_SECRET_KEY_GENERATED));
    assertTrue(lines.get(2).contains(" 2 "));
    assertTrue(lines.get(3).contains(END_INSECURE_BLOCK));
  }

  private void assertWarnFileIsEmpty() throws IOException {
    assertFileIsEmpty(warnFile);
  }

  private Checkpoint getLastCheckpoint() throws IOException, GeneralSecurityException {
    Path logFile = logFiles.get(0);
    List<String> lines = readAllLines(logFile);
    String line = lines.get(lines.size() - 1);

    int from = line.indexOf("PHMAC::");
    from += "PHMAC::".length();
    int to = line.indexOf(',', from);
    if (to < 0) {
      to = line.indexOf("*}");
    }
    byte[] LastHMac = Base64.getDecoder().decode(line.substring(from, to));

    from = line.indexOf("LSK::");
    from += "LSK::".length();
    to = line.indexOf(',', from);
    if (to < 0) {
      to = line.indexOf("*}");
    }
    byte[] encodedKey = Base64.getDecoder().decode(line.substring(from, to));
    SecretKey liberatedSessionKey = provider.generateSymmetricKey(encodedKey);

    return new Checkpoint(LastHMac, liberatedSessionKey);
  }

  private void manipulateLog() throws IOException {
    Path logFile = logFiles.get(0);
    String content = new String(readAllBytes(logFile), StandardCharsets.UTF_8);
    content = content.replace(" 0 ", " 1 ");
    write(logFile, content.getBytes(StandardCharsets.UTF_8));
  }

  private SecureAppender newSecureAppender() throws SecureLoggerException {
    SecureAppender appender = new SecureAppender();
    appender.setName(NAME);
    appender.setPattern(PATTERN);
    appender.setVerifyPattern(VERIFY_PATTERN);
    appender.setFile(baseFile.toString());
    appender.setEncoding(StandardCharsets.UTF_8.name());
    appender.setNumberLogLines(NUMBER_LOG_LINES);
    appender.setTimerLogMilliseconds(TIMER_LOG_MILLISECONDS);
    appender.setTriggeringTime(TIMER_LOG_MILLISECONDS / 2);
    appender.setVerifyPattern(VERIFY_PATTERN);
    appender.setLogFileNamePolicyName(LogFileNamePolicies.SEQUENTIAL_FILENAME.name());
    appender.setSignaturePkcs12FileName(SIGNATURE_PKCS12_FILE);
    appender.setCipherPkcs12FileName(CIPHER_PKCS12_FILE);
    appender.setSignaturePkcs12Password(SIGNATURE_PKCS12_PASSWORD);
    appender.setCipherPkcs12Password(CIPHER_PKCS12_PASSWORD);
    return appender;
  }

  private void truncateLog() throws IOException {
    Path logFile = logFiles.get(0);
    List<String> lines = readAllLines(logFile, StandardCharsets.UTF_8);
    lines = lines.subList(0, lines.size() - 1);
    StringJoiner content = new StringJoiner("\r");
    lines.forEach(content::add);
    write(logFile, content.toString().getBytes(StandardCharsets.UTF_8));
  }

  private void validateLog() {
    String certificateFile = SimpleEndToEndTest.class.getResource(CERTIFICATE_RESOURCE).getFile();
    String[] args = {"-c", certificateFile, "-l", baseFile.toString(), "-e",
        StandardCharsets.UTF_8.name(), "-p", PATTERN, "-v", VERIFY_PATTERN, "-o", folder.toString(),
        "-n", SequentialFileNamePolicy.class.getName()};
    LogValidator.main(args);
  }

  private void writeLog() throws SecureLoggerException {
    SecureAppender appender = newSecureAppender();
    appender.activateOptions();
    try {
      Logger logger = LogManager.getRootLogger();
      logger.addAppender(appender);
      try {
        for (int i = 0; i < EVENT_COUNT; i++) {
          logger.info(Integer.toString(i));
        }
      } finally {
        logger.removeAppender(appender);
      }
    } finally {
      appender.close();
    }
    logFiles = new LinkedList<>();
    Iterator<File> iterator = appender.getLogFileNamePolicy().logFiles();
    while (iterator.hasNext()) {
      logFiles.add(iterator.next().toPath());
    }
  }

  private static class Checkpoint {
    private final byte[] lastHMac;
    private final SecretKey liberatedSessionKey;

    public Checkpoint(byte[] lastHMac, SecretKey liberatedSessionKey) {
      this.lastHMac = lastHMac;
      this.liberatedSessionKey = liberatedSessionKey;
    }
  }

  private static class FakeCategory extends Category {
    public FakeCategory(String name) {
      super(name);
    }
  }
}
