/*
 * 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.ignite.spi.deployment.uri.scanners.file;

import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.ignite.internal.util.lang.GridTuple;
import org.apache.ignite.internal.util.typedef.F;
import org.apache.ignite.internal.util.typedef.internal.U;
import org.apache.ignite.spi.IgniteSpiException;
import org.apache.ignite.spi.deployment.uri.scanners.GridDeploymentFileHandler;
import org.apache.ignite.spi.deployment.uri.scanners.GridDeploymentFolderScannerHelper;
import org.apache.ignite.spi.deployment.uri.scanners.UriDeploymentScanner;
import org.apache.ignite.spi.deployment.uri.scanners.UriDeploymentScannerContext;

/**
 * URI deployment file scanner.
 */
public class UriDeploymentFileScanner implements UriDeploymentScanner {
    /** Default scan frequency. */
    public static final int DFLT_SCAN_FREQ = 5000;

    /** Per-URI contexts. */
    private final ConcurrentHashMap<URI, URIContext> uriCtxs = new ConcurrentHashMap<>();

    /** {@inheritDoc} */
    @Override public boolean acceptsURI(URI uri) {
        String proto = uri.getScheme().toLowerCase();

        return "file".equals(proto);
    }

    /** {@inheritDoc} */
    @Override public void scan(UriDeploymentScannerContext scanCtx) {
        URI uri = scanCtx.getUri();

        URIContext uriCtx = uriCtxs.get(uri);

        if (uriCtx == null) {
            uriCtx = createUriContext(uri, scanCtx);

            URIContext oldUriCtx = uriCtxs.putIfAbsent(uri, uriCtx);

            if (oldUriCtx != null)
                uriCtx = oldUriCtx;
        }

        uriCtx.scan(scanCtx);
    }

    /** {@inheritDoc} */
    @Override public long getDefaultScanFrequency() {
        return DFLT_SCAN_FREQ;
    }

    /**
     * Create context for the given URI.
     *
     * @param uri URI.
     * @param scanCtx Scanner context.
     * @return URI context.
     */
    private URIContext createUriContext(URI uri, final UriDeploymentScannerContext scanCtx) {
        String scanDirPath = uri.getPath();

        File scanDir = null;

        if (scanDirPath != null)
            scanDir = new File(scanDirPath);

        if (scanDir == null || !scanDir.isDirectory())
            throw new IgniteSpiException("URI is either not provided or is not a directory: " +
                U.hidePassword(uri.toString()));

        FileFilter pkgFilter = new FileFilter() {
            /** {@inheritDoc} */
            @Override public boolean accept(File pathname) {
                return scanCtx.getFilter().accept(null, pathname.getName());
            }
        };

        FileFilter pkgDirFilesFilter = new FileFilter() {
            /** {@inheritDoc} */
            @Override public boolean accept(File pathname) {
                // Allow all files in package.
                return pathname.isFile();
            }
        };

        return new URIContext(scanDir, pkgFilter, pkgDirFilesFilter);
    }

    /**
     * Converts given file name to the URI with "file" scheme.
     *
     * @param name File name to be converted.
     * @return File name with "file://" prefix.
     */
    private static String getFileUri(String name) {
        assert name != null;

        name = name.replace("\\", "/");

        return "file://" + (name.charAt(0) == '/' ? "" : '/') + name;
    }

    /**
     * Context for the given URI.
     */
    private static class URIContext {
        /** Scanning directory or file. */
        private final File scanDir;

        /** Package filter. */
        private final FileFilter pkgFilter;

        /** Package directory files filter. */
        private final FileFilter pkgDirFilesFilter;

        /** Cache of found package files or directories to check if any of it has been updated. */
        private final Map<File, Long> tstampCache = new HashMap<>();

        /** Cache of found files in a package to check if any of it has been updated. */
        private final Map<File, Map<File, Long>> pkgDirFilesTstampCache = new HashMap<>();

        /**
         * Constructor.
         *
         * @param scanDir Scan directory.
         * @param pkgFilter Package file filter.
         * @param pkgDirFilesFilter Package directory files filter.
         */
        private URIContext(File scanDir, FileFilter pkgFilter, FileFilter pkgDirFilesFilter) {
            this.scanDir = scanDir;
            this.pkgFilter = pkgFilter;
            this.pkgDirFilesFilter = pkgDirFilesFilter;
        }

        /**
         * Perform scan.
         *
         * @param scanCtx Scan context.
         */
        private void scan(final UriDeploymentScannerContext scanCtx) {
            final Set<File> foundFiles = scanCtx.isFirstScan() ?
                new HashSet<File>() : U.<File>newHashSet(tstampCache.size());

            GridDeploymentFileHandler hnd = new GridDeploymentFileHandler() {
                /** {@inheritDoc} */
                @Override public void handle(File file) {
                    foundFiles.add(file);

                    handleFile(file, scanCtx);
                }
            };

            // Scan directory for deploy units.
            GridDeploymentFolderScannerHelper.scanFolder(scanDir, pkgFilter, hnd);

            // Print warning if no packages found first time.
            if (scanCtx.isFirstScan() && foundFiles.isEmpty())
                U.warn(scanCtx.getLogger(), "No packages found in: " + U.hidePassword(scanCtx.getUri().toString()));

            if (!scanCtx.isFirstScan()) {
                Collection<File> deletedFiles = new HashSet<>(tstampCache.keySet());

                deletedFiles.removeAll(foundFiles);

                if (!deletedFiles.isEmpty()) {
                    List<String> uris = new ArrayList<>();

                    for (File file : deletedFiles) {
                        uris.add(getFileUri(file.getAbsolutePath()));
                    }

                    // Clear cache.
                    tstampCache.keySet().removeAll(deletedFiles);

                    pkgDirFilesTstampCache.keySet().removeAll(deletedFiles);

                    scanCtx.getListener().onDeletedFiles(uris);
                }
            }
        }

        /**
         * Tests whether given directory or file was changed since last check and if so
         * copies all directory sub-folders and files or file itself to the deployment
         * directory and than notifies listener about new or updated files.
         *
         * @param file Scanning directory or file.
         * @param ctx Scanner context.
         */
        private void handleFile(File file, UriDeploymentScannerContext ctx) {
            boolean changed;

            Long lastMod;

            if (file.isDirectory()) {
                GridTuple<Long> dirLastModified = F.t(file.lastModified());

                changed = checkPackageDirectoryChanged(file, dirLastModified);

                lastMod = dirLastModified.get();
            }
            else {
                lastMod = tstampCache.get(file);

                changed = lastMod == null || lastMod != file.lastModified();

                lastMod = file.lastModified();
            }

            // If file is new or has been modified.
            if (changed) {
                tstampCache.put(file, lastMod);

                if (ctx.getLogger().isDebugEnabled())
                    ctx.getLogger().debug("Discovered deployment file or directory: " + file);

                String fileName = file.getName();

                try {
                    File cpFile = ctx.createTempFile(fileName, ctx.getDeployDirectory());

                    // Delete file when JVM stopped.
                    cpFile.deleteOnExit();

                    if (file.isDirectory()) {
                        cpFile = new File(cpFile.getParent(), "dir_" + cpFile.getName());

                        // Delete directory when JVM stopped.
                        cpFile.deleteOnExit();
                    }

                    // Copy file to deploy directory.
                    U.copy(file, cpFile, true);

                    String fileUri = getFileUri(file.getAbsolutePath());

                    assert lastMod != null;

                    ctx.getListener().onNewOrUpdatedFile(cpFile, fileUri, lastMod);
                }
                catch (IOException e) {
                    U.error(ctx.getLogger(), "Error saving file: " + fileName, e);
                }
            }
        }

        /**
         * Tests whether certain directory was changed since given modification date.
         * It scans all directory files one by one and compares their modification
         * dates with those ones that was collected before.
         * <p>
         * If at least one file was changed (has modification date after given one)
         * whole directory is considered as modified.
         *
         * @param dir Scanning directory.
         * @param lastModified Last calculated Directory modification date.
         * @return {@code true} if directory was changed since last check and
         *      {@code false} otherwise.
         */
        @SuppressWarnings("ConstantConditions")
        private boolean checkPackageDirectoryChanged(File dir, final GridTuple<Long> lastModified) {
            final Map<File, Long> clssTstampCache;

            boolean firstScan = false;

            if (!pkgDirFilesTstampCache.containsKey(dir)) {
                firstScan = true;

                pkgDirFilesTstampCache.put(dir, clssTstampCache = new HashMap<>());
            }
            else
                clssTstampCache = pkgDirFilesTstampCache.get(dir);

            assert clssTstampCache != null;

            final GridTuple<Boolean> changed = F.t(false);

            final Set<File> foundFiles = firstScan ? new HashSet<File>() : U.<File>newHashSet(clssTstampCache.size());

            GridDeploymentFileHandler hnd = new GridDeploymentFileHandler() {
                @Override public void handle(File file) {
                    foundFiles.add(file);

                    Long fileLastModified = clssTstampCache.get(file);

                    if (fileLastModified == null || fileLastModified != file.lastModified()) {
                        clssTstampCache.put(file, fileLastModified = file.lastModified());

                        changed.set(true);
                    }

                    // Calculate last modified file in folder.
                    if (fileLastModified > lastModified.get())
                        lastModified.set(fileLastModified);
                }
            };

            // Scan package for changes.
            GridDeploymentFolderScannerHelper.scanFolder(dir, pkgDirFilesFilter, hnd);

            // Clear cache for deleted files.
            if (!firstScan && clssTstampCache.keySet().retainAll(foundFiles))
                changed.set(true);

            return changed.get();
        }
    }
}
