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

import 'package:analyzer/file_system/file_system.dart';
import 'package:analyzer/source/file_source.dart';
import 'package:analyzer/source/source.dart';
import 'package:analyzer/src/context/packages.dart';
import 'package:analyzer/src/file_system/file_system.dart';
import 'package:analyzer/src/generated/sdk.dart';
import 'package:analyzer/src/generated/source.dart';
import 'package:analyzer/src/summary/package_bundle_reader.dart';
import 'package:analyzer/src/util/file_paths.dart' as file_paths;
import 'package:analyzer/src/util/uri.dart';
import 'package:analyzer/src/utilities/uri_cache.dart';
import 'package:analyzer/src/workspace/blaze_watcher.dart';
import 'package:analyzer/src/workspace/workspace.dart';
import 'package:path/path.dart' as path;
import 'package:pub_semver/pub_semver.dart';

/// Instances of the class `BlazeFileUriResolver` resolve `file` URI's by first
/// resolving file uri's in the expected way, and then by looking in the
/// corresponding generated directories.
class BlazeFileUriResolver extends ResourceUriResolver {
  final BlazeWorkspace workspace;

  BlazeFileUriResolver(this.workspace) : super(workspace.provider);

  @override
  Uri pathToUri(String path) {
    var pathContext = workspace.provider.pathContext;
    for (var genRoot in [...workspace.binPaths, workspace.genfiles]) {
      if (pathContext.isWithin(genRoot, path)) {
        String relative = pathContext.relative(path, from: genRoot);
        var writablePath = pathContext.join(workspace.root, relative);
        return pathContext.toUri(writablePath);
      }
    }
    return workspace.provider.pathContext.toUri(path);
  }

  @override
  Source? resolveAbsolute(Uri uri) {
    if (!ResourceUriResolver.isFileUri(uri)) {
      return null;
    }
    String filePath = fileUriToNormalizedPath(provider.pathContext, uri);
    var file = workspace.findFile(filePath);
    if (file != null) {
      return FileSource(file, uri);
    }
    return null;
  }
}

/// The [UriResolver] that can resolve `package` URIs in [BlazeWorkspace].
class BlazePackageUriResolver extends UriResolver {
  final BlazeWorkspace _workspace;
  final path.Context _context;

  /// The cache of absolute [Uri]s to [Source]s mappings.
  final Map<Uri, Source?> _sourceCache = HashMap();

  BlazePackageUriResolver(BlazeWorkspace workspace)
    : _workspace = workspace,
      _context = workspace.provider.pathContext;

  @override
  Uri? pathToUri(String path) {
    // Search in each root.
    for (var root in [
      ..._workspace.binPaths,
      _workspace.genfiles,
      _workspace.root,
    ]) {
      var uriParts = _restoreUriParts(root, path);
      if (uriParts != null) {
        return uriCache.parse('package:${uriParts[0]}/${uriParts[1]}');
      }
    }

    return null;
  }

  @override
  Source? resolveAbsolute(Uri uri) {
    if (_sourceCache.containsKey(uri)) {
      return _sourceCache[uri];
    }
    Source? source;
    var fullFilePath = resolveAbsolutePath(uri);
    if (fullFilePath != null) {
      var file = _workspace.findFile(fullFilePath);
      if (file != null) {
        return FileSource(file, uri);
      }
    }
    _sourceCache[uri] = source;
    return source;
  }

  String? resolveAbsolutePath(Uri uri) {
    if (uri.isScheme('file')) {
      var path = fileUriToNormalizedPath(_context, uri);
      var pathRelativeToRoot = _workspace._relativeToRoot(path);
      if (pathRelativeToRoot == null) return null;
      return _context.join(_workspace.root, pathRelativeToRoot);
    }
    if (!uri.isScheme('package')) {
      return null;
    }
    String uriPath = Uri.decodeComponent(uri.path);
    int slash = uriPath.indexOf('/');

    // If the path either starts with a slash or has no slash, it is invalid.
    if (slash < 1) {
      return null;
    }

    if (uriPath.contains('//') || uriPath.contains('..')) {
      return null;
    }

    String packageName = uriPath.substring(0, slash);

    String fileUriPart = uriPath.substring(slash + 1);
    if (fileUriPart.isEmpty) {
      return null;
    }

    String filePath = fileUriPart.replaceAll('/', _context.separator);

    if (!packageName.contains('.')) {
      return _context.join(
        _workspace.root,
        'third_party',
        'dart',
        packageName,
        'lib',
        filePath,
      );
    } else {
      String packagePath = packageName.replaceAll('.', _context.separator);
      return _context.join(_workspace.root, packagePath, 'lib', filePath);
    }
  }

  /// Restore [filePath] to its 'package:' URI parts.
  ///
  /// Returns `null` if [root] is null or if [filePath] is not within [root].
  List<String>? _restoreUriParts(String? root, String filePath) {
    path.Context context = _workspace.provider.pathContext;
    if (root != null && context.isWithin(root, filePath)) {
      String relative = context.relative(filePath, from: root);
      List<String> components = context.split(relative);
      if (components.length > 4 &&
          components[0] == 'third_party' &&
          components[1] == 'dart' &&
          components[3] == 'lib') {
        String packageName = components[2];
        String pathInLib = components.skip(4).join('/');
        return [packageName, pathInLib];
      } else {
        for (int i = components.length - 2; i >= 2; i--) {
          String component = components[i];
          if (component == 'lib') {
            String packageName = components.getRange(0, i).join('.');
            String pathInLib = components.skip(i + 1).join('/');
            return [packageName, pathInLib];
          }
        }
      }
    }
    return null;
  }
}

/// Information about a Blaze workspace.
class BlazeWorkspace extends Workspace
    implements WorkspaceWithDefaultAnalysisOptions {
  /// The name of the file that identifies a set of Blaze Targets.
  ///
  /// For Dart package purposes, a BUILD file identifies a package.
  static const String _buildFileName = 'BUILD';

  final ResourceProvider provider;

  /// The absolute workspace root path.
  ///
  /// It contains the `WORKSPACE` file or its parent contains the `READONLY`
  /// folder.
  @override
  final String root;

  /// The absolute paths to all `blaze-bin` folders.
  ///
  /// In practice, there is usually one "bin" path, and sometimes there are two,
  /// on distributed build systems. It is very rare to have more than two.
  final List<String> binPaths;

  /// The absolute path to the `blaze-genfiles` folder.
  final String genfiles;

  /// Sometimes `BUILD` files are not preserved, and `xyz.packages` files
  /// are created instead. But looking for them is expensive, so we want
  /// to avoid this in cases when `BUILD` files are always available.
  final bool _lookForBuildFileSubstitutes;

  /// If `true`, the language version for packages will be provided.
  ///
  /// If `false`, [BlazeWorkspacePackage.languageVersion] will return `null`,
  /// so that [Packages] will be used to get the language versions.
  ///
  /// See https://buganizer.corp.google.com/issues/279909837
  final bool _provideLanguageVersion;

  /// The language version for this workspace, `null` if cannot be read.
  final Version? _languageVersion;

  /// The cache of packages. The key is the directory path, the value is
  /// the corresponding package.
  final Map<String, BlazeWorkspacePackage> _directoryToPackage = {};

  final _blazeCandidateFiles = StreamController<BlazeSearchInfo>.broadcast();

  BlazeWorkspace._(
    this.provider,
    this.root,
    this.binPaths,
    this.genfiles, {
    required bool lookForBuildFileSubstitutes,
    required bool provideLanguageVersion,
  }) : _lookForBuildFileSubstitutes = lookForBuildFileSubstitutes,
       _provideLanguageVersion = provideLanguageVersion,
       _languageVersion = provideLanguageVersion
           ? _readLanguageVersion(provider, root)
           : null;

  /// Stream of files that we tried to find along with their potential or actual
  /// paths.
  Stream<BlazeSearchInfo> get blazeCandidateFiles =>
      _blazeCandidateFiles.stream;

  @override
  bool get isBlaze => true;

  @override
  BlazePackageUriResolver get packageUriResolver =>
      BlazePackageUriResolver(this);

  @override
  SourceFactory createSourceFactory(
    DartSdk? sdk,
    SummaryDataStore? summaryData,
  ) {
    List<UriResolver> resolvers = <UriResolver>[];
    if (sdk != null) {
      resolvers.add(DartUriResolver(sdk));
    }
    resolvers.add(packageUriResolver);
    resolvers.add(BlazeFileUriResolver(this));
    if (summaryData != null) {
      resolvers.add(InSummaryUriResolver(summaryData));
    }
    return SourceFactory(resolvers);
  }

  /// Return the file with the given [absolutePath], looking first into
  /// directories for generated files: `blaze-bin` and `blaze-genfiles`, and
  /// then into the workspace root. The file in the workspace root is returned
  /// even if it does not exist. Return `null` if the given [absolutePath] is
  /// not in the workspace [root].
  File? findFile(String absolutePath) {
    path.Context context = provider.pathContext;
    try {
      String relative = context.relative(absolutePath, from: root);
      if (relative == '.') {
        return null;
      }
      // First check genfiles and bin directories. Note that we always use the
      // symlinks and not the [binPaths] or [genfiles] to make sure we use the
      // files corresponding to the most recent build configuration and get
      // consistent view of all the generated files.
      var generatedCandidates = [
        'blaze-genfiles',
        'blaze-bin',
      ].map((prefix) => context.join(root, context.join(prefix, relative)));
      for (var path in generatedCandidates) {
        File file = provider.getFile(path);
        if (file.exists) {
          _blazeCandidateFiles.add(
            BlazeSearchInfo(relative, generatedCandidates.toList()),
          );
          return file;
        }
      }
      // Writable
      File writableFile = provider.getFile(absolutePath);
      if (writableFile.exists) {
        return writableFile;
      }
      // If we couldn't find the file, assume that it has not yet been
      // generated, so send an event with all the paths that we tried.
      _blazeCandidateFiles.add(
        BlazeSearchInfo(relative, generatedCandidates.toList()),
      );
      // Not generated, return the default one.
      return writableFile;
    } catch (_) {
      return null;
    }
  }

  @override
  BlazeWorkspacePackage? findPackageFor(String filePath) {
    path.Context context = provider.pathContext;
    var directoryPath = context.dirname(filePath);

    var cachedPackage = _directoryToPackage[directoryPath];
    if (cachedPackage != null) {
      return cachedPackage;
    }

    if (!context.isWithin(root, directoryPath)) {
      return null;
    }

    // Handle files which are given with their location in "blaze-bin", etc.
    // This does not typically happen during usual analysis, but it still could,
    // and it can come up in tests.
    for (var binPath in [genfiles, ...binPaths]) {
      if (context.isWithin(binPath, directoryPath)) {
        return findPackageFor(
          context.join(root, context.relative(filePath, from: binPath)),
        );
      }
    }

    /// Return the package rooted at [folder].
    BlazeWorkspacePackage? packageRootedAt(Folder folder) {
      var uriParts = packageUriResolver._restoreUriParts(
        root,
        '${folder.path}/lib/__fake__.dart',
      );
      String? packageName;
      if (uriParts != null && uriParts.isNotEmpty) {
        packageName = uriParts[0];
      }
      // TODO(srawlins): If [packageName] could not be derived from [uriParts],
      //  I imagine this should throw.
      if (packageName == null) {
        return null;
      }
      var package = BlazeWorkspacePackage(packageName, folder, this);
      _directoryToPackage[directoryPath] = package;
      return package;
    }

    var startFolder = provider.getFolder(directoryPath);
    for (var folder in startFolder.withAncestors) {
      if (folder.path.length < root.length) {
        // We've walked up outside of [root], so [path] is definitely not
        // defined in any package in this workspace.
        return null;
      }

      if (folder.getChildAssumingFile(_buildFileName).exists) {
        // Found the BUILD file, denoting a Dart package.
        return packageRootedAt(folder);
      }

      if (_hasBuildFileSubstitute(folder)) {
        return packageRootedAt(folder);
      }
    }

    return null;
  }

  /// In some distributed build environments, BUILD files are not preserved.
  /// We can still look for a ".packages" file in order to determine a
  /// package's root. A ".packages" file found in [folder]'s sister path
  /// under a "bin" path among [binPaths] denotes a Dart package.
  ///
  /// For example, if the [root] of this BlazeWorkspace is
  /// "/build/work/abc123/workspace" with two "bin" folders,
  /// "/build/work/abc123/workspace/blaze-out/host/bin/" and
  /// "/build/work/abc123/workspace/blaze-out/k8-opt/bin/", and [folder]
  /// is at "/build/work/abc123/workspace/foo/bar", then we  must look for a
  /// file ending in ".packages" in the folders
  /// "/build/work/abc123/workspace/blaze-out/host/bin/foo/bar" and
  /// "/build/work/abc123/workspace/blaze-out/k8-opt/bin/foo/bar".
  bool _hasBuildFileSubstitute(Folder folder) {
    if (!_lookForBuildFileSubstitutes) {
      return false;
    }

    path.Context context = provider.pathContext;

    // [folder]'s path, relative to [root]. For example, "foo/bar".
    String relative = context.relative(folder.path, from: root);

    for (String bin in binPaths) {
      Folder binChild = provider.getFolder(
        context.normalize(context.join(bin, relative)),
      );
      if (binChild.exists &&
          binChild.getChildren().any((c) => c.path.endsWith('.packages'))) {
        // [folder]'s sister folder within [bin] contains a ".packages" file.
        return true;
      }
    }

    return false;
  }

  String? _relativeToRoot(String p) {
    path.Context context = provider.pathContext;
    // genfiles
    if (context.isWithin(genfiles, p)) {
      return context.relative(p, from: genfiles);
    }
    // bin
    for (String bin in binPaths) {
      if (context.isWithin(bin, p)) {
        return context.relative(p, from: bin);
      }
    }
    // Not generated
    if (context.isWithin(root, p)) {
      return context.relative(p, from: root);
    }
    // Failed reverse lookup
    return null;
  }

  /// Find the Blaze workspace that contains the given [filePath].
  ///
  /// This method walks up the file system from [filePath], looking for various
  /// "marker" files which indicate a Blaze workspace.
  ///
  /// At each folder _f_ with parent _p_, starting with [filePath]:
  ///
  /// * If _f_ has a sibling folder named "READONLY", and that folder has a
  ///   child folder with the same name as _f_, then a [BlazeWorkspace] rooted at
  ///   _f_ is returned.
  /// * If _f_ has a child folder named "blaze-out", then a [BlazeWorkspace]
  ///   rooted at _f_ is returned.
  /// * If _f_ has a child file named [file_paths.blazeWorkspaceMarker], then
  ///   a [BlazeWorkspace] rooted at _f_ is returned.
  static BlazeWorkspace? find(
    ResourceProvider provider,
    String filePath, {
    bool lookForBuildFileSubstitutes = true,
    bool provideLanguageVersion = true,
  }) {
    var context = provider.pathContext;
    var startFolder = provider.getFolder(filePath);
    for (var folder in startFolder.withAncestors) {
      var parent = folder.parent;

      var blazeOutFolder = parent.getChildAssumingFolder('blaze-out');
      if (blazeOutFolder.exists) {
        // Found the "out" folder; must be a Blaze workspace.
        String root = parent.path;
        var binPaths = _findBinFolderPaths(parent);
        binPaths = binPaths..add(context.join(root, 'blaze-bin'));
        return BlazeWorkspace._(
          provider,
          root,
          binPaths,
          context.join(root, 'blaze-genfiles'),
          lookForBuildFileSubstitutes: lookForBuildFileSubstitutes,
          provideLanguageVersion: provideLanguageVersion,
        );
      }

      // Found the WORKSPACE file, must be a non-git workspace.
      if (folder.getChildAssumingFile(file_paths.blazeWorkspaceMarker).exists) {
        String root = folder.path;
        var binPaths = _findBinFolderPaths(folder);
        binPaths = binPaths..add(context.join(root, 'blaze-bin'));
        return BlazeWorkspace._(
          provider,
          root,
          binPaths,
          context.join(root, 'blaze-genfiles'),
          lookForBuildFileSubstitutes: lookForBuildFileSubstitutes,
          provideLanguageVersion: provideLanguageVersion,
        );
      }
    }

    return null;
  }

  /// Create the Blaze workspace with the given [root].
  ///
  /// This method should be used during build in google3, when we know for
  /// sure the workspace root, but [file_paths.blazeWorkspaceMarker] is not
  /// available.
  static BlazeWorkspace forBuild({required Folder root}) {
    var provider = root.provider;
    var blazeBin = root.getChildAssumingFolder('blaze-bin');
    var blazeGenfiles = root.getChildAssumingFolder('blaze-genfiles');

    var binPaths = _findBinFolderPaths(root);
    binPaths.add(blazeBin.path);

    return BlazeWorkspace._(
      provider,
      root.path,
      binPaths,
      blazeGenfiles.path,
      lookForBuildFileSubstitutes: true,
      provideLanguageVersion: true,
    );
  }

  /// Find the "bin" folder path, by searching for it.
  ///
  /// Depending on the environment we're working in (source code tree, build
  /// environment subtree of sources, local workspace, blaze), the "bin"
  /// folder may be available at a symlink found at `$root/blaze-bin/`. If that
  /// symlink is not available, then we must search the immediate folders found
  /// in `$root/blaze-out/` for folders named "bin".
  ///
  /// If no "bin" folder is found in any of those locations, empty list is
  /// returned.
  static List<String> _findBinFolderPaths(Folder root) {
    var out = root.getChildAssumingFolder('blaze-out');
    if (!out.exists) {
      return [];
    }

    List<String> binPaths = [];
    for (var child in out.getChildren().whereType<Folder>()) {
      // Children are folders denoting architectures and build flags, like
      // 'k8-opt', 'k8-fastbuild', perhaps 'host'.
      Folder possibleBin = child.getChildAssumingFolder('bin');
      if (possibleBin.exists) {
        binPaths.add(possibleBin.path);
      }
    }
    return binPaths;
  }

  /// Return the default language version of the workspace.
  ///
  /// Return `null` if cannot be read, for example because the file does not
  /// exist, or is not available in this build configuration (batch analysis).
  static Version? _readLanguageVersion(
    ResourceProvider resourceProvider,
    String rootPath,
  ) {
    var file = resourceProvider.getFile(
      resourceProvider.pathContext.joinAll([
        rootPath,
        'dart',
        'build_defs',
        'bzl',
        'language.bzl',
      ]),
    );

    String content;
    try {
      content = file.readAsStringSync();
    } on FileSystemException {
      return null;
    }

    var pattern = RegExp(r'(^|\s+)_version\s*=\s*"(\d+\.\d+)"');
    for (var match in pattern.allMatches(content)) {
      return Version.parse('${match.group(2)}.0');
    }

    return null;
  }
}

/// Information about a package defined in a [BlazeWorkspace].
///
/// Separate from [Packages] or package maps, this class is designed to simply
/// understand whether arbitrary file paths represent libraries declared within
/// a given package in a [BlazeWorkspace].
class BlazeWorkspacePackage extends WorkspacePackageImpl {
  /// A prefix for any URI of a path in this package.
  final String _uriPrefix;

  @override
  final Folder root;

  @override
  final BlazeWorkspace workspace;

  List<String>? _enabledExperiments;
  Version? _languageVersion;

  BlazeWorkspacePackage(String packageName, this.root, this.workspace)
    : _uriPrefix = 'package:$packageName/';

  @override
  List<String>? get enabledExperiments {
    return _enabledExperiments;
  }

  @override
  Version? get languageVersion {
    if (!workspace._provideLanguageVersion) {
      return null;
    }
    return _languageVersion ?? workspace._languageVersion;
  }

  @override
  bool contains(Source source) {
    var uri = source.uri;
    if (uri.isScheme('package')) {
      return uri.toString().startsWith(_uriPrefix);
    }

    var path = source.fullName;
    return workspace.findPackageFor(path)?.root == root;
  }

  @override
  bool isInTestDirectory(File file) {
    // If the package itself is a "testing" package, then [file] counts as being
    // "in a test directory."
    return root.shortName == 'testing' ||
        root.path.contains('/testing/') ||
        root.getChildAssumingFolder('test').contains(file.path);
  }

  @override
  // TODO(brianwilkerson): Implement this by looking in the BUILD file for 'deps'
  //  lists.
  Packages packagesAvailableTo(String libraryPath) => Packages.empty;

  @override
  bool sourceIsInPublicApi(Source source) {
    var filePath = filePathFromSource(source);
    if (filePath == null) return false;

    var libFolder = root.getChildAssumingFolder('lib');
    if (libFolder.contains(filePath)) {
      // A file in "$root/lib" is public iff it is not in "$root/lib/src".
      var libSrcFolder = libFolder.getChildAssumingFolder('src');
      return !libSrcFolder.contains(filePath);
    }

    var relativeRoot = workspace.provider.pathContext.relative(
      root.path,
      from: workspace.root,
    );
    for (var binPath in workspace.binPaths) {
      Folder bin = workspace.provider.getFolder(binPath);
      libFolder = bin
          .getChildAssumingFolder(relativeRoot)
          .getChildAssumingFolder('lib');
      if (libFolder.contains(filePath)) {
        // A file in "$bin/lib" is public iff it is not in "$bin/lib/src".
        var libSrcFolder = libFolder.getChildAssumingFolder('src');
        return !libSrcFolder.contains(filePath);
      }
    }

    libFolder = workspace.provider
        .getFolder(workspace.genfiles)
        .getChildAssumingFolder(relativeRoot)
        .getChildAssumingFolder('lib');
    if (libFolder.contains(filePath)) {
      // A file in "$genfiles/lib" is public iff it is not in
      // "$genfiles/lib/src".
      var libSrcFolder = libFolder.getChildAssumingFolder('src');
      return !libSrcFolder.contains(filePath);
    }

    return false;
  }
}
