package net.ranides.assira.io;

import lombok.extern.slf4j.Slf4j;

import net.ranides.assira.collection.query.CQuery;
import net.ranides.assira.events.EventDispatcher;
import net.ranides.assira.events.EventListener;
import net.ranides.assira.events.EventRouter;
import net.ranides.assira.trace.ExceptionUtils;

import java.io.IOException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.WatchEvent;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;

import static java.nio.file.StandardWatchEventKinds.*;

/**
 * FileObserver is built on top of JDK WatchService.
 * It is AutoCloseable class which can observe many directories and send events into many EventListeners.
 *
 * You can register both event listeners and subscribe specific {@link FileObserverEvent} or
 * just register consumers which will receive {@link Path}.
 *
 * You can start and stop observing directories at any point in time.
 * Removed directories all handled and unregistered correctly.
 *
 * This class does not offer threading strategy.
 * {@link #process()} method runs in forever and can be terminated only by calling {@link #close} from another thread.
 * If you want to observe for changes you should create background thread on your own.
 *
 * By default every event is processed in the caller thread.
 * You can change this behaviour by passing your own {@link EventRouter} implementation.
 * By default we use {@link EventDispatcher}.
 */
@Slf4j
public class FileObserver implements AutoCloseable {

    private final WatchService service;

    private final Map<WatchKey, Path> observers = new ConcurrentHashMap<>();

    private final Map<Path, WatchKey> directories = new ConcurrentHashMap<>();

    private final EventRouter router;

    /**
     * Creates new observer dispatching all events in current thread (it uses EventDispatcher).
     * @throws IOException on error
     */
    public FileObserver() throws IOException {
        this(EventDispatcher.newInstance());
    }

    /**
     * Creates new observer using specified EventRounter to distribute events to listeners.
     * @param router router
     * @throws IOException on error
     */
    public FileObserver(EventRouter router) throws IOException {
        this.service = FileSystems.getDefault().newWatchService();
        this.router = router;
    }

    @Override
    public void close() {
        for (WatchKey w : observers.keySet()) {
            w.cancel();
        }
        observers.clear();
        directories.clear();
    }

    /**
     * Adds new observer. Observer will be noticed about any change inside any observed directory.
     *
     * @param listener listener
     * @return this
     */
    public FileObserver addEventListener(EventListener<? super FileObserverEvent> listener) {
        router.addEventListener(FileObserverEvent.class, listener);
        return this;
    }

    /**
     * Adds new observer. Observer will be noticed about changes of specified type inside any observed directory.
     * Whats more interesting, you can subscribe for other events than generated by FileObserverEvent.
     * For example, if your dispatcher uses some internal events, you can watch for them here.
     *
     * @param event type of event
     * @param listener listener
     * @param <T> type of event
     * @return this
     */
    public <T extends FileObserverEvent> FileObserver addEventListener(Class<T> event, EventListener<? super T> listener) {
        router.addEventListener(event, listener);
        return this;
    }

    /**
     * Adds new observer. Observer will be noticed about every created path.
     *
     * @param create observer
     * @return this
     */
    public FileObserver onCreate(Consumer<Path> create) {
        addEventListener(FileObserverEvent.Create.class, event -> create.accept(event.path()));
        return this;
    }

    /**
     * Adds new observer. Observer will be noticed about every deleted path.
     *
     * @param delete observer
     * @return this
     */
    public FileObserver onDelete(Consumer<Path> delete) {
        addEventListener(FileObserverEvent.Delete.class, event -> delete.accept(event.path()));
        return this;
    }

    /**
     * Adds new observer. Observer will be noticed about every modified path.
     *
     * @param modify observer
     * @return this
     */
    public FileObserver onModify(Consumer<Path> modify) {
        addEventListener(FileObserverEvent.Modify.class, event -> modify.accept(event.path()));
        return this;
    }


    /**
     * Adds new directory to observe. Every change inside it will be signalled to listeners
     *
     * @param directory directory
     * @return this
     */
    public FileObserver observe(Path directory) {
        try {
            Path path = PathUtils.normalize(directory);
            log.debug("Observe directory {}", path);
            Files.list(path).forEach(p -> {
                if(Files.isDirectory(p)) {
                    observe(p);
                }
                else {
                    try {
                        router.signalEvent(FileObserverEvent.create(p));
                    } catch (Exception cause) {
                        log.error(cause.getMessage(), cause);
                    }
                }
            });
            WatchKey key = path.register(service, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY);
            observers.put(key, path);
            directories.put(path, key);
        } catch (IOException e) {
            throw ExceptionUtils.rethrow(e);
        }
        return this;
    }

    /**
     * Stops observing specified directory.
     * Listeners are not informed about cancelling observation
     *
     * @param path path
     */
    public void unobserve(Path path) {
        Path normalized = PathUtils.normalize(path);
        CQuery.from(directories.keySet())
            .filter(k -> k.startsWith(normalized))
            .fetch()
            .forEach(this::cancel);
    }

    /**
     * Starts processing loop.
     * This method does not terminate until observer is closed.
     *
     * You can shutdown observer from another thread or inside one of event listeners.
     *
     * @throws InterruptedException if terminated
     */
    public void process() throws InterruptedException {
        WatchKey key;
        while(null != (key= service.take())) {
            log.trace("Change inside directory: {}", observers.get(key));
            process(key);
            observers.entrySet().removeIf(o -> !o.getKey().isValid());
        }
    }

    private boolean process(WatchKey key) {
        Map<Path,  WatchEvent.Kind<?>> paths = new HashMap<>();

        for (WatchEvent<?> event : key.pollEvents()) {
            WatchEvent.Kind<?> kind = event.kind();
            if (kind == OVERFLOW) {
                continue;
            }

            Path dirinfo = observers.get(key);
            Path filename = (Path) event.context();
            Path filepath = dirinfo.resolve(filename);

            log.trace("File event: {} {}", kind, filepath);

            if(kind == ENTRY_DELETE && directories.containsKey(filepath)) {
                cancel(filepath);
                continue;
            }

            if (Files.isDirectory(filepath)) {
                if(kind == ENTRY_CREATE) {
                    observe(filepath);
                } else {
                    // change events can be ignored
                    // delete events won't be here, because Files.isDirectory returns false for non-existent paths
                    continue;
                }
            }

            paths.put(filepath, merge(paths.get(filepath), kind));
        }

        for(Map.Entry<Path, WatchEvent.Kind<?>> entry : paths.entrySet()) {
            Path path = entry.getKey();
            WatchEvent.Kind<?> kind = entry.getValue();

            log.trace("File event process: {} {}", kind, path);

            try {
                if (kind == ENTRY_CREATE) {
                    if(Files.exists(path)) {
                        router.handleEvent(FileObserverEvent.create(path));
                    } else {
                        log.trace("ENTRY_CREATE for not-existent path: {}", path);
                    }
                } else if (kind == ENTRY_DELETE) {
                    router.handleEvent(FileObserverEvent.delete(path));
                } else if (kind == ENTRY_MODIFY) {
                    if(Files.exists(path)) {
                        router.handleEvent(FileObserverEvent.modify(path));
                    } else {
                        // BUG in JDK or Windows:
                        // if directory is removed, then we will receive ENTRY_MODIFY for every file inside
                        // it's weird, because we should receive ENTRY_DELETE
                        log.trace("ENTRY_MODIFY for not-existent path: {}", path);
                        router.handleEvent(FileObserverEvent.delete(path));
                    }
                } else {
                    log.warn("Unknown file system event {} {}", kind.name(), path);
                }
            } catch (Exception cause) {
                log.error(cause.getMessage(), cause);
            }
        }

        return key.reset();
    }

    private void cancel(Path filepath) {
        log.debug("Remove directory: " + filepath);
        directories.remove(filepath).cancel();
        router.handleEvent(FileObserverEvent.modify(filepath));
    }

    private WatchEvent.Kind<?> merge(WatchEvent.Kind<?> prev, WatchEvent.Kind<?> next) {
        //        NEW : C M D
        // create     : - - -
        // modify     : M - -
        // delete     : M - -
        if( next == ENTRY_CREATE && (prev == ENTRY_MODIFY || prev == ENTRY_DELETE)) {
            return ENTRY_MODIFY;
        } else {
            return next;
        }
    }

}
