// 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 'package:args/args.dart';
import 'package:dev_tools/roll_dev.dart';
import 'package:mockito/mockito.dart';

import './common.dart';

void main() {
  group('run()', () {
    const String usage = 'usage info...';
    const String level = 'z';
    const String commit = 'abcde012345';
    const String origin = 'upstream';
    const String lastVersion = '1.2.0-0.0.pre';
    const String nextVersion = '1.2.0-1.0.pre';
    FakeArgResults fakeArgResults;
    MockGit mockGit;

    setUp(() {
      mockGit = MockGit();
    });

    test('returns false if help requested', () {
      fakeArgResults = FakeArgResults(
        level: level,
        commit: commit,
        origin: origin,
        help: true,
      );
      expect(
        run(
          usage: usage,
          argResults: fakeArgResults,
          git: mockGit,
        ),
        false,
      );
    });

    test('returns false if level not provided', () {
      fakeArgResults = FakeArgResults(
        level: null,
        commit: commit,
        origin: origin,
      );
      expect(
        run(
          usage: usage,
          argResults: fakeArgResults,
          git: mockGit,
        ),
        false,
      );
    });

    test('returns false if commit not provided', () {
      fakeArgResults = FakeArgResults(
        level: level,
        commit: null,
        origin: origin,
      );
      expect(
        run(
          usage: usage,
          argResults: fakeArgResults,
          git: mockGit,
        ),
        false,
      );
    });

    test('throws exception if upstream remote wrong', () {
      const String remote = 'wrong-remote';
      when(mockGit.getOutput('remote get-url $origin', any)).thenReturn(remote);
      fakeArgResults = FakeArgResults(
        level: level,
        commit: commit,
        origin: origin,
      );
      const String errorMessage = 'The remote named $origin is set to $remote, when $kUpstreamRemote was expected.';
      expect(
        () => run(
          usage: usage,
          argResults: fakeArgResults,
          git: mockGit,
        ),
        throwsExceptionWith(errorMessage),
      );
    });

    test('throws exception if git checkout not clean', () {
      when(mockGit.getOutput('remote get-url $origin', any)).thenReturn(kUpstreamRemote);
      when(mockGit.getOutput('status --porcelain', any)).thenReturn(
        ' M dev/tools/test/roll_dev_test.dart',
      );
      fakeArgResults = FakeArgResults(
        level: level,
        commit: commit,
        origin: origin,
      );
      Exception exception;
      try {
        run(
          usage: usage,
          argResults: fakeArgResults,
          git: mockGit,
        );
      } on Exception catch (e) {
        exception = e;
      }
      const String pattern = r'Your git repository is not clean. Try running '
        '"git clean -fd". Warning, this will delete files! Run with -n to find '
        'out which ones.';
      expect(exception?.toString(), contains(pattern));
    });

    test('does not reset or tag if --just-print is specified', () {
      when(mockGit.getOutput('remote get-url $origin', any)).thenReturn(kUpstreamRemote);
      when(mockGit.getOutput('status --porcelain', any)).thenReturn('');
      when(mockGit.getOutput(
        'describe --match *.*.*-*.*.pre --exact-match --tags refs/remotes/$origin/dev',
        any,
      )).thenReturn(lastVersion);
      when(mockGit.getOutput(
        'rev-parse $lastVersion',
        any,
      )).thenReturn('zxy321');
      fakeArgResults = FakeArgResults(
        level: level,
        commit: commit,
        origin: origin,
        justPrint: true,
      );
      expect(run(
        usage: usage,
        argResults: fakeArgResults,
        git: mockGit,
      ), false);
      verify(mockGit.run('fetch $origin', any));
      verifyNever(mockGit.run('reset $commit --hard', any));
      verifyNever(mockGit.getOutput('rev-parse HEAD', any));
    });

    test('exits with exception if --skip-tagging is provided but commit isn\'t '
         'already tagged', () {
      when(mockGit.getOutput('remote get-url $origin', any)).thenReturn(kUpstreamRemote);
      when(mockGit.getOutput('status --porcelain', any)).thenReturn('');
      when(mockGit.getOutput(
        'describe --match *.*.*-*.*.pre --exact-match --tags refs/remotes/$origin/dev',
        any,
      )).thenReturn(lastVersion);
      when(mockGit.getOutput(
        'rev-parse $lastVersion',
        any,
      )).thenReturn('zxy321');
      const String exceptionMessage = 'Failed to verify $commit is already '
        'tagged. You can only use the flag `$kSkipTagging` if the commit has '
        'already been tagged.';
      when(mockGit.run(
        'describe --exact-match --tags $commit',
        any,
      )).thenThrow(Exception(exceptionMessage));

      fakeArgResults = FakeArgResults(
        level: level,
        commit: commit,
        origin: origin,
        skipTagging: true,
      );
      expect(
        () => run(
          usage: usage,
          argResults: fakeArgResults,
          git: mockGit,
        ),
        throwsExceptionWith(exceptionMessage),
      );
      verify(mockGit.run('fetch $origin', any));
      verifyNever(mockGit.run('reset $commit --hard', any));
      verifyNever(mockGit.getOutput('rev-parse HEAD', any));
    });

    test('throws exception if desired commit is already tip of dev branch', () {
      when(mockGit.getOutput('remote get-url $origin', any)).thenReturn(kUpstreamRemote);
      when(mockGit.getOutput('status --porcelain', any)).thenReturn('');
      when(mockGit.getOutput(
        'describe --match *.*.*-*.*.pre --exact-match --tags refs/remotes/$origin/dev',
        any,
      )).thenReturn(lastVersion);
      when(mockGit.getOutput(
        'rev-parse $lastVersion',
        any,
      )).thenReturn(commit);
      fakeArgResults = FakeArgResults(
        level: level,
        commit: commit,
        origin: origin,
        justPrint: true,
      );
      expect(
        () => run(
          usage: usage,
          argResults: fakeArgResults,
          git: mockGit,
        ),
        throwsExceptionWith('is already on the dev branch as'),
      );
      verify(mockGit.run('fetch $origin', any));
      verifyNever(mockGit.run('reset $commit --hard', any));
      verifyNever(mockGit.getOutput('rev-parse HEAD', any));
    });

    test('does not tag if last release is not direct ancestor of desired '
        'commit and --force not supplied', () {
      when(mockGit.getOutput('remote get-url $origin', any))
        .thenReturn(kUpstreamRemote);
      when(mockGit.getOutput('status --porcelain', any))
        .thenReturn('');
      when(mockGit.getOutput(
        'describe --match *.*.*-*.*.pre --exact-match --tags refs/remotes/$origin/dev',
        any,
      )).thenReturn(lastVersion);
      when(mockGit.getOutput(
        'rev-parse $lastVersion',
        any,
      )).thenReturn('zxy321');
      when(mockGit.run('merge-base --is-ancestor $lastVersion $commit', any))
        .thenThrow(Exception(
          'Failed to verify $lastVersion is a direct ancestor of $commit. The '
          'flag `--force` is required to force push a new release past a '
          'cherry-pick',
        ));
      fakeArgResults = FakeArgResults(
        level: level,
        commit: commit,
        origin: origin,
      );
      const String errorMessage = 'Failed to verify $lastVersion is a direct '
        'ancestor of $commit. The flag `--force` is required to force push a '
        'new release past a cherry-pick';
      expect(
        () => run(
          argResults: fakeArgResults,
          git: mockGit,
          usage: usage,
        ),
        throwsExceptionWith(errorMessage),
      );

      verify(mockGit.run('fetch $origin', any));
      verifyNever(mockGit.run('reset $commit --hard', any));
      verifyNever(mockGit.run('push $origin HEAD:dev', any));
      verifyNever(mockGit.run('tag $nextVersion', any));
    });

    test('does not tag but updates branch if --skip-tagging provided', () {
      when(mockGit.getOutput('remote get-url $origin', any))
        .thenReturn(kUpstreamRemote);
      when(mockGit.getOutput('status --porcelain', any))
        .thenReturn('');
      when(mockGit.getOutput(
        'describe --match *.*.*-*.*.pre --exact-match --tags refs/remotes/$origin/dev',
        any,
      )).thenReturn(lastVersion);
      when(mockGit.getOutput(
        'rev-parse $lastVersion',
        any,
      )).thenReturn('zxy321');
      when(mockGit.getOutput('rev-parse HEAD', any)).thenReturn(commit);
      fakeArgResults = FakeArgResults(
        level: level,
        commit: commit,
        origin: origin,
        skipTagging: true,
      );
      expect(run(
        usage: usage,
        argResults: fakeArgResults,
        git: mockGit,
      ), true);
      verify(mockGit.run('fetch $origin', any));
      verify(mockGit.run('reset $commit --hard', any));
      verifyNever(mockGit.run('tag $nextVersion', any));
      verifyNever(mockGit.run('push $origin $nextVersion', any));
      verify(mockGit.run('push $origin HEAD:dev', any));
    });

    test('successfully tags and publishes release', () {
      when(mockGit.getOutput('remote get-url $origin', any))
        .thenReturn(kUpstreamRemote);
      when(mockGit.getOutput('status --porcelain', any))
        .thenReturn('');
      when(mockGit.getOutput(
        'describe --match *.*.*-*.*.pre --exact-match --tags refs/remotes/$origin/dev',
        any,
      )).thenReturn('1.2.0-0.0.pre');
      when(mockGit.getOutput(
        'rev-parse $lastVersion',
        any,
      )).thenReturn('zxy321');
      when(mockGit.getOutput('rev-parse HEAD', any)).thenReturn(commit);
      fakeArgResults = FakeArgResults(
        level: level,
        commit: commit,
        origin: origin,
      );
      expect(run(
        usage: usage,
        argResults: fakeArgResults,
        git: mockGit,
      ), true);
      verify(mockGit.run('fetch $origin', any));
      verify(mockGit.run('reset $commit --hard', any));
      verify(mockGit.run('tag $nextVersion', any));
      verify(mockGit.run('push $origin $nextVersion', any));
      verify(mockGit.run('push $origin HEAD:dev', any));
    });

    test('successfully publishes release with --force', () {
      when(mockGit.getOutput('remote get-url $origin', any)).thenReturn(kUpstreamRemote);
      when(mockGit.getOutput('status --porcelain', any)).thenReturn('');
      when(mockGit.getOutput(
        'describe --match *.*.*-*.*.pre --exact-match --tags refs/remotes/$origin/dev',
        any,
      )).thenReturn(lastVersion);
      when(mockGit.getOutput(
        'rev-parse $lastVersion',
        any,
      )).thenReturn('zxy321');
      when(mockGit.getOutput('rev-parse HEAD', any)).thenReturn(commit);
      fakeArgResults = FakeArgResults(
        level: level,
        commit: commit,
        origin: origin,
        force: true,
      );
      expect(run(
        usage: usage,
        argResults: fakeArgResults,
        git: mockGit,
      ), true);
      verify(mockGit.run('fetch $origin', any));
      verify(mockGit.run('reset $commit --hard', any));
      verify(mockGit.run('tag $nextVersion', any));
      verify(mockGit.run('push --force $origin HEAD:dev', any));
    });
  });

  group('parseFullTag', () {
    test('returns match on valid version input', () {
      final List<String> validTags = <String>[
        '1.2.3-1.2.pre',
        '10.2.30-12.22.pre',
        '1.18.0-0.0.pre',
        '2.0.0-1.99.pre',
        '12.34.56-78.90.pre',
        '0.0.1-0.0.pre',
        '958.80.144-6.224.pre',
      ];
      for (final String validTag in validTags) {
        final Match match = parseFullTag(validTag);
        expect(match, isNotNull, reason: 'Expected $validTag to be parsed');
      }
    });

    test('returns null on invalid version input', () {
      final List<String> invalidTags = <String>[
        '1.2.3-1.2.pre-3-gabc123',
        '1.2.3-1.2.3.pre',
        '1.2.3.1.2.pre',
        '1.2.3-dev.1.2',
        '1.2.3-1.2-3',
        'v1.2.3',
        '2.0.0',
        'v1.2.3-1.2.pre',
        '1.2.3-1.2.pre_',
      ];
      for (final String invalidTag in invalidTags) {
        final Match match = parseFullTag(invalidTag);
        expect(match, null, reason: 'Expected $invalidTag to not be parsed');
      }
    });
  });

  group('getVersionFromParts', () {
    test('returns correct string from valid parts', () {
      List<int> parts = <int>[1, 2, 3, 4, 5];
      expect(getVersionFromParts(parts), '1.2.3-4.5.pre');

      parts = <int>[11, 2, 33, 1, 0];
      expect(getVersionFromParts(parts), '11.2.33-1.0.pre');
    });
  });

  group('incrementLevel()', () {
    const String hash = 'abc123';

    test('throws exception if hash is not valid release candidate', () {
      String level = 'z';

      String version = '1.0.0-0.0.pre-1-g$hash';
      expect(
        () => incrementLevel(version, level),
        throwsExceptionWith('Git reported the latest version as "$version"'),
        reason: 'should throw because $version should be an exact tag',
      );

      version = '1.2.3';
      expect(
        () => incrementLevel(version, level),
        throwsExceptionWith('Git reported the latest version as "$version"'),
        reason: 'should throw because $version should be a dev tag, not stable.'
      );

      version = '1.0.0-0.0.pre-1-g$hash';
      level = 'q';
      expect(
        () => incrementLevel(version, level),
        throwsExceptionWith('Git reported the latest version as "$version"'),
        reason: 'should throw because $level is unsupported',
      );
    });

    test('successfully increments x', () {
      const String level = 'x';

      String version = '1.0.0-0.0.pre';
      expect(incrementLevel(version, level), '2.0.0-0.0.pre');

      version = '10.20.0-40.50.pre';
      expect(incrementLevel(version, level), '11.0.0-0.0.pre');

      version = '1.18.0-3.0.pre';
      expect(incrementLevel(version, level), '2.0.0-0.0.pre');
    });

    test('successfully increments y', () {
      const String level = 'y';

      String version = '1.0.0-0.0.pre';
      expect(incrementLevel(version, level), '1.1.0-0.0.pre');

      version = '10.20.0-40.50.pre';
      expect(incrementLevel(version, level), '10.21.0-0.0.pre');

      version = '1.18.0-3.0.pre';
      expect(incrementLevel(version, level), '1.19.0-0.0.pre');
    });

    test('successfully increments z', () {
      const String level = 'z';

      String version = '1.0.0-0.0.pre';
      expect(incrementLevel(version, level), '1.0.0-1.0.pre');

      version = '10.20.0-40.50.pre';
      expect(incrementLevel(version, level), '10.20.0-41.0.pre');

      version = '1.18.0-3.0.pre';
      expect(incrementLevel(version, level), '1.18.0-4.0.pre');
    });
  });
}

Matcher throwsExceptionWith(String messageSubString) {
  return throwsA(
      isA<Exception>().having(
          (Exception e) => e.toString(),
          'description',
          contains(messageSubString),
      ),
  );
}

class FakeArgResults implements ArgResults {
  FakeArgResults({
    String level,
    String commit,
    String origin,
    bool justPrint = false,
    bool autoApprove = true, // so we don't have to mock stdin
    bool help = false,
    bool force = false,
    bool skipTagging = false,
  }) : _parsedArgs = <String, dynamic>{
    'increment': level,
    'commit': commit,
    'origin': origin,
    'just-print': justPrint,
    'yes': autoApprove,
    'help': help,
    'force': force,
    'skip-tagging': skipTagging,
  };

  @override
  String name;

  @override
  ArgResults command;

  @override
  final List<String> rest = <String>[];

  @override
  List<String> arguments;

  final Map<String, dynamic> _parsedArgs;

  @override
  Iterable<String> get options {
    return null;
  }

  @override
  dynamic operator [](String name) {
    return _parsedArgs[name];
  }

  @override
  bool wasParsed(String name) {
    return null;
  }
}

class MockGit extends Mock implements Git {}