package mirror.watchman;

import static com.google.common.collect.Lists.newArrayList;
import static java.nio.charset.StandardCharsets.UTF_8;
import static mirror.Utils.resetIfInterrupted;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

import org.apache.commons.io.FileUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.facebook.watchman.Callback;
import com.facebook.watchman.WatchmanClient.SubscriptionDescriptor;
import com.google.protobuf.TextFormat;

import jnr.posix.FileStat;
import mirror.FileWatcher;
import mirror.LoggingConfig;
import mirror.MirrorPaths;
import mirror.Update;
import mirror.UpdateTree;
import mirror.tasks.TaskFactory;
import mirror.tasks.ThreadBasedTaskFactory;

/**
 * A {@link FileWatcher} that uses <a href="https://facebook.github.io/watchman">watchman</a>.
 */
public class WatchmanFileWatcher implements FileWatcher {

  private static final Logger log = LoggerFactory.getLogger(WatchmanFileWatcher.class);
  private final MirrorPaths config;
  private final Watchman wm;
  private final Path ourRoot;
  private final BlockingQueue<Update> queue;
  private final BlockingQueue<Exception> exceptions = new LinkedBlockingQueue<>();
  // we may ask to watch /home/foo/bar, but watchman decides to watch /home/foo
  private volatile String watchmanRoot;
  private volatile Optional<String> watchmanPrefix;
  private volatile String initialScanClock;
  private volatile SubscriptionDescriptor subscription;

  /** Main method for doing manual debugging/observation of behavior. */
  public static void main(String[] args) throws Exception {
    LoggingConfig.initWithTracing();
    TaskFactory f = new ThreadBasedTaskFactory();
    Path testDirectory = Paths.get("/home/stephen/dir1");
    BlockingQueue<Update> queue = new LinkedBlockingQueue<>();
    MirrorPaths config = MirrorPaths.forTesting(testDirectory);
    WatchmanFileWatcher w = new WatchmanFileWatcher(WatchmanImpl.createIfAvailable().get(), config, queue);
    log.info("Starting performInitialScan");
    List<Update> initialScan = w.performInitialScan();
    initialScan.forEach(node -> {
      log.info("Initial: " + UpdateTree.toDebugString(node));
    });
    f.runTask(w);
    while (true) {
      log.info("Update: " + UpdateTree.toDebugString(queue.take()));
    }
  }

  public WatchmanFileWatcher(Watchman wm, MirrorPaths config, BlockingQueue<Update> queue) {
    this.config = config;
    this.wm = wm;
    try {
      // If we get passed /home/foo/./path watchman's path sensitiveness check complains,
      // so turn it into /home/foo/path.
      this.ourRoot = config.root.toFile().getCanonicalFile().toPath();
    } catch (IOException e) {
      throw new RuntimeException(e);
    }
    this.queue = queue;
  }

  @Override
  public void onStart() {
    try {
      startSubscription();
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
  }

  @Override
  public void onInterrupt() {
    try {
      log.debug("Stopping watchman");
      wm.close();
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
  }

  @Override
  public Duration runOneLoop() throws InterruptedException {
    try {
      // Keep blocking until we get more push notifications
      throw exceptions.take();
    } catch (WatchmanOverflowException e) {
      try {
        // try resetting the overflow
        wm.unsubscribe(subscription);
        wm.run("watch-del", watchmanRoot);
        startWatchAndInitialFind();
        startSubscription();
      } catch (Exception e2) {
        log.error("Could not reset watchman", e2);
      }
    } catch (InterruptedException e) {
      // BserEofException
      // shutting down
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
    return null;
  }

  @Override
  public List<Update> performInitialScan() throws Exception {
    startWatchAndInitialFind();
    List<Update> updates = new ArrayList<>(queue.size());
    queue.drainTo(updates);
    return updates;
  }

  @SuppressWarnings("unchecked")
  private void putFiles(Map<String, Object> response) {
    List<Map<String, Object>> files = (List<Map<String, Object>>) response.get("files");
    if (files == null) {
      throw new RuntimeException("Invalid response " + response);
    }
    files.forEach(this::putFile);
  }

  private void putFile(Map<String, Object> file) {
    int mode = ((Number) file.get("mode")).intValue();
    long mtime = ((Number) file.get("mtime_ms")).longValue();
    Object name = file.get("name");
    if (!(name instanceof String)) {
      return; // ignore non-utf8 file names as they are likely corrupted
    }
    resetIfInterrupted(() -> {
      Update.Builder ub = Update
        .newBuilder()
        .setPath((String) name)
        .setDelete(!(boolean) file.get("exists"))
        .setModTime(mtime)
        .setDirectory(isFileStatType(mode, FileStat.S_IFDIR))
        .setExecutable(isExecutable(mode))
        .setLocal(true);
      readSymlinkTargetIfNeeded(ub, mode);
      setIgnoreStringIfNeeded(ub);
      clearModTimeIfADelete(ub);
      Update u = ub.build();
      if (log.isTraceEnabled()) {
        log.trace("Queueing: " + TextFormat.shortDebugString(u));
      }
      if (config != null && config.shouldDebug(ub.getPath())) {
        log.info("Queueing: " + TextFormat.shortDebugString(u));
      }
      queue.put(u);
    });
  }

  private void startWatchAndInitialFind() throws Exception {
    // This will be a no-op after the first execution, as we don't currently clean up on our watches.
    Map<String, Object> result = wm.run("watch-project", ourRoot.toString());
    watchmanRoot = (String) result.get("watch");
    watchmanPrefix = Optional.ofNullable((String) result.get("relative_path"));
    log.info("Watchman root is {}", watchmanRoot);

    Map<String, Object> params = new HashMap<>();
    params.put("fields", newArrayList("name", "exists", "mode", "mtime_ms"));
    watchmanPrefix.ifPresent(prefix -> {
      params.put("relative_root", prefix);
    });
    Map<String, Object> r = wm.run("query", watchmanRoot, params);
    initialScanClock = (String) r.get("clock");
    putFiles(r);
  }

  private void startSubscription() throws Exception {
    Map<String, Object> params = new HashMap<>();
    // Pass since b/c we don't need to be re-sent everything that we already saw in performInitialScan.
    params.put("since", initialScanClock);
    params.put("fields", newArrayList("name", "exists", "mode", "mtime_ms"));
    watchmanPrefix.ifPresent(prefix -> {
      params.put("relative_root", prefix);
    });
    subscription = wm.subscribe(Paths.get(watchmanRoot), params, new Callback() {
      @Override
      public void call(Map<String, Object> message) {
        // this callback happens on a WatchmanClient thread, so any exceptions we want
        // to capture and put into the exceptions queue so our pumping thread can see it
        try {
          if (message.containsKey("error")) {
            if (((String) message.get("error")).contains("IN_Q_OVERFLOW")) {
              throw new WatchmanOverflowException();
            }
            throw new RuntimeException("Watchman error: " + message.get("error"));
          }
          @SuppressWarnings("unchecked")
          List<Map<String, Object>> files = (List<Map<String, Object>>) message.get("files");
          files.forEach(WatchmanFileWatcher.this::putFile);
        } catch (Exception e) {
          exceptions.add(e);
        }
      }
    });
  }

  // The modtime from watchman is the pre-deletion modtime; to be
  // considered newer, we increment the pre-deletiong modtime by
  // one. Currently that logic is in UpdateTree, so we just clear
  // out mod time here.
  private void clearModTimeIfADelete(Update.Builder ub) {
    if (ub.getDelete()) {
      ub.clearModTime();
    }
  }

  private void setIgnoreStringIfNeeded(Update.Builder ub) {
    if (ub.getPath().endsWith(".gitignore")) {
      try {
        Path path = ourRoot.resolve(ub.getPath());
        ub.setIgnoreString(FileUtils.readFileToString(path.toFile(), UTF_8));
      } catch (IOException e) {
        // ignore as the file probably disappeared
        log.debug("Exception reading .gitignore, assumed stale", e);
      }
    }
  }

  private void readSymlinkTargetIfNeeded(Update.Builder ub, int mode) {
    if (isFileStatType(mode, FileStat.S_IFLNK)) {
      readSymlinkTarget(ub);
    }
  }

  private void readSymlinkTarget(Update.Builder ub) {
    try {
      Path path = ourRoot.resolve(ub.getPath());
      Path symlink = Files.readSymbolicLink(path);
      String targetPath;
      if (symlink.isAbsolute()) {
        targetPath = path.getParent().normalize().relativize(symlink.normalize()).toString();
      } else {
        // the symlink is already relative, so we can leave it alone, e.g. foo.txt
        targetPath = symlink.toString();
      }
      ub.setSymlink(targetPath);
      // Ensure our modtime is of the symlink itself (I'm not actually
      // sure which modtime watchman sends back, but this is safest).
      ub.setModTime(Files.getLastModifiedTime(path, LinkOption.NOFOLLOW_LINKS).toMillis());
    } catch (IOException e) {
      // ignore as the file probably disappeared
      log.debug("Exception reading symlink, assumed stale", e);
    }
  }

  private static boolean isFileStatType(int mode, int mask) {
    return (mode & FileStat.S_IFMT) == mask;
  }

  private static boolean isExecutable(int mode) {
    return (mode & FileStat.S_IXUGO) != 0;
  }
}