package test.log
import java.io.*;
import java.nio.charset.CharsetEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Random;
import java.util.concurrent.Semaphore;

public class Logger {
    public static final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("MMM dd, yyyy hh:mm:ss a").withZone(ZoneId.systemDefault());
    private final String fileName = "log4j.log";
    public final String dirName;
    private final long maxFileSizeBytes;
    private Level minLevel;
    private final int maxBackups;
    private final File file;
    private CountingWriter w;
    private boolean stdoutMode;

    private static Logger logger;
    public static void setLogger(Logger logger){
        Logger.logger=logger;
    }
    public static Logger getLogger(String name) {
        if(logger==null) logger=new Logger();
        return logger;
    }

    public enum Level{debug(),info(),warn(),error(),fatal();
        private final String nameUcase;
        Level() {
            this.nameUcase = this.name().toUpperCase(Locale.ROOT);
        }
    }

    public Logger(long maxFileSizeBytes, Level minLevel, String dirName, boolean stdoutMode, int maxBackups) {
        this.maxFileSizeBytes = maxFileSizeBytes;
        this.minLevel = minLevel;
        this.dirName=dirName;
        this.stdoutMode = stdoutMode;
        this.file=new File(dirName,fileName);
        this.maxBackups = maxBackups;
        System.out.println("file= " + this.file);
        this.w=initFileWriter();
    }

    private Logger() {
        this(1024*1024*20L,Level.info,"/var/log",false, 10);
    }


    private CountingWriter initFileWriter() {
        try {
            final long length ;
            final boolean exists = file.exists();
            if (exists) {
                length = file.length();
            }
            else {
                final File parentFile = file.getParentFile();
                parentFile.mkdirs();
                length=0;
            }
            CharsetEncoder encoder = StandardCharsets.UTF_8.newEncoder();
            FileOutputStream outputStream=new FileOutputStream(file,exists);
            final CountingWriter countingWriter = new CountingWriter(new BufferedWriter(new OutputStreamWriter(outputStream, encoder)), length);
            stdoutMode=false;
            return countingWriter;
        } catch (Exception e) {
            System.err.println("error initialising logging! Setting 'stdout' mode");
            e.printStackTrace();
            stdoutMode=true;
        }
        return null;
    }
    public void flush() {
        try {
            w.flush();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private void log(Level level, String message, Throwable e){
        if(message==null && e==null) return;
        if(level.ordinal()<minLevel.ordinal()) return;
        try {
            if(stdoutMode){
                writeToStdErr(message,e,null,null);
                return;
            }
            if(rollLock.availablePermits()<1){
                writeToStdErr(message, e, "rolling", null);
                return;
            }
            w.write(dateFormatter.format(Instant.now()));
            w.write(' ');
            w.write(level.nameUcase);
            w.write(' ');
            w.write('[');
            w.write(Thread.currentThread().getName());
            w.write(']');
            w.write(' ');
            final boolean hasMessage = message != null;
            if(hasMessage){
                w.write(message);
            }
            if(e!=null){
                if(hasMessage) w.write(' ');
                final PrintWriter pw = new PrintWriter(w, false);
                e.printStackTrace(pw);
            }
            w.write('\n');
            if(level.ordinal()>= Level.warn.ordinal() || isDebugEnabled()) w.flush();
            //flush immediately (e.g. in example of fatal error
            //also flush immediately if debugging: since we don't care so much about perf if we're debugging
            if(w.getCountBytes()>=maxFileSizeBytes){
                if(rollLock.tryAcquire()){//if someone's already on it, just give up
                    try {
                        roll();
                    } finally {
                        rollLock.release();
                    }
                }
            }

        } catch (Exception e1) { //last thing we want is logging causing issues
            writeToStdErr(message, e, "exception encountered", e1);
        }

    }

    private void writeToStdErr(String message, Throwable e, String reasonCannotWriteToFile, Exception reasonError) {
        if (reasonCannotWriteToFile!=null){
            System.err.print("[");
            System.err.print(reasonCannotWriteToFile);
            System.err.print("] ");
        }
        if (reasonError!=null){
            System.err.print("[");
            reasonError.printStackTrace();
            System.err.print("] ");
        }
        if (message!=null) System.err.println(message);
        if (e!=null) e.printStackTrace();
    }

    private final Semaphore rollLock = new Semaphore(1);
    private void roll() {
        System.out.println("LOGGER::: roll thread=" + Thread.currentThread().getName() + " t=" + System.currentTimeMillis());
        renameOldBackupFiles();
        final File newestBackup = backupFile(1);
        try {
            w.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
        //rename the main log file to newest backup (which we just renamed)
        boolean success = file.renameTo(newestBackup);
        System.out.println("LOGGER::: rename " + file + " to=" + newestBackup + " success=" + success);
        this.w=initFileWriter();//should now be writing to file (which is empty)
    }

    private void renameOldBackupFiles() {
        File oldestBackup = backupFile(maxBackups);
        if (oldestBackup.exists()) {
            boolean deleted = oldestBackup.delete();
            System.out.println("LOGGER::: delete " + oldestBackup + " success=" + deleted);
        }
//               - rename all other backup files <5 in desc order (so log.4 -> log.5 and so on)
        for (int backup = maxBackups-1; backup > 0; backup--) {
            final File backupFile = backupFile(backup);
            if (!backupFile.exists()) continue;
            final File destFile = backupFile(backup + 1);
            boolean success = backupFile.renameTo(destFile);
            System.out.println("LOGGER::: rename " + backupFile + " to=" + destFile + " success=" + success);
        }
    }

    private File backupFile(int backupNumber) {
        return new File(dirName,String.format("%s.%s",fileName, backupNumber));
    }


    private static class CountingWriter extends Writer{
        private final Writer writer;
        private long countBytes;
        public CountingWriter(Writer w, long initialCount) {
            this.writer = w;
            countBytes=initialCount;
        }
        public void write(char[] cbuf, int off, int len) throws IOException {
            writer.write(cbuf,off,len);
            final long wrote = (len - off);
            countBytes += wrote;//this will likely be wrong if we use extended chars which take two bytes? ascii-type chars take 1 byte, but some take 2. But probably doesn't matter, unless we start logging korean strings or something?
        }
        public void flush() throws IOException { writer.flush(); }
        public void close() throws IOException { writer.close(); }

        public long getCountBytes() { return countBytes; }
    }

    //log4j api
    public void debug(String format) {debug(format,null); }
    public void debug(Throwable e) {debug(null,e); }
    public void debug(String format, Throwable e) {log(Level.debug,format,e); }

    public void info(String format) {info(format,null); }
    public void info(Throwable e) {info(null,e); }
    public void info(String format, Throwable e) {log(Level.info,format,e); }

    public void warn(String format) {warn(format,null);}
    public void warn(Throwable e) {warn(null,e); }
    public void warn(String format, Throwable e) {log(Level.warn,format,e); }

    public void error(String format) {error(format,null); }
    public void error(Throwable e) { error(null,e);}
    public void error(String format, Throwable e) {log(Level.error,format,e); }

    public void fatal(String format) {fatal(format,null); }
    public void fatal(Throwable e) {fatal(null,e); }
    public void fatal(String format, Throwable e) {
        log(Level.fatal,format,e);
    }

    //new api meths
    public void warnf(String format, Throwable e, Object... paramters) {warn(String.format(format, (Object[]) paramters),e);}
    public void infof(String format, Throwable e, Object... paramters) {info(String.format(format, (Object[]) paramters),e);}
    public void debugf(String format, Throwable e, Object... paramters) {debug(String.format(format, (Object[]) paramters),e);}


    public boolean isDebugEnabled() {
        return minLevel.ordinal()<=Level.debug.ordinal();
    }

}