// Copyright (c) 2017, 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:io';

import 'package:io/io.dart';
import 'package:path/path.dart' as p;
import 'package:source_span/source_span.dart';
import 'package:yaml/yaml.dart';

/// User-provided settings for invoking an executable.
class ExecutableSettings {
  /// Additional arguments to pass to the executable.
  final List<String> arguments;

  /// The path to the executable on Linux.
  ///
  /// This may be an absolute path or a basename, in which case it will be
  /// looked up on the system path. It may not be relative.
  final String? _linuxExecutable;

  /// Potential commands to execute on Mac OS to launch this executable.
  ///
  /// The values may be an absolute path, or a basename.
  /// The chosen command will be the first value from this list which either is
  /// a full path that exists, or is a basename. When a basename is included it
  /// should always be at the end of the list because it will always terminate
  /// the search through the list. Relative paths are not allowed.
  final List<String>? _macOSExectuables;

  /// The path to the executable on Windows.
  ///
  /// This may be an absolute path; a basename, in which case it will be looked
  /// up on the system path; or a relative path, in which case it will be looked
  /// up relative to the paths in the `LOCALAPPDATA`, `PROGRAMFILES`, and
  /// `PROGRAMFILES(X64)` environment variables.
  final String? _windowsExecutable;

  /// The environment variable, if any, to use as an override for the default
  /// path.
  final String? _environmentOverride;

  /// The path to the executable for the current operating system.
  String get executable {
    if (_environmentOverride != null) {
      final envVariable = Platform.environment[_environmentOverride];
      if (envVariable != null) return envVariable;
    }

    if (Platform.isMacOS) {
      if (_macOSExectuables != null) {
        for (final path in _macOSExectuables) {
          if (p.basename(path) == path) return path;
          if (p.isAbsolute(path)) {
            if (File(path).existsSync()) return path;
          } else {
            throw ArgumentError(
                'Mac OS executable must be a basename or an absolute path.'
                ' Got relative path: $path');
          }
        }
      }
      throw ArgumentError('Could not find a command basename or an existing '
          'path in $_macOSExectuables');
    }
    if (!Platform.isWindows) return _linuxExecutable!;
    final windowsExecutable = _windowsExecutable!;
    if (p.isAbsolute(windowsExecutable)) return windowsExecutable;
    if (p.basename(windowsExecutable) == windowsExecutable) {
      return windowsExecutable;
    }

    var prefixes = [
      Platform.environment['LOCALAPPDATA'],
      Platform.environment['PROGRAMFILES'],
      Platform.environment['PROGRAMFILES(X86)']
    ];

    for (var prefix in prefixes) {
      if (prefix == null) continue;

      var path = p.join(prefix, windowsExecutable);
      if (File(path).existsSync()) return path;
    }

    // If we can't find a path that works, return one that doesn't. This will
    // cause an "executable not found" error to surface.
    return p.join(
        prefixes.firstWhere((prefix) => prefix != null, orElse: () => '.')!,
        _windowsExecutable);
  }

  /// Whether to invoke the browser in headless mode.
  ///
  /// This is currently only supported by Chrome.
  bool get headless => _headless ?? true;
  final bool? _headless;

  /// Parses settings from a user-provided YAML mapping.
  factory ExecutableSettings.parse(YamlMap settings) {
    List<String>? arguments;
    var argumentsNode = settings.nodes['arguments'];
    if (argumentsNode != null) {
      var value = argumentsNode.value;
      if (value is String) {
        try {
          arguments = shellSplit(value);
        } on FormatException catch (error) {
          throw SourceSpanFormatException(error.message, argumentsNode.span);
        }
      } else {
        throw SourceSpanFormatException(
            'Must be a string.', argumentsNode.span);
      }
    }

    String? linuxExecutable;
    String? macOSExecutable;
    String? windowsExecutable;
    var executableNode = settings.nodes['executable'];
    if (executableNode != null) {
      var value = executableNode.value;
      if (value is String) {
        // Don't check this on Windows because people may want to set relative
        // paths in their global config.
        if (!Platform.isWindows) {
          _assertNotRelative(executableNode as YamlScalar);
        }

        linuxExecutable = value;
        macOSExecutable = value;
        windowsExecutable = value;
      } else if (executableNode is YamlMap) {
        linuxExecutable = _getExecutable(executableNode.nodes['linux']);
        macOSExecutable = _getExecutable(executableNode.nodes['mac_os']);
        windowsExecutable = _getExecutable(executableNode.nodes['windows'],
            allowRelative: true);
      } else {
        throw SourceSpanFormatException(
            'Must be a map or a string.', executableNode.span);
      }
    }

    var headless = true;
    var headlessNode = settings.nodes['headless'];
    if (headlessNode != null) {
      var value = headlessNode.value;
      if (value is bool) {
        headless = value;
      } else {
        throw SourceSpanFormatException(
            'Must be a boolean.', headlessNode.span);
      }
    }

    return ExecutableSettings(
        arguments: arguments,
        linuxExecutable: linuxExecutable,
        macOSExecutable: macOSExecutable,
        windowsExecutable: windowsExecutable,
        headless: headless);
  }

  /// Asserts that [executableNode] is a string or `null` and returns it.
  ///
  /// If [allowRelative] is `false` (the default), asserts that the value isn't
  /// a relative path.
  static String? _getExecutable(YamlNode? executableNode,
      {bool allowRelative = false}) {
    if (executableNode == null || executableNode.value == null) return null;
    if (executableNode.value is! String) {
      throw SourceSpanFormatException('Must be a string.', executableNode.span);
    }
    if (!allowRelative) _assertNotRelative(executableNode as YamlScalar);
    return executableNode.value as String;
  }

  /// Throws a [SourceSpanFormatException] if [executableNode]'s value is a
  /// relative POSIX path that's not just a plain basename.
  ///
  /// We loop up basenames on the PATH and we can resolve absolute paths, but we
  /// have no way of interpreting relative paths.
  static void _assertNotRelative(YamlScalar executableNode) {
    var executable = executableNode.value as String;
    if (!p.posix.isRelative(executable)) return;
    if (p.posix.basename(executable) == executable) return;

    throw SourceSpanFormatException(
        'Linux and Mac OS executables may not be relative paths.',
        executableNode.span);
  }

  ExecutableSettings(
      {Iterable<String>? arguments,
      String? linuxExecutable,
      String? macOSExecutable,
      List<String>? macOSExecutables,
      String? windowsExecutable,
      String? environmentOverride,
      bool? headless})
      : arguments = arguments == null ? const [] : List.unmodifiable(arguments),
        _linuxExecutable = linuxExecutable,
        _macOSExectuables =
            _normalizeMacExecutable(macOSExecutable, macOSExecutables),
        _windowsExecutable = windowsExecutable,
        _environmentOverride = environmentOverride,
        _headless = headless;

  /// Merges [this] with [other], with [other]'s settings taking priority.
  ExecutableSettings merge(ExecutableSettings other) => ExecutableSettings(
      arguments: arguments.toList()..addAll(other.arguments),
      headless: other._headless ?? _headless,
      linuxExecutable: other._linuxExecutable ?? _linuxExecutable,
      macOSExecutables: other._macOSExectuables ?? _macOSExectuables,
      windowsExecutable: other._windowsExecutable ?? _windowsExecutable);
}

List<String>? _normalizeMacExecutable(
    String? singleArgument, List<String>? listArgument) {
  if (listArgument != null) return listArgument;
  if (singleArgument != null) return [singleArgument];
  return null;
}
