package com.fizzed.jne;

/*-
 * #%L
 * jne
 * %%
 * Copyright (C) 2016 - 2017 Fizzed, Inc
 * %%
 * Licensed 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.
 * #L%
 */
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class JNE {

    static private final Logger log = LoggerFactory.getLogger(JNE.class);

    static public Options DEFAULT_OPTIONS = new Options();
    static private File TEMP_DIRECTORY;
    static private final ConcurrentHashMap<File, String> JAR_VERSION_HASHES = new ConcurrentHashMap<>();

    /**
     * Finds (extracts if necessary) a named executable for the runtime
     * operating system and architecture. The executable should be a regular
     * Java resource at the path /jne/[os]/[arch]/[exe]. The name of the file
     * will be automatically adjusted for the target platform. For example, on
     * Windows, to find the "cat" application, this method will actually search
     * for "cat.exe".
     *
     * @param name The executable name you would normally type on the
     * command-line. For example, "cat" or "ping" would search for "ping.exe" on
     * windows and "ping" on linux/mac.
     * @return The executable file or null if no executable found.
     * @throws java.io.IOException
     * @throws ExtractException Thrown if a runtime exception occurs while
     * finding or extracting the executable.
     */
    synchronized static public File findExecutable(String name) throws IOException {
        return findExecutable(name, null, null);
    }

    /**
     * Finds (extracts if necessary) a named executable for the runtime
     * operating system and architecture. The executable should be a regular
     * Java resource at the path /jne/[os]/[arch]/[exe]. The name of the file
     * will be automatically adjusted for the target platform. For example, on
     * Windows, to find the "cat" application, this method will actually search
     * for "cat.exe".
     *
     * @param name The executable name you would normally type on the
     * command-line. For example, "cat" or "ping" would search for "ping.exe" on
     * windows and "ping" on linux/mac.
     * @param targetName The executable name you would like the resource (if
     * found) to be named on extract.
     * @return The executable file or null if no executable found.
     * @throws java.io.IOException
     * @throws ExtractException Thrown if a runtime exception occurs while
     * finding or extracting the executable.
     */
    synchronized static public File findExecutable(String name, String targetName) throws IOException {
        return findExecutable(name, targetName, null);
    }

    /**
     * Finds (or extracts) a named executable for the runtime operating system
     * and architecture. The executable should be a regular Java resource at the
     * path /jne/[os]/[arch]/[exe].
     *
     * @param name The executable name you would normally type on the
     * command-line. For example, "cat" or "ping" would search for "ping.exe" on
     * windows and "ping" on linux/mac.
     * @param options The options to use when finding an executable. If null
     * then the default options will be used.
     * @return The executable file or null if no executable found.
     * @throws java.io.IOException
     * @throws ExtractException Thrown if a runtime exception occurs while
     * finding or extracting the executable.
     */
    synchronized static public File findExecutable(String name, Options options) throws IOException {
        return findExecutable(name, null, options);
    }

    /**
     * Finds (or extracts) a named executable for the runtime operating system
     * and architecture. The executable should be a regular Java resource at the
     * path /jne/[os]/[arch]/[exe].
     *
     * @param name The executable name you would normally type on the
     * command-line. For example, "cat" or "ping" would search for "ping.exe" on
     * windows and "ping" on linux/mac.
     * @param targetName The executable name you would like the resource (if
     * found) to be named on extract.
     * @param options The options to use when finding an executable. If null
     * then the default options will be used.
     * @return The executable file or null if no executable found.
     * @throws java.io.IOException
     * @throws ExtractException Thrown if a runtime exception occurs while
     * finding or extracting the executable.
     */
    synchronized static public File findExecutable(String name, String targetName, Options options) throws IOException {
        if (options == null) {
            options = DEFAULT_OPTIONS;
        }

        String fileName = options.createExecutableName(name, options.getOperatingSystem());

        String targetFileName = null;

        if (targetName != null) {
            targetFileName = options.createExecutableName(targetName, options.getOperatingSystem());
        }

        // always search for specific arch first
        File file = find(fileName, targetFileName, options, options.getOperatingSystem(), options.getHardwareArchitecture());

        // for x64 fallback to x86 if an exe was not found
        if (file == null && options.isX32ExecutableFallback() && options.getHardwareArchitecture() == HardwareArchitecture.X64) {
            file = find(fileName, targetFileName, options, options.getOperatingSystem(), HardwareArchitecture.X32);
        }

        return file;
    }

    /**
     * Same as findExecutable but throws an exception if the executable was not
     * found.
     */
    synchronized static public File requireExecutable(String name) throws IOException {
        return requireExecutable(name, null, null);
    }

    /**
     * Same as findExecutable but throws an exception if the executable was not
     * found.
     */
    synchronized static public File requireExecutable(String name, Options options) throws IOException {
        return requireExecutable(name, null, options);
    }

    /**
     * Same as findExecutable but throws an exception if the executable was not
     * found.
     */
    synchronized static public File requireExecutable(String name, String targetName, Options options) throws IOException {
        File file = findExecutable(name, targetName, options);
        if (file == null) {
            throw new ResourceNotFoundException("Resource executable " + name + " not found");
        }
        return file;
    }

    synchronized static public File findLibrary(String name) {
        return findLibrary(name, null, null);
    }

    synchronized static public File findLibrary(String name, Integer majorVersion) {
        return findLibrary(name, null, majorVersion);
    }

    synchronized static public File findLibrary(String name, Options options, Integer majorVersion) {
        if (options == null) {
            options = DEFAULT_OPTIONS;
        }

        // file name to try and find/extract (only support major for now since linux 99% of the time links to major)
        String fileName = options.createLibraryName(name, options.getOperatingSystem(), majorVersion, null, null);

        try {
            // always search for specific arch first
            return find(fileName, null, options, options.getOperatingSystem(), options.getHardwareArchitecture());
        } catch (IOException e) {
            throw new UnsatisfiedLinkError(e.getMessage());
        }
    }

    /**
     *

     * Loads a dynamic library. Attempts to find (extracts if necessary) a named
     * library for the runtime operating system and architecture. If the library
     * was found as a resource and/or extracted, it will then be loaded via
     * System.load(). If the library was not found as a resource, this method
     * will simply fallback to System.loadLibrary(). Thus, this method should be
     * safe as a drop-in replacement for calls to System.loadLibrary().
     *

     *

     * If including the library as a Java resource, the resource path will be
     * /jne/[os]/[arch]/[lib]. The name of the file will be automatically
     * adjusted for the target platform. For example, on Windows, to find the
     * "cat" library, this method will search for "cat.dll". On Linux, to find
     * the "cat" library, this method will search for "libcat.so". On Mac, to
     * find the "cat" library, this method will search for "libcat.dylib".
     *

     *
     * @param name The library name to find and load
     * @throws UnsatisfiedLinkError Thrown if a runtime exception occurs while
     * finding or extracting the executable.
     */
    synchronized static public void loadLibrary(String name) {
        loadLibrary(name, null, null);
    }

    synchronized static public void loadLibrary(String name, Integer majorVersion) {
        loadLibrary(name, null, majorVersion);
    }

    /**
     *

     * Loads a dynamic library. Attempts to find (extracts if necessary) a named
     * library for the runtime operating system and architecture. If the library
     * was found as a resource and/or extracted, it will then be loaded via
     * System.load(). If the library was not found as a resource, this method
     * will simply fallback to System.loadLibrary(). Thus, this method should be
     * safe as a drop-in replacement for calls to System.loadLibrary().
     *

     *

     * If including the library as a Java resource, the resource path will be
     * /jne/[os]/[arch]/[lib]. The name of the file will be automatically
     * adjusted for the target platform. For example, on Windows, to find the
     * "cat" library, this method will search for "cat.dll". On Linux, to find
     * the "cat" library, this method will search for "libcat.so". On Mac, to
     * find the "cat" library, this method will search for "libcat.dylib".
     *

     *
     * @param name The library name to find and load
     * @param options The options to use when finding the library. If null then
     * the default options will be used.
     * @param majorVersion
     * @throws UnsatisfiedLinkError Thrown if a runtime exception occurs while
     * finding or extracting the executable.
     */
    synchronized static public void loadLibrary(String name, Options options, Integer majorVersion) {
        // search for specific library
        File f = null;
        try {
            f = findLibrary(name, options, majorVersion);
        } catch (Exception e) {
            log.debug("exception while finding library: " + e.getMessage());
            throw new UnsatisfiedLinkError("Unable to cleanly find (or extract) library [" + name + "] as resource");
        }

        // temporarily prepend library path to load library if found
        if (f != null) {
            // since loading of dependencies of a library cannot dynamically happen
            // and the user would be required to provide a valid LD_LIBRARY_PATH when
            // launching the java process -- we don't need to do use loadLibrary
            // and can just tell it to load a specific library file
            log.debug("System.load(" + f.getAbsolutePath() + ")");
            System.load(f.getAbsolutePath());
        } else {
            log.debug("falling back to System.loadLibrary(" + name + ")");
            // fallback to java method
            System.loadLibrary(name);
        }

        log.debug("library [" + name + "] loaded!");
    }

    /**
     * Finds (or extracts) a named file. Will first attempt to locate the file
     * for the runtime operating system and architecture, then fallback to just
     * the runtime operating system, and finally fallback to the resource
     * prefix. For example, a file named "resource.txt" running on a JVM on x64
     * linux would search the following 3 resource paths:
     *
     * /jne/linux/x64/resource.txt /jne/linux/resource.txt /jne/resource.txt
     *
     * @param name The file name to find or extract.
     * @return The file or null if no file found.
     * @throws java.io.IOException
     * @throws ExtractException Thrown if a runtime exception occurs while
     * finding or extracting the executable.
     */
    synchronized static public File findFile(String name) throws IOException {
        return JNE.findFile(name, null);
    }

    /**
     * Finds (or extracts) a named file. Will first attempt to locate the file
     * for the runtime operating system and architecture, then fallback to just
     * the runtime operating system, and finally fallback to the resource
     * prefix. For example, a file named "resource.txt" running on a JVM on x64
     * linux would search the following 3 resource paths:
     *
     * /jne/linux/x64/resource.txt /jne/linux/resource.txt /jne/resource.txt
     *
     * @param name The file name to find or extract.
     * @param options The options to use when finding an executable. If null
     * then the default options will be used.
     * @return The file or null if no file found.
     * @throws java.io.IOException
     * @throws ExtractException Thrown if a runtime exception occurs while
     * finding or extracting the executable.
     */
    synchronized static public File findFile(String name, Options options) throws IOException {
        if (options == null) {
            options = DEFAULT_OPTIONS;
        }

        // 1. try with os & arch
        File file = JNE.find(name, name, options, options.getOperatingSystem(), options.getHardwareArchitecture());

        // 2. try with os & any arch
        if (file == null) {
            file = JNE.find(name, name, options, options.getOperatingSystem(), HardwareArchitecture.ANY);
        }

        // 3. try with os & any arch
        if (file == null) {
            file = JNE.find(name, name, options, OperatingSystem.ANY, HardwareArchitecture.ANY);
        }

        return file;
    }

    /**
     * Same as findFile but throws an exception if the file was not found.
     */
    synchronized static public File requireFile(String name) throws IOException {
        return JNE.requireFile(name, null);
    }

    /**
     * Same as findFile but throws an exception if the file was not found.
     */
    synchronized static public File requireFile(String name, Options options) throws IOException {
        File file = findFile(name, options);
        if (file == null) {
            throw new ResourceNotFoundException("Resource file " + name + " not found");
        }
        return file;
    }

    /**
     * Underlying method used by findExecutable and loadLibrary to find and
     * extract executables as needed. Although public, it's NOT recommended to
     * use this method unless you know what you're doing.
     *
     * @param fileName
     * @param targetFileName
     * @param options
     * @param os
     * @param arch
     * @return
     * @throws IOException
     * @throws ExtractException
     */
    synchronized static public File find(String fileName, String targetFileName, Options options, OperatingSystem os, HardwareArchitecture arch) throws IOException {
        if (options == null) {
            options = DEFAULT_OPTIONS;
        }

        if (os == null || os == OperatingSystem.UNKNOWN) {
            throw new ExtractException("Unable to detect operating system (e.g. Windows)");
        }

        if (arch == null || arch == HardwareArchitecture.UNKNOWN) {
            throw new ExtractException("Unable to detect hardware architecture (e.g. x86)");
        }

        if (targetFileName == null) {
            targetFileName = fileName;
        }

        log.debug("finding fileName [" + fileName + "] targetFileName [" + targetFileName + "] os [" + os + "] arch [" + arch + "]...");

        //String resourcePath = options.getResourcePrefix() + "/" + os.name().toLowerCase() + "/" + arch.name().toLowerCase() + "/" + name;
        String resourcePath = options.createResourcePath(os, arch, fileName);

        log.debug("finding resource [" + resourcePath + "]");

        URL url = JNE.class.getResource(resourcePath);
        if (url == null) {
            log.debug("resource [" + resourcePath + "] not found");
            return null;
        }

        // support for "file" and "jar"
        log.debug("resource found @ " + url);

        if (url.getProtocol().equals("jar")) {
            log.debug("resource in jar; extracting file if necessary...");

            // in the case of where the app specifies an extract directory and
            // does not request deleteOnExit we need a way to detect if the
            // executables changed from the previous app run -- we do this with
            // a very basic "hash" for an extracted resource. We basically combine
            // the path of the jar and manifest version of when the exe was extracted
            String versionHash = getJarVersionHashForResource(url);
            log.debug("version hash [" + versionHash + "]");

            // where should we extract the executable?
            File d = options.getExtractDir();
            if (d == null) {
                d = getOrCreateTempDirectory(options.isCleanupExtracted());
            } else {
                // does the extract dir exist?
                if (!d.exists()) {
                    d.mkdirs();
                }
                if (!d.isDirectory()) {
                    throw new ExtractException("Extract dir [" + d + "] is not a directory");
                }
            }

            log.debug("using dir [" + d + "]");

            // create both target exe and hash files
            File exeFile = new File(d, targetFileName);
            File exeHashFile = new File(exeFile.getAbsolutePath() + ".hash");

            // if file already exists verify its hash
            if (exeFile.exists()) {
                log.debug("file already exists; verifying if hash matches");
                // verify the version hash still matches
                if (!exeHashFile.exists()) {
                    // hash file missing -- we will force a new extract to be safe
                    exeFile.delete();
                } else {
                    // hash file exists, verify it matches what we expect
                    String existingHash = readFileToString(exeHashFile);
                    if (existingHash == null || !existingHash.equals(versionHash)) {
                        log.debug("hash mismatch; deleting files; will freshly extract file");
                        // hash mismatch -- will force an overwrite of both files
                        exeFile.delete();
                        exeHashFile.delete();
                    } else {
                        log.debug("hash matches; will use existing file");
                        // hash match (exeFile and exeHashFile are both perrrrfect)
                        //System.out.println("exe already extracted AND hash matched -- reusing same exe");
                        return exeFile;
                    }
                }
            }

            // does exe already exist? (previously extracted)
            if (!exeFile.exists()) {
                try {
                    log.debug("extracting [" + url + "] to [" + exeFile + "]...");
                    extractTo(url, exeFile);

                    // set file to "executable"
                    log.debug("setting to executable");
                    exeFile.setExecutable(true);

                    // create corrosponding hash file
                    log.debug("writing hash file");
                    writeStringToFile(exeHashFile, versionHash);

                    // schedule files for deletion?
                    if (options.isCleanupExtracted()) {
                        log.debug("scheduling file and hash for delete on exit");
                        exeFile.deleteOnExit();
                        exeHashFile.deleteOnExit();
                    }
                } catch (IOException e) {
                    log.debug("failed to extract file");
                    throw new ExtractException("Unable to cleanly extract executable from jar", e);
                }
            }

            log.debug("returning [" + exeFile + "]");
            return exeFile;
        } else if (url.getProtocol().equals("file")) {
            log.debug("resource in file");
            try {
                File exeFile = new File(url.toURI());
                if (!exeFile.canExecute()) {
                    log.debug("setting file to executable");
                    if (!exeFile.setExecutable(true)) {
                        log.debug("unable to cleanly set file to executable");
                        throw new ExtractException("Executable was found but it cannot be set to execute [" + exeFile.getAbsolutePath() + "]");
                    }
                }
                log.debug("returning [" + exeFile + "]");
                return exeFile;
            } catch (URISyntaxException e) {
                log.debug("uri syntax error");
                throw new ExtractException("Unable to create executable file from uri", e);
            }
        } else {
            throw new ExtractException("Unsupported executable resource protocol [" + url.getProtocol() + "]");
        }
    }

    static private void extractTo(URL url, File file) throws IOException {
        final InputStream in = url.openStream();
        try {
            try (OutputStream out = new BufferedOutputStream(new FileOutputStream(file, false))) {
                int len;
                byte[] buffer = new byte[8192];
                while ((len = in.read(buffer)) > -1) {
                    out.write(buffer, 0, len);
                }
                out.flush();
            }
        } finally {
            if (in != null) {
                in.close();
            }
        }
    }

    static private String readFileToString(File file) throws IOException {
        StringBuilder result = new StringBuilder();
        try (BufferedInputStream is = new BufferedInputStream(new FileInputStream(file))) {
            byte[] buf = new byte[1024];
            int len;
            while ((len = is.read(buf)) > -1) {
                result.append(new String(buf, 0, len, "UTF-8"));
            }
        }
        return result.toString();
    }

    static private void writeStringToFile(File file, String s) throws IOException {
        try (FileOutputStream os = new FileOutputStream(file, false)) {
            os.write(s.getBytes("UTF-8"));
            os.flush();
        }
    }

    static private String getJarVersionHashForResource(URL resource) throws IOException {
        // get the file that points to the underlying jar for this resource
        File jarFile = JarUtil.getJarFileForResource(resource);

        if (JAR_VERSION_HASHES.containsKey(jarFile)) {
            return JAR_VERSION_HASHES.get(jarFile);
        } else {
            // calculate new hash for jar
            String manifestVersion = JarUtil.getManifestVersionNumber(jarFile);

            StringBuilder hashBuilder = new StringBuilder();
            hashBuilder.append("file:");
            hashBuilder.append(jarFile.getAbsolutePath());
            hashBuilder.append("|last_modified:");
            hashBuilder.append(jarFile.lastModified());
            hashBuilder.append("|version:");
            hashBuilder.append(manifestVersion);

            String hash = hashBuilder.toString();

            JAR_VERSION_HASHES.put(jarFile, hash);

            return hash;
        }
    }

    /**
     * Attempts to create a temporary directory that did not exist previously.
     */
    static private File getOrCreateTempDirectory(boolean deleteOnExit) throws ExtractException {
        // return the single instance if already created
        if ((TEMP_DIRECTORY != null) && TEMP_DIRECTORY.exists()) {
            return TEMP_DIRECTORY;
        }

        // use jvm supplied temp directory in case multiple jvms compete
//        try {
//            Path tempDirectory = Files.createTempDirectory("jne.");
//            File tempDirectoryAsFile = tempDirectory.toFile();
//            if (deleteOnExit) {
//                tempDirectoryAsFile.deleteOnExit();
//            }
//            return tempDirectoryAsFile;
//        } catch (IOException e) {
//            throw new ExtractException("Unable to create temporary dir", e);
//        }

        // use totally unique name to avoid race conditions
        try {
            Path baseDir = Paths.get(System.getProperty("java.io.tmpdir"));
            Path tempDirectory = baseDir.resolve("jne." + UUID.randomUUID().toString());
            Files.createDirectories(tempDirectory);
            File tempDirectoryAsFile = tempDirectory.toFile();
            if (deleteOnExit) {
                tempDirectoryAsFile.deleteOnExit();
            }
            // save temp directory so its only exactracted once
            TEMP_DIRECTORY = tempDirectoryAsFile;
            return TEMP_DIRECTORY;
        } catch (IOException e) {
            throw new ExtractException("Unable to create temporary dir", e);
        }

//        File baseDir = new File(System.getProperty("java.io.tmpdir"));
//        String baseName = System.currentTimeMillis() + "-";
//
//        for (int counter = 0; counter < TEMP_DIR_ATTEMPTS; counter++) {
//            File d = new File(baseDir, baseName + counter);
//            if (d.mkdirs()) {
//                // schedule this directory to be deleted on exit
//                if (deleteOnExit) {
//                    d.deleteOnExit();
//                }
//                tempDirectory = d;
//                return d;
//            }
//        }
//
//        throw new ExtractException("Failed to create temporary directory within " + TEMP_DIR_ATTEMPTS + " attempts (tried " + baseName + "0 to " + baseName + (TEMP_DIR_ATTEMPTS - 1) + ')');
    }

}


/*
 * Copyright 2015 Groupon.com
 *
 * Licensed 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 com.arpnetworking.steno;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

/**
 * Extension of Steno logger designed for use particularly with Steno encoder
 * while also providing log event rate limiting. This class is thread safe.
 *
 * @since 1.12.0
 *
 * @author Ville Koskela (ville dot koskela at inscopemetrics dot com)
 */
/* package private */ class RateLimitLogger extends Logger {

    /**
     * Log for a particular level using the ARRAY_MARKER.
     *
     * @param level The log event level.
     * @param event The log event name.
     * @param message The log event message.
     * @param dataKeys The array of data keys.
     * @param dataValues The array of data values.
     * @param throwable The Throwable.
     */
    @Override
    /* package private */ void log(
            final LogLevel level,
            final String event,
            final String message,
            final String[] dataKeys,
            final Object[] dataValues,
            final Throwable throwable) {

        if (shouldLog(level)) {
            final String[] augmentedDataKeys = Arrays.copyOf(dataKeys, dataKeys.length + 2);
            final Object[] augmentedDataValues = Arrays.copyOf(dataValues, dataValues.length + 2);
            augmentedDataKeys[augmentedDataKeys.length - 2] = "_skipped";
            augmentedDataKeys[augmentedDataKeys.length - 1] = "_lastLogTime";
            augmentedDataValues[augmentedDataValues.length - 2] = _skipped.getAndSet(0);
            augmentedDataValues[augmentedDataValues.length - 1] = _lastLogTime.getAndSet(_clock.instant());

            super.log(level, event, message, augmentedDataKeys, augmentedDataValues, throwable);
        }
    }

    /**
     * Log for a particular level using the LISTS_MARKER.
     *
     * @param level The log event level.
     * @param event The log event name.
     * @param dataKeys The List of data keys.
     * @param dataValues The List of data values.
     * @param contextKeys The List of context keys.
     * @param contextValues The List of context values.
     * @param throwable The Throwable.
     */
    @Override
    /* package private */ void log(
            final LogLevel level,
            final String event,
            final List<String> dataKeys,
            final List<Object> dataValues,
            final List<String> contextKeys,
            final List<Object> contextValues,
            final Throwable throwable) {

        if (shouldLog(level)) {
            dataKeys.add("_skipped");
            dataKeys.add("_lastLogTime");
            dataValues.add(_skipped.getAndSet(0));
            dataValues.add(_lastLogTime.getAndSet(_clock.instant()));

            super.log(level, event, dataKeys, dataValues, contextKeys, contextValues, throwable);
        }
    }

    /* package private */ RateLimitLogger(final org.slf4j.Logger slf4jLogger, final Duration duration) {
        this(slf4jLogger, duration, Clock.systemUTC());
    }

    private boolean shouldLog(final LogLevel level) {
        // Check this first to avoid counting unlogged messages against the logging rate
        if (!level.isEnabled(getSlf4jLogger())) {
            return false;
        }
        final Instant now = _clock.instant();
        if (_lastLogTime.get() != null) {
            if (!_lastLogTime.get().plus(_duration).isBefore(now)) {
                _skipped.incrementAndGet();
                return false;
            }
        }
        return true;
    }

    /* package private */ RateLimitLogger(final org.slf4j.Logger slf4jLogger, final Duration duration, final Clock clock) {
        super(slf4jLogger);
        _duration = duration;
        _clock = clock;
    }

    private final Duration _duration;
    private final Clock _clock;
    private final AtomicReference<Instant> _lastLogTime = new AtomicReference<>();
    private final AtomicInteger _skipped = new AtomicInteger(0);
}


/*
 * Copyright 2015 Groupon.com
 *
 * Licensed 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 com.arpnetworking.steno;

import com.arpnetworking.logback.annotations.Loggable;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonIgnore;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import javax.annotation.Nullable;

/**
 * Factory for creating ImmutableMap instances from key-value
 * data which may contain null keys or values. Most Map
 * implementations cannot contain either. This factory discards any such
 * keys or values and adds keys "_nullKeys" and/or "_nullValues" with value
 * true. If no null keys/values are found then no additional data is inserted.
 *
 * @author Ville Koskela (ville dot koskela at inscopemetrics dot com)
 * @since 1.7.0
 */
public final class LogValueMapFactory {

    /**
     * Create a Builder for a null-safe immutable map. It is strongly recommended
     * that you use the static factory accepting the target instance unless that is not applicable.
     *
     * @since 1.7.0
     *
     * @return New Builder instance.
     */
    public static Builder builder() {
        return new Builder(Optional.empty());
    }

    /**
     * Create a Builder for a null-safe immutable map with a reference to the target
     * instance being logged. This permits injection of bean identifier attributes if so configured.
     *
     * @since 1.9.0
     *
     * @param o Instance of the Object being logged.
     * @return New Builder instance.
     */
    public static Builder builder(@Nullable final Object o) {
        return new Builder(Optional.ofNullable(o));
    }

    /**
     * Construct an immutable map from one key-value pair. Although this is more convenient than the
     * static factory methods this method does not capture the instance being logged.
     *
     * @since 1.9.2
     *
     * @param k1 Key one.
     * @param v1 Value one.
     * @return New LogValueMap.
     */
    public static LogValueMap of(final String k1, final Object v1) {
        return builder()
                .put(k1, v1)
                .build();
    }

    /**
     * Construct an immutable map from one key-value pair. Although this is more convenient than the
     * static factory methods this method does not capture the instance being logged.
     *
     * @since 1.9.2
     *
     * @param k1 Key one.
     * @param v1 Value one.
     * @param k2 Key two.
     * @param v2 Value two.
     * @return New LogValueMap.
     */
    public static LogValueMap of(
            final String k1, final Object v1,
            final String k2, final Object v2) {
        return builder()
                .put(k1, v1)
                .put(k2, v2)
                .build();
    }

    /**
     * Construct an immutable map from one key-value pair. Although this is more convenient than the
     * static factory methods this method does not capture the instance being logged.
     *
     * @since 1.9.2
     *
     * @param k1 Key one.
     * @param v1 Value one.
     * @param k2 Key two.
     * @param v2 Value two.
     * @param k3 Key three.
     * @param v3 Value three.
     * @return New LogValueMap.
     */
    public static LogValueMap of(
            final String k1, final Object v1,
            final String k2, final Object v2,
            final String k3, final Object v3) {
        return builder()
                .put(k1, v1)
                .put(k2, v2)
                .put(k3, v3)
                .build();
    }

    /**
     * Construct an immutable map from one key-value pair. Although this is more convenient than the
     * static factory methods this method does not capture the instance being logged.
     *
     * @since 1.9.2
     *
     * @param k1 Key one.
     * @param v1 Value one.
     * @param k2 Key two.
     * @param v2 Value two.
     * @param k3 Key three.
     * @param v3 Value three.
     * @param k4 Key four.
     * @param v4 Value four.
     * @return New LogValueMap.
     */
    // CHECKSTYLE.OFF: ParameterNumber - Provided for compatibility wth ImmutableMap.of
    public static LogValueMap of(
            final String k1, final Object v1,
            final String k2, final Object v2,
            final String k3, final Object v3,
            final String k4, final Object v4) {
        return builder()
                .put(k1, v1)
                .put(k2, v2)
                .put(k3, v3)
                .put(k4, v4)
                .build();
    }
    // CHECKSTYLE.ON: ParameterNumber

    /**
     * Construct an immutable map from one key-value pair. Although this is more convenient than the
     * static factory methods this method does not capture the instance being logged.
     *
     * @since 1.9.2
     *
     * @param k1 Key one.
     * @param v1 Value one.
     * @param k2 Key two.
     * @param v2 Value two.
     * @param k3 Key three.
     * @param v3 Value three.
     * @param k4 Key four.
     * @param v4 Value four.
     * @param k5 Key five.
     * @param v5 Value five.
     * @return New LogValueMap.
     */
    // CHECKSTYLE.OFF: ParameterNumber - Provided for compatibility wth ImmutableMap.of
    public static LogValueMap of(
            final String k1, final Object v1,
            final String k2, final Object v2,
            final String k3, final Object v3,
            final String k4, final Object v4,
            final String k5, final Object v5) {
        return builder()
                .put(k1, v1)
                .put(k2, v2)
                .put(k3, v3)
                .put(k4, v4)
                .put(k5, v5)
                .build();
    }
    // CHECKSTYLE.ON: ParameterNumber

    private LogValueMapFactory() {}

    /**
     * Custom Map implementation for custom serialization.
     *
     * @since 1.9.0
     */
    @Loggable
    public static final class LogValueMap implements Serializable {

        /**
         * Public constructor.
         *
         * @param target The instance being represented for logging.
         * @param data The representation of the target instance for logging.
         */
        public LogValueMap(final Optional<Object> target, final Map<String, Object> data) {
            _target = target;
            _data = data;
        }

        @JsonIgnore
        public Optional<Object> getTarget() {
            return _target;
        }

        @JsonAnyGetter
        public Map<String, Object> getData() {
            return _data;
        }

        @Override
        public String toString() {
            final StringBuilder builder = new StringBuilder();
            builder.append("{");
            if (_target.isPresent()) {
                builder.append("_id=")
                        .append(Integer.toHexString(System.identityHashCode(_target.get())))
                        .append(" _class=")
                        .append(_target.get().getClass().getName())
                        .append(" ");
            }
            for (final Map.Entry<String, Object> entry : _data.entrySet()) {
                builder.append(entry.getKey())
                        .append("=")
                        .append(entry.getValue().toString())
                        .append(" ");
            }
            if (_target.isPresent() || !_data.isEmpty()) {
                builder.setLength(builder.length() - 1);
            }
            builder.append("}");
            return builder.toString();
        }

        private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
            in.defaultReadObject();
            _target = Optional.empty();
        }

        private transient Optional<Object> _target = Optional.empty();
        private final Map<String, Object> _data;

        private static final long serialVersionUID = -2817278417438085513L;
    }

    /**
     * Builder for null-safe log value immutable map.
     *
     * @since 1.7.0
     */
    public static final class Builder {

        /**
         * Public constructor. This permits injection of bean identifier attributes if so configured.
         *
         * @since 1.9.0
         *
         * @param target The target instance to build a log value map for.
         */
        private Builder(final Optional<Object> target) {
            _target = target;
        }

        /**
         * Construct the map for the log value.
         *
         * @since 1.7.0
         *
         * @return New map for the log value.
         */
        public LogValueMap build() {
            if (_nullKeys) {
                put("_nullKeys", true);
            }
            if (_nullValues) {
                put("_nullValues", true);
            }
            return new LogValueMap(_target, _data);
        }

        /**
         * Add a key and value in a null-safe manner to the log value map. If either is
         * null the entry is suppressed. If any entries are suppressed one or both of
         * "_nullKeys" and "_nullValues" will be inserted as keys with a value true
         * into the log value map. These indicate the suppression of one or more null
         * keys or values respectively.
         *
         * @since 1.7.0
         *
         * @param key The entry key.
         * @param value The entry value.
         * @return This Builder instance.
         */
        public Builder put(@Nullable final String key, @Nullable final Object value) {
            if (key == null) {
                _nullKeys = true;
                return this;
            }
            if (value == null) {
                _nullValues = true;
                return this;
            }
            _data.put(key, value);
            return this;
        }

        private final Optional<Object> _target;
        private final Map<String, Object> _data = new LinkedHashMap<>();
        private boolean _nullKeys = false;
        private boolean _nullValues = false;
    }
}