// Copyright (c) 2012, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:async';
import 'dart:io';

import 'package:async/async.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
import 'package:test_descriptor/test_descriptor.dart' as d;
import 'package:watcher/watcher.dart';

/// Edit this to run fast-running tests many times.
int runsPerTest = 1;

typedef WatcherFactory = Watcher Function(String directory);

/// Sets the function used to create the watcher.
set watcherFactory(WatcherFactory factory) {
  _watcherFactory = factory;
}

/// A directory where a test file will be updated to wait for a new modification
/// time.
Directory? _waitForModificationTimesDirectory;

late WatcherFactory _watcherFactory;

/// Creates a new [Watcher] that watches a temporary file or directory.
///
/// If [path] is provided, watches a subdirectory in the sandbox with that name.
/// Or, pass [exactPath] to watch an exact relative or absolute path without
/// modifying it to add the sandbox path.
Watcher createWatcher({String? exactPath, String? path}) {
  if (exactPath != null) {
    path = exactPath;
  } else if (path == null) {
    path = d.sandbox;
  } else {
    path = p.join(d.sandbox, path);
  }

  return _watcherFactory(path);
}

/// The stream of events from the watcher started with [startWatcher].
late StreamQueue<WatchEvent> _watcherEvents;

/// Whether the event stream has been closed.
///
/// If this is not done by a test (by calling [startClosingEventStream]) it will
/// be done automatically via [addTearDown] in [startWatcher].
var _hasClosedStream = true;

/// Enables [sleepUntilNewModificationTime].
///
/// Resets at the end of the test.
///
/// IMPORTANT NOTE about "polling" watchers and tests:
///
/// The polling watchers can notice changes if the "last modified" time of a
/// file changed or if its size changed.
///
/// Most tests that make changes also change the size of a file, so they do not
/// have to care about whether the timing works out for the modification time to
/// be different.
///
/// Tests that intentionally write to a file _without_ changing its contents
/// must also call [sleepUntilNewModificationTime] if testing a polling
/// watcher. That ensures that the write causes a new modification time.
///
/// At the time of writing in 2025, Windows "last modified" time is much coarser
/// than other platforms, at 1s.
void enableSleepUntilNewModificationTime() {
  if (_waitForModificationTimesDirectory != null) return;
  _waitForModificationTimesDirectory =
      Directory.systemTemp.createTempSync('dart_test_');
  addTearDown(() {
    _waitForModificationTimesDirectory!.deleteSync(recursive: true);
    _waitForModificationTimesDirectory = null;
  });
}

/// Sleeps the until a new modification time is available from the platform.
///
/// This ensures that the next write can be detected by polling watches based on
/// its "last modified" time, whatever the contents.
void sleepUntilNewModificationTime() {
  _waitForModificationTimesDirectory ??= Directory.systemTemp.createTempSync();
  var file = File(p.join(_waitForModificationTimesDirectory!.path, 'file'));
  if (file.existsSync()) file.deleteSync();
  file.createSync();
  final time = file.statSync().modified;
  while (true) {
    file.deleteSync();
    file.createSync();
    final updatedTime = file.statSync().modified;
    if (time != updatedTime) {
      return;
    }
    sleep(const Duration(milliseconds: 1));
  }
}

/// Creates a new [Watcher] that watches a temporary file or directory and
/// starts monitoring it for events.
///
/// If [path] is provided, watches a path in the sandbox with that name.
/// /// Or, pass [exactPath] to watch an exact path irrespective of the sandbox.
Future<void> startWatcher({String? exactPath, String? path}) async {
  // We want to wait until we're ready *after* we subscribe to the watcher's
  // events.
  var watcher = createWatcher(exactPath: exactPath, path: path);
  _watcherEvents = StreamQueue(watcher.events);
  // Forces a subscription to the underlying stream.
  unawaited(_watcherEvents.hasNext);

  _hasClosedStream = false;
  addTearDown(startClosingEventStream);

  await watcher.ready;
}

/// Schedule closing the watcher stream after the event queue has been pumped.
///
/// This is necessary when events are allowed to occur, but don't have to occur,
/// at the end of a test. Otherwise, if they don't occur, the test will wait
/// indefinitely because they might in the future and because the watcher is
/// normally only closed after the test completes.
void startClosingEventStream() async {
  if (_hasClosedStream) return;
  _hasClosedStream = true;
  await pumpEventQueue();
  await _watcherEvents.cancel(immediate: true);
}

/// Add [streamMatcher] as an expectation to [_watcherEvents].
///
/// [streamMatcher] can be a [StreamMatcher], a [Matcher], or a value.
Future _expect(Matcher streamMatcher) {
  return expectLater(_watcherEvents, emits(streamMatcher));
}

/// Expects that [matchers] will match emitted events in any order.
///
/// [matchers] may be [Matcher]s or values, but not [StreamMatcher]s.
Future inAnyOrder(Iterable matchers) =>
    _expect(emitsInAnyOrder(matchers.toSet()));

/// Returns a StreamMatcher that matches a [WatchEvent] with the given [type]
/// and [path].
Matcher isWatchEvent(ChangeType type, String path, {bool ignoreCase = false}) {
  var normalizedPath = p.join(d.sandbox, p.normalize(path));
  if (ignoreCase) normalizedPath = normalizedPath.toLowerCase();
  return predicate((e) {
    if (e is! WatchEvent) return false;
    var eventPath = e.path;
    if (ignoreCase) eventPath = eventPath.toLowerCase();
    return e.type == type && eventPath == normalizedPath;
  }, 'is $type $path');
}

/// Returns a [Matcher] that matches a [WatchEvent] for an add event for [path].
Matcher isAddEvent(String path, {bool ignoreCase = false}) =>
    isWatchEvent(ChangeType.ADD, path, ignoreCase: ignoreCase);

/// Returns a [Matcher] that matches a [WatchEvent] for a modification event for
/// [path].
Matcher isModifyEvent(String path, {bool ignoreCase = false}) =>
    isWatchEvent(ChangeType.MODIFY, path, ignoreCase: ignoreCase);

/// Returns a [Matcher] that matches a [WatchEvent] for a removal event for
/// [path].
Matcher isRemoveEvent(String path, {bool ignoreCase = false}) =>
    isWatchEvent(ChangeType.REMOVE, path, ignoreCase: ignoreCase);

/// Takes the first event emitted during [duration], or returns `null` if there
/// is none.
Future<WatchEvent?> waitForEvent({
  Duration duration = const Duration(seconds: 1),
}) async {
  final result = await _watcherEvents.peek
      .then<WatchEvent?>((e) => e)
      .timeout(duration, onTimeout: () => null);
  if (result != null) _watcherEvents.take(1).ignore();
  return result;
}

/// Expects that no events are emitted for [duration].
Future expectNoEvents({Duration duration = const Duration(seconds: 1)}) async {
  expect(await waitForEvent(duration: duration), isNull);
}

/// Takes all events emitted for [duration].
Future<List<WatchEvent>> takeEvents({required Duration duration}) async {
  final result = <WatchEvent>[];
  final stopwatch = Stopwatch()..start();
  while (stopwatch.elapsed < duration) {
    final event = await waitForEvent(duration: duration - stopwatch.elapsed);
    if (event != null) {
      result.add(event);
    }
  }
  return result;
}

/// Returns a copy of [events] without events about paths that have a REMOVE
/// event as the last event.
///
/// For example, drops all events for a path with an ADD then a MODIFY then a
/// REMOVE; keeps all events for a path with ADD, REMOVE, ADD.
///
/// This allows tests to avoid flakes due to equivalent but different events
/// about transient files.
List<WatchEvent> foldDeletes(List<WatchEvent> events) {
  final lastEventByPath = {for (final event in events) event.path: event};
  return events
      .where((event) => lastEventByPath[event.path]!.type != ChangeType.REMOVE)
      .toList();
}

/// Expects that the next event emitted will be for an add event for [path].
Future expectAddEvent(String path) =>
    _expect(isWatchEvent(ChangeType.ADD, path));

/// Expects that the next event emitted will be for a modification event for
/// [path].
Future expectModifyEvent(String path) =>
    _expect(isWatchEvent(ChangeType.MODIFY, path));

/// Expects that the next event emitted will be for a removal event for [path].
Future expectRemoveEvent(String path) =>
    _expect(isWatchEvent(ChangeType.REMOVE, path));

/// Writes a file in the sandbox at [path] with [contents].
///
/// If [path] is currently a link it is deleted and a file is written in its
/// place.
///
/// If [contents] is omitted, creates an empty file.
void writeFile(String path, {String? contents}) {
  contents ??= '';

  var fullPath = p.join(d.sandbox, path);

  // Create any needed subdirectories.
  var dir = Directory(p.dirname(fullPath));
  if (!dir.existsSync()) {
    dir.createSync(recursive: true);
  }

  var file = File(fullPath);
  // `File.writeAsStringSync` would write through the link, so if there is a
  // link then start by deleting it.
  if (FileSystemEntity.typeSync(fullPath, followLinks: false) ==
      FileSystemEntityType.link) {
    file.deleteSync();
  }
  file.writeAsStringSync(contents);
  // Check that `fullPath` now refers to a file, not a link.
  expect(FileSystemEntity.typeSync(fullPath), FileSystemEntityType.file);
}

/// Writes a file in the sandbox at [link] pointing to [target].
///
/// [target] is relative to the sandbox, not to [link].
///
/// If [unawaitedAsync], the link is written asynchronously and not awaited.
/// Otherwise, it's synchronous. See the note in `windows.dart` for issue 61797
/// for why this is needed for testing on Windows.
void writeLink({
  required String link,
  required String target,
  bool unawaitedAsync = false,
}) {
  var fullPath = p.join(d.sandbox, link);

  // Create any needed subdirectories.
  var dir = Directory(p.dirname(fullPath));
  if (!dir.existsSync()) {
    dir.createSync(recursive: true);
  }

  if (unawaitedAsync) {
    unawaited(Link(fullPath).create(p.join(d.sandbox, target)));
  } else {
    Link(fullPath).createSync(p.join(d.sandbox, target));
  }
}

/// Deletes a file in the sandbox at [path].
void deleteFile(String path) {
  final fullPath = p.join(d.sandbox, path);
  expect(FileSystemEntity.typeSync(fullPath, followLinks: false),
      FileSystemEntityType.file);
  File(fullPath).deleteSync();
}

/// Deletes a link in the sandbox at [path].
void deleteLink(String path) {
  final fullPath = p.join(d.sandbox, path);
  expect(FileSystemEntity.typeSync(fullPath, followLinks: false),
      FileSystemEntityType.link);
  Link(fullPath).deleteSync();
}

/// Renames a file in the sandbox from [from] to [to].
void renameFile(String from, String to) {
  var absoluteTo = p.join(d.sandbox, to);
  File(p.join(d.sandbox, from)).renameSync(absoluteTo);
  expect(FileSystemEntity.typeSync(absoluteTo, followLinks: false),
      FileSystemEntityType.file);
}

/// Renames a link in the sandbox from [from] to [to].
///
/// On MacOS and Linux links can also be named with `renameFile`. On Windows,
/// however, a link must be renamed with this method.
void renameLink(String from, String to) {
  var absoluteTo = p.join(d.sandbox, to);
  Link(p.join(d.sandbox, from)).renameSync(absoluteTo);
  expect(FileSystemEntity.typeSync(absoluteTo, followLinks: false),
      FileSystemEntityType.link);
}

/// Creates a directory in the sandbox at [path].
void createDir(String path) {
  Directory(p.join(d.sandbox, path)).createSync();
}

/// Renames a directory in the sandbox from [from] to [to].
void renameDir(String from, String to) {
  var absoluteTo = p.join(d.sandbox, to);
  // Fails sometimes on Windows, so guard+retry.
  retryForPathAccessException(
      () => Directory(p.join(d.sandbox, from)).renameSync(absoluteTo));
  expect(FileSystemEntity.typeSync(absoluteTo, followLinks: false),
      FileSystemEntityType.directory);
}

/// Deletes a directory in the sandbox at [path].
void deleteDir(String path) {
  final fullPath = p.join(d.sandbox, path);
  expect(FileSystemEntity.typeSync(fullPath, followLinks: false),
      FileSystemEntityType.directory);
  Directory(fullPath).deleteSync(recursive: true);
}

/// Runs [callback] with every permutation of non-negative numbers for each
/// argument less than [limit].
///
/// Returns a set of all values returns by [callback].
///
/// [limit] defaults to 3.
Set<S> withPermutations<S>(S Function(int, int, int) callback, {int? limit}) {
  limit ??= 3;
  var results = <S>{};
  for (var i = 0; i < limit; i++) {
    for (var j = 0; j < limit; j++) {
      for (var k = 0; k < limit; k++) {
        results.add(callback(i, j, k));
      }
    }
  }
  return results;
}

/// Retries [action] until it does not throw [PathAccessException].
void retryForPathAccessException(void Function() action) {
  while (true) {
    try {
      action();
      return;
    } on PathAccessException catch (e) {
      stderr.writeln('Temporary failure, retrying: $e');
    }
  }
}

/// Returns the union of all elements in each set in [sets].
Set<T> unionAll<T>(Iterable<Set<T>> sets) =>
    sets.fold(<T>{}, (union, set) => union.union(set));
