// 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:meta/meta.dart';

import '../base/config.dart';
import '../base/file_system.dart';
import '../base/logger.dart';
import '../base/platform.dart';
import '../cache.dart';
import 'custom_device_config.dart';

/// Represents the custom devices config file on disk which in turn
/// contains a list of individual custom device configs.
class CustomDevicesConfig {
  /// Load a [CustomDevicesConfig] from a (possibly non-existent) location on disk.
  ///
  /// The config is loaded on construction. Any error while loading will be logged
  /// but will not result in an exception being thrown. The file will not be deleted
  /// when it's not valid JSON (which other configurations do) and will not
  /// be implicitly created when it doesn't exist.
  CustomDevicesConfig({
    required Platform platform,
    required FileSystem fileSystem,
    required Logger logger,
  }) : _platform = platform,
       _fileSystem = fileSystem,
       _logger = logger,
       _configLoader = (() => Config.managed(
         _kCustomDevicesConfigName,
         fileSystem: fileSystem,
         logger: logger,
         platform: platform,
       ));

  @visibleForTesting
  CustomDevicesConfig.test({
    required FileSystem fileSystem,
    required Logger logger,
    Directory? directory,
    Platform? platform,
  }) : _platform = platform ?? FakePlatform(),
       _fileSystem = fileSystem,
       _logger = logger,
       _configLoader = (() => Config.test(
         name: _kCustomDevicesConfigName,
         directory: directory,
         logger: logger,
         managed: true,
       ));

  static const _kCustomDevicesConfigName = 'custom_devices.json';
  static const _kCustomDevicesConfigKey = 'custom-devices';
  static const _kSchema = r'$schema';
  static const _kCustomDevices = 'custom-devices';

  final Platform _platform;
  final FileSystem _fileSystem;
  final Logger _logger;
  final Config Function() _configLoader;

  // When the custom devices feature is disabled, CustomDevicesConfig is
  // constructed anyway. So loading the config in the constructor isn't a good
  // idea. (The Config ctor logs any errors)
  //
  // I also didn't want to introduce a FeatureFlags argument to the constructor
  // and conditionally load the config when the feature is enabled, because
  // sometimes we need that Config object even when the feature is disabled.
  // For example inside ensureFileExists, which is used when enabling
  // the feature.
  //
  // Instead, users of this config should handle the feature flags. So for
  // example don't get [devices] when the feature is disabled.
  Config? __config;
  Config get _config {
    __config ??= _configLoader();
    return __config!;
  }

  String get _defaultSchema {
    final Uri uri = _fileSystem
        .directory(Cache.flutterRoot)
        .childDirectory('packages')
        .childDirectory('flutter_tools')
        .childDirectory('static')
        .childFile('custom-devices.schema.json')
        .uri;

    // otherwise it won't contain the Uri schema, so the file:// at the start
    // will be missing
    assert(uri.isAbsolute);

    return uri.toString();
  }

  /// Ensure the config file exists on disk by creating one with default values
  /// if it doesn't exist yet.
  ///
  /// The config file should always be present so we can give the user a path
  /// to a file they can edit.
  void ensureFileExists() {
    if (!_fileSystem.file(_config.configPath).existsSync()) {
      _config.setValue(_kSchema, _defaultSchema);
      _config.setValue(_kCustomDevices, <dynamic>[
        CustomDeviceConfig.getExampleForPlatform(_platform).toJson(),
      ]);
    }
  }

  List<dynamic>? _getDevicesJsonValue() {
    final dynamic json = _config.getValue(_kCustomDevicesConfigKey);

    if (json == null) {
      return null;
    } else if (json is! List) {
      const msg =
          "Could not load custom devices config. config['$_kCustomDevicesConfigKey'] is not a JSON array.";
      _logger.printError(msg);
      throw const CustomDeviceRevivalException(msg);
    }

    return json;
  }

  /// Get the list of [CustomDeviceConfig]s that are listed in the config file
  /// including disabled ones.
  ///
  /// Throws an Exception when the config could not be loaded and logs any
  /// errors.
  List<CustomDeviceConfig> get devices {
    final List<dynamic>? typedListNullable = _getDevicesJsonValue();
    if (typedListNullable == null) {
      return <CustomDeviceConfig>[];
    }

    final List<dynamic> typedList = typedListNullable;
    final revived = <CustomDeviceConfig>[];
    for (final MapEntry<int, dynamic> entry in typedList.asMap().entries) {
      try {
        revived.add(CustomDeviceConfig.fromJson(entry.value));
      } on CustomDeviceRevivalException catch (e) {
        final msg = 'Could not load custom device from config index ${entry.key}: $e';
        _logger.printError(msg);
        throw CustomDeviceRevivalException(msg);
      }
    }

    return revived;
  }

  /// Get the list of [CustomDeviceConfig]s that are listed in the config file
  /// including disabled ones.
  ///
  /// Returns an empty list when the config could not be loaded and logs any
  /// errors.
  List<CustomDeviceConfig> tryGetDevices() {
    try {
      return devices;
    } on Exception {
      // any Exceptions are logged by [devices] already.
      return <CustomDeviceConfig>[];
    }
  }

  /// Set the list of [CustomDeviceConfig]s in the config file.
  ///
  /// It should generally be avoided to call this often, since this could mean
  /// data loss. If you want to add or remove a device from the config,
  /// consider using [add] or [remove].
  set devices(List<CustomDeviceConfig> configs) {
    _config.setValue(
      _kCustomDevicesConfigKey,
      configs.map<dynamic>((CustomDeviceConfig c) => c.toJson()).toList(),
    );
  }

  /// Add a custom device to the config file.
  ///
  /// Works even when some of the custom devices in the config file are not
  /// valid.
  ///
  /// May throw a [CustomDeviceRevivalException] if `config['custom-devices']`
  /// is not a list.
  void add(CustomDeviceConfig config) {
    _config.setValue(_kCustomDevicesConfigKey, <dynamic>[
      ...?_getDevicesJsonValue(),
      config.toJson(),
    ]);
  }

  /// Returns true if the config file contains a device with id [deviceId].
  bool contains(String deviceId) {
    return devices.any((CustomDeviceConfig device) => device.id == deviceId);
  }

  /// Removes the first device with this device id from the config file.
  ///
  /// Returns true if the device was successfully removed, false if a device
  /// with this id could not be found.
  bool remove(String deviceId) {
    final List<CustomDeviceConfig> modifiedDevices = devices;

    // we use this instead of filtering so we can detect if we actually removed
    // anything.
    final CustomDeviceConfig? device = modifiedDevices.cast<CustomDeviceConfig?>().firstWhere(
      (CustomDeviceConfig? d) => d!.id == deviceId,
      orElse: () => null,
    );

    if (device == null) {
      return false;
    }

    modifiedDevices.remove(device);
    devices = modifiedDevices;
    return true;
  }

  String get configPath => _config.configPath;
}
