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

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

import 'package:analyzer/instrumentation/service.dart';
import 'package:analyzer/src/util/platform_info.dart';
import 'package:http/http.dart' as http;
import 'package:meta/meta.dart';

/// A class for interacting with the Pub API.
///
/// https://github.com/dart-lang/pub/blob/master/doc/repository-spec-v2.md
///
/// Failed requests will automatically be retried.
class PubApi {
  static const packageNameListPath = '/api/package-name-completion-data';
  static const packageInfoPath = '/api/packages';

  /// Maximum number of retries if requests fail.
  static const maxFailedRequests = 5;

  /// Initial wait period between retries. Doubled for each failure (but restarts
  /// from this value for each new request).
  static int _failedRetryInitialDelaySeconds = 1;

  @visibleForTesting
  static set failedRetryInitialDelaySeconds(int value) {
    _failedRetryInitialDelaySeconds = value;
  }

  final InstrumentationService instrumentationService;
  final http.Client httpClient;

  /// The Base URL for hosted Pub packages, excluding the trailing slash.
  ///
  /// Returns 'https://pub.dev' if no hosted URL is set or the value
  /// is invalid.
  final String pubHostedUrl;

  final _headers = {
    'Accept': 'application/vnd.pub.v2+json',
    'Accept-Encoding': 'gzip',
    'User-Agent':
        'Dart Analysis Server/${platform.version.split(' ').first}'
        ' (+https://github.com/dart-lang/sdk)',
  };

  new(
    this.instrumentationService,
    http.Client? httpClient,
    String? envPubHostedUrl,
  ) : httpClient = httpClient != null
          ? _NoCloseHttpClient(httpClient)
          : http.Client(),
      pubHostedUrl = _validPubHostedUrl(envPubHostedUrl);

  /// Fetches a list of package names from the Pub API.
  ///
  /// Failed requests will be retried a number of times. If no successful response
  /// is received, will return null.
  Future<List<PubApiPackage>?> allPackages() async {
    var json = await _getJson('$pubHostedUrl$packageNameListPath');
    if (json == null) {
      return null;
    }

    var packageNames = json['packages'];
    return packageNames is List
        ? packageNames.map((name) => PubApiPackage(name as String)).toList()
        : null;
  }

  void close() {
    httpClient.close();
  }

  /// Fetches package details from the Pub API.
  ///
  /// Failed requests will be retried a number of times. If no successful response
  /// is received, will return null.
  Future<PubApiPackageDetails?> packageInfo(String packageName) async {
    var json = await _getJson('$pubHostedUrl$packageInfoPath/$packageName');
    if (json == null) {
      return null;
    }

    var latest = json['latest'] as Map<String, Object?>?;
    if (latest == null) {
      return null;
    }

    var pubspec = latest['pubspec'] as Map<String, Object?>?;
    var description = pubspec != null
        ? pubspec['description'] as String?
        : null;
    var version = latest['version'] as String?;
    return PubApiPackageDetails(packageName, description, version);
  }

  /// Calls a pub API and decodes the resulting JSON.
  ///
  /// Automatically retries the request for specific types of failures after
  /// [_failedRetryInitialDelaySeconds] doubling each time. After [maxFailedRequests]
  /// requests or upon a 4XX response, will return `null` and not retry.
  Future<Map<String, Object?>?> _getJson(String url) async {
    var requestCount = 0;
    var retryAfterSeconds = _failedRetryInitialDelaySeconds;
    while (requestCount++ < maxFailedRequests) {
      try {
        var response = await httpClient.get(Uri.parse(url), headers: _headers);
        if (response.statusCode == 200) {
          instrumentationService.logInfo('Pub API request successful for $url');
          return jsonDecode(response.body) as Map<String, Object?>?;
        } else if (response.statusCode >= 400 && response.statusCode < 500) {
          // Do not retry 4xx responses.
          instrumentationService.logError(
            'Pub API returned ${response.statusCode} ${response.reasonPhrase} '
            'for $url. Not retrying.',
          );
          return null;
        }
        instrumentationService.logError(
          'Pub API returned ${response.statusCode} ${response.reasonPhrase} '
          'for $url on attempt $requestCount',
        );
      } catch (e) {
        if (e is! IOException && e is! FormatException) {
          instrumentationService.logError(
            'Error calling pub API for $url. Not retrying. $e',
          );
          return null;
        }
        instrumentationService.logError('Error calling pub API for $url: $e');
      }
      if (requestCount >= maxFailedRequests) {
        instrumentationService.logInfo(
          'Pub API request failed after $requestCount requests',
        );
      } else {
        // Sleep before the next try.
        await Future.delayed(Duration(seconds: retryAfterSeconds));
        retryAfterSeconds *= 2;
      }
    }
    return null;
  }

  /// Returns a valid Pub base URL from [envPubHostedUrl] if valid, otherwise using
  /// the default 'https://pub.dev'.
  static String _validPubHostedUrl(String? envPubHostedUrl) {
    var validUrl =
        envPubHostedUrl != null &&
            (Uri.tryParse(envPubHostedUrl)?.isAbsolute ?? false)
        ? envPubHostedUrl
        : 'https://pub.dev';

    // Discard any trailing slashes, as all API paths start with them.
    return validUrl.endsWith('/')
        ? validUrl.substring(0, validUrl.length - 1)
        : validUrl;
  }
}

class PubApiPackage {
  final String packageName;

  new(this.packageName);
}

class PubApiPackageDetails {
  final String packageName;
  final String? description;
  final String? latestVersion;

  new(this.packageName, this.description, this.latestVersion);
}

/// A wrapper over a package:http Client that does not pass on calls to [close].
///
/// This is used to prevent the server closing a client that may be provided to
/// it (while still allowing it to close any client it creates itself).
class _NoCloseHttpClient extends http.BaseClient {
  final http.Client client;

  new(this.client);

  @override
  Future<http.StreamedResponse> send(http.BaseRequest request) =>
      client.send(request);
}
