// Copyright (c) 2021, 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.

/// @docImport 'package:analyzer/file_system/physical_file_system.dart';
library;

import 'dart:async';
import 'dart:convert';

import 'package:analysis_server/src/services/pub/pub_api.dart';
import 'package:analysis_server/src/services/pub/pub_command.dart';
import 'package:analyzer/file_system/file_system.dart';
import 'package:analyzer/instrumentation/service.dart';
import 'package:meta/meta.dart';

/// Information about Pub packages that can be converted to/from JSON and
/// cached to disk.
class PackageDetailsCache {
  static const cacheVersion = 3;
  static const maxCacheAge = Duration(hours: 18);
  static const maxPackageDetailsRequestsInFlight = 5;

  /// Requests to write the cache from fetching package details will be
  /// debounced by this duration to prevent many writes while the user may be
  /// cursoring though completion requests that will trigger fetching
  /// descriptions/versions.
  static const _writeCacheDebounceDuration = Duration(seconds: 3);

  final Map<String, PubPackage> packages;
  DateTime lastUpdatedUtc;

  new _(this.packages, DateTime lastUpdated)
    : lastUpdatedUtc = lastUpdated.toUtc();

  Duration get cacheTimeRemaining {
    var cacheAge = DateTime.now().toUtc().difference(lastUpdatedUtc);
    var cacheTimeRemaining = maxCacheAge - cacheAge;
    return cacheTimeRemaining < Duration.zero
        ? Duration.zero
        : cacheTimeRemaining;
  }

  Map<String, Object> toJson() {
    return {
      'version': cacheVersion,
      'lastUpdated': lastUpdatedUtc.toIso8601String(),
      'packages': packages.values.toList(),
    };
  }

  static PackageDetailsCache empty() {
    return PackageDetailsCache._({}, DateTime.utc(2000));
  }

  static PackageDetailsCache fromApiResults(List<PubApiPackage> apiPackages) {
    var packages = Map.fromEntries(
      apiPackages.map(
        (package) =>
            MapEntry(package.packageName, PubPackage.fromName(package)),
      ),
    );

    return PackageDetailsCache._(packages, DateTime.now().toUtc());
  }

  /// Deserializes cached package data from JSON.
  ///
  /// If the JSON version does not match the current version, will return null.
  static PackageDetailsCache? fromJson(Map<String, Object?> json) {
    if (json['version'] != cacheVersion) {
      return null;
    }

    var packagesJson = json['packages'];
    if (packagesJson is! List<Object?>) {
      return null;
    }

    var packages = <PubPackage>[];
    for (var packageJson in packagesJson) {
      if (packageJson is! Map<String, Object?>) {
        return null;
      }
      var nameJson = packageJson['packageName'];
      if (nameJson is! String) {
        return null;
      }
      packages.add(PubPackage.fromJson(packageJson));
    }

    var packageMap = Map.fromEntries(
      packages.map((package) => MapEntry(package.packageName, package)),
    );

    var lastUpdatedJson = json['lastUpdated'];
    if (lastUpdatedJson is! String) {
      return null;
    }
    var lastUpdated = DateTime.tryParse(lastUpdatedJson);
    if (lastUpdated == null) {
      return null;
    }

    return PackageDetailsCache._(packageMap, lastUpdated);
  }
}

/// Information about a single Pub package.
class PubPackage {
  String packageName;
  String? description;
  String? latestVersion;

  new fromDetails(PubApiPackageDetails package)
    : packageName = package.packageName,
      description = package.description,
      latestVersion = package.latestVersion;

  new fromJson(Map<String, Object?> json)
    : packageName = json['packageName'] as String,
      description = json['description'] as String?,
      latestVersion = json['latestVersion'] as String?;

  new fromName(PubApiPackage package) : packageName = package.packageName;

  Map<String, Object> toJson() {
    return {
      'packageName': packageName,
      'description': ?description,
      'latestVersion': ?latestVersion,
    };
  }
}

/// A service for providing Pub package information.
///
/// Uses a [PubApi] to communicate with the Pub API and a [PubCommand] to
/// interact with the local `pub` command.
///
/// Expensive results are cached to disk using [resourceProvider].
class PubPackageService {
  final InstrumentationService _instrumentationService;
  final PubApi _api;

  /// A wrapper over the "pub" command line too.
  ///
  /// This can be null when not running on a real file system because it may
  /// try to interact with folders that don't really exist.
  final PubCommand? _command;

  Timer? _nextPackageNameListRequestTimer;
  Timer? _nextWriteDiskCacheTimer;

  /// [ResourceProvider] used for accessing the disk for caches and checking
  /// project types. This will be a [PhysicalResourceProvider] outside of tests.
  final ResourceProvider resourceProvider;

  /// The current cache of package information. Initially `null`, but
  /// overwritten after first read of cache from disk or fetch from the API.
  @visibleForTesting
  PackageDetailsCache? packageCache;

  int _packageDetailsRequestsInFlight = 0;

  /// A cache of version numbers from running the "pub outdated" command used
  /// for completion in pubspec.yaml.
  final _pubspecPackageVersions =
      <String, Map<String, PubOutdatedPackageDetails>>{};

  new(
    this._instrumentationService,
    this.resourceProvider,
    this._api,
    this._command,
  );

  /// Gets the last set of package results from the Pub API or an empty List if
  /// no results.
  ///
  /// This data is used for completion of package names in pubspec.yaml
  /// and for clients that support lazy resolution of completion items may also
  /// include their descriptions and/or version numbers.
  List<PubPackage> get cachedPackages =>
      packageCache?.packages.values.toList() ?? [];

  @visibleForTesting
  bool get isPackageNamesTimerRunning =>
      _nextPackageNameListRequestTimer != null;

  @visibleForTesting
  File get packageCacheFile {
    var cacheFolder = resourceProvider.getStateLocation(
      '.pub-package-details-cache',
    )!..create();
    return cacheFolder.getFile('packages.json');
  }

  /// Begins preloading caches for package names and pub versions.
  void beginCachePreloads(List<String> pubspecs) {
    beginPackageNamePreload();
    for (var pubspec in pubspecs) {
      fetchPackageVersionsViaPubOutdated(pubspec, pubspecWasModified: false);
    }
  }

  /// Begin a timer to pre-load and update the package name list if one has not
  /// already been started.
  void beginPackageNamePreload() {
    if (isPackageNamesTimerRunning) {
      return;
    }

    // If first time, try to read from disk.
    var cache = packageCache;
    if (cache == null) {
      cache ??= readDiskCache() ?? PackageDetailsCache.empty();
      packageCache = cache;
    }

    // If there is no queued request, initialize one when the current cache expires.
    _nextPackageNameListRequestTimer ??= Timer(
      cache.cacheTimeRemaining,
      _fetchFromServer,
    );
  }

  /// Gets the latest cached package version fetched from the Pub API for the
  /// package [packageName].
  String? cachedPubApiLatestVersion(String packageName) =>
      packageCache?.packages[packageName]?.latestVersion;

  /// Gets the package versions cached using "pub outdated" for the package
  /// [packageName] for the project using [pubspecPath].
  ///
  /// Versions in here might only be available for packages that are in the
  /// pubspec on disk. Newly-added packages in the overlay might not be
  /// available.
  PubOutdatedPackageDetails? cachedPubOutdatedVersions(
    String pubspecPath,
    String packageName,
  ) {
    var pubspecCache = _pubspecPackageVersions[pubspecPath];
    return pubspecCache != null ? pubspecCache[packageName] : null;
  }

  /// Begin a request to pre-load package versions using the "pub outdated"
  /// command.
  ///
  /// If [pubspecWasModified] is true, the command will always be run. Otherwise it
  /// will only be run if data is not already cached.
  Future<void> fetchPackageVersionsViaPubOutdated(
    String pubspecPath, {
    required bool pubspecWasModified,
  }) async {
    var pubCommand = _command;
    if (pubCommand == null) {
      return;
    }

    // If we already have a cache for the file and it was not modified (only
    // opened) we do not need to re-run the command.
    if (!pubspecWasModified &&
        _pubspecPackageVersions.containsKey(pubspecPath)) {
      return;
    }

    // Check if this pubspec is inside a DEPS-managed folder, and if so
    // just cache an empty set of results since Pub is not managing
    // dependencies.
    if (_hasAncestorDEPSFile(pubspecPath)) {
      _pubspecPackageVersions.putIfAbsent(pubspecPath, () => {});
      return;
    }

    var results = await pubCommand.outdatedVersions(pubspecPath);
    var cache = _pubspecPackageVersions.putIfAbsent(pubspecPath, () => {});
    for (var package in results) {
      // We use the versions from the "pub outdated" results but only cache them
      // in-memory for this specific pubspec, as the resolved version may be
      // restricted by constraints/dependencies in the pubspec. The "pub"
      // command does caching of the JSON versions to make "pub outdated" fast.
      cache[package.packageName] = package;
    }
  }

  /// Clears package caches for [pubspecPath].
  ///
  /// Does not remove other caches that are not pubspec-specific (for example
  /// the latest version pulled directly from the Pub API independent of
  /// pubspec).
  Future<void> flushPackageCaches(String pubspecPath) async {
    _pubspecPackageVersions.remove(pubspecPath);
  }

  /// Gets package details for package [packageName].
  ///
  /// If the package details are not cached, will call the Pub API and cache
  /// the result. Results are cached for the same period as the main package
  /// list cache - that is, when the package list cache expires, all cached
  /// package details will go with it.
  Future<PubPackage?> packageDetails(String packageName) async {
    var packageData = packageCache?.packages[packageName];
    // If we don't have the version for this package, we don't have its full details.
    if (packageData?.latestVersion == null &&
        // Limit the number of package details requests that can be in-flight at
        // once since an editor may send many of these requests as the user
        // cursors through the results (a good editor will cancel the resolve
        // requests, but we may have already started the requests synchronously
        // before handling a cancellation).
        _packageDetailsRequestsInFlight <=
            PackageDetailsCache.maxPackageDetailsRequestsInFlight) {
      _packageDetailsRequestsInFlight++;
      try {
        var details = await _api.packageInfo(packageName);
        if (details != null) {
          packageData = PubPackage.fromDetails(details);
          packageCache?.packages[packageName] = packageData;
          _writeDiskCacheDebounced();
        }
      } finally {
        _packageDetailsRequestsInFlight--;
      }
    }
    return packageData;
  }

  @visibleForTesting
  PackageDetailsCache? readDiskCache() {
    var file = packageCacheFile;
    if (!file.exists) {
      return null;
    }
    try {
      var contents = file.readAsStringSync();
      var json = jsonDecode(contents);
      if (json is Map<String, Object?>) {
        return PackageDetailsCache.fromJson(json);
      } else {
        return null;
      }
    } catch (e) {
      _instrumentationService.logError('Error reading pub cache file: $e');
      return null;
    }
  }

  void shutdown() {
    _nextPackageNameListRequestTimer?.cancel();
    _command?.shutdown();
  }

  @visibleForTesting
  void writeDiskCache([PackageDetailsCache? cache]) {
    cache ??= packageCache;
    if (cache == null) {
      return;
    }
    var file = packageCacheFile;
    file.writeAsStringSync(jsonEncode(cache.toJson()));
  }

  Future<void> _fetchFromServer() async {
    try {
      var packages = await _api.allPackages();

      // If we never got a valid response, just skip until the next refresh.
      if (packages == null) {
        return;
      }

      var packageCache = PackageDetailsCache.fromApiResults(packages);
      this.packageCache = packageCache;
      writeDiskCache();
    } catch (e) {
      _instrumentationService.logError('Failed to fetch packages from Pub: $e');
    } finally {
      _nextPackageNameListRequestTimer = Timer(
        PackageDetailsCache.maxCacheAge,
        _fetchFromServer,
      );
    }
  }

  /// Checks whether there is a DEPS file in any folder walking up from the
  /// pubspec at [pubspecPath].
  bool _hasAncestorDEPSFile(String pubspecPath) {
    var pathContext = resourceProvider.pathContext;
    var folder = pathContext.dirname(pubspecPath);
    do {
      if (resourceProvider.getFile(pathContext.join(folder, 'DEPS')).exists) {
        return true;
      }
      folder = pathContext.dirname(folder);
    } while (folder != pathContext.dirname(folder));
    return false;
  }

  /// Writes the package cache to disk after
  /// [PackageDetailsCache._writeCacheDebounceDuration] has elapsed, restarting
  /// the timer each time this method is called.
  void _writeDiskCacheDebounced() {
    _nextWriteDiskCacheTimer?.cancel();
    _nextWriteDiskCacheTimer = Timer(
      PackageDetailsCache._writeCacheDebounceDuration,
      writeDiskCache,
    );
  }
}
