/*
 * 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.commons.configuration2.builder.combined;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.text.MessageFormat;

import org.apache.commons.configuration2.BaseHierarchicalConfiguration;
import org.apache.commons.configuration2.CombinedConfiguration;
import org.apache.commons.configuration2.Configuration;
import org.apache.commons.configuration2.XMLConfiguration;
import org.apache.commons.configuration2.builder.BasicBuilderParameters;
import org.apache.commons.configuration2.builder.BasicBuilderProperties;
import org.apache.commons.configuration2.builder.BasicConfigurationBuilder;
import org.apache.commons.configuration2.builder.CopyObjectDefaultHandler;
import org.apache.commons.configuration2.builder.FileBasedBuilderParametersImpl;
import org.apache.commons.configuration2.builder.FileBasedBuilderProperties;
import org.apache.commons.configuration2.builder.ReloadingDetectorFactory;
import org.apache.commons.configuration2.builder.ReloadingFileBasedConfigurationBuilder;
import org.apache.commons.configuration2.builder.fluent.Parameters;
import org.apache.commons.configuration2.ex.ConfigurationException;
import org.apache.commons.configuration2.io.FileHandler;
import org.apache.commons.configuration2.reloading.AlwaysReloadingDetector;
import org.apache.commons.configuration2.reloading.RandomReloadingDetector;
import org.apache.commons.configuration2.sync.ReadWriteSynchronizer;
import org.apache.commons.configuration2.sync.Synchronizer;
import org.apache.commons.configuration2.tree.MergeCombiner;
import org.apache.commons.configuration2.tree.xpath.XPathExpressionEngine;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;

/**
 * Test class for {@code ReloadingCombinedConfigurationBuilder} which actually accesses files to be reloaded.
 *
 */
public class TestReloadingCombinedConfigurationBuilderFileBased {
    /**
     * A test builder class which always returns the same configuration.
     */
    private static class ConstantConfigurationBuilder extends BasicConfigurationBuilder<BaseHierarchicalConfiguration> {
        private final BaseHierarchicalConfiguration configuration;

        public ConstantConfigurationBuilder(final BaseHierarchicalConfiguration conf) {
            super(BaseHierarchicalConfiguration.class);
            configuration = conf;
        }

        @Override
        public BaseHierarchicalConfiguration getConfiguration() throws ConfigurationException {
            return configuration;
        }
    }

    /**
     * A thread class for testing concurrent reload operations.
     */
    private static class ReloadThread extends Thread {
        /** The builder to be queried. */
        private final ReloadingCombinedConfigurationBuilder builder;

        /** An array for reporting failures. */
        private final int[] failures;

        /** The index of this thread in the array with failures. */
        private final int index;

        /** The number of test operations. */
        private final int count;

        ReloadThread(final ReloadingCombinedConfigurationBuilder bldr, final int[] failures, final int index, final int count) {
            builder = bldr;
            this.failures = failures;
            this.index = index;
            this.count = count;
        }

        @Override
        public void run() {
            failures[index] = 0;
            for (int i = 0; i < count; i++) {
                try {
                    builder.getReloadingController().checkForReloading(null);
                    final String value = builder.getConfiguration().getString("/property[@name='config']/@value");
                    if (value == null || !value.equals("100")) {
                        ++failures[index];
                    }
                } catch (final Exception ex) {
                    ++failures[index];
                }
            }
        }
    }

    /** Constant for the prefix for XML configuration sources. */
    private static final String PROP_SRC = "override.xml";

    /** Constant for the prefix of the reload property. */
    private static final String PROP_RELOAD = "default.xmlReload";

    /** Constant for content of a XML configuration for reload tests. */
    private static final String RELOAD_CONTENT = "<config><default><xmlReload{1}>{0}</xmlReload{1}></default></config>";

    /**
     * Adds a source for a configuration which can be reloaded to the definition configuration.
     *
     * @param config the definition configuration
     * @param fileName the name of the file
     */
    private static void addReloadSource(final Configuration config, final String fileName) {
        config.addProperty(PROP_SRC + "(-1)[@fileName]", fileName);
        config.addProperty(PROP_SRC + "[@config-reload]", Boolean.TRUE);
    }

    /**
     * Returns the name of a test property.
     *
     * @param idx the index of the property
     * @return the test property with this index
     */
    private static String testProperty(final int idx) {
        return PROP_RELOAD + idx;
    }

    /**
     * Helper method for writing a file.
     *
     * @param file the file to be written
     * @param content the file's content
     * @throws IOException if an error occurs
     */
    private static void writeFile(final File file, final String content) throws IOException {
        try (PrintWriter out = new PrintWriter(new FileWriter(file))) {
            out.print(content);
        }
    }

    /** A helper object for managing temporary files. */
    @Rule
    public TemporaryFolder folder = new TemporaryFolder();

    /** A helper object for creating builder parameters. */
    private Parameters parameters;

    /** The builder to be tested. */
    private ReloadingCombinedConfigurationBuilder builder;

    /**
     * Helper method for testing whether the builder's definition file can be reloaded. This method expects that the test
     * builder has been fully initialized.
     *
     * @param defFile the path to the definition file
     * @throws IOException if an I/O error occurs.
     * @throws ConfigurationException if a configuration-related error occurs
     * @throws InterruptedException if waiting is interrupted
     */
    private void checkReloadDefinitionFile(final File defFile) throws IOException, ConfigurationException, InterruptedException {
        final File src1 = writeReloadFile(null, 1, 0);
        final File src2 = writeReloadFile(null, 1, 1);
        writeDefinitionFile(defFile, src1);
        CombinedConfiguration config = builder.getConfiguration();
        assertEquals("Wrong initial value", 0, config.getInt(testProperty(1)));

        // No change definition file
        boolean reloaded = false;
        for (int attempts = 0; attempts < 50 && !reloaded; attempts++) {
            writeDefinitionFile(defFile, src2);
            reloaded = builder.getReloadingController().checkForReloading(null);
            if (!reloaded) {
                Thread.sleep(100);
            }
        }
        assertTrue("Need for reload not detected", reloaded);
        config = builder.getConfiguration();
        assertEquals("Wrong reloaded value", 1, config.getInt(testProperty(1)));
    }

    @Before
    public void setUp() throws Exception {
        parameters = new Parameters();
        builder = new ReloadingCombinedConfigurationBuilder();
    }

    /**
     * Tests concurrent access to a reloading builder for combined configurations.
     */
    @Test
    public void testConcurrentGetAndReload() throws Exception {
        final int threadCount = 4;
        final int loopCount = 100;
        final ReloadingDetectorFactory detectorFactory = (handler, params) -> new RandomReloadingDetector();
        final BaseHierarchicalConfiguration defConf = new BaseHierarchicalConfiguration();
        defConf.addProperty("header.result.nodeCombiner[@config-class]", MergeCombiner.class.getName());
        defConf.addProperty("header.result.expressionEngine[@config-class]", XPathExpressionEngine.class.getName());
        addReloadSource(defConf, "configA.xml");
        addReloadSource(defConf, "configB.xml");
        final Synchronizer sync = new ReadWriteSynchronizer();
        builder.configure(parameters.combined().setDefinitionBuilder(new ConstantConfigurationBuilder(defConf)).setSynchronizer(sync)
            .registerChildDefaultsHandler(BasicBuilderProperties.class, new CopyObjectDefaultHandler(new BasicBuilderParameters().setSynchronizer(sync)))
            .registerChildDefaultsHandler(FileBasedBuilderProperties.class,
                new CopyObjectDefaultHandler(new FileBasedBuilderParametersImpl().setReloadingDetectorFactory(detectorFactory))));

        assertEquals("Wrong initial value", "100", builder.getConfiguration().getString("/property[@name='config']/@value"));

        final Thread testThreads[] = new Thread[threadCount];
        final int failures[] = new int[threadCount];

        for (int i = 0; i < testThreads.length; ++i) {
            testThreads[i] = new ReloadThread(builder, failures, i, loopCount);
            testThreads[i].start();
        }

        int totalFailures = 0;
        for (int i = 0; i < testThreads.length; ++i) {
            testThreads[i].join();
            totalFailures += failures[i];
        }
        assertEquals(totalFailures + " failures Occurred", 0, totalFailures);
    }

    /**
     * Tests whether the default definition builder is capable of detecting a change in the definition configuration.
     */
    @Test
    public void testReloadDefinitionFileDefaultBuilder() throws ConfigurationException, IOException, InterruptedException {
        final File defFile = folder.newFile();
        builder.configure(parameters.combined().setDefinitionBuilderParameters(parameters.xml().setReloadingRefreshDelay(0L).setFile(defFile)));
        checkReloadDefinitionFile(defFile);
    }

    /**
     * Tests whether a change in the definition file is detected and causes a reload if a specific builder for the
     * definition configuration is provided.
     */
    @Test
    public void testReloadDefinitionFileExplicitBuilder() throws ConfigurationException, IOException, InterruptedException {
        final File defFile = folder.newFile();
        builder.configure(parameters.combined().setDefinitionBuilder(
            new ReloadingFileBasedConfigurationBuilder<>(XMLConfiguration.class).configure(parameters.xml().setReloadingRefreshDelay(0L).setFile(defFile))));
        checkReloadDefinitionFile(defFile);
    }

    /**
     * Tests whether a changed file is detected on disk.
     */
    @Test
    public void testReloadFromFile() throws ConfigurationException, IOException {
        final File xmlConf1 = writeReloadFile(null, 1, 0);
        final File xmlConf2 = writeReloadFile(null, 2, 0);
        final ReloadingDetectorFactory detectorFactory = (handler, params) -> new AlwaysReloadingDetector();
        final BaseHierarchicalConfiguration defConf = new BaseHierarchicalConfiguration();
        addReloadSource(defConf, xmlConf1.getAbsolutePath());
        addReloadSource(defConf, xmlConf2.getAbsolutePath());
        builder.configure(parameters.combined().setDefinitionBuilder(new ConstantConfigurationBuilder(defConf)).registerChildDefaultsHandler(
            FileBasedBuilderProperties.class, new CopyObjectDefaultHandler(new FileBasedBuilderParametersImpl().setReloadingDetectorFactory(detectorFactory))));
        CombinedConfiguration config = builder.getConfiguration();
        assertEquals("Wrong initial value (1)", 0, config.getInt(testProperty(1)));
        assertEquals("Wrong initial value (2)", 0, config.getInt(testProperty(2)));

        writeReloadFile(xmlConf1, 1, 1);
        builder.getReloadingController().checkForReloading(null);
        config = builder.getConfiguration();
        assertEquals("Updated value not reloaded (1)", 1, config.getInt(testProperty(1)));
        assertEquals("Value modified", 0, config.getInt(testProperty(2)));

        writeReloadFile(xmlConf2, 2, 2);
        builder.getReloadingController().checkForReloading(null);
        config = builder.getConfiguration();
        assertEquals("Wrong value for config 1", 1, config.getInt(testProperty(1)));
        assertEquals("Updated value not reloaded (2)", 2, config.getInt(testProperty(2)));
    }

    /**
     * Writes a configuration definition file that refers to the specified file source.
     *
     * @param defFile the target definition file
     * @param src the configuration source file to be referenced
     * @throws ConfigurationException if an error occurs
     */
    private void writeDefinitionFile(final File defFile, final File src) throws ConfigurationException {
        final XMLConfiguration defConf = new XMLConfiguration();
        addReloadSource(defConf, src.getAbsolutePath());
        new FileHandler(defConf).save(defFile);
    }

    /**
     * Writes a file for testing reload operations.
     *
     * @param f the file to be written or <b>null</b> for creating a new one
     * @param tagIdx the index of the tag
     * @param value the value of the reload test property
     * @return the file that was written
     * @throws IOException if an error occurs
     */
    private File writeReloadFile(final File f, final int tagIdx, final int value) throws IOException {
        return writeReloadFile(f, MessageFormat.format(RELOAD_CONTENT, value, tagIdx));
    }

    /**
     * Helper method for writing a test file for reloading. The file will be created in the test directory. It is also
     * scheduled for automatic deletion after the test.
     *
     * @param f the file to be written or <b>null</b> for creating a new one
     * @param content the content of the file
     * @return the {@code File} object for the test file
     * @throws IOException if an error occurs
     */
    private File writeReloadFile(final File f, final String content) throws IOException {
        final File file = f != null ? f : folder.newFile();
        writeFile(file, content);
        return file;
    }
}
