/*
 * 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.catalina.valves;


import java.io.BufferedWriter;
import java.io.CharArrayWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.UnsupportedEncodingException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.Objects;
import java.util.TimeZone;

import org.apache.catalina.LifecycleException;
import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;
import org.apache.tomcat.util.ExceptionUtils;
import org.apache.tomcat.util.buf.B2CConverter;


/**
 * This is a concrete implementation of {@link AbstractAccessLogValve} that outputs the access log to a file. The
 * features of this implementation include:
 * <ul>
 * <li>Automatic date-based rollover of log files</li>
 * <li>Optional log file rotation</li>
 * </ul>
 * <p>
 * For UNIX users, another field called <code>checkExists</code> is also available. If set to true, the log file's
 * existence will be checked before each logging. This way an external log rotator can move the file somewhere and
 * Tomcat will start with a new file.
 * </p>
 * <p>
 * For JMX junkies, a public method called <code>rotate</code> has been made available to allow you to tell this
 * instance to move the existing log file to somewhere else and start writing a new log file.
 * </p>
 */
public class AccessLogValve extends AbstractAccessLogValve {

    private static final Log log = LogFactory.getLog(AccessLogValve.class);

    // ------------------------------------------------------ Constructor
    public AccessLogValve() {
        super();
    }

    // ----------------------------------------------------- Instance Variables


    /**
     * The as-of date for the currently open log file, or a zero-length string if there is no open log file.
     */
    private volatile String dateStamp = "";


    /**
     * The directory in which log files are created.
     */
    private String directory = "logs";

    /**
     * The prefix that is added to log file filenames.
     */
    protected volatile String prefix = "access_log";


    /**
     * Should we rotate our log file? Default is true (like old behavior)
     */
    protected boolean rotatable = true;

    /**
     * Should we defer inclusion of the date stamp in the file name until rotate time? Default is false.
     */
    protected boolean renameOnRotate = false;


    /**
     * Buffered logging.
     */
    private boolean buffered = true;


    /**
     * The suffix that is added to log file filenames.
     */
    protected volatile String suffix = "";


    /**
     * The PrintWriter to which we are currently logging, if any.
     */
    protected PrintWriter writer = null;


    /**
     * A date formatter to format a Date using the format given by <code>fileDateFormat</code>.
     */
    protected SimpleDateFormat fileDateFormatter = null;


    /**
     * The current log file we are writing to. Helpful when checkExists is true.
     */
    protected File currentLogFile = null;

    /**
     * Instant when the log daily rotation was last checked.
     */
    private volatile long rotationLastChecked = 0L;

    /**
     * Do we check for log file existence? Helpful if an external agent renames the log file so we can automagically
     * recreate it.
     */
    private boolean checkExists = false;

    /**
     * Date format to place in log file name.
     */
    protected String fileDateFormat = ".yyyy-MM-dd";

    /**
     * Character set used by the log file. If it is <code>null</code>, UTF-8 will be used. An empty string will be
     * treated as <code>null</code> when this property is assigned.
     */
    protected volatile String encoding = null;

    /**
     * The number of days to retain the access log files before they are removed.
     */
    private int maxDays = -1;
    private volatile boolean checkForOldLogs = false;

    // ------------------------------------------------------------- Properties


    public int getMaxDays() {
        return maxDays;
    }


    public void setMaxDays(int maxDays) {
        this.maxDays = maxDays;
    }


    /**
     * @return the directory in which we create log files.
     */
    public String getDirectory() {
        return directory;
    }


    /**
     * Set the directory in which we create log files.
     *
     * @param directory The new log file directory
     */
    public void setDirectory(String directory) {
        this.directory = directory;
    }

    /**
     * Check for file existence before logging.
     *
     * @return <code>true</code> if file existence is checked first
     */
    public boolean isCheckExists() {

        return checkExists;

    }


    /**
     * Set whether to check for log file existence before logging.
     *
     * @param checkExists true meaning to check for file existence.
     */
    public void setCheckExists(boolean checkExists) {

        this.checkExists = checkExists;

    }


    /**
     * @return the log file prefix.
     */
    public String getPrefix() {
        return prefix;
    }


    /**
     * Set the log file prefix.
     *
     * @param prefix The new log file prefix
     */
    public void setPrefix(String prefix) {
        this.prefix = prefix;
    }


    /**
     * Should we rotate the access log.
     *
     * @return <code>true</code> if the access log should be rotated
     */
    public boolean isRotatable() {
        return rotatable;
    }


    /**
     * Configure whether the access log should be rotated.
     *
     * @param rotatable true if the log should be rotated
     */
    public void setRotatable(boolean rotatable) {
        this.rotatable = rotatable;
    }


    /**
     * Should we defer inclusion of the date stamp in the file name until rotate time.
     *
     * @return <code>true</code> if the logs file names are time stamped only when they are rotated
     */
    public boolean isRenameOnRotate() {
        return renameOnRotate;
    }


    /**
     * Set the value if we should defer inclusion of the date stamp in the file name until rotate time
     *
     * @param renameOnRotate true if defer inclusion of date stamp
     */
    public void setRenameOnRotate(boolean renameOnRotate) {
        this.renameOnRotate = renameOnRotate;
    }


    /**
     * Is the logging buffered. Usually buffering can increase performance.
     *
     * @return <code>true</code> if the logging uses a buffer
     */
    public boolean isBuffered() {
        return buffered;
    }


    /**
     * Set the value if the logging should be buffered
     *
     * @param buffered <code>true</code> if buffered.
     */
    public void setBuffered(boolean buffered) {
        this.buffered = buffered;
    }


    /**
     * @return the log file suffix.
     */
    public String getSuffix() {
        return suffix;
    }


    /**
     * Set the log file suffix.
     *
     * @param suffix The new log file suffix
     */
    public void setSuffix(String suffix) {
        this.suffix = suffix;
    }

    /**
     * @return the date format date based log rotation.
     */
    public String getFileDateFormat() {
        return fileDateFormat;
    }


    /**
     * Set the date format date based log rotation.
     *
     * @param fileDateFormat The format for the file timestamp
     */
    public void setFileDateFormat(String fileDateFormat) {
        String newFormat;
        newFormat = Objects.requireNonNullElse(fileDateFormat, "");
        this.fileDateFormat = newFormat;

        synchronized (this) {
            fileDateFormatter = new SimpleDateFormat(newFormat, Locale.US);
            fileDateFormatter.setTimeZone(TimeZone.getDefault());
        }
    }

    /**
     * Return the character set name that is used to write the log file.
     *
     * @return Character set name, or <code>null</code> if the default character set is used.
     */
    public String getEncoding() {
        return encoding;
    }

    /**
     * Set the character set that is used to write the log file.
     *
     * @param encoding The name of the character set.
     */
    public void setEncoding(String encoding) {
        if (encoding != null && !encoding.isEmpty()) {
            this.encoding = encoding;
        } else {
            this.encoding = null;
        }
    }

    // --------------------------------------------------------- Public Methods

    /**
     * Provides support for access log rotation.
     */
    @Override
    public synchronized void backgroundProcess() {
        if (getState().isAvailable() && getEnabled() && writer != null && buffered) {
            writer.flush();
        }

        int maxDays = this.maxDays;
        String prefix = this.prefix;
        String suffix = this.suffix;

        if (rotatable && checkForOldLogs && maxDays > 0) {
            long deleteIfLastModifiedBefore = System.currentTimeMillis() - (maxDays * 24L * 60 * 60 * 1000);
            File dir = getDirectoryFile();
            if (dir.isDirectory()) {
                String[] oldAccessLogs = dir.list();

                if (oldAccessLogs != null) {
                    for (String oldAccessLog : oldAccessLogs) {
                        boolean match = false;

                        if (prefix != null && !prefix.isEmpty()) {
                            if (!oldAccessLog.startsWith(prefix)) {
                                continue;
                            }
                            match = true;
                        }

                        if (suffix != null && !suffix.isEmpty()) {
                            if (!oldAccessLog.endsWith(suffix)) {
                                continue;
                            }
                            match = true;
                        }

                        if (match) {
                            File file = new File(dir, oldAccessLog);
                            if (file.isFile() && file.lastModified() < deleteIfLastModifiedBefore) {
                                if (!file.delete()) {
                                    log.warn(sm.getString("accessLogValve.deleteFail", file.getAbsolutePath()));
                                }
                            }
                        }
                    }
                }
            }
            checkForOldLogs = false;
        }
    }

    /**
     * Rotate the log file if necessary.
     */
    public void rotate() {
        if (rotatable) {
            // Only do a logfile switch check once a second, max.
            long systime = System.currentTimeMillis();
            if ((systime - rotationLastChecked) > 1000) {
                synchronized (this) {
                    if ((systime - rotationLastChecked) > 1000) {
                        rotationLastChecked = systime;

                        String tsDate;
                        // Check for a change of date
                        tsDate = fileDateFormatter.format(new Date(systime));

                        // If the date has changed, switch log files
                        if (!dateStamp.equals(tsDate)) {
                            close(true);
                            dateStamp = tsDate;
                            open();
                        }
                    }
                }
            }
        }
    }

    /**
     * Rename the existing log file to something else. Then open the old log file name up once again. Intended to be
     * called by a JMX agent.
     *
     * @param newFileName The file name to move the log file entry to
     *
     * @return true if a file was rotated with no error
     */
    public synchronized boolean rotate(String newFileName) {

        if (currentLogFile != null) {
            File holder = currentLogFile;
            close(false);
            try {
                holder.renameTo(new File(newFileName));
            } catch (Throwable t) {
                ExceptionUtils.handleThrowable(t);
                log.error(sm.getString("accessLogValve.rotateFail"), t);
            }

            /* Make sure date is correct */
            dateStamp = fileDateFormatter.format(new Date(System.currentTimeMillis()));

            open();
            return true;
        } else {
            return false;
        }

    }

    // -------------------------------------------------------- Private Methods


    private File getDirectoryFile() {
        File dir = new File(directory);
        if (!dir.isAbsolute()) {
            dir = new File(getContainer().getCatalinaBase(), directory);
        }
        return dir;
    }


    /**
     * Create a File object based on the current log file name. Directories are created as needed but the underlying
     * file is not created or opened.
     *
     * @param useDateStamp include the timestamp in the file name.
     *
     * @return the log file object
     */
    private File getLogFile(boolean useDateStamp) {
        // Create the directory if necessary
        File dir = getDirectoryFile();
        if (!dir.mkdirs() && !dir.isDirectory()) {
            log.error(sm.getString("accessLogValve.openDirFail", dir));
        }

        // Calculate the current log file name
        File pathname;
        if (useDateStamp) {
            pathname = new File(dir.getAbsoluteFile(), prefix + dateStamp + suffix);
        } else {
            pathname = new File(dir.getAbsoluteFile(), prefix + suffix);
        }
        File parent = pathname.getParentFile();
        if (!parent.mkdirs() && !parent.isDirectory()) {
            log.error(sm.getString("accessLogValve.openDirFail", parent));
        }
        return pathname;
    }

    /**
     * Move a current but rotated log file back to the unrotated one. Needed if date stamp inclusion is deferred to
     * rotation time.
     */
    private void restore() {
        File newLogFile = getLogFile(false);
        File rotatedLogFile = getLogFile(true);
        if (rotatedLogFile.exists() && !newLogFile.exists() && !rotatedLogFile.equals(newLogFile)) {
            try {
                if (!rotatedLogFile.renameTo(newLogFile)) {
                    log.error(sm.getString("accessLogValve.renameFail", rotatedLogFile, newLogFile));
                }
            } catch (Throwable t) {
                ExceptionUtils.handleThrowable(t);
                log.error(sm.getString("accessLogValve.renameFail", rotatedLogFile, newLogFile), t);
            }
        }
    }


    /**
     * Close the currently open log file (if any)
     *
     * @param rename Rename file to final name after closing
     */
    private synchronized void close(boolean rename) {
        if (writer == null) {
            return;
        }
        writer.flush();
        writer.close();
        if (rename && renameOnRotate) {
            File newLogFile = getLogFile(true);
            if (!newLogFile.exists()) {
                try {
                    if (!currentLogFile.renameTo(newLogFile)) {
                        log.error(sm.getString("accessLogValve.renameFail", currentLogFile, newLogFile));
                    }
                } catch (Throwable t) {
                    ExceptionUtils.handleThrowable(t);
                    log.error(sm.getString("accessLogValve.renameFail", currentLogFile, newLogFile), t);
                }
            } else {
                log.error(sm.getString("accessLogValve.alreadyExists", currentLogFile, newLogFile));
            }
        }
        writer = null;
        dateStamp = "";
        currentLogFile = null;
    }


    @Override
    public void log(CharArrayWriter message) {

        rotate();

        /* In case something external rotated the file instead */
        if (checkExists) {
            synchronized (this) {
                if (currentLogFile != null && !currentLogFile.exists()) {
                    try {
                        close(false);
                    } catch (Throwable t) {
                        ExceptionUtils.handleThrowable(t);
                        log.info(sm.getString("accessLogValve.closeFail"), t);
                    }

                    /* Make sure date is correct */
                    dateStamp = fileDateFormatter.format(new Date(System.currentTimeMillis()));

                    open();
                }
            }
        }

        // Log this message
        try {
            message.write(System.lineSeparator());
            synchronized (this) {
                if (writer != null) {
                    message.writeTo(writer);
                    if (!buffered) {
                        writer.flush();
                    }
                }
            }
        } catch (IOException ioe) {
            log.warn(sm.getString("accessLogValve.writeFail", message.toString()), ioe);
        }
    }


    /**
     * Open the new log file for the date specified by <code>dateStamp</code>.
     */
    protected synchronized void open() {
        // Open the current log file
        // If no rotate - no need for dateStamp in fileName
        File pathname = getLogFile(rotatable && !renameOnRotate);

        Charset charset = null;
        if (encoding != null) {
            try {
                charset = B2CConverter.getCharset(encoding);
            } catch (UnsupportedEncodingException ex) {
                log.error(sm.getString("accessLogValve.unsupportedEncoding", encoding), ex);
            }
        }
        if (charset == null) {
            charset = StandardCharsets.UTF_8;
        }

        try {
            writer = new PrintWriter(
                    new BufferedWriter(new OutputStreamWriter(new FileOutputStream(pathname, true), charset), 128000),
                    false);

            currentLogFile = pathname;
        } catch (IOException ioe) {
            writer = null;
            currentLogFile = null;
            log.error(sm.getString("accessLogValve.openFail", pathname, System.getProperty("user.name")), ioe);
        }
        // Rotating a log file will always trigger a new file to be opened so
        // when a new file is opened, check to see if any old files need to be
        // removed.
        checkForOldLogs = true;
    }

    /**
     * Start this component and implement the requirements of
     * {@link org.apache.catalina.util.LifecycleBase#startInternal()}.
     *
     * @exception LifecycleException if this component detects a fatal error that prevents this component from being
     *                                   used
     */
    @Override
    protected void startInternal() throws LifecycleException {

        // Initialize the Date formatters
        String format = getFileDateFormat();
        fileDateFormatter = new SimpleDateFormat(format, Locale.US);
        fileDateFormatter.setTimeZone(TimeZone.getDefault());
        dateStamp = fileDateFormatter.format(new Date(System.currentTimeMillis()));
        if (rotatable && renameOnRotate) {
            restore();
        }
        open();

        super.startInternal();
    }


    /**
     * Stop this component and implement the requirements of
     * {@link org.apache.catalina.util.LifecycleBase#stopInternal()}.
     *
     * @exception LifecycleException if this component detects a fatal error that prevents this component from being
     *                                   used
     */
    @Override
    protected void stopInternal() throws LifecycleException {

        super.stopInternal();
        close(false);
    }
}
