// Copyright 2018 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.

import 'package:path/path.dart' as p;
import 'package:stream_transform/stream_transform.dart';
import 'package:watcher/watcher.dart';

import '../importer/filesystem.dart';
import '../io.dart';
import '../stylesheet_graph.dart';
import '../util/map.dart';
import '../util/multi_dir_watcher.dart';
import 'concurrent.dart';
import 'options.dart';

/// Watches all the files in [graph] for changes and updates them as necessary.
Future<void> watch(ExecutableOptions options, StylesheetGraph graph) async {
  var directoriesToWatch = [
    ..._sourceDirectoriesToDestinations(options).keys,
    for (var dir in _sourcesToDestinations(options).keys) p.dirname(dir),
    ...options.loadPaths,
  ];

  var dirWatcher = MultiDirWatcher(poll: options.poll);
  await Future.wait(
    directoriesToWatch.map((dir) {
      // If a directory doesn't exist, watch its parent directory so that we're
      // notified once it starts existing.
      while (!dirExists(dir)) {
        dir = p.dirname(dir);
      }
      return dirWatcher.watch(dir);
    }),
  );

  // Before we start paying attention to changes, compile all the stylesheets as
  // they currently exist. This ensures that changes that come in update a
  // known-good state.
  var watcher = _Watcher(options, graph);
  var sourcesToDestinations = _sourcesToDestinations(options);
  for (var source in sourcesToDestinations.keys) {
    graph.addCanonical(
      FilesystemImporter.cwd,
      p.toUri(canonicalize(source)),
      p.toUri(source),
      recanonicalize: false,
    );
  }
  var success = await compileStylesheets(
    options,
    graph,
    sourcesToDestinations,
    ifModified: true,
  );
  if (!success && options.stopOnError) {
    dirWatcher.events.listen(null).cancel();
    return;
  }

  print("Sass is watching for changes. Press Ctrl-C to stop.\n");
  await watcher.watch(dirWatcher);
}

/// Holds state that's shared across functions that react to changes on the
/// filesystem.
final class _Watcher {
  /// The options for the Sass executable.
  final ExecutableOptions _options;

  /// The graph of stylesheets being compiled.
  final StylesheetGraph _graph;

  /// A map from source paths to destinations that need to be recompiled once
  /// the current batch of events has been processed.
  final Map<String, String> _toRecompile = {};

  _Watcher(this._options, this._graph);

  /// Deletes the file at [path] and prints a message about it.
  void _delete(String path) {
    try {
      deleteFile(path);
      var buffer = StringBuffer();
      if (_options.color) buffer.write("\u001b[33m");
      buffer.write("Deleted $path.");
      if (_options.color) buffer.write("\u001b[0m");
      print(buffer);
    } on FileSystemException {
      // If the file doesn't exist, that's fine.
    }
  }

  /// Listens to `watcher.events` and updates the filesystem accordingly.
  ///
  /// Returns a future that will only complete if an unexpected error occurs.
  Future<void> watch(MultiDirWatcher watcher) async {
    await for (var batch in _debounceEvents(watcher.events)) {
      for (var event in batch) {
        var extension = p.extension(event.path);
        if (extension != '.sass' &&
            extension != '.scss' &&
            extension != '.css') {
          continue;
        }

        switch (event.type) {
          case ChangeType.MODIFY:
            _handleModify(event.path);

          case ChangeType.ADD:
            _handleAdd(event.path);

          case ChangeType.REMOVE:
            _handleRemove(event.path);
        }
      }

      var toRecompile = {..._toRecompile};
      _toRecompile.clear();
      var success = await compileStylesheets(
        _options,
        _graph,
        toRecompile,
        ifModified: true,
      );
      if (!success && _options.stopOnError) return;
    }
  }

  /// Handles a modify event for the stylesheet at [path].
  ///
  /// Returns whether all necessary recompilations succeeded.
  void _handleModify(String path) {
    var url = _canonicalize(path);

    // It's important to access the node ahead-of-time because it's possible
    // that `_graph.reload()` notices the file has been deleted and removes it
    // from the graph.
    if (_graph.nodes[url] case var node?) {
      _graph.reload(url);
      _recompileDownstream([node]);
    } else {
      _handleAdd(path);
    }
  }

  /// Handles an add event for the stylesheet at [url].
  ///
  /// Returns whether all necessary recompilations succeeded.
  void _handleAdd(String path) {
    var destination = _destinationFor(path);
    if (destination != null) _toRecompile[path] = destination;
    var downstream = _graph.addCanonical(
      FilesystemImporter.cwd,
      _canonicalize(path),
      p.toUri(path),
    );
    _recompileDownstream(downstream);
  }

  /// Handles a remove event for the stylesheet at [url].
  ///
  /// Returns whether all necessary recompilations succeeded.
  void _handleRemove(String path) async {
    var url = _canonicalize(path);

    if (_graph.nodes.containsKey(url)) {
      if (_destinationFor(path) case var destination?) {
        _delete(destination);
        _delete("$destination.map");
      }
    }

    var downstream = _graph.remove(FilesystemImporter.cwd, url);
    _recompileDownstream(downstream);
  }

  /// Returns the canonical URL for the stylesheet path [path].
  Uri _canonicalize(String path) => p.toUri(canonicalize(path));

  /// Combine [WatchEvent]s that happen in quick succession.
  ///
  /// Otherwise, if a file is erased and then rewritten, we can end up reading
  /// the intermediate erased version. This returns a stream of batches of
  /// events that all happened in succession.
  Stream<List<WatchEvent>> _debounceEvents(Stream<WatchEvent> events) {
    return events.debounceBuffer(Duration(milliseconds: 25)).map((buffer) {
      var typeForPath = p.PathMap<ChangeType>();
      for (var event in buffer) {
        var oldType = typeForPath[event.path];
        typeForPath[event.path] = switch ((oldType, event.type)) {
          (null, var newType) => newType,
          (_, ChangeType.REMOVE) => ChangeType.REMOVE,
          (ChangeType.ADD, _) => ChangeType.ADD,
          (_, _) => ChangeType.MODIFY,
        };
      }

      return [
        // PathMap always has nullable keys
        for (var (path!, type) in typeForPath.pairs) WatchEvent(type, path),
      ];
    });
  }

  /// Marks [nodes] and everything that transitively imports them for
  /// recompilation, if necessary.
  void _recompileDownstream(Iterable<StylesheetNode> nodes) {
    var seen = <StylesheetNode>{};
    while (nodes.isNotEmpty) {
      nodes = [
        for (var node in nodes)
          if (seen.add(node)) node,
      ];

      _toRecompile.addAll(_sourceEntrypointsToDestinations(nodes));

      nodes = [for (var node in nodes) ...node.downstream];
    }
  }

  /// Returns a sourcesToDestinations mapping for nodes that are entrypoints.
  Map<String, String> _sourceEntrypointsToDestinations(
    Iterable<StylesheetNode> nodes,
  ) {
    var entrypoints = <String, String>{};
    for (var node in nodes) {
      var url = node.canonicalUrl;
      if (url.scheme != 'file') continue;

      var source = p.fromUri(url);
      if (_destinationFor(source) case var destination?) {
        entrypoints[source] = destination;
      }
    }
    return entrypoints;
  }

  /// If a Sass file at [source] should be compiled to CSS, returns the path to
  /// the CSS file it should be compiled to.
  ///
  /// Otherwise, returns `null`.
  String? _destinationFor(String source) {
    if (_sourcesToDestinations(_options)[source] case var destination?) {
      return destination;
    }
    if (p.basename(source).startsWith('_')) return null;

    for (var (sourceDir, destinationDir)
        in _sourceDirectoriesToDestinations(_options).pairs) {
      if (!p.isWithin(sourceDir, source)) continue;

      var destination = p.join(
        destinationDir,
        p.setExtension(p.relative(source, from: sourceDir), '.css'),
      );

      // Don't compile ".css" files to their own locations.
      if (!p.equals(destination, source)) return destination;
    }

    return null;
  }
}

/// Exposes [options.sourcesToDestinations] as a non-nullable map, since stdin
/// inputs and stdout outputs aren't allowed in `--watch` mode.
Map<String, String> _sourcesToDestinations(ExecutableOptions options) =>
    options.sourcesToDestinations.cast<String, String>();

/// Exposes [options.sourcesDirectoriesToDestinations] as a non-nullable map,
/// since stdin inputs and stdout outputs aren't allowed in `--watch` mode.
Map<String, String> _sourceDirectoriesToDestinations(
  ExecutableOptions options,
) =>
    options.sourceDirectoriesToDestinations.cast<String, String>();
