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

library source.caching_pub_package_map_provider;

import 'dart:convert';
import 'dart:core';
import 'dart:io' as io;

import 'package:analyzer/file_system/file_system.dart';
import 'package:analyzer/source/package_map_provider.dart';
import 'package:analyzer/source/pub_package_map_provider.dart';
import 'package:analyzer/src/dart/sdk/sdk.dart';
import 'package:analyzer/src/generated/engine.dart';
import 'package:analyzer/src/generated/source.dart';

/**
 * The function used to write the cache file.
 * Returns the modification stamp for the newly written file.
 */
typedef int WriteFile(File file, String content);

/**
 * [PubPackageMapProvider] extension which caches pub list results.
 * These results are cached in memory and in a single place on disk that is
 * shared cross session and between different simultaneous sessions.
 *
 * TODO(paulberry): before this class is used again, it should be ported over
 * to extend OptimizingPubPackageMapProvider instead of PubPackageMapProvider.
 */
class CachingPubPackageMapProvider extends PubPackageMapProvider {
  static const cacheKey = 'pub_list_cache';
  static const cacheVersion = 1;
  static const cacheVersionKey = 'pub_list_cache_version';
  static const pubListResultKey = 'pub_list_result';
  static const modificationStampsKey = 'modification_stamps';

  /**
   * A cache of folder path to pub list information as shown below
   * or `null` if the cache has not yet been initialized.
   *
   *     {
   *       "path/to/folder": {
   *         "pub_list_result": {
   *           "packages": {
   *             "foo": "path/to/foo",
   *             "bar": ["path/to/bar1", "path/to/bar2"],
   *             "myapp": "path/to/myapp",  // self link is included
   *           },
   *           "input_files": [
   *             "path/to/myapp/pubspec.lock"
   *           ]
   *         },
   *         "modification_stamps": {
   *           "path/to/myapp/pubspec.lock": 1424305309
   *         }
   *       }
   *       "path/to/another/folder": {
   *         ...
   *       }
   *       ...
   *     }
   */
  Map<String, Map> _cache;

  /**
   * The modification time of the cache file
   * or `null` if it has not yet been read.
   */
  int _cacheModificationTime;

  /**
   * The function used to write the cache file.
   */
  WriteFile _writeFile;

  /**
   * Construct a new instance.
   * [RunPubList] and [WriteFile] implementations may be injected for testing
   */
  CachingPubPackageMapProvider(
      ResourceProvider resourceProvider, FolderBasedDartSdk sdk,
      [RunPubList runPubList, this._writeFile])
      : super(resourceProvider, sdk, runPubList) {
    if (_writeFile == null) {
      _writeFile = _writeFileDefault;
    }
  }

  File get cacheFile => _cacheDir.getChild('cache');
  Folder get _cacheDir => resourceProvider.getStateLocation('.pub-list');
  File get _touchFile => _cacheDir.getChild('touch');

  @override
  PackageMapInfo computePackageMap(Folder folder) {
    //
    // Return error if folder does not exist, but don't remove previously
    // cached result because folder may be only temporarily inaccessible
    //
    if (!folder.exists) {
      return computePackageMapError(folder);
    }
    // Ensure cache is up to date
    _readCache();
    // Check for cached entry
    Map entry = _cache[folder.path];
    if (entry != null) {
      Map<String, int> modificationStamps =
          entry[modificationStampsKey] as Map<String, int>;
      if (modificationStamps != null) {
        //
        // Check to see if any dependencies have changed
        // before returning cached result
        //
        if (!_haveDependenciesChanged(modificationStamps)) {
          return parsePackageMap(entry[pubListResultKey], folder);
        }
      }
    }
    int runCount = 0;
    PackageMapInfo info;
    while (true) {
      // Capture the current time so that we can tell if an input file
      // has changed while running pub list. This is done
      // by writing to a file rather than getting millisecondsSinceEpoch
      // because file modification time has different granularity
      // on diferent systems.
      int startStamp;
      try {
        startStamp = _writeFile(_touchFile, 'touch');
      } catch (exception, stackTrace) {
        AnalysisEngine.instance.logger.logInformation(
            'Exception writing $_touchFile\n$exception\n$stackTrace');
        startStamp = new DateTime.now().millisecondsSinceEpoch;
      }
      // computePackageMap calls parsePackageMap which caches the result
      info = super.computePackageMap(folder);
      ++runCount;
      if (!_haveDependenciesChangedSince(info, startStamp)) {
        // If no dependencies have changed while running pub then finished
        break;
      }
      if (runCount == 4) {
        // Don't run forever
        AnalysisEngine.instance.logger
            .logInformation('pub list called $runCount times: $folder');
        break;
      }
    }
    _writeCache();
    return info;
  }

  @override
  PackageMapInfo parsePackageMap(Map obj, Folder folder) {
    PackageMapInfo info = super.parsePackageMap(obj, folder);
    Map<String, int> modificationStamps = new Map<String, int>();
    for (String path in info.dependencies) {
      Resource res = resourceProvider.getResource(path);
      if (res is File && res.exists) {
        modificationStamps[path] = res.createSource().modificationStamp;
      }
    }
    // Assumes entry has been initialized by computePackageMap
    _cache[folder.path] = <String, Map>{
      pubListResultKey: obj,
      modificationStampsKey: modificationStamps
    };
    return info;
  }

  /**
   * Determine if any of the dependencies have changed.
   */
  bool _haveDependenciesChanged(Map<String, int> modificationStamps) {
    for (String path in modificationStamps.keys) {
      Resource res = resourceProvider.getResource(path);
      if (res is File) {
        if (!res.exists ||
            res.createSource().modificationStamp != modificationStamps[path]) {
          return true;
        }
      } else {
        return true;
      }
    }
    return false;
  }

  /**
   * Determine if any of the dependencies have changed since the given time.
   */
  bool _haveDependenciesChangedSince(PackageMapInfo info, int startStamp) {
    for (String path in info.dependencies) {
      Resource res = resourceProvider.getResource(path);
      if (res is File) {
        int modStamp = res.createSource().modificationStamp;
        if (modStamp != null && modStamp >= startStamp) {
          return true;
        }
      }
    }
    return false;
  }

  /**
   * Read the cache from disk if it has not been read before.
   */
  void _readCache() {
    // TODO(danrubel) This implementation assumes that
    // two separate processes are not accessing the cache file at the same time
    Source source = cacheFile.createSource();
    if (source.exists() &&
        (_cache == null ||
            _cacheModificationTime != source.modificationStamp)) {
      try {
        TimestampedData<String> data = source.contents;
        Map map = JSON.decode(data.data);
        if (map[cacheVersionKey] == cacheVersion) {
          _cache = map[cacheKey] as Map<String, Map>;
          _cacheModificationTime = data.modificationTime;
        }
      } catch (exception, stackTrace) {
        AnalysisEngine.instance.logger.logInformation(
            'Exception reading $cacheFile\n$exception\n$stackTrace');
      }
    }
    if (_cache == null) {
      _cache = new Map<String, Map>();
    }
  }

  /**
   * Write the cache to disk.
   */
  void _writeCache() {
    try {
      _cacheModificationTime = _writeFile(cacheFile,
          JSON.encode({cacheVersionKey: cacheVersion, cacheKey: _cache}));
    } catch (exception, stackTrace) {
      AnalysisEngine.instance.logger.logInformation(
          'Exception writing $cacheFile\n$exception\n$stackTrace');
    }
  }

  /**
   * Update the given file with the specified content.
   */
  int _writeFileDefault(File cacheFile, String content) {
    // TODO(danrubel) This implementation assumes that
    // two separate processes are not accessing the cache file at the same time
    io.File file = new io.File(cacheFile.path);
    if (!file.parent.existsSync()) {
      file.parent.createSync(recursive: true);
    }
    file.writeAsStringSync(content, flush: true);
    return file.lastModifiedSync().millisecondsSinceEpoch;
  }
}
