package de.dhbwstuttgart.util;

import com.diogonunes.jcolor.Attribute;
import de.dhbwstuttgart.core.ConsoleInterface;
import de.dhbwstuttgart.server.SocketServer;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.output.NullOutputStream;

import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.Objects;
import java.util.function.Supplier;
import java.util.stream.Collectors;

import static com.diogonunes.jcolor.Ansi.colorize;

public class Logger {

  private static final DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

  protected final Writer writer;
  protected final String prefix;

  private static final File logFolder = new File(System.getProperty("user.dir") + "/logFiles/");
  public static final Logger NULL_LOGGER = new NullLogger();

  private static Writer defaultWriter;
  private static void initLogger() {
    if (defaultWriter != null) return;
    if (ConsoleInterface.writeLogFiles) {
      try {
        Files.createDirectories(logFolder.toPath());
      } catch (Exception e) {
        throw new RuntimeException("Could not create directory for log files: " + logFolder, e);
      }
    }

    var formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss");
    var currentTimestamp = LocalDateTime.now().format(formatter);
    try {
      defaultWriter = new FileWriter(new File(logFolder, currentTimestamp + ".txt"));
    } catch (IOException e) {
      throw new RuntimeException(e);
    }
  }

  public static void resetLogs() {
    defaultWriter = null;
      try {
          FileUtils.cleanDirectory(logFolder);
      } catch (IOException e) {
          throw new RuntimeException(e);
      }
      initLogger();
  }

  public Logger() {
    this(null, "");
  }

  public Logger(String prefix) {
    this(null, prefix);
  }

  public Logger(Writer writer, String prefix) {
    this.prefix = (Objects.equals(prefix, "")) ? "" : "["+prefix+"] ";
    initLogger();
    this.writer = writer;
  }

  private Writer getWriter() {
    if (writer != null) return writer;
    return defaultWriter;
  }

  /**
   * Create a new logger object from the path provided
   *
   * @param filePath  The path to the output file. Recommended file extension ".log"
   * @return          The Logger object for this output file
   */
  public static Logger forFile(String filePath, String prefix) {
    File file = Path.of(filePath).toFile();
    try {
      Writer fileWriter = new FileWriter(file);
      return new Logger(fileWriter, prefix);
    }
    catch (IOException exception) {
      throw new RuntimeException("Failed to created Logger for file " + filePath, exception);
    }
  }

  /**
   * Create a new logger object that inherits the writer of the given logger object
   *
   * @param logger    The old logger object that will provide the writer
   * @return          The new prefix for the new logger object
   */
  public static Logger inherit(Logger logger, String newPrefix) {
    return new Logger(logger.writer, newPrefix);
  }

  /**
   * Tint the prefix in the color of the logLevel
   * @param logLevel  The logLevel to set the tint to
   * @return          The tinted string (using ANSI sequences)
   */
  protected String getPrefix(LogLevel logLevel) {
    String fullPrefix = prefix + "[" + logLevel + "] ";
    return switch (logLevel) {
      case DEBUG    -> colorize(fullPrefix, Attribute.BRIGHT_MAGENTA_TEXT());
      case INFO     -> colorize(fullPrefix, Attribute.BLUE_TEXT());
      case WARNING  -> colorize(fullPrefix, Attribute.YELLOW_TEXT());
      case ERROR    -> colorize(fullPrefix, Attribute.RED_TEXT());
      case SUCCESS  -> colorize(fullPrefix, Attribute.GREEN_TEXT());
    };
  }

  /**
   * Print text to the output or error stream, depending on the logLevel
   * @param s         The string to print
   * @param logLevel  If logLevel == error, then print to stderr or print to stdout otherwise
   */
  protected void print(String s, LogLevel logLevel) {
    if (!ConsoleInterface.writeToConsole) return;
    String coloredPrefix = this.getPrefix(logLevel);

    // if we are running the server, prepend the timestamp
    if(SocketServer.isServerRunning()) {
      String timestamp = LocalDateTime.now().format(timeFormatter);
      coloredPrefix = "[" + timestamp + "] " + coloredPrefix;
    }

    // output to the correct output-stream
    if (logLevel.getValue() == LogLevel.ERROR.getValue()) {
      System.out.println(coloredPrefix + s);
    }
    else {
      System.err.println(coloredPrefix + s);
    }
  }

  public boolean isLogLevelActive(LogLevel logLevel) {
    return logLevel.isHigherOrEqualTo(ConsoleInterface.logLevel);
  }

  /**
   * Write text to the attached writer if there is any
   * @param s   The string to print
   */
  protected void write(String s) {
    var writer = getWriter();
    if (writer != null && ConsoleInterface.writeLogFiles) {
      // writing to file should only be done when necessary
      synchronized (writer) {
        try {
          writer.write(s + "\n");
          writer.flush();
        }
        catch (IOException exception) {
          throw new RuntimeException("Failed writing to file", exception);
        }
      }
    }
  }

  /**
   * Base method for logging a string value. Should mostly be used by the Logger internal functions that
   * abstract the logLevel away from the parameters
   *
   * @hidden Only relevant for the Logger and very special cases with dynamic logLevel
   * @param s         The text to log
   * @param logLevel  The logLevel on which the text should be logged
   */
  public void log(String s, LogLevel logLevel) {
    if (isLogLevelActive(logLevel)) {
      this.print(s, logLevel);
      this.write(s);
    }
  }
  public void log(Supplier<String> supp, LogLevel logLevel) {
    if (isLogLevelActive(logLevel)) {
      this.log(supp.get(), logLevel);
    }
  }
  public void log(Object obj, LogLevel logLevel) {
    if (isLogLevelActive(logLevel)) {
      this.log(obj.toString(), logLevel);
    }
  }


  /**
   * Output a debug message
   * @param s The string to log
   */
  public void debug(String s) {
    this.log(s, LogLevel.DEBUG);
  }
  public void debug(Object o) {
    this.log(o, LogLevel.DEBUG);
  }
  public void debug(Supplier<String> supp) {
    this.log(supp, LogLevel.DEBUG);
  }

  /**
   * Output an info message
   * @param s The string to log
   */
  public void info(String s) {
    this.log(s, LogLevel.INFO);
  }
  public void info(Object o) {
    this.log(o, LogLevel.INFO);
  }
  public void info(Supplier<String> supp) {
    this.log(supp, LogLevel.INFO);
  }

  /**
   * Output a warning message
   * @param s The string to log
   */
  public void warn(String s) {
    this.log(s, LogLevel.WARNING);
  }
  public void warn(Object o) {
    this.log(o, LogLevel.WARNING);
  }
  public void warn(Supplier<String> supp) {
    this.log(supp, LogLevel.WARNING);
  }

  /**
   * Output an error message
   * @param s The string to log
   */
  public void error(String s) {
    this.log(s, LogLevel.ERROR);
  }
  public void error(Object o) {
    this.log(o, LogLevel.ERROR);
  }
  public void error(Supplier<String> supp) {
    this.log(supp, LogLevel.ERROR);
  }


  /**
   * Output a success message
   * @param s The string to log
   */
  public void success(String s) {
    this.log(s, LogLevel.SUCCESS);
  }
  public void success(Object o) {
    this.log(o, LogLevel.SUCCESS);
  }
  public void success(Supplier<String> supp) {
    this.log(supp, LogLevel.SUCCESS);
  }

  /**
   * Special logging function that prints a throwable object and all of its recursive causes (including stacktrace)
   * as an error
   *
   * @param throwable The Throwable object to output
   */
  public void exception(Throwable throwable) {
    // Format the exception output
    String s = "Exception: " + throwable.getMessage() + "\n" +
      Arrays.stream(throwable.getStackTrace()).map(stackTraceElement ->
        " | " + stackTraceElement.toString()
      ).collect(Collectors.joining("\n"));

    // if there is a cause printed afterward, announce it with the print of the exception
    if (throwable.getCause() != null) {
      s += "\n\nCaused by: ";
    }

    // print the exception
    this.error(s);

    // print the cause recursively
    if (throwable.getCause() != null) {
      this.exception(throwable.getCause());
    }
  }

  public void close() {
    if (this.writer != null) {
      try {
        this.writer.close();
      }
      catch (IOException exception) {
        throw new RuntimeException("Failed to close a loggers writer. Was it maybe already closed? ", exception);
      }
    }
  }


  /**
   * An enum representing the different log levels as integers:
   * <ul>
   *  <li>DEBUG: highly specific output only for debugging</li>
   *  <li>INFO: informational output about the current state of the program</li>
   *  <li>WARNING: warnings about potential issues or an unexpected state</li>
   *  <li>ERROR: invalid states, errors and exceptions</li>
   *  <li>SUCCESS: successfully executed key steps of the program</li>
   * </ul>
   */
  public enum LogLevel {
    /** Highly specific output only for debugging */
    DEBUG(0),
    /** Informational output about the current state of the program */
    INFO(1),
    /** Warnings about potential issues or an unexpected state **/
    WARNING(2),
    /** Invalid states, errors and exceptions */
    ERROR(3),
    /** Successfully executed key steps of the program */
    SUCCESS(4);

    private final int value;
    LogLevel(final int newValue) {
      value = newValue;
    }

    public boolean isHigherOrEqualTo(LogLevel other) {
      return this.value >= other.value;
    }

    public int getValue() {
      return value;
    }

    public static LogLevel fromValue(int value) {
      return switch (value) {
        case 0 -> LogLevel.DEBUG;
        case 1 -> LogLevel.INFO;
        case 2 -> LogLevel.WARNING;
        case 3 -> LogLevel.ERROR;
        case 4 -> LogLevel.SUCCESS;
        default -> throw new RuntimeException("Invalid log level value: " + value);
      };
    }
  }


  /**
   * A special case of logger that will never output anything
   */
  private static class NullLogger extends Logger {
    @Override
    public void log(String s, LogLevel logLevel) {
      // Do nothing. Yay
    }
  }
}