// Copyright 2023 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.

/// @docImport 'package:flutter/material.dart';
library;

import 'package:collection/collection.dart';

// TODO(https://github.com/flutter/devtools/issues/7955): let extensions declare
// the type of tool they are providing: 'static-only', 'runtime-only', or
// 'static-and-runtime'.

/// Describes an extension that can be dynamically loaded into a custom screen
/// in DevTools.
class DevToolsExtensionConfig implements Comparable<DevToolsExtensionConfig> {
  DevToolsExtensionConfig._({
    required this.name,
    required this.issueTrackerLink,
    required this.version,
    required this.materialIconCodePoint,
    required this.requiresConnection,
    required this.extensionAssetsPath,
    required this.devtoolsOptionsUri,
    required this.isPubliclyHosted,
    required this.detectedFromStaticContext,
  });

  factory DevToolsExtensionConfig.parse(Map<String, Object?> json) {
    // Default to true if this value is not specified in the JSON.
    final requiresConnectionValue = json[requiresConnectionKey];
    final requiresConnection =
        requiresConnectionValue != false && requiresConnectionValue != 'false';

    if (json
        case {
          // The exptected keys below are required fields in the extension's
          // config.yaml file.
          nameKey: final String name,
          issueTrackerKey: final String issueTracker,
          versionKey: final String version,
          // ignore: avoid-unnecessary-type-assertions, this can be a String or an int
          materialIconCodePointKey: final Object codePointFromJson,
          // The expected keys below are not from the extension's config.yaml
          // file; they are generated during the extension detection mechanism
          // in the DevTools server.
          extensionAssetsPathKey: final String extensionAssetsPath,
          devtoolsOptionsUriKey: final String devtoolsOptionsUri,
          isPubliclyHostedKey: final String isPubliclyHosted,
          detectedFromStaticContextKey: final String detectedFromStaticContext,
          // Note that the field [requiresConnectionKey] is not required for
          // this check because it is optional.
        }) {
      final underscoresAndLetters = RegExp(r'^[a-z0-9_]*$');
      if (!underscoresAndLetters.hasMatch(name)) {
        throw StateError(
          'The "name" field in the extension config.yaml should only contain '
          'lowercase letters, numbers, and underscores but instead was '
          '"$name". This should be a valid Dart package name that matches the '
          'package name this extension belongs to.',
        );
      }

      // Defaults to the code point for [Icons.extensions_outlined] if parsing
      // fails.
      final int codePoint;
      const defaultCodePoint = 0xf03f;
      if (codePointFromJson is String) {
        codePoint = int.tryParse(codePointFromJson) ?? defaultCodePoint;
      } else {
        codePoint = codePointFromJson as int;
      }

      return DevToolsExtensionConfig._(
        // These values are required fields in the extension's config.yaml file.
        name: name,
        issueTrackerLink: issueTracker,
        version: version,
        materialIconCodePoint: codePoint,
        // These values are optional fields in the extension's config.yaml file
        // and will use default values if not specified.
        requiresConnection: requiresConnection,
        // These values are generated by the DevTools server.
        extensionAssetsPath: extensionAssetsPath,
        devtoolsOptionsUri: devtoolsOptionsUri,
        isPubliclyHosted: bool.parse(isPubliclyHosted),
        detectedFromStaticContext: bool.parse(detectedFromStaticContext),
      );
    } else {
      _assertGeneratedKeysPresent(json);
      final jsonKeysFromConfigFile = Set.of(json.keys.toSet())
        ..removeAll([
          ..._serverGeneratedKeys,
          ..._optionalKeys,
        ]);
      final diff = _requiredKeys.toSet().difference(
            jsonKeysFromConfigFile,
          );
      if (diff.isNotEmpty) {
        throw StateError(
          'Missing required fields ${diff.toString()} in the extension '
          'config.yaml.',
        );
      } else {
        // All the required keys are present, but the value types did not match.
        final sb = StringBuffer();
        for (final entry in json.entries) {
          sb.writeln(
            '   ${entry.key}: ${entry.value} (${entry.value.runtimeType})',
          );
        }
        throw StateError(
          'Unexpected value types in the extension config.yaml. Expected all '
          'values to be of type String, but one or more had a different type:\n'
          '${sb.toString()}',
        );
      }
    }
  }

  // The following keys are required in the extension's config.yaml file.
  static const nameKey = 'name';
  static const issueTrackerKey = 'issueTracker';
  static const versionKey = 'version';
  static const materialIconCodePointKey = 'materialIconCodePoint';
  static const _requiredKeys = [
    nameKey,
    issueTrackerKey,
    versionKey,
    materialIconCodePointKey,
  ];

  // The following keys are optional in the extension's 'config.yaml' file.
  static const requiresConnectionKey = 'requiresConnection';
  static const _optionalKeys = [requiresConnectionKey];

  // The following keys are never expected to be in the extension's config.yaml
  // file. They are generated during the extension detection mechanism in the
  // DevTools server.
  static const extensionAssetsPathKey = 'extensionAssetsPath';
  static const devtoolsOptionsUriKey = 'devtoolsOptionsUri';
  static const isPubliclyHostedKey = 'isPubliclyHosted';
  static const detectedFromStaticContextKey = 'detectedFromStaticContext';
  static const _serverGeneratedKeys = [
    extensionAssetsPathKey,
    devtoolsOptionsUriKey,
    isPubliclyHostedKey,
    detectedFromStaticContextKey,
  ];

  /// The package name that this extension is for.
  ///
  /// This value should be defined by the extension's config.yaml file.
  final String name;

  // TODO(kenz): we might want to add validation to these issue tracker
  // links to ensure they don't point to the DevTools repo or flutter repo.
  // If an invalid issue tracker link is provided, we can default to
  // 'pub.dev/packages/$name'.
  /// The link to the issue tracker for this DevTools extension.
  ///
  /// This value should be defined by the extension's config.yaml file.
  ///
  /// This should not point to the flutter/devtools or flutter/flutter issue
  /// trackers, but rather to the issue tracker for the package that provides
  /// the extension, or to the repo where the extension is developed.
  final String issueTrackerLink;

  /// The version for the DevTools extension.
  ///
  /// This value should be defined by the extension's config.yaml file.
  ///
  /// This may match the version of the parent package or use a different
  /// versioning system as decided by the extension author.
  final String version;

  /// The code point for the material icon that will parsed by Flutter's
  /// [IconData] class for displaying in DevTools.
  ///
  /// This value should be defined by the extension's config.yaml file. If the
  /// provided value cannot be parsed, `defaultCodePoint` will be used.
  ///
  /// This code point should be part of the 'MaterialIcons' font family.
  /// See https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/material/icons.dart.
  final int materialIconCodePoint;

  /// Whether this extension requires a connected app to use.
  ///
  /// This value can be defined by the extension's 'config.yaml' file. If it is
  /// not defined, this will default to true.
  final bool requiresConnection;

  /// The absolute path to this extension's assets on disk.
  ///
  /// This will most likely be in the user's pub cache, but may also be
  /// somewhere else on the user's machine if, for example, a dependency is
  /// specified as a path dependency.
  ///
  /// This value will NOT be defined by the extension's config.yaml file; it
  /// is derived on the DevTools server as part of the extension detection
  /// mechanism.
  final String extensionAssetsPath;

  /// The `file://` URI to the `devtools_options.yaml` file that this
  /// extension's enabled state will be stored at.
  ///
  /// The should be equivalent to the package root that contains the
  /// `.dart_tool/package_config.json` file where this extension was detected
  /// from.
  ///
  /// This value will NOT be defined by the extension's 'config.yaml' file; it
  /// is derived on the DevTools server as part of the extension detection
  /// mechanism.
  final String devtoolsOptionsUri;

  /// Whether this extension is distrubuted in a public package on pub.dev.
  ///
  /// This value will NOT be defined by the extension's config.yaml file; it
  /// is derived on the DevTools server as part of the extension detection
  /// mechanism.
  final bool isPubliclyHosted;

  /// Whether this extension was detected from a static context.
  ///
  /// A true value means that this extension was detected as a "static"
  /// extension. A "static extension is one that was detected from a dependency
  /// in the user's project roots, as defined by the Dart Tooling Daemon.
  ///
  /// A false value means that this extension was detected as a "runtime"
  /// extension. A "runtime" extension is one that was detected from one of the
  /// running app's dependencies.
  ///
  /// This value will NOT be defined by the extension's 'config.yaml' file; it
  /// is derived on the DevTools server as part of the extension detection
  /// mechanism.
  final bool detectedFromStaticContext;

  String get displayName => name.toLowerCase();

  String get identifier => '${displayName}_$version';

  String get analyticsSafeName => isPubliclyHosted ? name : 'private';

  Map<String, Object?> toJson() => {
        nameKey: name,
        issueTrackerKey: issueTrackerLink,
        versionKey: version,
        materialIconCodePointKey: materialIconCodePoint,
        requiresConnectionKey: requiresConnection.toString(),
        extensionAssetsPathKey: extensionAssetsPath,
        devtoolsOptionsUriKey: devtoolsOptionsUri,
        isPubliclyHostedKey: isPubliclyHosted.toString(),
        detectedFromStaticContextKey: detectedFromStaticContext.toString(),
      };

  @override
  int compareTo(DevToolsExtensionConfig other) {
    var compare = name.compareTo(other.name);
    if (compare == 0) {
      compare = extensionAssetsPath.compareTo(other.extensionAssetsPath);
      if (compare == 0) {
        return devtoolsOptionsUri.compareTo(other.devtoolsOptionsUri);
      }
    }
    return compare;
  }

  @override
  bool operator ==(Object other) {
    return other is DevToolsExtensionConfig &&
        other.name == name &&
        other.issueTrackerLink == issueTrackerLink &&
        other.version == version &&
        other.materialIconCodePoint == materialIconCodePoint &&
        other.requiresConnection == requiresConnection &&
        other.extensionAssetsPath == extensionAssetsPath &&
        other.devtoolsOptionsUri == devtoolsOptionsUri &&
        other.isPubliclyHosted == isPubliclyHosted &&
        other.detectedFromStaticContext == detectedFromStaticContext;
  }

  @override
  int get hashCode => Object.hash(
        name,
        issueTrackerLink,
        version,
        materialIconCodePoint,
        requiresConnection,
        extensionAssetsPath,
        devtoolsOptionsUri,
        isPubliclyHosted,
        detectedFromStaticContext,
      );

  static void _assertGeneratedKeysPresent(Map<String, Object?> json) {
    final missingKeys = <String>[];
    for (final key in _serverGeneratedKeys) {
      if (!json.containsKey(key)) {
        missingKeys.add(key);
      }
    }
    if (missingKeys.isNotEmpty) {
      throw StateError(
        'Missing generated keys ${missingKeys.toString()} when trying to parse '
        'DevToolsExtensionConfig object.',
      );
    }
  }
}

/// Describes the enablement state of a DevTools extension.
enum ExtensionEnabledState {
  /// The extension has been enabled manually by the user.
  enabled,

  /// The extension has been disabled manually by the user.
  disabled,

  /// The extension has been neither enabled nor disabled by the user.
  none,

  /// Something went wrong with reading or writing the activation state.
  ///
  /// We should ignore extensions with this activation state.
  error;

  /// Parses [value] and returns the matching [ExtensionEnabledState] if found.
  static ExtensionEnabledState from(String? value) {
    return ExtensionEnabledState.values
            .firstWhereOrNull((e) => e.name == value) ??
        ExtensionEnabledState.none;
  }
}
