// 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:io';

import 'package:analysis_server/src/services/pub/pub_api.dart';
import 'package:analysis_server/src/services/pub/pub_command.dart';
import 'package:analysis_server/src/services/pub/pub_package_service.dart';
import 'package:analyzer/instrumentation/service.dart';
import 'package:analyzer_testing/resource_provider_mixin.dart';
import 'package:http/http.dart';
import 'package:test/test.dart';
import 'package:test_reflective_loader/test_reflective_loader.dart';

import '../mocks.dart';
import 'server_abstract.dart';

void main() {
  defineReflectiveSuite(() {
    defineReflectiveTests(PubApiTest);
    defineReflectiveTests(PubCommandTest);
    defineReflectiveTests(PubPackageServiceTest);
  });
}

@reflectiveTest
class PubApiTest {
  static const pubDefaultUrl = 'https://pub.dev';

  Uri? lastCalledUrl;
  late MockHttpClient httpClient;

  Future<void> check_pubHostedUrl(String? envValue, String expectedBase) async {
    var api = PubApi(InstrumentationService.NULL_SERVICE, httpClient, envValue);
    await api.allPackages();
    expect(
      lastCalledUrl.toString(),
      equals('$expectedBase/api/package-name-completion-data'),
    );
  }

  void setUp() {
    httpClient = MockHttpClient();
    lastCalledUrl = null;
    httpClient.sendHandler = (BaseRequest request) async {
      lastCalledUrl = request.url;
      return Response('{}', 200);
    };
  }

  Future<void> test_envPubHostedUrl_emptyString() =>
      check_pubHostedUrl('', pubDefaultUrl);

  Future<void> test_envPubHostedUrl_invalidUrl() =>
      check_pubHostedUrl('test', pubDefaultUrl);

  Future<void> test_envPubHostedUrl_missingScheme() =>
      // It's hard to tell that this is intended to be a valid URL minus the scheme
      // so it will fail validation and fall back to the default.
      check_pubHostedUrl('pub.example.org', pubDefaultUrl);

  Future<void> test_envPubHostedUrl_null() =>
      check_pubHostedUrl(null, pubDefaultUrl);

  Future<void> test_envPubHostedUrl_valid() =>
      check_pubHostedUrl('https://pub.example.org', 'https://pub.example.org');

  Future<void> test_envPubHostedUrl_validTrailingSlash() =>
      check_pubHostedUrl('https://pub.example.org/', 'https://pub.example.org');

  Future<void> test_httpClient_closesOwn() async {
    var api = PubApi(InstrumentationService.NULL_SERVICE, null, null);
    api.close();
    expect(
      () => api.httpClient.get(Uri.parse('https://www.google.co.uk/')),
      throwsA(anything),
    );
  }

  Future<void> test_httpClient_doesNotCloseProvided() async {
    var api = PubApi(InstrumentationService.NULL_SERVICE, httpClient, null);
    api.close();
    expect(httpClient.wasClosed, isFalse);
  }
}

@reflectiveTest
class PubCommandTest with ResourceProviderMixin {
  late MockProcessRunner processRunner;
  late PubCommand pubCommand;
  late String pubspecPath, pubspec2Path;

  void setUp() {
    pubspecPath = convertPath('/home/project/pubspec.yaml');
    pubspec2Path = convertPath('/home/project2/pubspec.yaml');
    processRunner = MockProcessRunner();
    pubCommand = PubCommand(
      InstrumentationService.NULL_SERVICE,
      resourceProvider.pathContext,
      processRunner,
    );
  }

  Future<void> test_doesNotRunConcurrently() async {
    var isRunning = false;
    processRunner.startHandler =
        (executable, args, {workingDirectory, environment}) async {
          expect(
            isRunning,
            isFalse,
            reason: 'pub commands should not run concurrently',
          );
          isRunning = true;
          await pumpEventQueue(times: 500);
          isRunning = false;
          return MockProcess(0, 0, 'a', 'a');
        };
    await Future.wait([
      pubCommand.outdatedVersions(pubspecPath),
      pubCommand.outdatedVersions(pubspecPath),
    ]);
  }

  Future<void> test_killsCommandOnShutdown() async {
    // Create a process that won't complete (unless it's killed, where
    // the MockProcess will complete it with a non-zero exit code).
    var process = MockProcess(0, Completer<int>().future, '', '');
    processRunner.startHandler =
        (executable, args, {workingDirectory, environment}) => process;

    // Start running the (endless) command.
    var commandFuture = pubCommand.outdatedVersions(pubspecPath);
    await pumpEventQueue(times: 500);

    // Trigger shutdown, which should kill any in-process commands.
    pubCommand.shutdown();

    // Expect the process we created above terminates with non-zero exit code.
    expect(await process.exitCode, equals(MockProcess.killedExitCode));
    expect(commandFuture, completes);
  }

  Future<void> test_outdated_args() async {
    processRunner.startHandler =
        (executable, args, {workingDirectory, environment}) {
          expect(executable, Platform.resolvedExecutable);
          expect(args, equals(['pub', 'outdated', '--show-all', '--json']));
          expect(workingDirectory, equals(convertPath('/home/project')));
          expect(
            environment!['PUB_ENVIRONMENT'],
            anyOf(
              equals('analysis_server.pub_api'),
              endsWith(':analysis_server.pub_api'),
            ),
          );
          return MockProcess(0, 0, '', '');
        };
    await pubCommand.outdatedVersions(pubspecPath);
  }

  Future<void> test_outdated_invalidJson() async {
    processRunner.startHandler =
        (executable, args, {workingDirectory, environment}) =>
            MockProcess(1, 0, 'NOT VALID JSON', '');
    var result = await pubCommand.outdatedVersions(pubspecPath);
    expect(result, isEmpty);
  }

  Future<void> test_outdated_missingFields() async {
    var validJson = r'''
    {
      "packages": [
        {
          "package":    "foo",
          "current":    { "version": "1.0.0" },
          "upgradable": { "version": "2.0.0" },
          "resolvable": { }
        }
      ]
    }
    ''';
    processRunner.startHandler =
        (executable, args, {workingDirectory, environment}) =>
            MockProcess(1, 0, validJson, '');
    var result = await pubCommand.outdatedVersions(pubspecPath);
    expect(result, hasLength(1));
    var package = result.first;
    expect(package.packageName, equals('foo'));
    expect(package.currentVersion, equals('1.0.0'));
    expect(package.upgradableVersion, equals('2.0.0'));
    expect(package.resolvableVersion, isNull);
    expect(package.latestVersion, isNull);
  }

  Future<void> test_outdated_multiplePubspecs() async {
    var pubspecJson1 = r'''
    {
      "packages": [
        {
          "package":    "foo",
          "resolvable": { "version": "1.1.1" }
        }
      ]
    }
    ''';
    var pubspecJson2 = r'''
    {
      "packages": [
        {
          "package":    "foo",
          "resolvable": { "version": "2.2.2" }
        }
      ]
    }
    ''';

    processRunner
        .startHandler = (executable, args, {workingDirectory, environment}) {
      // Return different json based on the directory we were invoked in.
      var json =
          workingDirectory == resourceProvider.pathContext.dirname(pubspecPath)
          ? pubspecJson1
          : pubspecJson2;
      return MockProcess(1, 0, json, '');
    };
    var result1 = await pubCommand.outdatedVersions(pubspecPath);
    var result2 = await pubCommand.outdatedVersions(pubspec2Path);
    expect(result1.first.resolvableVersion, equals('1.1.1'));
    expect(result2.first.resolvableVersion, equals('2.2.2'));
  }

  Future<void> test_outdated_nonZeroExitCode() async {
    processRunner.startHandler =
        (executable, args, {workingDirectory, environment}) =>
            MockProcess(1, 123, '{}', '');
    var result = await pubCommand.outdatedVersions(pubspecPath);
    expect(result, isEmpty);
  }

  Future<void> test_validJson() async {
    var validJson = r'''
    {
      "packages": [
        {
          "package":    "foo",
          "current":    { "version": "1.0.0" },
          "upgradable": { "version": "2.0.0" },
          "resolvable": { "version": "3.0.0" },
          "latest":     { "version": "4.0.0" }
        },
        {
          "package":    "bar",
          "current":    { "version": "1.0.0" },
          "upgradable": { "version": "2.0.0" },
          "resolvable": { "version": "3.0.0" },
          "latest":     { "version": "4.0.0" }
        }
      ]
    }
    ''';
    processRunner.startHandler =
        (executable, args, {workingDirectory, environment}) =>
            MockProcess(1, 0, validJson, '');
    var result = await pubCommand.outdatedVersions(pubspecPath);
    expect(result, hasLength(2));
    for (var (index, package) in result.indexed) {
      expect(package.packageName, equals(index == 0 ? 'foo' : 'bar'));
      expect(package.currentVersion, equals('1.0.0'));
      expect(package.upgradableVersion, equals('2.0.0'));
      expect(package.resolvableVersion, equals('3.0.0'));
      expect(package.latestVersion, equals('4.0.0'));
    }
  }
}

@reflectiveTest
class PubPackageServiceTest extends AbstractLspAnalysisServerTest {
  /// A sample API response for package names. This should match the JSON served
  /// at https://pub.dev/api/package-name-completion-data.
  static const samplePackageList = '''
  { "packages": ["one", "two", "three"] }
  ''';

  void expectPackages(List<String> packageNames) => expect(
    server.pubPackageService.cachedPackages.map((p) => p.packageName),
    equals(packageNames),
  );

  void providePubApiPackageList(String jsonResponse) {
    httpClient.sendHandler = (BaseRequest request) async {
      if (request.url.toString().endsWith(PubApi.packageNameListPath)) {
        return Response(jsonResponse, 200);
      } else {
        throw UnimplementedError();
      }
    };
  }

  @override
  void setUp() {
    super.setUp();
    // Cause retries to run immediately.
    PubApi.failedRetryInitialDelaySeconds = 0;
  }

  Future<void> test_packageCache_diskCacheReplacedIfStale() async {
    // This test should mirror test_packageCache_diskCacheUsedIfFresh
    // besides the lastUpdated timestamp + expectations.
    providePubApiPackageList(samplePackageList);

    // Write a cache that should be considered stale.
    server.pubPackageService.writeDiskCache(
      PackageDetailsCache.fromApiResults([PubApiPackage('stalePackage1')])
        ..lastUpdatedUtc = DateTime.utc(1990),
    );

    await initialize();
    await openFile(pubspecFileUri, '');
    await pumpEventQueue();

    // Expect the sample list to have overwritten the stale cache.
    expectPackages(['one', 'two', 'three']);
  }

  Future<void> test_packageCache_diskCacheUsedIfFresh() async {
    // This test should mirror test_packageCache_diskCacheReplacedIfStale
    // besides the lastUpdated timestamp + expectations.
    providePubApiPackageList(samplePackageList);

    // Write a cache that is not stale.
    server.pubPackageService.writeDiskCache(
      PackageDetailsCache.fromApiResults([PubApiPackage('freshPackage1')]),
    );

    await initialize();
    await openFile(pubspecFileUri, '');
    await pumpEventQueue();

    // Expect the fresh cache to still be used.
    expectPackages(['freshPackage1']);
  }

  Future<void> test_packageCache_doesNotRetryOn400() async {
    var requestNum = 1;
    httpClient.sendHandler = (BaseRequest request) async {
      if (request.url.toString().endsWith(PubApi.packageNameListPath)) {
        return requestNum++ == 1
            ? Response('ERROR', 400)
            : Response(samplePackageList, 200);
      } else {
        throw UnimplementedError();
      }
    };

    await initialize();
    expectPackages([]);

    await openFile(pubspecFileUri, '');
    await pumpEventQueue();

    expectPackages([]);
    expect(httpClient.sendHandlerCalls, equals(1));
  }

  Future<void> test_packageCache_doesNotRetryUnknownException() async {
    httpClient.sendHandler = (BaseRequest request) async =>
        throw UnimplementedError();

    await initialize();
    expectPackages([]);

    await openFile(pubspecFileUri, '');
    await pumpEventQueue();

    expectPackages([]);
    expect(httpClient.sendHandlerCalls, equals(1));
  }

  Future<void> test_packageCache_fetchesFromServer() async {
    // Provide the sample packages in the web request.
    providePubApiPackageList(samplePackageList);

    await initialize();
    expectPackages([]);

    await openFile(pubspecFileUri, '');
    await pumpEventQueue();

    expectPackages(['one', 'two', 'three']);
  }

  Future<void> test_packageCache_initializesOnPubspecOpen() async {
    await initialize();

    expect(server.pubPackageService.isPackageNamesTimerRunning, isFalse);
    expect(server.pubPackageService.packageCache, isNull);
    expectPackages([]);
    await openFile(pubspecFileUri, '');
    await pumpEventQueue();

    expect(server.pubPackageService.isPackageNamesTimerRunning, isTrue);
    expect(server.pubPackageService.packageCache, isNotNull);
    expectPackages([]);
  }

  Future<void> test_packageCache_readsDiskCache() async {
    server.pubPackageService.writeDiskCache(
      PackageDetailsCache.fromApiResults([PubApiPackage('package1')]),
    );

    await initialize();
    expectPackages([]);

    await openFile(pubspecFileUri, '');
    await pumpEventQueue();

    expectPackages(['package1']);
  }

  Future<void> test_packageCache_retriesOn500() async {
    var requestNum = 1;
    httpClient.sendHandler = (BaseRequest request) async {
      if (request.url.toString().endsWith(PubApi.packageNameListPath)) {
        return requestNum++ == 1
            ? Response('ERROR', 500)
            : Response(samplePackageList, 200);
      } else {
        throw UnimplementedError();
      }
    };

    await initialize();
    expectPackages([]);

    await openFile(pubspecFileUri, '');
    await pumpEventQueue();

    expectPackages(['one', 'two', 'three']);
    expect(httpClient.sendHandlerCalls, equals(2));
  }

  Future<void> test_packageCache_retriesOnInvalidJson() async {
    var requestNum = 1;
    httpClient.sendHandler = (BaseRequest request) async {
      if (request.url.toString().endsWith(PubApi.packageNameListPath)) {
        return requestNum++ == 1
            ? Response('$samplePackageList{{{{{{{', 200)
            : Response(samplePackageList, 200);
      } else {
        throw UnimplementedError();
      }
    };

    await initialize();
    expectPackages([]);

    await openFile(pubspecFileUri, '');
    await pumpEventQueue();

    expectPackages(['one', 'two', 'three']);
    expect(httpClient.sendHandlerCalls, equals(2));
  }

  Future<void> test_packageCache_timeRemaining() async {
    void expectHoursRemaining(DateTime cacheTime, int expectedHoursRemaining) {
      var cache = PackageDetailsCache.empty();
      cache.lastUpdatedUtc = cacheTime.toUtc();

      var remainingHours = cache.cacheTimeRemaining.inHours;
      expect(remainingHours, isNonNegative);
      expect(remainingHours, closeTo(expectedHoursRemaining, 1));
    }

    var maxHours = PackageDetailsCache.maxCacheAge.inHours;

    // Very old cache should have no time remaining.
    expectHoursRemaining(DateTime(2020, 12), 0);

    // Cache from 1 hour ago should max-1 hours remaining.
    expectHoursRemaining(DateTime.now().add(Duration(hours: -1)), maxHours - 1);

    // Cache from 10 hours ago should max-10 hours remaining.
    expectHoursRemaining(
      DateTime.now().add(Duration(hours: -10)),
      maxHours - 10,
    );

    // Cache from maxAge ago should have no hours remaining.
    expectHoursRemaining(
      DateTime.now().add(-PackageDetailsCache.maxCacheAge),
      0,
    );
  }

  Future<void> test_packageCache_writesDiskCache() async {
    // Provide the sample packages in the web request.
    providePubApiPackageList(samplePackageList);

    await initialize();
    expect(server.pubPackageService.readDiskCache(), isNull);

    await openFile(pubspecFileUri, '');
    await pumpEventQueue();

    var cache = server.pubPackageService.readDiskCache()!;
    var packages = cache.packages.values.toList();

    expect(packages.map((p) => p.packageName), equals(['one', 'two', 'three']));
  }
}
