// Copyright (c) 2018, 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 'package:collection/collection.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:yaml/yaml.dart';

part 'dependency.g.dart';

Map<String, Dependency> parseDeps(Map? source) =>
    source?.map((k, v) {
      final key = k as String;
      Dependency? value;
      try {
        value = _fromJson(v, k);
      } on CheckedFromJsonException catch (e) {
        if (e.map is! YamlMap) {
          // This is likely a "synthetic" map created from a String value
          // Use `source` to throw this exception with an actual YamlMap and
          // extract the associated error information.
          throw CheckedFromJsonException(source, key, e.className!, e.message);
        }
        rethrow;
      }

      if (value == null) {
        throw CheckedFromJsonException(
          source,
          key,
          'Pubspec',
          'Not a valid dependency value.',
        );
      }
      return MapEntry(key, value);
    }) ??
    {};

const _sourceKeys = ['sdk', 'git', 'path', 'hosted'];

/// Returns `null` if the data could not be parsed.
Dependency? _fromJson(Object? data, String name) {
  if (data is String || data == null) {
    return _$HostedDependencyFromJson({'version': data});
  }

  if (data is Map) {
    final matchedKeys =
        data.keys.cast<String>().where((key) => key != 'version').toList();

    if (data.isEmpty || (matchedKeys.isEmpty && data.containsKey('version'))) {
      return _$HostedDependencyFromJson(data);
    } else {
      final firstUnrecognizedKey =
          matchedKeys.firstWhereOrNull((k) => !_sourceKeys.contains(k));

      return $checkedNew<Dependency>('Dependency', data, () {
        if (firstUnrecognizedKey != null) {
          throw UnrecognizedKeysException(
            [firstUnrecognizedKey],
            data,
            _sourceKeys,
          );
        }
        if (matchedKeys.length > 1) {
          throw CheckedFromJsonException(
            data,
            matchedKeys[1],
            'Dependency',
            'A dependency may only have one source.',
          );
        }

        final key = matchedKeys.single;

        return switch (key) {
          'git' => GitDependency.fromData(data[key]),
          'path' => PathDependency.fromData(data[key]),
          'sdk' => _$SdkDependencyFromJson(data),
          'hosted' => _$HostedDependencyFromJson(data)
            ..hosted?._nameOfPackage = name,
          _ => throw StateError('There is a bug in pubspec_parse.'),
        };
      });
    }
  }

  // Not a String or a Map – return null so parent logic can throw proper error
  return null;
}

sealed class Dependency {}

@JsonSerializable()
class SdkDependency extends Dependency {
  final String sdk;
  @JsonKey(fromJson: _constraintFromString)
  final VersionConstraint version;

  SdkDependency(this.sdk, {VersionConstraint? version})
      : version = version ?? VersionConstraint.any;

  @override
  bool operator ==(Object other) =>
      other is SdkDependency && other.sdk == sdk && other.version == version;

  @override
  int get hashCode => Object.hash(sdk, version);

  @override
  String toString() => 'SdkDependency: $sdk';
}

@JsonSerializable()
class GitDependency extends Dependency {
  @JsonKey(fromJson: parseGitUri)
  final Uri url;
  final String? ref;
  final String? path;

  GitDependency(this.url, {this.ref, this.path});

  factory GitDependency.fromData(Object? data) {
    if (data is String) {
      data = {'url': data};
    }

    if (data is Map) {
      return _$GitDependencyFromJson(data);
    }

    throw ArgumentError.value(data, 'git', 'Must be a String or a Map.');
  }

  @override
  bool operator ==(Object other) =>
      other is GitDependency &&
      other.url == url &&
      other.ref == ref &&
      other.path == path;

  @override
  int get hashCode => Object.hash(url, ref, path);

  @override
  String toString() => 'GitDependency: url@$url';
}

Uri? parseGitUriOrNull(String? value) =>
    value == null ? null : parseGitUri(value);

Uri parseGitUri(String value) => _tryParseScpUri(value) ?? Uri.parse(value);

/// Supports URIs like `[user@]host.xz:path/to/repo.git/`
/// See https://git-scm.com/docs/git-clone#_git_urls_a_id_urls_a
Uri? _tryParseScpUri(String value) {
  final colonIndex = value.indexOf(':');

  if (colonIndex < 0) {
    return null;
  } else if (colonIndex == value.indexOf('://')) {
    // If the first colon is part of a scheme, it's not an scp-like URI
    return null;
  }
  final slashIndex = value.indexOf('/');

  if (slashIndex >= 0 && slashIndex < colonIndex) {
    // Per docs: This syntax is only recognized if there are no slashes before
    // the first colon. This helps differentiate a local path that contains a
    // colon. For example the local path foo:bar could be specified as an
    // absolute path or ./foo:bar to avoid being misinterpreted as an ssh url.
    return null;
  }

  final atIndex = value.indexOf('@');
  if (colonIndex > atIndex) {
    final user = atIndex >= 0 ? value.substring(0, atIndex) : null;
    final host = value.substring(atIndex + 1, colonIndex);
    final path = value.substring(colonIndex + 1);
    return Uri(scheme: 'ssh', userInfo: user, host: host, path: path);
  }
  return null;
}

class PathDependency extends Dependency {
  final String path;

  PathDependency(this.path);

  factory PathDependency.fromData(Object? data) {
    if (data is String) {
      return PathDependency(data);
    }
    throw ArgumentError.value(data, 'path', 'Must be a String.');
  }

  @override
  bool operator ==(Object other) =>
      other is PathDependency && other.path == path;

  @override
  int get hashCode => path.hashCode;

  @override
  String toString() => 'PathDependency: path@$path';
}

@JsonSerializable(disallowUnrecognizedKeys: true)
class HostedDependency extends Dependency {
  @JsonKey(fromJson: _constraintFromString)
  final VersionConstraint version;

  @JsonKey(disallowNullValue: true)
  final HostedDetails? hosted;

  HostedDependency({VersionConstraint? version, this.hosted})
      : version = version ?? VersionConstraint.any;

  @override
  bool operator ==(Object other) =>
      other is HostedDependency &&
      other.version == version &&
      other.hosted == hosted;

  @override
  int get hashCode => Object.hash(version, hosted);

  @override
  String toString() => 'HostedDependency: $version';
}

@JsonSerializable(disallowUnrecognizedKeys: true)
class HostedDetails {
  /// The name of the target dependency as declared in a `hosted` block.
  ///
  /// This may be null if no explicit name is present, for instance because the
  /// hosted dependency was declared as a string (`hosted: pub.example.org`).
  @JsonKey(name: 'name')
  final String? declaredName;

  @JsonKey(fromJson: parseGitUriOrNull, disallowNullValue: true)
  final Uri? url;

  @JsonKey(includeFromJson: false, includeToJson: false)
  String? _nameOfPackage;

  /// The name of this package on the package repository.
  ///
  /// If this hosted block has a [declaredName], that one will be used.
  /// Otherwise, the name will be inferred from the surrounding package name.
  String get name => declaredName ?? _nameOfPackage!;

  HostedDetails(this.declaredName, this.url);

  factory HostedDetails.fromJson(Object data) {
    if (data is String) {
      data = {'url': data};
    }

    if (data is Map) {
      return _$HostedDetailsFromJson(data);
    }

    throw ArgumentError.value(data, 'hosted', 'Must be a Map or String.');
  }

  @override
  bool operator ==(Object other) =>
      other is HostedDetails && other.name == name && other.url == url;

  @override
  int get hashCode => Object.hash(name, url);
}

VersionConstraint _constraintFromString(String? input) =>
    input == null ? VersionConstraint.any : VersionConstraint.parse(input);
