// 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.

library pub.pubspec;

import 'dart:async';
import 'package:yaml/yaml.dart';
import 'package:path/path.dart' as path;

import 'io.dart';
import 'package.dart';
import 'path_rep.dart';
import 'source_registry.dart';
import 'utils.dart';
import 'version.dart';
import 'wrap/barback_wrap.dart';

/// The parsed contents of a pubspec file.
///
/// The fields of a pubspec are, for the most part, validated when they're first
/// accessed. This allows a partially-invalid pubspec to be used if only the
/// valid portions are relevant. To get a list of all errors in the pubspec, use
/// [allErrors].
class Pubspec {
  // If a new lazily-initialized field is added to this class and the
  // initialization can throw a [PubspecError], that error should also be
  // exposed through [allErrors].

  /// The registry of sources to use when parsing [dependencies] and
  /// [devDependencies].
  ///
  /// This will be null if this was created using [new Pubspec] or [new
  /// Pubspec.empty].
  final SourceRegistry _sources;

  /// The location from which the pubspec was loaded.
  ///
  /// This can be null if the pubspec was created in-memory or if its location
  /// is unknown or can't be represented by a Uri.
  final Uri _location;

  /// In chrome apps the uri is of type chrome-extension://, so the relative path cannot
  /// be computed using uri. The pubspec path is used instead to resolve the relative path.
  String _pubspecPath;

  /// All pubspec fields. This includes the fields from which other properties
  /// are derived.
  final Map fields;

  /// The package's name.
  String get name {
    if (_name != null) return _name;

    var name = fields['name'];
    if (name == null) {
      throw new PubspecException(null, _location,
          'Missing the required "name" field.');
    } else if (name is! String) {
      throw new PubspecException(null, _location,
          '"name" field must be a string, but was "$name".');
    }

    _name = name;
    return _name;
  }
  String _name;

  /// The package's version.
  Version get version {
    if (_version != null) return _version;

    var version = fields['version'];
    if (version == null) {
      _version = Version.none;
      return _version;
    }
    if (version is! String) {
      _error('"version" field must be a string, but was "$version".');
    }

    _version = _wrapFormatException('version number', 'version',
        () => new Version.parse(version));
    return _version;
  }
  Version _version;

  /// The additional packages this package depends on.
  List<PackageDep> get dependencies {
    if (_dependencies != null) return _dependencies;
    _dependencies = _parseDependencies('dependencies');
    if (_devDependencies == null) {
      _checkDependencyOverlap(_dependencies, devDependencies);
    }
    return _dependencies;
  }
  List<PackageDep> _dependencies;

  /// The packages this package depends on when it is the root package.
  List<PackageDep> get devDependencies {
    if (_devDependencies != null) return _devDependencies;
    _devDependencies = _parseDependencies('dev_dependencies');
    if (_dependencies == null) {
      _checkDependencyOverlap(dependencies, _devDependencies);
    }
    return _devDependencies;
  }
  List<PackageDep> _devDependencies;

  /// The dependency constraints that this package overrides when it is the
  /// root package.
  ///
  /// Dependencies here will replace any dependency on a package with the same
  /// name anywhere in the dependency graph.
  List<PackageDep> get dependencyOverrides {
    if (_dependencyOverrides != null) return _dependencyOverrides;
    _dependencyOverrides = _parseDependencies('dependency_overrides');
    return _dependencyOverrides;
  }
  List<PackageDep> _dependencyOverrides;

  /// The ids of the transformers to use for this package.
  List<Set<TransformerId>> get transformers {
    if (_transformers != null) return _transformers;

    var transformers = fields['transformers'];
    if (transformers == null) {
      _transformers = [];
      return _transformers;
    }

    if (transformers is! List) {
      _error('"transformers" field must be a list, but was "$transformers".');
    }

    var i = 0;
    _transformers = transformers.map((phase) {
      var field = "transformers";
      if (phase is! List) {
        phase = [phase];
      } else {
        field = "$field[${i++}]";
      }

      return phase.map((transformer) {
        if (transformer is! String && transformer is! Map) {
          _error('"$field" field must be a string or map, but was '
              '"$transformer".');
        }

        var library;
        var configuration;
        if (transformer is String) {
          library = transformer;
        } else {
          if (transformer.length != 1) {
            _error('"$field" must have a single key: the transformer '
                'identifier.');
          } else if (transformer.keys.single is! String) {
            _error('"$field" transformer identifier must be a string, but was '
                '"$library".');
          }

          library = transformer.keys.single;
          configuration = transformer.values.single;
          if (configuration is! Map) {
            _error('"$field.$library" field must be a map, but was '
                '"$configuration".');
          }
        }

        var id = _wrapFormatException("transformer configuration",
            "$field.$library",
            () => new TransformerId.parse(library, configuration));

        if (id.package != name &&
            !id.isBuiltInTransformer &&
            !dependencies.any((ref) => ref.name == id.package) &&
            !devDependencies.any((ref) => ref.name == id.package) &&
            !dependencyOverrides.any((ref) => ref.name == id.package)) {
          _error('"$field.$library" refers to a package that\'s not a '
              'dependency.');
        }

        return id;
      }).toSet();
    }).toList();

    return _transformers;
  }
  List<Set<TransformerId>> _transformers;

  /// The environment-related metadata.
  PubspecEnvironment get environment {
    if (_environment != null) return _environment;

    var yaml = fields['environment'];
    if (yaml == null) {
      _environment = new PubspecEnvironment(VersionConstraint.any);
      return _environment;
    }

    if (yaml is! Map) {
      _error('"environment" field must be a map, but was "$yaml".');
    }

    _environment = new PubspecEnvironment(
        _parseVersionConstraint(yaml['sdk'], 'environment.sdk'));
    return _environment;
  }
  PubspecEnvironment _environment;

  /// Whether or not the pubspec has no contents.
  bool get isEmpty =>
    name == null && version == Version.none && dependencies.isEmpty;

  /// Loads the pubspec for a package located in [packageDir].
  ///
  /// If [expectedName] is passed and the pubspec doesn't have a matching name
  /// field, this will throw a [PubspecError].
  static Future<Pubspec> load(PathRep packageDir, SourceRegistry sources,
      {String expectedName}) {
    var pubspecPath = packageDir.join('pubspec.yaml');
    var pubspecUri = pubspecPath.toUri();

    return readTextFile(pubspecPath)
      .then((text) => new Pubspec.parse(
          text, sources, expectedName: expectedName, location: pubspecUri, path: pubspecPath.fullPath))
      .catchError((e) => throw new PubspecException(expectedName, pubspecUri,
          'Could not find a file named "pubspec.yaml" in "$packageDir".'));
  }

  Pubspec(this._name, this._version, this._dependencies, this._devDependencies,
          this._dependencyOverrides, this._environment, this._transformers,
          [Map fields])
    : this.fields = fields == null ? {} : fields,
      _sources = null,
      _location = null;

  Pubspec.empty()
    : _sources = null,
      _location = null,
      _name = null,
      _pubspecPath = null,
      _version = Version.none,
      _dependencies = <PackageDep>[],
      _devDependencies = <PackageDep>[],
      _environment = new PubspecEnvironment(),
      _transformers = <Set<TransformerId>>[],
      fields = {};

  /// Returns a Pubspec object for an already-parsed map representing its
  /// contents.
  ///
  /// If [expectedName] is passed and the pubspec doesn't have a matching name
  /// field, this will throw a [PubspecError].
  ///
  /// [location] is the location from which this pubspec was loaded.
  Pubspec.fromMap(this.fields, this._sources, {String expectedName,
      Uri location, String path})
      : _location = location {

    _pubspecPath = path;
    if (expectedName == null) return;

    // If [expectedName] is passed, ensure that the actual 'name' field exists
    // and matches the expectation.

    // If the 'name' field doesn't exist, manually throw an exception rather
    // than relying on the exception thrown by [name] so that we can provide a
    // suggested fix.
    if (fields['name'] == null) {
      throw new PubspecException(expectedName, _location,
          'Missing the required "name" field (e.g. "name: $expectedName").');
    }

    try {
      if (name == expectedName) return;
      throw new PubspecException(expectedName, _location,
          '"name" field "$name" doesn\'t match expected name '
          '"$expectedName".');
    } on PubspecException catch (e) {
      // Catch and re-throw any exceptions thrown by [name] so that they refer
      // to [expectedName] for additional context.
      throw new PubspecException(expectedName, e.location,
          split1(e.message, '\n').last);
    }
  }

  /// Parses the pubspec stored at [filePath] whose text is [contents]. If the
  /// pubspec doesn't define version for itself, it defaults to [Version.none].
  /// [filePath] may be `null` if the pubspec is not on the user's local
  /// file system.
  factory Pubspec.parse(String contents, SourceRegistry sources,
      {String expectedName, Uri location, String path}) {
    if (contents.trim() == '') return new Pubspec.empty();

    var parsedPubspec = loadYaml(contents);
    if (parsedPubspec == null) return new Pubspec.empty();

    if (parsedPubspec is! Map) {
      throw new PubspecException(expectedName, location,
          'The pubspec must be a YAML mapping.');
    }

    return new Pubspec.fromMap(parsedPubspec, sources,
        expectedName: expectedName, location: location, path: path);
  }

  /// Returns a list of most errors in this pubspec.
  ///
  /// This will return at most one error for each field.
  List<PubspecException> get allErrors {
    var errors = <PubspecException>[];
    _getError(fn()) {
      try {
        fn();
      } on PubspecException catch (e) {
        errors.add(e);
      }
    }

    _getError(() => this.name);
    _getError(() => this.version);
    _getError(() => this.dependencies);
    _getError(() => this.devDependencies);
    _getError(() => this.transformers);
    _getError(() => this.environment);
    return errors;
  }

  /// Parses the dependency field named [field], and returns the corresponding
  /// list of dependencies.
  List<PackageDep> _parseDependencies(String field) {
    var dependencies = <PackageDep>[];

    var yaml = fields[field];
    // Allow an empty dependencies key.
    if (yaml == null) return dependencies;

    if (yaml is! Map || yaml.keys.any((e) => e is! String)) {
      _error('"$field" field should be a map of package names, but was '
          '"$yaml".');
    }

    yaml.forEach((name, spec) {
      if (fields['name'] != null && name == this.name) {
        _error('"$field.$name": Package may not list itself as a '
            'dependency.');
      }

      var description;
      var sourceName;

      var versionConstraint = new VersionRange();
      if (spec == null) {
        description = name;
        sourceName = _sources.defaultSource.name;
      } else if (spec is String) {
        description = name;
        sourceName = _sources.defaultSource.name;
        versionConstraint = _parseVersionConstraint(spec, "$field.$name");
      } else if (spec is Map) {
        if (spec.containsKey('version')) {
          // Don't write to the immutable YAML map.
          spec = new Map.from(spec);
          versionConstraint = _parseVersionConstraint(spec.remove('version'),
              "$field.$name.version");
        }

        var sourceNames = spec.keys.toList();
        if (sourceNames.length > 1) {
          _error('"$field.$name" field may only have one source, but it had '
              '${toSentence(sourceNames)}.');
        }

        sourceName = sourceNames.single;
        if (sourceName is! String) {
          _error('"$field.$name" source name must be a string, but was '
              '"$sourceName".');
        }

        description = spec[sourceName];
      } else {
        _error('"$field.$name" field must be a string or a mapping.');
      }

      // If we have a valid source, use it to process the description. Allow
      // unknown sources so pub doesn't choke on old pubspecs.
      if (_sources.contains(sourceName)) {
        var descriptionField = "$field.$name";
        if (spec is Map) descriptionField = "$descriptionField.$sourceName";
        _wrapFormatException('description', descriptionField, () {
          var pubspecPath;
          if (_location != null && _isFileUri(_location)) {
            pubspecPath = path.fromUri(_location);
          } else if (_pubspecPath != null) {
            pubspecPath = _pubspecPath;
          }

          description = _sources[sourceName].parseDescription(
              new PathRep(pubspecPath), description, fromLockFile: false);
        });
      }

      dependencies.add(new PackageDep(
          name, sourceName, versionConstraint, description));
    });

    return dependencies;
  }

  /// Parses [yaml] to a [VersionConstraint].
  ///
  /// If [yaml] is `null`, returns [VersionConstraint.any].
  VersionConstraint _parseVersionConstraint(yaml, String field) {
    if (yaml == null) return VersionConstraint.any;
    if (yaml is! String) {
      _error('"$field" must be a string, but was "$yaml".');
    }

    return _wrapFormatException('version constraint', field,
        () => new VersionConstraint.parse(yaml));
  }

  // Make sure the same package doesn't appear as both a regular and dev
  // dependency.
  void _checkDependencyOverlap(List<PackageDep> dependencies,
      List<PackageDep> devDependencies) {
    var dependencyNames = dependencies.map((dep) => dep.name).toSet();
    var collisions = dependencyNames.intersection(
        devDependencies.map((dep) => dep.name).toSet());
    if (collisions.isEmpty) return;

    _error('${pluralize('Package', collisions.length)} '
        '${toSentence(collisions.map((package) => '"$package"'))} cannot '
        'appear in both "dependencies" and "dev_dependencies".');
  }

  /// Runs [fn] and wraps any [FormatException] it throws in a
  /// [PubspecException].
  ///
  /// [description] should be a noun phrase that describes whatever's being
  /// parsed or processed by [fn]. [field] should be the location of whatever's
  /// being processed within the pubspec.
  _wrapFormatException(String description, String field, fn()) {
    try {
      return fn();
    } on FormatException catch (e) {
      _error('Invalid $description for "$field": ${e.message}');
    }
  }

  /// Throws a [PubspecException] with the given message.
  void _error(String message) {
    var name;
    try {
      name = this.name;
    } on PubspecException catch (_) {
      // [name] is null.
    }

    throw new PubspecException(name, _location, message);
  }
}

/// The environment-related metadata in the pubspec. Corresponds to the data
/// under the "environment:" key in the pubspec.
class PubspecEnvironment {
  /// The version constraint specifying which SDK versions this package works
  /// with.
  final VersionConstraint sdkVersion;

  PubspecEnvironment([VersionConstraint sdk])
      : sdkVersion = sdk != null ? sdk : VersionConstraint.any;
}

/// An exception thrown when parsing a pubspec.
///
/// These exceptions are often thrown lazily while accessing pubspec properties.
/// Their string representation contains additional contextual information about
/// the pubspec for which parsing failed.
class PubspecException extends ApplicationException {
  /// The name of the package that the pubspec is for.
  ///
  /// This can be null if the pubspec didn't specify a name and no external name
  /// was provided.
  final String name;

  /// The location of the pubspec.
  ///
  /// This can be null if the pubspec has no physical location, or if the
  /// location is unknown.
  final Uri location;

  PubspecException(String name, Uri location, String subMessage)
      : this.name = name,
        this.location = location,
        super(_computeMessage(name, location, subMessage));

  static String _computeMessage(String name, Uri location, String subMessage) {
    var str = 'Error in';

    if (name != null) {
      str += ' pubspec for package "$name"';
      if (location != null) str += ' loaded from';
    } else if (location == null) {
      str += ' pubspec for an unknown package';
    }

    if (location != null) {
      if (_isFileUri(location)) {
        str += ' ${nicePath(path.fromUri(location))}';
      } else {
        str += ' $location';
      }
    }

    return "$str:\n$subMessage";
  }
}

/// Returns whether [uri] is a file URI.
///
/// This is slightly more complicated than just checking if the scheme is
/// 'file', since relative URIs also refer to the filesystem on the VM.
bool _isFileUri(Uri uri) => uri.scheme == 'file' || uri.scheme == '';
