package com.atlassian.bitbucket.mesh.util;

import com.atlassian.bitbucket.mesh.Product;
import com.atlassian.bitbucket.mesh.io.UnderLock;
import com.google.common.collect.ImmutableSet;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.*;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.*;

import static java.util.Objects.requireNonNull;
import static java.util.Optional.empty;
import static java.util.Optional.of;
import static org.apache.commons.lang3.StringUtils.*;

/**
 * Utilities for loading and storing properties files.
 */
public class PropertiesUtils {

    private static final String UPDATED_COMMENT = "!! Updated by " + Product.FULL_NAME;

    private PropertiesUtils() {
        throw new UnsupportedOperationException(getClass().getName() +
                " is a utility class and should not be instantiated");
    }

    /**
     * Loads properties from the file at the specified {@code path}, using UTF-8 encoding, and returns them.
     * <p>
     * If the properties file may not exist, consider {@link #loadIfPresent} instead.
     *
     * @param path the path to read properties from
     * @return the loaded properties
     * @throws IOException if the specified path doesn't exist, is not a file, or cannot be read
     */
    @Nonnull
    public static Properties load(@Nonnull Path path) throws IOException {
        requireNonNull(path, "path");

        try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
            Properties properties = new Properties();
            properties.load(reader);

            return properties;
        }
    }

    /**
     * Loads properties from the file at the specified {@code path}, using UTF-8 encoding, if it exists. If the
     * path does not exist, {@code empty()} will be returned instead of throwing an exception.
     *
     * @param path the path to read properties from, which may not exist
     * @return the loaded properties, or {@code empty()} if the specified path does not exist
     * @throws IOException if the specified path <i>exists</i>, but cannot be read
     */
    @Nonnull
    public static Optional<Properties> loadIfPresent(@Nonnull Path path) throws IOException {
        try {
            return of(load(path));
        } catch (FileNotFoundException | NoSuchFileException e) {
            return empty();
        }
    }

    /**
     * Stores the provided {@code properties} at the specified {@code path}, using UTF-8 encoding.
     *
     * @param properties the properties to store
     * @param path       the path to write the properties to
     * @throws IOException if the properties cannot be written
     */
    public static void store(@Nonnull Properties properties, @Nonnull Path path) throws IOException {
        requireNonNull(properties, "properties");
        requireNonNull(path, "path");

        try (Writer writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) {
            properties.store(writer, null);
        }
    }

    /**
     * Updates the properties file at {@code path} with the values from the provided {@code properties}. This updates
     * <em>only</em> properties with keys that are included in the provided {@code properties}. Any properties found in
     * the file that are not present in {@code properties} are left unchanged. A property can be removed from the
     * properties file by setting the corresponding property to a blank string or {@code null} in {@code properties}.
     * <p>
     * The properties file is updated while holding a file lock to prevent concurrent updates. If the properties file
     * did not exist, a new file is created.
     *
     * @param properties the properties to add, replace or remove
     * @param path       the path of the properties file
     * @param banner     optional banner to add at the top of the properties file, if the file is newly created
     * @throws IOException if the properties cannot be written
     * @throws OverlappingFileLockException if a file lock cannot be acquired
     * @return the keys of the properties that were added, removed or updated
     */
    @Nonnull
    public static Set<String> update(@Nonnull Properties properties, @Nonnull Path path,
                                     @Nullable String banner) throws IOException {
        ImmutableSet.Builder<String> builder = ImmutableSet.builder();
        UnderLock.forFile(path).edit((reader, writer) -> {
            if (reader == null && banner != null) {
                writer.write(banner);
                if (!banner.endsWith("\n")) {
                    writer.write("\n");
                }
            }
            updatePropertiesFile(reader, writer, properties, builder);
        }, true);
        return builder.build();
    }

    private static void updatePropertiesFile(BufferedReader reader, Writer writer, Properties properties,
                                             ImmutableSet.Builder<String> updatedKeys) throws IOException {
        String eol = "\n";
        String commentFragment = null;
        String line;
        String logicalLine = null;
        Map<String, String> remaining = new TreeMap<>();
        properties.forEach((key, value) -> remaining.put(String.valueOf(key), String.valueOf(value)));

        while (reader != null && (line = reader.readLine()) != null) {
            if (logicalLine == null) {
                if (isBlank(line)) {
                    //A blank line that is not part of a multi-line logical line means that it's safe to flush any
                    //comment fragment we have buffered
                    if (commentFragment != null) {
                        writer.write(commentFragment);
                    }
                    writer.write(line);
                    writer.write(eol);
                    commentFragment = null;
                    continue;
                }

                if (isComment(line)) {
                    commentFragment = (commentFragment == null ? line : commentFragment + line) + eol;
                    continue;
                }
            }

            logicalLine = (logicalLine == null ? line : logicalLine + line) + eol;
            if (isPropertyContinuedOnNextLine(line)) {
                //It's a multi-line property, continue reading until we have a complete property
                continue;
            }

            //We've read complete logical line
            String key = parseKey(logicalLine);
            if (key == null || !remaining.containsKey(key)) {
                //The property is unchanged, flush the accompanying comment (if any) and the logical line
                if (commentFragment != null) {
                    writer.write(commentFragment);
                    commentFragment = null;
                }
                writer.write(logicalLine);
            } else {
                String value = remaining.remove(key);
                if (isNotBlank(value)) {
                    if (commentFragment != null) {
                        writer.write(commentFragment);
                        //If there was a preceding comment section, add a comment in the file to make it clear to a
                        //sysadmin that any manual configuration was overwritten.
                        if (!commentFragment.contains(UPDATED_COMMENT)) {
                            writer.write(UPDATED_COMMENT);
                            writer.write(eol);
                        }
                    }

                    writer.write(key);
                    writer.write('=');
                    writer.write(value);
                    writer.write(eol);

                    if (!Objects.equals(value, trim(logicalLine.substring(key.length() + 1)))) {
                        updatedKeys.add(key);
                    }
                } else {
                    updatedKeys.add(key);
                }
                //else: an empty value was provided for the key, which is interpreted as an instruction to
                //      remove the override and fall back to the default setting, if one exists
                commentFragment = null;
            }
            logicalLine = null;
        }
        for (Map.Entry<String, String> entry : remaining.entrySet()) {
            if (isNotBlank(entry.getValue())) {
                writer.write(entry.getKey());
                writer.write('=');
                writer.write(entry.getValue());
                writer.write(eol);

                updatedKeys.add(entry.getKey());
            }
        }
    }

    private static boolean isComment(String line) {
        String trimmed = stripStart(line, " \t\f");
        return trimmed.startsWith("#") || trimmed.startsWith("!");
    }

    private static boolean isPropertyContinuedOnNextLine(String line) {
        //A line from the properties file is considered multi-line if the line ends with an odd number of escape
        //characters
        int count = 0;
        for (int i = line.length() - 1; i >= 0 && line.charAt(i) == '\\'; --i) {
            count++;
        }
        return (count % 2) == 1;
    }

    /**
     * @param c the character
     * @return {@code true} if the character is treated as whitespace as described in the javadoc for
     *         {@link Properties#load(Reader)}
     */
    private static boolean isWhitespace(char c) {
        return c == ' ' || c == '\t' || c == '\f' || c == '\r' || c == '\n';
    }

    /**
     * @param logicalLine a single property definition from a properties file, which may span multiple lines
     * @return the property key
     * @see Properties#load(Reader) for a detailed description of the properties file syntax
     */
    private static String parseKey(String logicalLine) {
        int startIndex = -1;
        int length = logicalLine.length();
        for (int i = 0; i < length; ++i) {
            char c = logicalLine.charAt(i);
            boolean whitespace = isWhitespace(c);
            if (startIndex == -1) {
                //Still looking for the start of the key
                if (!whitespace) {
                    //Found non-whitespace, this must be the start of the key
                    startIndex = i;
                }
            } else {
                //Looking for the end of the key, which is defined as whitespace, '=' or ':'
                if (whitespace || c == ':' || c == '=') {
                    return logicalLine.substring(startIndex, i);
                }
            }
        }

        //Parsed through the whole string without finding the end of the key. It's either an empty line, or a key
        //without a value
        return startIndex == -1 ? null : logicalLine.substring(startIndex);
    }
}
