// Copyright (c) 2025, 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:io';

import '../../event.dart';
import '../../paths.dart';
import '../../testing.dart';
import '../../watch_event.dart';
import 'native_watch.dart';

/// Linux directory watcher.
///
/// Watches a single directory and maintains child [WatchTree] instances for
/// subdirectories.
class WatchTree {
  final AbsolutePath watchedDirectory;

  /// Native watch on [watchedDirectory].
  NativeWatch? _nativeWatch;

  /// Known subdirectories and their watch trees.
  final Map<RelativePath, WatchTree> _directories = {};

  /// Known files.
  final Set<RelativePath> _files = {};

  /// Called to emit a user-visible watch event.
  final void Function(WatchEvent) _emitEvent;

  /// Called to emit a user-visible error.
  final void Function(Object, StackTrace) _onError;

  /// Called when [watchedDirectory] is deleted.
  final void Function() _watchedDirectoryWasDeleted;

  /// Watches [watchedDirectory] and its subdirectories.
  ///
  /// If [starting] then initial setup is in progress and "add" events are not
  /// emitted for files already in the directory.
  ///
  /// Pass handlers [emitEvent], [onError] and [watchedDirectoryWasDeleted].
  WatchTree({
    required this.watchedDirectory,
    required bool starting,
    required void Function(WatchEvent) emitEvent,
    required void Function(Object, StackTrace) onError,
    required void Function() watchedDirectoryWasDeleted,
  })  : _emitEvent = emitEvent,
        _onError = onError,
        _watchedDirectoryWasDeleted = watchedDirectoryWasDeleted {
    logForTesting?.call('WatchTree(),$watchedDirectory');
    _watch(starting: starting, recovering: false);
  }

  /// Stops watching.
  void stopWatching() {
    logForTesting?.call('WatchTree,$watchedDirectory,stopWatching');
    _nativeWatch?.close();
    for (final directory in _directories.values) {
      directory.stopWatching();
    }
  }

  /// Watches [watchedDirectory].
  ///
  /// Creates a [NativeWatch] then lists the directory to get the current state.
  ///
  /// If [starting], don't emit "add" events for files that are discovered.
  ///
  /// Set [recovering] to recover from a failure of the native watcher. This
  /// compares to the last known state, and emits "add" for new files, "remove"
  /// for files that have disappeared, and "modify" for files that are still
  /// present.
  void _watch({required bool starting, required bool recovering}) {
    logForTesting
        ?.call('WatchTree,$watchedDirectory,_watch,$starting,$recovering');
    _nativeWatch?.close();
    _nativeWatch = NativeWatch(
        watchedDirectory: watchedDirectory,
        restartWatching: () {
          _watch(starting: false, recovering: true);
        },
        watchedDirectoryWasDeleted: () {
          _emitDeleteTree();
          _watchedDirectoryWasDeleted();
        },
        onError: _onError,
        onEvents: _onEvents);

    final listedFiles = <RelativePath>{};
    final listedDirectories = <RelativePath>{};
    try {
      for (final entity in watchedDirectory.listSync()) {
        if (entity is File || entity is Link) {
          listedFiles.add(entity.pathRelativeTo(watchedDirectory));
        } else if (entity is Directory) {
          listedDirectories.add(entity.pathRelativeTo(watchedDirectory));
        }
      }
    } catch (_) {
      // Nothing found, use empty sets so everything is handled as deleted.
    }

    logForTesting?.call('Watch,$watchedDirectory,list,'
        'files=$listedFiles,directories=$listedDirectories');
    if (recovering) {
      // Emit deletes for missing files.
      for (final file in _files.toList()) {
        if (!listedFiles.contains(file)) {
          _emitDeleteFile(file);
        }
      }
    }
    // Emit for present files. If `recovering`, emit modify events for files
    // that are still present. If not `starting`, emit add events for new files.
    for (final file in listedFiles) {
      if (_files.contains(file)) {
        if (recovering) {
          _emitModifyFile(file);
        }
      } else {
        _addFile(file, emit: !starting);
      }
    }
    if (recovering) {
      // Emit deletes for missing directories.
      for (final directory in _directories.keys.toList()) {
        if (!listedDirectories.contains(directory)) {
          _emitDeleteDirectory(directory);
        }
      }
    }
    // Handle present directories. Start watching if not already watched.
    for (final directory in listedDirectories) {
      if (!_directories.containsKey(directory)) {
        _watchDirectory(directory, starting: starting);
      }
    }
  }

  /// Polls [path] then updates state and emits events as needed.
  ///
  /// Polling is triggered when a sequence of delete and create events arrives
  /// for the path but the order can't be determined. So if a file existed and
  /// still exists then it's a "modify", not a no-op; and for a directory, it
  /// needs to be watched again.
  void _poll(RelativePath path) {
    logForTesting?.call('Watch,$watchedDirectory,_poll,$path');
    final type = watchedDirectory.append(path).typeSync();
    if (type == FileSystemEntityType.file ||
        type == FileSystemEntityType.link) {
      logForTesting?.call('Watch,$watchedDirectory,poll,$path,file');
      if (_directories.containsKey(path)) {
        _emitDeleteDirectory(path);
      }
      if (_files.contains(path)) {
        _emitModifyFile(path);
      } else {
        _addFile(path);
      }
    } else if (type == FileSystemEntityType.directory) {
      logForTesting?.call('Watch,$watchedDirectory,poll,$path,directory');
      if (_files.contains(path)) {
        _emitDeleteFile(path);
      }
      if (_directories.containsKey(path)) {
        _emitDeleteDirectory(path);
        _watchDirectory(path);
      } else {
        _watchDirectory(path);
      }
    } else {
      logForTesting?.call('Watch,$watchedDirectory,poll,$path,missing');
      if (_files.contains(path)) {
        _emitDeleteFile(path);
      }
      if (_directories.keys.contains(path)) {
        _emitDeleteDirectory(path);
      }
    }
  }

  /// Handles events from [_nativeWatch].
  void _onEvents(List<Event> events) {
    logForTesting?.call('Watch,$watchedDirectory,_onEvents,$events');
    // Handle by path. Move events are already split into separate create and
    // delete events by `NativeWatch`.
    final eventsByPath = <RelativePath, List<EventType>>{};
    for (final event in events) {
      final eventPath = event.pathRelativeTo(watchedDirectory);
      eventsByPath.putIfAbsent(eventPath, () => []).add(event.type);
    }

    // As described in https://github.com/dart-lang/sdk/issues/62014 the VM
    // tries to combine the "create" and "delete" parts of a move operation into
    // one "move" event. But, if the move is to a different directory then only
    // half of the move is received. Currently this event is moved to the end of
    // the batch. This means that the ordering of create and delete events for
    // one path within the batch can't be relied on to decide whether the file
    // now exists or has been deleted.
    //
    // So, separate out ambigious paths as [pathsToPoll], and check the
    // filesystem state for them instead of reporting based on events received.
    final pathsToPoll = <RelativePath>{};
    for (final entry in eventsByPath.entries) {
      final eventPath = entry.key;
      final eventTypes = entry.value;
      final eventTypesSet = eventTypes.toSet();

      // Path needs polling if it had both a delete and a create.
      final needsPolling = eventTypesSet.contains(EventType.delete) &&
          (eventTypesSet.contains(EventType.createFile) ||
              eventTypesSet.contains(EventType.createDirectory));
      if (needsPolling) {
        logForTesting
            ?.call('Watch,$watchedDirectory,onEvents,$eventPath,ambiguous');
        pathsToPoll.add(eventPath);
        continue;
      }

      // Not ambiguous, so the events can be applied in order. Work out the
      // final state.
      var isCreatedDirectory = false;
      var isCreatedFile = false;
      var isModified = false;
      var isDeleted = false;

      for (final eventType in eventTypes) {
        switch (eventType) {
          case EventType.createDirectory:
            isCreatedDirectory = true;
            isCreatedFile = false;

          case EventType.createFile:
            isCreatedFile = true;
            isCreatedDirectory = false;

          case EventType.modifyFile:
            isModified = true;

          case EventType.delete:
            isCreatedFile = false;
            isCreatedDirectory = false;
            isModified = false;
            isDeleted = true;

          default:
            throw StateError(eventType.name);
        }
      }

      // Emit events based on the computed state.
      if (isCreatedDirectory) {
        _watchDirectory(eventPath);
      } else if (isModified || isCreatedFile) {
        if (_files.contains(eventPath)) {
          _emitModifyFile(eventPath);
        } else {
          _addFile(eventPath);
        }
      } else if (isDeleted) {
        _delete(eventPath);
      }
    }

    // Poll the paths that were ambiguous.
    for (final path in pathsToPoll) {
      _poll(path);
    }
  }

  /// Emits events for deleting the entire tree.
  void _emitDeleteTree() {
    logForTesting?.call('WatchTree,$watchedDirectory,_emitDeleteTree');
    _nativeWatch?.close();
    for (final file in _files.toList()) {
      _emitDeleteFile(file);
    }
    for (final directory in _directories.keys.toList()) {
      _emitDeleteDirectory(directory);
    }
  }

  /// Watches [directory].
  ///
  /// If [starting], "add" events are not emitted for files already in the
  /// directory.
  void _watchDirectory(RelativePath directory, {bool starting = false}) {
    logForTesting?.call('Watch,$watchedDirectory,createDirectory,$directory');
    _directories.remove(directory)?._emitDeleteTree();
    _directories[directory] = WatchTree(
        emitEvent: _emitEvent,
        onError: (Object e, StackTrace s) {
          // Ignore exceptions from subdirectories except "out of watchers"
          // which is an unrecoverable error.
          if (e is FileSystemException &&
              e.message.contains('Failed to watch path') &&
              e.osError?.errorCode == 28) {
            _onError(e, s);
          }
        },
        watchedDirectoryWasDeleted: () {
          _emitDeleteDirectory(directory);
        },
        watchedDirectory: watchedDirectory.append(directory),
        starting: starting);
  }

  /// Adds [file] to known [_files].
  ///
  /// If [emit], emits an "add" event.
  void _addFile(RelativePath file, {bool emit = true}) {
    logForTesting?.call('Watch,$watchedDirectory,_addFile,$emit,$file');
    _files.add(file);
    if (emit) {
      _emitEvent(watchedDirectory.append(file).addEvent);
    }
  }

  /// Emits a "modify" event for [file].
  void _emitModifyFile(RelativePath file) {
    logForTesting?.call('Watch,$watchedDirectory,_emitModifyFile,$file');
    _emitEvent(watchedDirectory.append(file).modifyEvent);
  }

  /// Removes [path] from [_files] or [_directories].
  ///
  /// If it was a known file, emits the "delete" event.
  ///
  /// If it was a known directory, emits "delete" events and stops watching it.
  ///
  /// If it was not known, just logs an "unmatched delete".
  void _delete(RelativePath path) {
    logForTesting?.call('Watch,$watchedDirectory,_delete,$path');
    if (_files.contains(path)) {
      _emitDeleteFile(path);
    } else if (_directories.containsKey(path)) {
      _emitDeleteDirectory(path);
    } else {
      logForTesting?.call('Watch,$watchedDirectory,delete,unmatched delete');
    }
  }

  /// Emits a "delete" event for [file] and removes it from [_files].
  void _emitDeleteFile(RelativePath file) {
    logForTesting?.call('Watch,$watchedDirectory,deleteFile,$file');
    _files.remove(file);
    _emitEvent(watchedDirectory.append(file).removeEvent);
  }

  /// Stops watching [directory] and removes it from [_directories].
  void _emitDeleteDirectory(RelativePath directory) {
    logForTesting?.call('Watch,$watchedDirectory,deleteDirectory,$directory');
    _directories.remove(directory)!._emitDeleteTree();
  }
}
