// Copyright 2014 The Flutter Authors. 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 'package:args/args.dart';
import 'package:args/command_runner.dart';
import 'package:flutter_tools/src/base/logger.dart';
import 'package:flutter_tools/src/base/platform.dart';
import 'package:flutter_tools/src/convert.dart';
import 'package:vm_service/vm_service.dart' as vm_service;
import 'package:path/path.dart' as path; // ignore: package_path_import

import 'package:flutter_tools/src/base/common.dart';
import 'package:flutter_tools/src/base/context.dart';
import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/base/io.dart';
import 'package:flutter_tools/src/commands/create.dart';
import 'package:flutter_tools/src/runner/flutter_command.dart';
import 'package:flutter_tools/src/runner/flutter_command_runner.dart';
import 'package:flutter_tools/src/globals.dart' as globals;
import 'package:meta/meta.dart';
import 'package:fake_async/fake_async.dart';
import 'package:test_api/test_api.dart' as test_package show TypeMatcher, test; // ignore: deprecated_member_use
import 'package:test_api/test_api.dart' hide TypeMatcher, isInstanceOf; // ignore: deprecated_member_use
// ignore: deprecated_member_use
export 'package:test_core/test_core.dart' hide TypeMatcher, isInstanceOf, test; // Defines a 'package:test' shim.

/// A matcher that compares the type of the actual value to the type argument T.
// TODO(ianh): Remove this once https://github.com/dart-lang/matcher/issues/98 is fixed
test_package.TypeMatcher<T> isInstanceOf<T>() => isA<T>();

void tryToDelete(Directory directory) {
  // This should not be necessary, but it turns out that
  // on Windows it's common for deletions to fail due to
  // bogus (we think) "access denied" errors.
  try {
    if (directory.existsSync()) {
      directory.deleteSync(recursive: true);
    }
  } on FileSystemException catch (error) {
    print('Failed to delete ${directory.path}: $error');
  }
}

/// Gets the path to the root of the Flutter repository.
///
/// This will first look for a `FLUTTER_ROOT` environment variable. If the
/// environment variable is set, it will be returned. Otherwise, this will
/// deduce the path from `platform.script`.
String getFlutterRoot() {
  const Platform platform = LocalPlatform();
  if (platform.environment.containsKey('FLUTTER_ROOT')) {
    return platform.environment['FLUTTER_ROOT'];
  }

  Error invalidScript() => StateError('Could not determine flutter_tools/ path from script URL (${globals.platform.script}); consider setting FLUTTER_ROOT explicitly.');

  Uri scriptUri;
  switch (platform.script.scheme) {
    case 'file':
      scriptUri = platform.script;
      break;
    case 'data':
      final RegExp flutterTools = RegExp(r'(file://[^"]*[/\\]flutter_tools[/\\][^"]+\.dart)', multiLine: true);
      final Match match = flutterTools.firstMatch(Uri.decodeFull(platform.script.path));
      if (match == null) {
        throw invalidScript();
      }
      scriptUri = Uri.parse(match.group(1));
      break;
    default:
      throw invalidScript();
  }

  final List<String> parts = path.split(globals.fs.path.fromUri(scriptUri));
  final int toolsIndex = parts.indexOf('flutter_tools');
  if (toolsIndex == -1) {
    throw invalidScript();
  }
  final String toolsPath = path.joinAll(parts.sublist(0, toolsIndex + 1));
  return path.normalize(path.join(toolsPath, '..', '..'));
}

CommandRunner<void> createTestCommandRunner([ FlutterCommand command ]) {
  final FlutterCommandRunner runner = TestFlutterCommandRunner();
  if (command != null) {
    runner.addCommand(command);
  }
  return runner;
}

/// Matcher for functions that throw [AssertionError].
final Matcher throwsAssertionError = throwsA(isA<AssertionError>());

/// Matcher for functions that throw [ToolExit].
Matcher throwsToolExit({ int exitCode, Pattern message }) {
  Matcher matcher = isToolExit;
  if (exitCode != null) {
    matcher = allOf(matcher, (ToolExit e) => e.exitCode == exitCode);
  }
  if (message != null) {
    matcher = allOf(matcher, (ToolExit e) => e.message?.contains(message) ?? false);
  }
  return throwsA(matcher);
}

/// Matcher for [ToolExit]s.
final test_package.TypeMatcher<ToolExit> isToolExit = isA<ToolExit>();

/// Matcher for functions that throw [ProcessException].
Matcher throwsProcessException({ Pattern message }) {
  Matcher matcher = isProcessException;
  if (message != null) {
    matcher = allOf(matcher, (ProcessException e) => e.message?.contains(message));
  }
  return throwsA(matcher);
}

/// Matcher for [ProcessException]s.
final test_package.TypeMatcher<ProcessException> isProcessException = isA<ProcessException>();

/// Creates a flutter project in the [temp] directory using the
/// [arguments] list if specified, or `--no-pub` if not.
/// Returns the path to the flutter project.
Future<String> createProject(Directory temp, { List<String> arguments }) async {
  arguments ??= <String>['--no-pub'];
  final String projectPath = globals.fs.path.join(temp.path, 'flutter_project');
  final CreateCommand command = CreateCommand();
  final CommandRunner<void> runner = createTestCommandRunner(command);
  await runner.run(<String>['create', ...arguments, projectPath]);
  // Created `.packages` since it's not created when the flag `--no-pub` is passed.
  globals.fs.file(globals.fs.path.join(projectPath, '.packages')).createSync();
  return projectPath;
}

Future<void> expectToolExitLater(Future<dynamic> future, Matcher messageMatcher) async {
  try {
    await future;
    fail('ToolExit expected, but nothing thrown');
  } on ToolExit catch(e) {
    expect(e.message, messageMatcher);
  // Catch all exceptions to give a better test failure message.
  } catch (e, trace) { // ignore: avoid_catches_without_on_clauses
    fail('ToolExit expected, got $e\n$trace');
  }
}

Matcher containsIgnoringWhitespace(String toSearch) {
  return predicate(
    (String source) {
      return collapseWhitespace(source).contains(collapseWhitespace(toSearch));
    },
    'contains "$toSearch" ignoring whitespace.',
  );
}

/// The tool overrides `test` to ensure that files created under the
/// system temporary directory are deleted after each test by calling
/// `LocalFileSystem.dispose()`.
@isTest
void test(String description, FutureOr<void> body(), {
  String testOn,
  Timeout timeout,
  dynamic skip,
  List<String> tags,
  Map<String, dynamic> onPlatform,
  int retry,
}) {
  test_package.test(
    description,
    () async {
      addTearDown(() async {
        await LocalFileSystem.dispose();
      });
      return body();
    },
    timeout: timeout,
    skip: skip,
    tags: tags,
    onPlatform: onPlatform,
    retry: retry,
    testOn: testOn,
  );
}

/// Executes a test body in zone that does not allow context-based injection.
///
/// For classes which have been refactored to excluded context-based injection
/// or globals like [fs] or [platform], prefer using this test method as it
/// will prevent accidentally including these context getters in future code
/// changes.
///
/// For more information, see https://github.com/flutter/flutter/issues/47161
@isTest
void testWithoutContext(String description, FutureOr<void> body(), {
  String testOn,
  Timeout timeout,
  dynamic skip,
  List<String> tags,
  Map<String, dynamic> onPlatform,
  int retry,
  }) {
  return test(
    description, () async {
      return runZoned(body, zoneValues: <Object, Object>{
        contextKey: const NoContext(),
      });
    },
    timeout: timeout,
    skip: skip,
    tags: tags,
    onPlatform: onPlatform,
    retry: retry,
    testOn: testOn,
  );
}

/// Runs a callback using FakeAsync.run while continually pumping the
/// microtask queue. This avoids a deadlock when tests `await` a Future
/// which queues a microtask that will not be processed unless the queue
/// is flushed.
Future<T> runFakeAsync<T>(Future<T> Function(FakeAsync time) f) async {
  return FakeAsync().run((FakeAsync time) async {
    bool pump = true;
    final Future<T> future = f(time).whenComplete(() => pump = false);
    while (pump) {
      time.flushMicrotasks();
    }
    return future;
  });
}

/// An implementation of [AppContext] that throws if context.get is called in the test.
///
/// The intention of the class is to ensure we do not accidentally regress when
/// moving towards more explicit dependency injection by accidentally using
/// a Zone value in place of a constructor parameter.
class NoContext implements AppContext {
  const NoContext();

  @override
  T get<T>() {
    throw UnsupportedError(
      'context.get<$T> is not supported in test methods. '
      'Use Testbed or testUsingContext if accessing Zone injected '
      'values.'
    );
  }

  @override
  String get name => 'No Context';

  @override
  Future<V> run<V>({
    FutureOr<V> Function() body,
    String name,
    Map<Type, Generator> overrides,
    Map<Type, Generator> fallbacks,
    ZoneSpecification zoneSpecification,
  }) async {
    return body();
  }
}

/// A fake implementation of a vm_service that mocks the JSON-RPC request
/// and response structure.
class FakeVmServiceHost {
  FakeVmServiceHost({
    @required List<VmServiceExpectation> requests,
  }) : _requests = requests {
    _vmService = vm_service.VmService(
      _input.stream,
      _output.add,
    );
    _applyStreamListen();
    _output.stream.listen((String data) {
      final Map<String, Object> request = json.decode(data) as Map<String, Object>;
      if (_requests.isEmpty) {
        throw Exception('Unexpected request: $request');
      }
      final FakeVmServiceRequest fakeRequest = _requests.removeAt(0) as FakeVmServiceRequest;
      expect(request, isA<Map<String, Object>>()
        .having((Map<String, Object> request) => request['method'], 'method', fakeRequest.method)
        .having((Map<String, Object> request) => request['params'], 'args', fakeRequest.args)
      );
      if (fakeRequest.close) {
        _vmService.dispose();
        expect(_requests, isEmpty);
        return;
      }
      if (fakeRequest.errorCode == null) {
        _input.add(json.encode(<String, Object>{
          'jsonrpc': '2.0',
          'id': request['id'],
          'result': fakeRequest.jsonResponse ?? <String, Object>{'type': 'Success'},
        }));
      } else {
        _input.add(json.encode(<String, Object>{
          'jsonrpc': '2.0',
          'id': request['id'],
          'error': <String, Object>{
            'code': fakeRequest.errorCode,
          }
        }));
      }
      _applyStreamListen();
    });
  }

  final List<VmServiceExpectation> _requests;
  final StreamController<String> _input = StreamController<String>();
  final StreamController<String> _output = StreamController<String>();

  vm_service.VmService get vmService => _vmService;
  vm_service.VmService _vmService;

  bool get hasRemainingExpectations => _requests.isNotEmpty;

  // remove FakeStreamResponse objects from _requests until it is empty
  // or until we hit a FakeRequest
  void _applyStreamListen() {
    while (_requests.isNotEmpty && !_requests.first.isRequest) {
      final FakeVmServiceStreamResponse response = _requests.removeAt(0) as FakeVmServiceStreamResponse;
      _input.add(json.encode(<String, Object>{
        'jsonrpc': '2.0',
        'method': 'streamNotify',
        'params': <String, Object>{
          'streamId': response.streamId,
          'event': response.event.toJson(),
        },
      }));
    }
  }
}

abstract class VmServiceExpectation {
  bool get isRequest;
}

class FakeVmServiceRequest implements VmServiceExpectation {
  const FakeVmServiceRequest({
    @required this.method,
    this.args = const <String, Object>{},
    this.jsonResponse,
    this.errorCode,
    this.close = false,
  });

  final String method;

  /// When true, the vm service is automatically closed.
  final bool close;

  /// If non-null, the error code for a [vm_service.RPCError] in place of a
  /// standard response.
  final int errorCode;
  final Map<String, Object> args;
  final Map<String, Object> jsonResponse;

  @override
  bool get isRequest => true;
}

class FakeVmServiceStreamResponse implements VmServiceExpectation {
  const FakeVmServiceStreamResponse({
    @required this.event,
    @required this.streamId,
  });

  final vm_service.Event event;
  final String streamId;

  @override
  bool get isRequest => false;
}

class TestFlutterCommandRunner extends FlutterCommandRunner {
  @override
  Future<void> runCommand(ArgResults topLevelResults) async {
    final Logger topLevelLogger = globals.logger;
    final Map<Type, dynamic> contextOverrides = <Type, dynamic>{
      if (topLevelResults['verbose'] as bool)
        Logger: VerboseLogger(topLevelLogger),
    };
    return context.run<void>(
      overrides: contextOverrides.map<Type, Generator>((Type type, dynamic value) {
        return MapEntry<Type, Generator>(type, () => value);
      }),
      body: () => super.runCommand(topLevelResults),
    );
  }
}

/// A file system that allows preconfiguring certain entities.
///
/// This is useful for inserting mocks/entities which throw errors or
/// have other behavior that is not easily configured through the
/// filesystem interface.
class ConfiguredFileSystem extends ForwardingFileSystem {
  ConfiguredFileSystem(FileSystem delegate, {@required this.entities}) : super(delegate);

  final Map<String, FileSystemEntity> entities;

  @override
  File file(dynamic path) {
    return (entities[path] as File) ?? super.file(path);
  }

  @override
  Directory directory(dynamic path) {
    return (entities[path] as Directory) ?? super.directory(path);
  }
}
