/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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 org.apache.tomee.jul.handler.rotating;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FilenameFilter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.io.UnsupportedEncodingException;
import java.nio.file.Files;
import java.nio.file.attribute.BasicFileAttributes;
import java.sql.Timestamp;
import java.util.Date;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.logging.ErrorManager;
import java.util.logging.Filter;
import java.util.logging.Formatter;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogManager;
import java.util.logging.LogRecord;
import java.util.regex.Pattern;
import java.util.zip.Deflater;
import java.util.zip.GZIPOutputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

/**
 * NOTE: for simplicity the prefix `org.apache.tomee.jul.handler.rotating.LocalFileHandler.` has been removed of name columns.
 * <p/>
 * |===
 * | Name                      | Default Value                                     | Description
 * | filenamePattern           | ${catalina.base}/logs/logs.%s.%03d.log            | where log files are created, it uses String.format() and gives you the date and file number - in this order.
 * | limit                     | 10 Megabytes                                      | limit size indicating the file should be rotated
 * | dateCheckInterval         | 5 seconds                                         | how often the date should be computed to rotate the file (don't do it each time for performances reason, means you can get few records of next day in a file name with current day)
 * | bufferSize                | -1 bytes                                          | if positive the in memory buffer used to store data before flushing them to the disk
 * | encoding                  | -                                                 | file encoding
 * | level                     | ALL                                               | level this handler accepts
 * | filter                    | -                                                 | filter used to check if the message should be logged
 * | formatter                 | java.util.logging.SimpleFormatter                 | formatter used to format messages
 * | archiveDirectory          | ${catalina.base}/logs/archives/                   | where compressed logs are put.
 * | archiveFormat             | gzip                                              | zip or gzip.
 * | archiveOlderThan          | -1 days                                           | how many days files are kept before being compressed
 * | purgeOlderThan            | -1 days                                           | how many days files are kept before being deleted, note: it applies on archives and not log files so 2 days of archiving and 3 days of purge makes it deleted after 5 days.
 * | compressionLevel          | -1                                                | In case of zip archiving the zip compression level (-1 for off or 0-9).
 * | formatterPattern          | -                                                 | SimpleFormatter pattern (ignored if formatter is provided).
 * | formatterLocale           | -                                                 | Locale to use.
 * |===
 * <p/>
 * NOTE: archiving and purging are done only when a file is rotated, it means it can be ignored during days if there is no logging activity.
 * <p/>
 * NOTE: archiving and purging is done in a background thread pool, you can configure the number of threads in thanks to
 * `org.apache.tomee.jul.handler.rotating.BackgroundTaskRunner.threads` property in `conf/logging.properties`.
 * Default is 2 which should be fine for most applications.
 */
/*
 Open point/enhancements:
  - date pattern/filename pattern instead of hardcoded String.format?
  - write another async version? ensure it flushed well, use disruptor? -> bench seems to show it is useless
 */
public class LocalFileHandler extends Handler {
    private static final int BUFFER_SIZE = 8102;

    private long limit = 0;
    private int bufferSize = -1;
    private Pattern filenameRegex;
    private Pattern archiveFilenameRegex;
    private String filenamePattern = "${catalina.base}/logs/logs.%s.%03d.log";
    private String archiveFormat = "gzip";
    private long dateCheckInterval = TimeUnit.SECONDS.toMillis(5);
    private long archiveExpiryDuration;
    private int compressionLevel;
    private long purgeExpiryDuration;
    private File archiveDir;

    private volatile int currentIndex;
    private volatile long lastTimestamp;
    private volatile String date;
    private volatile PrintWriter writer;
    private volatile int written;
    private final ReadWriteLock writerLock = new ReentrantReadWriteLock();
    private final Lock backgroundTaskLock = new ReentrantLock();
    private volatile boolean closed;

    public LocalFileHandler() {
        configure();
    }

    private void configure() {
        date = currentDate();

        final String className = getClass().getName(); //allow classes to override

        final ClassLoader cl = Thread.currentThread().getContextClassLoader();

        dateCheckInterval = new Duration(getProperty(className + ".dateCheckInterval", String.valueOf(dateCheckInterval))).asMillis();
        filenamePattern = replace(getProperty(className + ".filenamePattern", filenamePattern));
        limit = new Size(getProperty(className + ".limit", String.valueOf("10 Mega"))).asBytes();

        final int lastSep = Math.max(filenamePattern.lastIndexOf('/'), filenamePattern.lastIndexOf('\\'));
        String fileNameReg = lastSep >= 0 ? filenamePattern.substring(lastSep + 1) : filenamePattern;
        fileNameReg = fileNameReg.replace("%s", "\\d{4}\\-\\d{2}\\-\\d{2}"); // date.
        {   // file rotation index
            final int indexIdxStart = fileNameReg.indexOf('%');
            if (indexIdxStart >= 0) {
                final int indexIdxEnd = fileNameReg.indexOf('d', indexIdxStart);
                if (indexIdxEnd >= 0) {
                    fileNameReg = fileNameReg.substring(0, indexIdxStart) + "\\d*" + fileNameReg.substring(indexIdxEnd + 1, fileNameReg.length());
                }
            }
        }
        filenameRegex = Pattern.compile(fileNameReg);

        compressionLevel = Integer.parseInt(getProperty(className + ".compressionLevel", String.valueOf(Deflater.DEFAULT_COMPRESSION)));
        archiveExpiryDuration = new Duration(getProperty(className + ".archiveOlderThan", String.valueOf("-1 days"))).asMillis();
        archiveDir = new File(replace(getProperty(className + ".archiveDirectory", "${catalina.base}/logs/archives/")));
        archiveFormat = replace(getProperty(className + ".archiveFormat", archiveFormat));
        archiveFilenameRegex = Pattern.compile(fileNameReg + "\\." + archiveFormat);

        purgeExpiryDuration = new Duration(getProperty(className + ".purgeOlderThan", String.valueOf("-1 days"))).asMillis();

        try {
            bufferSize = (int) new Size(getProperty(className + ".bufferSize", "-1 b")).asBytes();
        } catch (final NumberFormatException ignore) {
            // no-op
        }

        final String encoding = getProperty(className + ".encoding", null);
        if (encoding != null && encoding.length() > 0) {
            try {
                setEncoding(encoding);
            } catch (final UnsupportedEncodingException ex) {
                // no-op
            }
        }

        setLevel(Level.parse(getProperty(className + ".level", "" + Level.ALL)));

        final String filterName = getProperty(className + ".filter", null);
        if (filterName != null) {
            try {
                setFilter(Filter.class.cast(cl.loadClass(filterName).newInstance()));
            } catch (final Exception e) {
                // Ignore
            }
        }

        final String formatterName = getProperty(className + ".formatter", null);
        if (formatterName != null) {
            try {
                setFormatter(Formatter.class.cast(cl.loadClass(formatterName).newInstance()));
            } catch (final Exception e) {
                setFormatter(newSimpleFormatter(className));
            }
        } else {
            setFormatter(newSimpleFormatter(className));
        }

        setErrorManager(new ErrorManager());

        lastTimestamp = System.currentTimeMillis();
    }

    private Formatter newSimpleFormatter(final String className) {
        final String defaultFormat = System.getProperty("java.util.logging.SimpleFormatter.format", "%1$tb %1$td, %1$tY %1$tl:%1$tM:%1$tS %1$Tp %2$s%n%4$s: %5$s%6$s%n");
        final String format = getProperty(className + ".formatterPattern", defaultFormat);
        final String locale = getProperty(className + ".formatterLocale", null);
        return new PatternFormatter(format, locale == null ? Locale.getDefault() : newLocale(locale));
    }

    private Locale newLocale(final String str) { // LocaleUtils [lang3]
        if (str == null) {
            return null;
        }
        if (str.isEmpty()) {
            return new Locale("", "");
        }
        if (str.contains("#")) {
            throw new IllegalArgumentException("Invalid locale format: " + str);
        }
        final int len = str.length();
        if (len < 2) {
            throw new IllegalArgumentException("Invalid locale format: " + str);
        }
        final char ch0 = str.charAt(0);
        if (ch0 == '_') {
            if (len < 3) {
                throw new IllegalArgumentException("Invalid locale format: " + str);
            }
            final char ch1 = str.charAt(1);
            final char ch2 = str.charAt(2);
            if (!Character.isUpperCase(ch1) || !Character.isUpperCase(ch2)) {
                throw new IllegalArgumentException("Invalid locale format: " + str);
            }
            if (len == 3) {
                return new Locale("", str.substring(1, 3));
            }
            if (len < 5) {
                throw new IllegalArgumentException("Invalid locale format: " + str);
            }
            if (str.charAt(3) != '_') {
                throw new IllegalArgumentException("Invalid locale format: " + str);
            }
            return new Locale("", str.substring(1, 3), str.substring(4));
        }

        final String[] split = str.split("_", -1);
        final int occurrences = split.length -1;
        switch (occurrences) {
            case 0:
                return new Locale(str.toUpperCase(Locale.ENGLISH));
            case 1:
                return new Locale(split[0], split[1]);
            case 2:
                return new Locale(split[0], split[1], split[2]);
            default:
                throw new IllegalArgumentException("Invalid locale format: " + str);
        }
    }

    protected String currentDate() {
        return new Timestamp(System.currentTimeMillis()).toString().substring(0, 10);
    }

    @Override
    public void publish(final LogRecord record) {
        if (!isLoggable(record)) {
            return;
        }

        final long now = System.currentTimeMillis();
        final String tsDate;
        // just do it once / sec if we have a lot of log, can make some log appearing in the wrong file but better than doing it each time
        if (now - lastTimestamp > dateCheckInterval) { // using as much as possible volatile to avoid to lock too much
            lastTimestamp = now;
            tsDate = currentDate();
        } else {
            tsDate = null;
        }

        try {
            writerLock.readLock().lock();
            rotateIfNeeded(tsDate);

            final String result;
            try {
                result = getFormatter().format(record);
            } catch (final Exception e) {
                reportError(null, e, ErrorManager.FORMAT_FAILURE);
                return;
            }

            try {
                if (writer != null) {
                    writer.write(result);
                    if (bufferSize < 0) {
                        writer.flush();
                    }
                } else {
                    reportError(getClass().getSimpleName() + " is closed or not yet initialized, unable to log [" + result + "]", null, ErrorManager.WRITE_FAILURE);
                }
            } catch (final Exception e) {
                reportError(null, e, ErrorManager.WRITE_FAILURE);
            }
        } finally {
            writerLock.readLock().unlock();
        }
    }

    private void rotateIfNeeded(final String currentDate) {
        if (!closed && writer == null) {
            try {
                writerLock.readLock().unlock();
                writerLock.writeLock().lock();

                if (!closed && writer == null) {
                    openWriter();
                }
            } finally {
                writerLock.writeLock().unlock();
                writerLock.readLock().lock();
            }
        } else if (shouldRotate(currentDate)) {
            try {
                writerLock.readLock().unlock();
                writerLock.writeLock().lock();

                if (shouldRotate(currentDate)) {
                    close();
                    if (currentDate != null && !date.equals(currentDate)) {
                        currentIndex = 0;
                        date = currentDate;
                    }
                    openWriter();
                }
            } finally {
                writerLock.writeLock().unlock();
                writerLock.readLock().lock();
            }
        }
    }

    private boolean shouldRotate(final String currentDate) { // new day, new file or limit exceeded
        return (currentDate != null && !date.equals(currentDate)) || (limit > 0 && written >= limit);
    }

    @Override
    public void close() {
        closed = true;

        writerLock.writeLock().lock();
        try {
            if (writer == null) {
                return;
            }
            writer.write(getFormatter().getTail(this));
            writer.flush();
            writer.close();
            writer = null;
        } catch (final Exception e) {
            reportError(null, e, ErrorManager.CLOSE_FAILURE);
        } finally {
            writerLock.writeLock().unlock();
        }

        // wait for bg tasks if running
        backgroundTaskLock.lock();
        backgroundTaskLock.unlock();
    }

    @Override
    public void flush() {
        writerLock.readLock().lock();
        try {
            writer.flush();
        } catch (final Exception e) {
            reportError(null, e, ErrorManager.FLUSH_FAILURE);
        } finally {
            writerLock.readLock().unlock();
        }
    }

    protected void openWriter() {
        final long beforeRotation = System.currentTimeMillis();

        writerLock.writeLock().lock();
        FileOutputStream fos = null;
        OutputStream os = null;
        try {
            File pathname;
            do {
                pathname = new File(formatFilename(filenamePattern, date, currentIndex));
                final File parent = pathname.getParentFile();
                if (!parent.isDirectory() && !parent.mkdirs()) {
                    reportError("Unable to create [" + parent + "]", null, ErrorManager.OPEN_FAILURE);
                    writer = null;
                    return;
                }
                currentIndex++;
            } while (pathname.isFile()); // loop to ensure we don't overwrite existing files

            final String encoding = getEncoding();
            fos = new FileOutputStream(pathname, true);
            os = new CountingStream(bufferSize > 0 ? new BufferedOutputStream(fos, bufferSize) : fos);
            writer = new PrintWriter((encoding != null) ? new OutputStreamWriter(os, encoding) : new OutputStreamWriter(os), false);
            writer.write(getFormatter().getHead(this));
        } catch (final Exception e) {
            reportError(null, e, ErrorManager.OPEN_FAILURE);
            writer = null;
            if (fos != null) {
                try {
                    fos.close();
                } catch (final IOException e1) {
                    // no-op
                }
            }
            if (os != null) {
                try {
                    os.close();
                } catch (final IOException e1) {
                    // no-op
                }
            }
        } finally {
            writerLock.writeLock().unlock();
        }

        BackgroundTaskRunner.push(new Runnable() {
            @Override
            public void run() {
                backgroundTaskLock.lock();
                try {
                    evict(beforeRotation);
                } catch (final Exception e) {
                    reportError("Can't do the log eviction", e, ErrorManager.GENERIC_FAILURE);
                } finally {
                    backgroundTaskLock.unlock();
                }
            }
        });
    }

    private void evict(final long now) {
        if (purgeExpiryDuration > 0) { // purging archives
            final File[] archives = archiveDir.listFiles(new FilenameFilter() {
                @Override
                public boolean accept(final File dir, final String name) {
                    return archiveFilenameRegex.matcher(name).matches();
                }
            });

            if (archives != null) {
                for (final File archive : archives) {
                    try {
                        final BasicFileAttributes attr = Files.readAttributes(archive.toPath(), BasicFileAttributes.class);
                        if (now - attr.creationTime().toMillis() > purgeExpiryDuration) {
                            if (!Files.deleteIfExists(archive.toPath())) {
                                // dont try to delete on exit cause we will find it again
                                reportError("Can't delete " + archive.getAbsolutePath() + ".", null, ErrorManager.GENERIC_FAILURE);
                            }
                        }
                    } catch (final IOException e) {
                        throw new IllegalStateException(e);
                    }
                }
            }
        }
        if (archiveExpiryDuration > 0) { // archiving log files
            final File[] logs = new File(formatFilename(filenamePattern, "0000-00-00", 0)).getParentFile()
                    .listFiles(new FilenameFilter() {
                        @Override
                        public boolean accept(final File dir, final String name) {
                            return filenameRegex.matcher(name).matches();
                        }
                    });

            if (logs != null) {
                for (final File file : logs) {
                    try {
                        final BasicFileAttributes attr = Files.readAttributes(file.toPath(), BasicFileAttributes.class);
                        if (attr.creationTime().toMillis() < now && now - attr.lastModifiedTime().toMillis() > archiveExpiryDuration) {
                            createArchive(file);
                        }
                    } catch (final IOException e) {
                        throw new IllegalStateException(e);
                    }
                }
            }
        }
    }

    private String formatFilename(final String pattern, final String date, final int index) {
        return String.format(pattern, date, index);
    }

    private void createArchive(final File source) {
        final File target = new File(archiveDir, source.getName() + "." + archiveFormat);
        if (target.isFile()) {
            return;
        }

        final File parentFile = target.getParentFile();
        if (!parentFile.isDirectory() && !parentFile.mkdirs()) {
            throw new IllegalStateException("Can't create " + parentFile.getAbsolutePath());
        }

        if (archiveFormat.equalsIgnoreCase("gzip")) {
            try (final OutputStream outputStream = new BufferedOutputStream(new GZIPOutputStream(new FileOutputStream(target)))) {
                final byte[] buffer = new byte[BUFFER_SIZE];
                try (final FileInputStream inputStream = new FileInputStream(source)) {
                    copyStream(inputStream, outputStream, buffer);
                } catch (final IOException e) {
                    throw new IllegalStateException(e);
                }
            } catch (final IOException e) {
                throw new IllegalStateException(e);
            }
        } else { // consider file defines a zip whatever extension it is
            try (final ZipOutputStream outputStream = new ZipOutputStream(new FileOutputStream(target))) {
                outputStream.setLevel(compressionLevel);

                final byte[] buffer = new byte[BUFFER_SIZE];
                try (final FileInputStream inputStream = new FileInputStream(source)) {
                    final ZipEntry zipEntry = new ZipEntry(source.getName());
                    outputStream.putNextEntry(zipEntry);
                    copyStream(inputStream, outputStream, buffer);
                } catch (final IOException e) {
                    throw new IllegalStateException(e);
                }
            } catch (final IOException e) {
                throw new IllegalStateException(e);
            }
        }
        try {
            if (!Files.deleteIfExists(source.toPath())) {
                reportError("Can't delete " + source.getAbsolutePath() + ".", null, ErrorManager.GENERIC_FAILURE);
            }
        } catch (final IOException e) {
            throw new IllegalStateException(e);
        }
    }

    private static void copyStream(final InputStream inputStream, final OutputStream outputStream, final byte[] buffer) throws IOException {
        int n;
        while ((n = inputStream.read(buffer)) != -1) {
            outputStream.write(buffer, 0, n);
        }
    }

    protected String getProperty(final String name, final String defaultValue) {
        String value = LogManager.getLogManager().getProperty(name);
        if (value == null) {
            value = defaultValue;
        } else {
            value = value.trim();
        }
        return value;
    }

    protected static String replace(final String str) { // [lang3] would be good but no dep for these classes is better
        String result = str;
        int start = str.indexOf("${");
        if (start >= 0) {
            final StringBuilder builder = new StringBuilder();
            int end = -1;
            while (start >= 0) {
                builder.append(str, end + 1, start);
                end = str.indexOf('}', start + 2);
                if (end < 0) {
                    end = start - 1;
                    break;
                }

                final String propName = str.substring(start + 2, end);
                String replacement = !propName.isEmpty() ? System.getProperty(propName) : null;
                if (replacement == null) {
                    replacement = System.getenv(propName);
                }
                if (replacement != null) {
                    builder.append(replacement);
                } else {
                    builder.append(str, start, end + 1);
                }
                start = str.indexOf("${", end + 1);
            }
            builder.append(str, end + 1, str.length());
            result = builder.toString();
        }
        return result;
    }

    private final class CountingStream extends OutputStream {
        private final OutputStream out;

        private CountingStream(final OutputStream out) {
            this.out = out;
            written = 0;
        }

        @Override
        public void write(final int b) throws IOException {
            out.write(b);
            written++;
        }

        @Override
        public void write(final byte[] buff) throws IOException {
            out.write(buff);
            written += buff.length;
        }

        @Override
        public void write(final byte[] buff, final int off, final int len) throws IOException {
            out.write(buff, off, len);
            written += len;
        }

        @Override
        public void flush() throws IOException {
            out.flush();
        }

        @Override
        public void close() throws IOException {
            out.close();
        }
    }

    public static class PatternFormatter extends Formatter {
        private final ThreadLocal<Date> date = new ThreadLocal<Date>() {
            @Override
            protected Date initialValue() {
                return new Date();
            }
        };

        private final String format;
        private final Locale locale;

        public PatternFormatter(final String format, final Locale locale) {
            this.format = format;
            this.locale = locale;
        }

        @Override
        public String format(final LogRecord record) {
            final Date date = this.date.get();
            date.setTime(record.getMillis());

            String source;
            if (record.getSourceClassName() != null) {
                source = record.getSourceClassName();
                if (record.getSourceMethodName() != null) {
                    source += " " + record.getSourceMethodName();
                }
            } else {
                source = record.getLoggerName();
            }

            final String message = formatMessage(record);

            String throwable = "";
            final Throwable thrown = record.getThrown();
            if (thrown != null) {
                final StringWriter sw = new StringWriter();
                final PrintWriter pw = new PrintWriter(sw);
                pw.println();
                thrown.printStackTrace(pw);
                pw.close();
                throwable = sw.toString();
            }

            return String.format(
                    locale, format,
                    date, source,
                    record.getLoggerName(),
                    Locale.ENGLISH == locale ? record.getLevel().getName() : record.getLevel().getLocalizedName(),
                    message, throwable);
        }
    }
}
