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

import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:file/memory.dart';
import 'package:platform/platform.dart';
import 'package:process/process.dart';
import 'package:process/src/interface/common.dart';
import 'package:test/test.dart';

void main() {
  group('getExecutablePath', () {
    late FileSystem fs;
    late Directory workingDir, dir1, dir2, dir3;

    void initialize(FileSystemStyle style) {
      setUp(() {
        fs = MemoryFileSystem(style: style);
        workingDir = fs.systemTempDirectory.createTempSync('work_dir_');
        dir1 = fs.systemTempDirectory.createTempSync('dir1_');
        dir2 = fs.systemTempDirectory.createTempSync('dir2_');
        dir3 = fs.systemTempDirectory.createTempSync('dir3_');
      });
    }

    tearDown(() {
      for (final Directory directory in <Directory>[
        workingDir,
        dir1,
        dir2,
        dir3
      ]) {
        directory.deleteSync(recursive: true);
      }
    });

    group('on windows', () {
      late Platform platform;

      initialize(FileSystemStyle.windows);

      setUp(() {
        platform = FakePlatform(
          operatingSystem: 'windows',
          environment: <String, String>{
            'PATH': '${dir1.path};${dir2.path}',
            'PATHEXT': '.exe;.bat'
          },
        );
      });

      test('absolute', () {
        String command = fs.path.join(dir3.path, 'bla.exe');
        final String expectedPath = command;
        fs.file(command).createSync();

        String? executablePath = getExecutablePath(
          command,
          workingDir.path,
          platform: platform,
          fs: fs,
        );
        _expectSamePath(executablePath, expectedPath);

        command = fs.path.withoutExtension(command);
        executablePath = getExecutablePath(
          command,
          workingDir.path,
          platform: platform,
          fs: fs,
        );
        _expectSamePath(executablePath, expectedPath);
      });

      test('in path', () {
        String command = 'bla.exe';
        final String expectedPath = fs.path.join(dir2.path, command);
        fs.file(expectedPath).createSync();

        String? executablePath = getExecutablePath(
          command,
          workingDir.path,
          platform: platform,
          fs: fs,
        );
        _expectSamePath(executablePath, expectedPath);

        command = fs.path.withoutExtension(command);
        executablePath = getExecutablePath(
          command,
          workingDir.path,
          platform: platform,
          fs: fs,
        );
        _expectSamePath(executablePath, expectedPath);
      });

      test('in path multiple times', () {
        String command = 'bla.exe';
        final String expectedPath = fs.path.join(dir1.path, command);
        final String wrongPath = fs.path.join(dir2.path, command);
        fs.file(expectedPath).createSync();
        fs.file(wrongPath).createSync();

        String? executablePath = getExecutablePath(
          command,
          workingDir.path,
          platform: platform,
          fs: fs,
        );
        _expectSamePath(executablePath, expectedPath);

        command = fs.path.withoutExtension(command);
        executablePath = getExecutablePath(
          command,
          workingDir.path,
          platform: platform,
          fs: fs,
        );
        _expectSamePath(executablePath, expectedPath);
      });

      test('in subdir of work dir', () {
        String command = fs.path.join('.', 'foo', 'bla.exe');
        final String expectedPath = fs.path.join(workingDir.path, command);
        fs.file(expectedPath).createSync(recursive: true);

        String? executablePath = getExecutablePath(
          command,
          workingDir.path,
          platform: platform,
          fs: fs,
        );
        _expectSamePath(executablePath, expectedPath);

        command = fs.path.withoutExtension(command);
        executablePath = getExecutablePath(
          command,
          workingDir.path,
          platform: platform,
          fs: fs,
        );
        _expectSamePath(executablePath, expectedPath);
      });

      test('in work dir', () {
        String command = fs.path.join('.', 'bla.exe');
        final String expectedPath = fs.path.join(workingDir.path, command);
        final String wrongPath = fs.path.join(dir2.path, command);
        fs.file(expectedPath).createSync();
        fs.file(wrongPath).createSync();

        String? executablePath = getExecutablePath(
          command,
          workingDir.path,
          platform: platform,
          fs: fs,
        );
        _expectSamePath(executablePath, expectedPath);

        command = fs.path.withoutExtension(command);
        executablePath = getExecutablePath(
          command,
          workingDir.path,
          platform: platform,
          fs: fs,
        );
        _expectSamePath(executablePath, expectedPath);
      });

      test('with multiple extensions', () {
        const String command = 'foo';
        final String expectedPath = fs.path.join(dir1.path, '$command.exe');
        final String wrongPath1 = fs.path.join(dir1.path, '$command.bat');
        final String wrongPath2 = fs.path.join(dir2.path, '$command.exe');
        fs.file(expectedPath).createSync();
        fs.file(wrongPath1).createSync();
        fs.file(wrongPath2).createSync();

        final String? executablePath = getExecutablePath(
          command,
          workingDir.path,
          platform: platform,
          fs: fs,
        );
        _expectSamePath(executablePath, expectedPath);
      });

      test('not found', () {
        const String command = 'foo.exe';

        final String? executablePath = getExecutablePath(
          command,
          workingDir.path,
          platform: platform,
          fs: fs,
        );
        expect(executablePath, isNull);
      });

      test('not found with throwOnFailure throws exception with match state',
          () {
        const String command = 'foo.exe';
        expect(
            () => getExecutablePath(
                  command,
                  workingDir.path,
                  platform: platform,
                  fs: fs,
                  throwOnFailure: true,
                ),
            throwsA(isA<ProcessPackageExecutableNotFoundException>()
                .having(
                    (ProcessPackageExecutableNotFoundException
                            notFoundException) =>
                        notFoundException.candidates,
                    'candidates',
                    isEmpty)
                .having(
                    (ProcessPackageExecutableNotFoundException
                            notFoundException) =>
                        notFoundException.workingDirectory,
                    'workingDirectory',
                    equals(workingDir.path))
                .having(
                    (ProcessPackageExecutableNotFoundException
                            notFoundException) =>
                        notFoundException.toString(),
                    'toString',
                    contains(
                        '  Working Directory: C:\\.tmp_rand0\\work_dir_rand0\n'
                        '  Search Path:\n'
                        '    C:\\.tmp_rand0\\dir1_rand0\n'
                        '    C:\\.tmp_rand0\\dir2_rand0\n'))));
      });

      test('when path has spaces', () {
        expect(
            sanitizeExecutablePath(r'Program Files\bla.exe',
                platform: platform),
            r'"Program Files\bla.exe"');
        expect(
            sanitizeExecutablePath(r'ProgramFiles\bla.exe', platform: platform),
            r'ProgramFiles\bla.exe');
        expect(
            sanitizeExecutablePath(r'"Program Files\bla.exe"',
                platform: platform),
            r'"Program Files\bla.exe"');
        expect(
            sanitizeExecutablePath(r'"Program Files\bla.exe"',
                platform: platform),
            r'"Program Files\bla.exe"');
        expect(
            sanitizeExecutablePath(r'C:\"Program Files"\bla.exe',
                platform: platform),
            r'C:\"Program Files"\bla.exe');
      });

      test('with absolute path when currentDirectory getter throws', () {
        final FileSystem fsNoCwd = MemoryFileSystemNoCwd(fs);
        final String command = fs.path.join(dir3.path, 'bla.exe');
        final String expectedPath = command;
        fs.file(command).createSync();

        final String? executablePath = getExecutablePath(
          command,
          null,
          platform: platform,
          fs: fsNoCwd,
        );
        _expectSamePath(executablePath, expectedPath);
      });

      test('with relative path when currentDirectory getter throws', () {
        final FileSystem fsNoCwd = MemoryFileSystemNoCwd(fs);
        final String command = fs.path.join('.', 'bla.exe');

        final String? executablePath = getExecutablePath(
          command,
          null,
          platform: platform,
          fs: fsNoCwd,
        );
        expect(executablePath, isNull);
      });
    });

    group('on Linux', () {
      late Platform platform;

      initialize(FileSystemStyle.posix);

      setUp(() {
        platform = FakePlatform(
            operatingSystem: 'linux',
            environment: <String, String>{'PATH': '${dir1.path}:${dir2.path}'});
      });

      test('absolute', () {
        final String command = fs.path.join(dir3.path, 'bla');
        final String expectedPath = command;
        final String wrongPath = fs.path.join(dir3.path, 'bla.bat');
        fs.file(command).createSync();
        fs.file(wrongPath).createSync();

        final String? executablePath = getExecutablePath(
          command,
          workingDir.path,
          platform: platform,
          fs: fs,
        );
        _expectSamePath(executablePath, expectedPath);
      });

      test('in path multiple times', () {
        const String command = 'xxx';
        final String expectedPath = fs.path.join(dir1.path, command);
        final String wrongPath = fs.path.join(dir2.path, command);
        fs.file(expectedPath).createSync();
        fs.file(wrongPath).createSync();

        final String? executablePath = getExecutablePath(
          command,
          workingDir.path,
          platform: platform,
          fs: fs,
        );
        _expectSamePath(executablePath, expectedPath);
      });

      test('not found', () {
        const String command = 'foo';

        final String? executablePath = getExecutablePath(
          command,
          workingDir.path,
          platform: platform,
          fs: fs,
        );
        expect(executablePath, isNull);
      });

      test('not found with throwOnFailure throws exception with match state',
          () {
        const String command = 'foo';
        expect(
            () => getExecutablePath(
                  command,
                  workingDir.path,
                  platform: platform,
                  fs: fs,
                  throwOnFailure: true,
                ),
            throwsA(isA<ProcessPackageExecutableNotFoundException>()
                .having(
                    (ProcessPackageExecutableNotFoundException
                            notFoundException) =>
                        notFoundException.candidates,
                    'candidates',
                    isEmpty)
                .having(
                    (ProcessPackageExecutableNotFoundException
                            notFoundException) =>
                        notFoundException.workingDirectory,
                    'workingDirectory',
                    equals(workingDir.path))
                .having(
                    (ProcessPackageExecutableNotFoundException
                            notFoundException) =>
                        notFoundException.toString(),
                    'toString',
                    contains('  Working Directory: /.tmp_rand0/work_dir_rand0\n'
                        '  Search Path:\n'
                        '    /.tmp_rand0/dir1_rand0\n'
                        '    /.tmp_rand0/dir2_rand0\n'))));
      });

      test('when path has spaces', () {
        expect(
            sanitizeExecutablePath('/usr/local/bin/foo bar',
                platform: platform),
            '/usr/local/bin/foo bar');
      });
    });
  });
  group('Real Filesystem', () {
    // These tests don't use the memory filesystem because Dart can't modify file
    // executable permissions, so we have to create them with actual commands.

    late Platform platform;
    late Directory tmpDir;
    late Directory pathDir1;
    late Directory pathDir2;
    late Directory pathDir3;
    late Directory pathDir4;
    late Directory pathDir5;
    late File command1;
    late File command2;
    late File command3;
    late File command4;
    late File command5;
    const Platform localPlatform = LocalPlatform();
    late FileSystem fs;

    setUp(() {
      fs = const LocalFileSystem();
      tmpDir = fs.systemTempDirectory.createTempSync();
      pathDir1 = tmpDir.childDirectory('path1')..createSync();
      pathDir2 = tmpDir.childDirectory('path2')..createSync();
      pathDir3 = tmpDir.childDirectory('path3')..createSync();
      pathDir4 = tmpDir.childDirectory('path4')..createSync();
      pathDir5 = tmpDir.childDirectory('path5')..createSync();
      command1 = pathDir1.childFile('command')..createSync();
      command2 = pathDir2.childFile('command')..createSync();
      command3 = pathDir3.childFile('command')..createSync();
      command4 = pathDir4.childFile('command')..createSync();
      command5 = pathDir5.childFile('command')..createSync();
      platform = FakePlatform(
        operatingSystem: localPlatform.operatingSystem,
        environment: <String, String>{
          'PATH': <Directory>[
            pathDir1,
            pathDir2,
            pathDir3,
            pathDir4,
            pathDir5,
          ].map<String>((Directory dir) => dir.absolute.path).join(':'),
        },
      );
    });

    tearDown(() {
      tmpDir.deleteSync(recursive: true);
    });

    test('Only returns executables in PATH', () {
      if (localPlatform.isWindows) {
        // Windows doesn't check for executable-ness, and we can't run 'chmod'
        // on Windows anyhow.
        return;
      }

      // Make the second command in the path executable, but not the first.
      // No executable permissions
      io.Process.runSync('chmod', <String>['0644', '--', command1.path]);
      // Only group executable permissions
      io.Process.runSync('chmod', <String>['0645', '--', command2.path]);
      // Only other executable permissions
      io.Process.runSync('chmod', <String>['0654', '--', command3.path]);
      // All executable permissions, but not readable
      io.Process.runSync('chmod', <String>['0311', '--', command4.path]);
      // All executable permissions
      io.Process.runSync('chmod', <String>['0755', '--', command5.path]);

      final String? executablePath = getExecutablePath(
        'command',
        tmpDir.path,
        platform: platform,
        fs: fs,
      );

      // Make sure that the path returned is for the last command, since that
      // one comes last in the PATH, but is the only one executable by the
      // user.
      _expectSamePath(executablePath, command5.absolute.path);
    });

    test(
        'Test that finding non-executable paths throws with proper information',
        () {
      if (localPlatform.isWindows) {
        // Windows doesn't check for executable-ness, and we can't run 'chmod'
        // on Windows anyhow.
        return;
      }

      // Make the second command in the path executable, but not the first.
      // No executable permissions
      io.Process.runSync('chmod', <String>['0644', '--', command1.path]);
      // Only group executable permissions
      io.Process.runSync('chmod', <String>['0645', '--', command2.path]);
      // Only other executable permissions
      io.Process.runSync('chmod', <String>['0654', '--', command3.path]);
      // All executable permissions, but not readable
      io.Process.runSync('chmod', <String>['0311', '--', command4.path]);

      expect(
          () => getExecutablePath(
                'command',
                tmpDir.path,
                platform: platform,
                fs: fs,
                throwOnFailure: true,
              ),
          throwsA(isA<ProcessPackageExecutableNotFoundException>()
              .having(
                  (ProcessPackageExecutableNotFoundException
                          notFoundException) =>
                      notFoundException.candidates,
                  'candidates',
                  equals(<String>[
                    '${tmpDir.path}/path1/command',
                    '${tmpDir.path}/path2/command',
                    '${tmpDir.path}/path3/command',
                    '${tmpDir.path}/path4/command',
                    '${tmpDir.path}/path5/command',
                  ]))
              .having(
                  (ProcessPackageExecutableNotFoundException
                          notFoundException) =>
                      notFoundException.toString(),
                  'toString',
                  contains(
                      'ProcessPackageExecutableNotFoundException: Found candidates, but lacked sufficient permissions to execute "command".\n'
                      '  Command: command\n'
                      '  Working Directory: ${tmpDir.path}\n'
                      '  Candidates:\n'
                      '    ${tmpDir.path}/path1/command\n'
                      '    ${tmpDir.path}/path2/command\n'
                      '    ${tmpDir.path}/path3/command\n'
                      '    ${tmpDir.path}/path4/command\n'
                      '    ${tmpDir.path}/path5/command\n'
                      '  Search Path:\n'
                      '    ${tmpDir.path}/path1\n'
                      '    ${tmpDir.path}/path2\n'
                      '    ${tmpDir.path}/path3\n'
                      '    ${tmpDir.path}/path4\n'
                      '    ${tmpDir.path}/path5\n'))));
    });

    test('Test that finding no executable paths throws with proper information',
        () {
      if (localPlatform.isWindows) {
        // Windows doesn't check for executable-ness, and we can't run 'chmod'
        // on Windows anyhow.
        return;
      }

      expect(
          () => getExecutablePath(
                'non-existent-command',
                tmpDir.path,
                platform: platform,
                fs: fs,
                throwOnFailure: true,
              ),
          throwsA(isA<ProcessPackageExecutableNotFoundException>()
              .having(
                  (ProcessPackageExecutableNotFoundException
                          notFoundException) =>
                      notFoundException.candidates,
                  'candidates',
                  isEmpty)
              .having(
                  (ProcessPackageExecutableNotFoundException
                          notFoundException) =>
                      notFoundException.toString(),
                  'toString',
                  contains(
                      'ProcessPackageExecutableNotFoundException: Failed to find "non-existent-command" in the search path.\n'
                      '  Command: non-existent-command\n'
                      '  Working Directory: ${tmpDir.path}\n'
                      '  Search Path:\n'
                      '    ${tmpDir.path}/path1\n'
                      '    ${tmpDir.path}/path2\n'
                      '    ${tmpDir.path}/path3\n'
                      '    ${tmpDir.path}/path4\n'
                      '    ${tmpDir.path}/path5\n'))));
    });
  });
}

void _expectSamePath(String? actual, String? expected) {
  expect(actual, isNotNull);
  expect(actual!.toLowerCase(), expected!.toLowerCase());
}

class MemoryFileSystemNoCwd extends ForwardingFileSystem {
  MemoryFileSystemNoCwd(super.delegate);

  @override
  Directory get currentDirectory {
    throw const FileSystemException('Access denied');
  }
}
