// 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' as io;
import 'dart:io';
import 'dart:isolate';

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 '../utils.dart';

void fileTests({required bool isNative}) {
  for (var i = 0; i != runsPerTest; ++i) {
    _fileTests(isNative: isNative);
  }
}

void _fileTests({required bool isNative}) {
  test('error reported if directory does not exist', () async {
    await startWatcher(path: 'missing_path');

    // TODO(davidmorgan): reconcile differences.
    if (isNative && !Platform.isMacOS) {
      expect(expectNoEvents, throwsA(isA<PathNotFoundException>()));
    } else {
      // The polling watcher and the MacOS watcher do not throw on missing file
      // on watch.
      await expectNoEvents();
      writeFile('missing_path/file.txt');
      await expectAddEvent('missing_path/file.txt');
    }
  });

  // ResubscribableWatcher wraps all the directory watchers to add handling of
  // multiple subscribers. The underlying watcher is created when there is at
  // least one subscriber and closed when there are zero subscribers. So,
  // exercise that behavior in various ways.
  test('ResubscribableWatcher handles multiple subscriptions ', () async {
    final watcher = createWatcher();

    // One subscription, one event, close the subscription.
    final queue1 = StreamQueue(watcher.events);
    final event1 = queue1.next;
    await watcher.ready;
    writeFile('a.txt');
    expect(await event1, isAddEvent('a.txt'));
    await queue1.cancel(immediate: true);

    // Open before "ready", cancel before event.
    final queue2a = StreamQueue(watcher.events);
    // Open before "ready", cancel after one event.
    final queue2b = StreamQueue(watcher.events);
    // Open before "ready", cancel after two events.
    final queue2c = StreamQueue(watcher.events);

    final queue2aHasNext = queue2a.hasNext;
    unawaited(queue2a.cancel(immediate: true));
    expect(await queue2aHasNext, false);

    await watcher.ready;

    // Open after "ready", cancel before event.
    final queue2d = StreamQueue(watcher.events);

    // Open after "ready", cancel after one event.
    final queue2e = StreamQueue(watcher.events);

    // Open after "ready", cancel after two events.
    final queue2f = StreamQueue(watcher.events);

    final queue2dHasNext = queue2d.hasNext;
    unawaited(queue2d.cancel(immediate: true));
    expect(await queue2dHasNext, false);

    writeFile('b.txt');

    expect(await queue2b.next, isAddEvent('b.txt'));
    expect(await queue2c.next, isAddEvent('b.txt'));
    expect(await queue2e.next, isAddEvent('b.txt'));
    expect(await queue2f.next, isAddEvent('b.txt'));
    final queue2bHasNext = queue2b.hasNext;
    await queue2b.cancel(immediate: true);
    expect(await queue2bHasNext, false);
    final queue2eHasNext = queue2e.hasNext;
    await queue2e.cancel(immediate: true);
    expect(await queue2eHasNext, false);

    // Remaining subscriptions still get events.
    writeFile('c.txt');
    expect(await queue2c.next, isAddEvent('c.txt'));
    expect(await queue2f.next, isAddEvent('c.txt'));
    final queue2cHasNext = queue2c.hasNext;
    await queue2c.cancel(immediate: true);
    expect(await queue2cHasNext, false);
    final queue2fHasNext = queue2f.hasNext;
    await queue2f.cancel(immediate: true);
    expect(await queue2fHasNext, false);

    // Repeat the first simple test: one subscription, one event, close the
    // subscription.
    final queue3 = StreamQueue(watcher.events);
    await watcher.ready;
    writeFile('d.txt');
    expect(await queue3.next, isAddEvent('d.txt'));
    final queue3HasNext = queue3.hasNext;
    await queue3.cancel(immediate: true);
    expect(await queue3HasNext, false);
  });

  test('does not notify for files that already exist when started', () async {
    // Make some pre-existing files.
    writeFile('a.txt');
    writeFile('b.txt');

    await startWatcher();

    // Change one after the watcher is running.
    writeFile('b.txt', contents: 'modified');

    // We should get a modify event for the changed file, but no add events
    // for them before this.
    await expectModifyEvent('b.txt');
  });

  test('notifies when a file is added', () async {
    await startWatcher();
    writeFile('file.txt');
    await expectAddEvent('file.txt');
  });

  test('notifies when a file is modified', () async {
    writeFile('file.txt');
    await startWatcher();
    writeFile('file.txt', contents: 'modified');
    await expectModifyEvent('file.txt');
  });

  test('notifies when a file is removed', () async {
    writeFile('file.txt');
    await startWatcher();
    deleteFile('file.txt');
    await expectRemoveEvent('file.txt');
  });

  test('notifies when a file is modified multiple times', () async {
    writeFile('file.txt');
    await startWatcher();
    writeFile('file.txt', contents: 'modified');
    await expectModifyEvent('file.txt');
    writeFile('file.txt', contents: 'modified again');
    await expectModifyEvent('file.txt');
  });

  test('notifies even if the file contents are unchanged', () async {
    writeFile('a.txt', contents: 'same');
    writeFile('b.txt', contents: 'before');
    await startWatcher();

    if (!isNative) sleepUntilNewModificationTime();
    writeFile('a.txt', contents: 'same');
    writeFile('b.txt', contents: 'after');
    await inAnyOrder([isModifyEvent('a.txt'), isModifyEvent('b.txt')]);
  });

  test('when the watched directory is deleted, removes all files', () async {
    writeFile('dir/a.txt');
    writeFile('dir/b.txt');

    await startWatcher(path: 'dir');

    deleteDir('dir');
    await inAnyOrder([isRemoveEvent('dir/a.txt'), isRemoveEvent('dir/b.txt')]);
  });

  test('when the watched directory is moved, removes all files', () async {
    writeFile('dir/a.txt');
    writeFile('dir/b.txt');

    await startWatcher(path: 'dir');

    renameDir('dir', 'moved_dir');
    createDir('dir');
    await inAnyOrder([isRemoveEvent('dir/a.txt'), isRemoveEvent('dir/b.txt')]);
  });

  // Regression test for b/30768513.
  test(
      "doesn't crash when the directory is moved immediately after a subdir "
      'is added', () async {
    writeFile('dir/a.txt');
    writeFile('dir/b.txt');

    await startWatcher(path: 'dir');

    createDir('dir/subdir');
    renameDir('dir', 'moved_dir');
    createDir('dir');
    await inAnyOrder([isRemoveEvent('dir/a.txt'), isRemoveEvent('dir/b.txt')]);
  });

  group('moves', () {
    test('notifies when a file is moved within the watched directory',
        () async {
      writeFile('old.txt');
      await startWatcher();
      renameFile('old.txt', 'new.txt');

      await inAnyOrder([isAddEvent('new.txt'), isRemoveEvent('old.txt')]);
    });

    test('notifies when a file is moved from outside the watched directory',
        () async {
      writeFile('old.txt');
      createDir('dir');
      await startWatcher(path: 'dir');

      renameFile('old.txt', 'dir/new.txt');
      await expectAddEvent('dir/new.txt');
    });

    test('notifies when a file is moved outside the watched directory',
        () async {
      writeFile('dir/old.txt');
      await startWatcher(path: 'dir');

      renameFile('dir/old.txt', 'new.txt');
      await expectRemoveEvent('dir/old.txt');
    });

    test('notifies when a file is moved onto an existing one', () async {
      writeFile('from.txt');
      writeFile('to.txt', contents: 'different');
      await startWatcher();

      renameFile('from.txt', 'to.txt');
      await inAnyOrder([isRemoveEvent('from.txt'), isModifyEvent('to.txt')]);
    });
  });

  group('clustered changes', () {
    test("doesn't notify when a file is created and then immediately removed",
        () async {
      writeFile('test.txt');
      await startWatcher();
      writeFile('file.txt');
      deleteFile('file.txt');
    });

    test('reports when a file is moved between directories then deleted',
        () async {
      writeFile('a/test.txt');
      createDir('b');
      await startWatcher(path: 'b');

      renameFile('a/test.txt', 'b/test.txt');
      deleteFile('b/test.txt');

      final events =
          await takeEvents(duration: const Duration(milliseconds: 500));

      // It's correct to report either nothing or an add+remove.
      expect(
          events,
          anyOf([
            isEmpty,
            containsAll([
              isAddEvent('b/test.txt'),
              isRemoveEvent('b/test.txt'),
            ]),
          ]));
      expect(events, isNot(contains(isModifyEvent('b/test.txt'))));
    });

    test(
        'reports a modification when a file is deleted and then immediately '
        'recreated', () async {
      writeFile('file.txt');
      await startWatcher();

      deleteFile('file.txt');
      writeFile('file.txt', contents: 're-created');

      await expectModifyEvent('file.txt');
    });

    test(
        'reports a modification when a file is moved and then immediately '
        'recreated', () async {
      writeFile('old.txt');
      await startWatcher();

      renameFile('old.txt', 'new.txt');
      writeFile('old.txt', contents: 're-created');

      await inAnyOrder([isModifyEvent('old.txt'), isAddEvent('new.txt')]);
    });

    test(
        'reports a removal when a file is modified and then immediately '
        'removed', () async {
      writeFile('file.txt');
      await startWatcher();

      writeFile('file.txt', contents: 'modified');
      deleteFile('file.txt');

      await expectRemoveEvent('file.txt');
    });

    test('reports an add when a file is added and then immediately modified',
        () async {
      await startWatcher();

      writeFile('file.txt');
      writeFile('file.txt', contents: 'modified');

      await expectAddEvent('file.txt');
    });
  });

  group('subdirectories', () {
    test('watches files in subdirectories', () async {
      await startWatcher();
      writeFile('a/b/c/d/file.txt');
      await expectAddEvent('a/b/c/d/file.txt');
    });

    test(
        'notifies when a subdirectory is moved within the watched directory '
        'and then its contents are modified', () async {
      writeFile('old/file.txt');
      await startWatcher();

      renameDir('old', 'new');
      await inAnyOrder(
          [isRemoveEvent('old/file.txt'), isAddEvent('new/file.txt')]);

      writeFile('new/file.txt', contents: 'modified');
      await expectModifyEvent('new/file.txt');
    });

    test('notifies when a file is replaced by a subdirectory', () async {
      writeFile('new');
      writeFile('old/file.txt');
      await startWatcher();

      deleteFile('new');
      renameDir('old', 'new');
      await inAnyOrder([
        isRemoveEvent('new'),
        isRemoveEvent('old/file.txt'),
        isAddEvent('new/file.txt')
      ]);
    });

    test('notifies when a subdirectory is replaced by a file', () async {
      writeFile('old');
      writeFile('new/file.txt');
      await startWatcher();

      renameDir('new', 'newer');
      renameFile('old', 'new');
      await inAnyOrder([
        isRemoveEvent('new/file.txt'),
        isAddEvent('newer/file.txt'),
        isRemoveEvent('old'),
        isAddEvent('new')
      ]);
    });

    test('emits events for many nested files added at once', () async {
      withPermutations((i, j, k) => writeFile('sub/sub-$i/sub-$j/file-$k.txt'));

      createDir('dir');
      await startWatcher(path: 'dir');
      renameDir('sub', 'dir/sub');

      await inAnyOrder(withPermutations(
          (i, j, k) => isAddEvent('dir/sub/sub-$i/sub-$j/file-$k.txt')));
    });

    test('emits events for many nested files removed at once', () async {
      withPermutations(
          (i, j, k) => writeFile('dir/sub/sub-$i/sub-$j/file-$k.txt'));

      createDir('dir');
      await startWatcher(path: 'dir');

      // Rename the directory rather than deleting it because native watchers
      // report a rename as a single DELETE event for the directory, whereas
      // they report recursive deletion with DELETE events for every file in the
      // directory.
      renameDir('dir/sub', 'sub');

      await inAnyOrder(withPermutations(
          (i, j, k) => isRemoveEvent('dir/sub/sub-$i/sub-$j/file-$k.txt')));
    });

    test('emits events for many nested files moved at once', () async {
      withPermutations(
          (i, j, k) => writeFile('dir/old/sub-$i/sub-$j/file-$k.txt'));

      createDir('dir');
      await startWatcher(path: 'dir');
      renameDir('dir/old', 'dir/new');

      await inAnyOrder(unionAll(withPermutations((i, j, k) {
        return {
          isRemoveEvent('dir/old/sub-$i/sub-$j/file-$k.txt'),
          isAddEvent('dir/new/sub-$i/sub-$j/file-$k.txt')
        };
      })));
    });

    test(
        'emits events for many nested files moved out then immediately back in',
        () async {
      withPermutations(
          (i, j, k) => writeFile('dir/sub/sub-$i/sub-$j/file-$k.txt'));

      await startWatcher(path: 'dir');

      renameDir('dir/sub', 'sub');
      renameDir('sub', 'dir/sub');

      if (isNative) {
        if (Platform.isMacOS || Platform.isWindows) {
          // MacOS/Windows watcher reports as "modify" instead of remove then add.
          await inAnyOrder(withPermutations(
              (i, j, k) => isModifyEvent('dir/sub/sub-$i/sub-$j/file-$k.txt')));
        } else {
          await inAnyOrder(withPermutations(
              (i, j, k) => isRemoveEvent('dir/sub/sub-$i/sub-$j/file-$k.txt')));
          await inAnyOrder(withPermutations(
              (i, j, k) => isAddEvent('dir/sub/sub-$i/sub-$j/file-$k.txt')));
        }
      } else {
        // Polling watchers can't detect this as directory contents mtimes
        // aren't updated when the directory is moved.
        await expectNoEvents();
      }
    });

    test(
        'emits events for many files added at once in a subdirectory with the '
        'same name as a removed file', () async {
      writeFile('dir/sub');
      withPermutations((i, j, k) => writeFile('old/sub-$i/sub-$j/file-$k.txt'));
      await startWatcher(path: 'dir');

      deleteFile('dir/sub');
      renameDir('old', 'dir/sub');

      var events = withPermutations(
          (i, j, k) => isAddEvent('dir/sub/sub-$i/sub-$j/file-$k.txt'));
      events.add(isRemoveEvent('dir/sub'));
      await inAnyOrder(events);
    });

    test('are still watched after move', () async {
      await startWatcher();

      writeFile('a/b/file.txt');
      await expectAddEvent('a/b/file.txt');

      renameDir('a', 'c');
      await inAnyOrder(
          [isRemoveEvent('a/b/file.txt'), isAddEvent('c/b/file.txt')]);

      writeFile('c/b/file2.txt');
      await expectAddEvent('c/b/file2.txt');
      await expectNoEvents();
    });

    test('multiple deletes order is respected', () async {
      createDir('watched');
      writeFile('a/1');
      writeFile('b/1');

      await startWatcher(path: 'watched');

      renameDir('a', 'watched/x');
      renameDir('watched/x', 'a');
      renameDir('b', 'watched/x');
      writeFile('watched/x/1', contents: 'updated');
      // This is a "duplicate" delete of x, but it's not the same delete and the
      // watcher needs to notice that it happens after the update to x/1 so
      // there is no file left behind.
      renameDir('watched/x', 'b');

      expect(
          foldDeletes(await takeEvents(duration: const Duration(seconds: 1))),
          isEmpty);
    });

    test('subdirectory watching is robust against races', () async {
      // Make sandboxPath accessible to child isolates created by Isolate.run.
      final sandboxPath = d.sandbox;
      final dirNames = [for (var i = 0; i < 500; i++) 'dir$i'];
      await startWatcher();

      // Repeatedly create and delete subdirectories in attempt to trigger
      // a race.
      for (var i = 0; i < 10; i++) {
        for (var dir in dirNames) {
          createDir(dir);
        }
        await Isolate.run(() async {
          await Future.wait([
            for (var dir in dirNames)
              io.Directory('$sandboxPath/$dir').delete(),
          ]);
        });
      }
    });
  });

  test(
      'does not notify about the watched directory being deleted and '
      'recreated immediately before watching', () async {
    createDir('dir');
    writeFile('dir/old.txt');
    deleteDir('dir');
    createDir('dir');

    await startWatcher(path: 'dir');
    writeFile('dir/newer.txt');
    await expectAddEvent('dir/newer.txt');
  });

  test('does not suppress files with the same prefix as a directory', () async {
    // Regression test for https://github.com/dart-lang/watcher/issues/83
    writeFile('some_name.txt');

    await startWatcher();

    writeFile('some_name/some_name.txt');
    deleteFile('some_name.txt');

    await inAnyOrder([
      isAddEvent('some_name/some_name.txt'),
      isRemoveEvent('some_name.txt')
    ]);
  });

  bool filesystemIsCaseSensitive() {
    final directory = Directory.systemTemp.createTempSync();
    final filePath = p.join(directory.path, 'a');
    final file = File(filePath)..createSync();
    final result = !File(filePath.toUpperCase()).existsSync();
    file.deleteSync();
    return result;
  }

  group('on case-insensitive filesystem', skip: filesystemIsCaseSensitive(),
      () {
    test('events with case-only changes', () async {
      if (filesystemIsCaseSensitive()) return;

      writeFile('A.txt');
      writeFile('B.txt');
      writeFile('C.txt');

      await startWatcher();

      writeFile('A.TXT', contents: 'modified');
      deleteFile('B.TXT');
      renameFile('C.txt', 'C.TXT');

      if (isNative && Platform.isWindows) {
        // On Windows events arrive with case the files were created with, not
        // the case that was used when modifying them. So the delete of `B.txt`
        // as `B.TXT` is picked up. But, the watcher does not correctly handle
        // the "remove" of `C.txt` from the rename, and sends an incorrect
        // "modify". TODO(davidmorgan): fix it.
        // See: https://github.com/dart-lang/tools/issues/2271.
        await inAnyOrder([
          isModifyEvent('A.txt'),
          isRemoveEvent('B.txt'),
          isModifyEvent('C.txt'),
          isAddEvent('C.TXT'),
        ]);
      } else if (isNative && Platform.isMacOS) {
        // On MacOS the delete event arrives with case used to operate on the
        // file, so the delete of `B.txt` as `B.TXT` is not picked up. It has
        // the same problem as Windows with the move of `C.txt`.
        // See: https://github.com/dart-lang/tools/issues/2271.
        await inAnyOrder([
          isModifyEvent('A.txt'),
          isModifyEvent('C.txt'),
          isAddEvent('C.TXT'),
        ]);
      } else {
        await inAnyOrder([
          isModifyEvent('A.txt'),
          isRemoveEvent('B.txt'),
          isRemoveEvent('C.txt'),
          isAddEvent('C.TXT'),
        ]);
      }

      await expectNoEvents();
    });

    test('works when watch root is specified with case-only changes', () async {
      if (filesystemIsCaseSensitive()) return;

      writeFile('a');
      writeFile('b');
      writeFile('c');

      final sandboxPathWithDifferentCase = d.sandbox.toUpperCase();
      expect(sandboxPathWithDifferentCase, isNot(d.sandbox));
      await startWatcher(exactPath: sandboxPathWithDifferentCase);

      writeFile('a', contents: 'modified');
      deleteFile('b');
      renameFile('c', 'e');
      writeFile('d');

      await inAnyOrder([
        isModifyEvent('a', ignoreCase: true),
        isRemoveEvent('b', ignoreCase: true),
        isRemoveEvent('c', ignoreCase: true),
        isAddEvent('e', ignoreCase: true),
        isAddEvent('d', ignoreCase: true),
      ]);
    });
  });
}
