/*
 * 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.nifi.persistence;

import org.apache.nifi.processor.DataUnit;
import org.apache.nifi.util.FormatUtils;
import org.apache.nifi.util.NiFiProperties;
import org.apache.nifi.util.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Calendar;
import java.util.Comparator;
import java.util.GregorianCalendar;
import java.util.List;
import java.util.TimeZone;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class FlowConfigurationArchiveManager {

    private static final Logger logger = LoggerFactory.getLogger(FlowConfigurationArchiveManager.class);

    /**
     * Represents archive file name such as followings:
     * <li>yyyyMMddTHHmmss+HHmm_original-file-name</li>
     * <li>yyyyMMddTHHmmss-HHmm_original-file-name</li>
     * <li>yyyyMMddTHHmmssZ_original-file-name</li>
     */
    private final Pattern archiveFilenamePattern = Pattern.compile("^([\\d]{8}T[\\d]{6}([\\+\\-][\\d]{4}|Z))_.+$");
    private final Path archiveDir;
    private final Integer maxCount;
    private final Long maxTimeMillis;
    private final Long maxStorageBytes;

    public FlowConfigurationArchiveManager(final NiFiProperties properties) {
        this.maxCount = properties.getFlowConfigurationArchiveMaxCount();

        String maxTime = properties.getFlowConfigurationArchiveMaxTime();
        String maxStorage = properties.getFlowConfigurationArchiveMaxStorage();

        if (maxCount == null && StringUtils.isBlank(maxTime) && StringUtils.isBlank(maxStorage)) {
            // None of limitation is specified, fall back to the default configuration;
            maxTime = NiFiProperties.DEFAULT_FLOW_CONFIGURATION_ARCHIVE_MAX_TIME;
            maxStorage = NiFiProperties.DEFAULT_FLOW_CONFIGURATION_ARCHIVE_MAX_STORAGE;
            logger.info("None of archive max limitation is specified, fall back to the default configuration, maxTime={}, maxStorage={}",
                    maxTime, maxStorage);
        }

        this.maxTimeMillis = StringUtils.isBlank(maxTime) ? null : FormatUtils.getTimeDuration(maxTime, TimeUnit.MILLISECONDS);
        this.maxStorageBytes = StringUtils.isBlank(maxStorage) ? null : DataUnit.parseDataSize(maxStorage, DataUnit.B).longValue();

        // Determine the archive directory
        final String archiveDirVal = properties.getFlowConfigurationArchiveDir();
        final Path archiveDirectory;
        if (archiveDirVal == null || archiveDirVal.trim().isEmpty()) {
            File persistenceFile = properties.getFlowConfigurationFile();
            archiveDirectory = (archiveDirVal == null || archiveDirVal.equals(""))
                ? persistenceFile.toPath().getParent().resolve("archive") : new File(archiveDirVal).toPath();
        } else {
            archiveDirectory = Paths.get(archiveDirVal);
        }
        this.archiveDir = archiveDirectory;
    }

    private String createArchiveFileName(final String originalFlowConfigFileName) {
        TimeZone tz = TimeZone.getDefault();
        Calendar cal = GregorianCalendar.getInstance(tz);
        int offsetInMillis = tz.getOffset(cal.getTimeInMillis());
        final int year = cal.get(Calendar.YEAR);
        final int month = cal.get(Calendar.MONTH) + 1;
        final int day = cal.get(Calendar.DAY_OF_MONTH);
        final int hour = cal.get(Calendar.HOUR_OF_DAY);
        final int min = cal.get(Calendar.MINUTE);
        final int sec = cal.get(Calendar.SECOND);

        String offset = String.format("%s%02d%02d",
                (offsetInMillis >= 0 ? "+" : "-"),
                Math.abs(offsetInMillis / 3600000),
                Math.abs((offsetInMillis / 60000) % 60));

        return String.format("%d%02d%02dT%02d%02d%02d%s_%s",
                year, month, day, hour, min, sec, offset, originalFlowConfigFileName);
    }

    /**
     * Setup a file to archive data flow. Create archive directory if it doesn't exist yet.
     * @return Resolved archive file which is ready to write to
     * @throws IOException when it fails to access archive dir
     */
    private File setupArchiveFile(final String originalFilename) throws IOException {
        Files.createDirectories(archiveDir);
        if (!Files.isDirectory(archiveDir)) {
            throw new IOException("Archive directory doesn't appear to be a directory " + archiveDir);
        }

        final Path archiveFile = archiveDir.resolve(createArchiveFileName(originalFilename));
        return archiveFile.toFile();
    }

    /**
     * Archive current flow configuration file by copying the original file to the archive directory.
     * Before creating new archive file, this method removes old archives to satisfy following conditions:
     * <ul>
     * <li>Number of archive files less than or equal to maxCount</li>
     * <li>Keep archive files which has last modified timestamp no older than current system timestamp - maxTimeMillis</li>
     * <li>Total size of archive files less than or equal to maxStorageBytes</li>
     * </ul>
     * This method keeps other files intact, so that users can keep particular archive by copying it with different name.
     * Whether a given file is archive file or not is determined by the filename.
     * Since archive file name consists of timestamp up to seconds, if archive is called multiple times within a second,
     * it will overwrite existing archive file with the same name.
     * @return Newly created archive file, archive filename is computed by adding ISO8601
     * timestamp prefix to the original filename, ex) 20160706T160719+0900_flow.xml.gz
     * @throws IOException If it fails to create new archive file.
     * Although, other IOExceptions like the ones thrown during removing expired archive files will not be thrown.
     */
    public File archive(final File file) throws IOException {
        final String originalFlowConfigFileName = file.getName();

        final File archiveFile = setupArchiveFile(file.getName());

        // Collect archive files by its name
        final long now = System.currentTimeMillis();
        final AtomicLong totalArchiveSize = new AtomicLong(0);
        final List<Path> archives = Files.walk(archiveDir, 1).filter(p -> {
            final String filename = p.getFileName().toString();
            if (Files.isRegularFile(p) && filename.endsWith("_" + originalFlowConfigFileName)) {
                final Matcher matcher = archiveFilenamePattern.matcher(filename);
                if (matcher.matches() && filename.equals(matcher.group(1) + "_" + originalFlowConfigFileName)) {
                    try {
                        totalArchiveSize.getAndAdd(Files.size(p));
                    } catch (IOException e) {
                        logger.warn("Failed to get file size of {} due to {}", p, e);
                    }
                    return true;
                }
            }
            return false;
        }).collect(Collectors.toList());

        // Sort by timestamp.
        archives.sort(Comparator.comparingLong(path -> path.toFile().lastModified()));

        logger.debug("archives={}", archives);

        final int archiveCount = archives.size();
        final long flowConfigFileSize = file.length();
        IntStream.range(0, archiveCount).filter(i -> {
            // If maxCount is specified, remove old archives
            boolean old = maxCount != null && maxCount > 0 && (archiveCount - i) > maxCount - 1;
            // If maxTime is specified, remove expired archives
            final File archive = archives.get(i).toFile();
            old = old || (maxTimeMillis != null && maxTimeMillis > 0 && (now - archive.lastModified()) > maxTimeMillis);
            // If maxStorage is specified, remove old archives
            old = old || (maxStorageBytes != null && maxStorageBytes > 0 && (totalArchiveSize.get() + flowConfigFileSize > maxStorageBytes));

            if (old) {
                totalArchiveSize.getAndAdd(archive.length() * -1);
                logger.info("Removing old archive file {} to reduce storage usage. currentSize={}", archive, totalArchiveSize);
            }
            return old;
        }).forEach(i -> {
            try {
                Files.delete(archives.get(i));
            } catch (IOException e) {
                logger.warn("Failed to delete {} to reduce storage usage, due to {}", archives.get(i), e);
            }
        });

        // Create new archive file.
        Files.copy(file.toPath(), archiveFile.toPath(), StandardCopyOption.REPLACE_EXISTING);

        if (maxStorageBytes != null && maxStorageBytes > 0 && flowConfigFileSize > maxStorageBytes) {
            logger.warn("Size of {} ({}) exceeds configured maxStorage size ({}). Archive won't be able to keep old files.",
                file, flowConfigFileSize, maxStorageBytes);
        }

        return archiveFile;
    }
}
