// Copyright (c) 2022, 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 'package:collection/collection.dart';

import 'meta_model.dart';

/// Helper methods to clean the meta model to produce better Dart classes.
///
/// Cleaning includes:
///
/// - Unwrapping comments that have been wrapped in the source model
/// - Removing relative hyperlinks from comments that assume rendering in an
///     HTML page with anchors
/// - Merging types that are distinct in the meta model but we want as one
/// - Removing types in the spec that we will never use
/// - Renaming types that may have long or sub-optimal generated names
/// - Simplifying union types that contain duplicates/overlaps
class LspMetaModelCleaner {
  /// A pattern to match newlines in source comments that are likely for
  /// wrapping and not formatting. This allows us to rewrap based on our indent
  /// level/line length without potentially introducing very short lines.
  final _sourceCommentWrappingNewlinesPattern = RegExp(
    r'\w[`\]\)\}.]*\n[`\[\{\(]*\w',
  );

  /// A pattern matching the spec's older HTML links that we can extract type
  /// references from.
  ///
  ///     A description of [SomeType[]] (#SomeType).
  final _sourceCommentDocumentLinksPattern = RegExp(
    r'\[`?([\w \-.]+)(?:\[\])?`?\]\s?\((#[^)]+)\)',
  );

  /// A pattern matching references in the LSP meta model comments that
  /// reference other types.
  ///
  ///     {@link TypeName description}
  ///
  /// Type names may have suffixes that shouldn't be included in the group such
  /// as
  ///
  ///     {@link TypeName[] description}
  final _sourceCommentReferencesPattern = RegExp(
    r'{@link\s+([\w.]+)[\[\]]*(?:\s+[\w`. ]+[\[\]]*)?}',
  );

  /// A pattern that matches references in the LSP meta model comments to the
  /// Thenable or Promise types.
  final _sourceCommentThenablePromisePattern = RegExp(
    r'\b(?:Thenable|Promise)\b',
  );

  /// Whether to include proposed types and fields in generated code.
  ///
  /// The LSP meta model is often regenerated from the latest code and includes
  /// unreleased/proposed APIs that we usually don't want to pollute the
  /// generated code with until they are finalized.
  final includeProposed = false;

  /// Cleans an entire [LspMetaModel].
  LspMetaModel cleanModel(LspMetaModel model) {
    var types = cleanTypes(model.types);
    return LspMetaModel(
      types: types,
      methods: model.methods.where(_includeEntityInOutput).toList(),
    );
  }

  /// Cleans a List of types.
  List<LspEntity> cleanTypes(List<LspEntity> types) {
    types = _mergeTypes(types);
    types = types.where(_includeEntityInOutput).map(_clean).toList();
    types = _renameTypes(types).where(_includeEntityInOutput).toList();
    return types;
  }

  /// Whether a type should be retained type signatures in generated code.
  bool _allowTypeInUnions(TypeBase type) {
    // Don't allow arrays of MarkedStrings, but do allow simple MarkedStrings.
    // The only place that uses these are Hovers and we only send one value
    // (to match the MarkupString equiv) so the array just makes the types
    // unnecessarily complicated.
    if (type is ArrayType) {
      // TODO(dantup): Consider removing this, it's not adding much.
      var elementType = type.elementType;
      if (elementType is TypeReference && elementType.name == 'MarkedString') {
        return false;
      }
    }
    return true;
  }

  /// Cleans a single [LspEntity].
  LspEntity _clean(LspEntity type) {
    if (type is Interface) {
      return _cleanInterface(type);
    } else if (type is LspEnum) {
      return _cleanNamespace(type);
    } else if (type is TypeAlias) {
      return _cleanTypeAlias(type);
    } else {
      throw 'Cleaning $type is not implemented.';
    }
  }

  String? _cleanComment(String? text) {
    if (text == null) {
      return text;
    }

    // Unwrap any wrapping in the source by replacing any matching newlines with
    // spaces.
    text = text.replaceAllMapped(
      _sourceCommentWrappingNewlinesPattern,
      (match) => match[0]!.replaceAll('\n', ' '),
    );

    // Replace any references to other types with a format that's valid for
    // Dart.
    text = text.replaceAllMapped(
      _sourceCommentDocumentLinksPattern,
      (match) => '[${match[1]!}]',
    );
    text = text.replaceAllMapped(
      _sourceCommentReferencesPattern,
      (match) => '[${match[1]!}]',
    );

    // Replace any references to Thenable/Promise to Future.
    text = text.replaceAllMapped(
      _sourceCommentThenablePromisePattern,
      (match) => '[Future]',
    );

    return text;
  }

  Constant _cleanConst(Constant const_) {
    return Constant(
      name: const_.name,
      comment: _cleanComment(const_.comment),
      type: _cleanType(const_.type),
      value: const_.value,
    );
  }

  Field _cleanField(String parentName, Field field) {
    var improvedType = _getImprovedType(parentName, field.name);
    var type = improvedType ?? field.type;

    return Field(
      name: field.name,
      comment: _cleanComment(field.comment),
      type: _cleanType(type),
      allowsNull: field.allowsNull,
      allowsUndefined: field.allowsUndefined,
    );
  }

  Interface _cleanInterface(Interface interface) {
    return Interface(
      name: interface.name,
      comment: _cleanComment(interface.comment),
      isProposed: interface.isProposed,
      baseTypes: interface.baseTypes
          .where((type) => _includeTypeInOutput(type.name))
          .toList(),
      members: interface.members
          .where(_includeEntityInOutput)
          .map((member) => _cleanMember(interface.name, member))
          .toList(),
    );
  }

  Member _cleanMember(String parentName, Member member) {
    if (member is Field) {
      return _cleanField(parentName, member);
    } else if (member is Constant) {
      return _cleanConst(member);
    } else {
      throw 'Cleaning $member is not implemented.';
    }
  }

  LspEnum _cleanNamespace(LspEnum namespace) {
    return LspEnum(
      name: namespace.name,
      comment: _cleanComment(namespace.comment),
      isProposed: namespace.isProposed,
      typeOfValues: namespace.typeOfValues,
      flags: namespace.flags,
      constants: namespace.constants
          .where(_includeEntityInOutput)
          .map(_cleanConst)
          .toList(),
    );
  }

  TypeBase _cleanType(TypeBase type) {
    if (type is UnionType) {
      return _cleanUnionType(type);
    } else if (type is ArrayType) {
      return ArrayType(_cleanType(type.elementType));
    } else {
      return type;
    }
  }

  TypeAlias _cleanTypeAlias(TypeAlias typeAlias) {
    return TypeAlias(
      name: typeAlias.name,
      comment: _cleanComment(typeAlias.comment),
      baseType: _cleanType(typeAlias.baseType),
      renameReferences: typeAlias.renameReferences,
    );
  }

  /// Removes any duplicate types in a union.
  ///
  /// For example, if we map multiple types into `Object?` we don't want to end
  /// up with `Either2<Object?, Object?>`.
  ///
  /// Key on `dartType` to ensure we combine different types that will map down
  /// to the same type.
  TypeBase _cleanUnionType(UnionType type) {
    var uniqueTypes = Map.fromEntries(
      type.types
          .where(_allowTypeInUnions)
          .map((t) => MapEntry(t.uniqueTypeIdentifier, t)),
    ).values.toList();

    // If our list includes something that maps to Object? as well as other
    // types, we should just treat the whole thing as Object? as we get no value
    // typing Either4<bool, String, num, Object?> but it becomes much more
    // difficult to use.
    if (uniqueTypes.any(isNullableAnyType)) {
      return uniqueTypes.firstWhere(isNullableAnyType);
    }

    // Finally, sort the types by name so that we always generate the same type
    // for the same combination to improve reuse of helper methods used in
    // multiple handlers.
    uniqueTypes.sort((t1, t2) => t1.dartType.compareTo(t2.dartType));

    // Recursively clean the inner types.
    uniqueTypes = uniqueTypes.map(_cleanType).toList();

    if (uniqueTypes.length == 1) {
      return uniqueTypes.single;
    } else if (uniqueTypes.every(isLiteralType)) {
      return LiteralUnionType(uniqueTypes.cast<LiteralType>());
    } else if (uniqueTypes.any(isNullType)) {
      var remainingTypes = uniqueTypes.whereNot(isNullType).toList();
      var nonNullType = remainingTypes.length == 1
          ? remainingTypes.single
          : UnionType(remainingTypes);
      return NullableType(nonNullType);
    } else {
      return UnionType(uniqueTypes);
    }
  }

  /// Improves types in code generated from the LSP model, including:
  ///
  /// - Making some untyped fields (like `CompletionItem.data`) strong typed for
  ///   our use.
  ///
  /// - Simplifying unions for types generated only by the server to avoid a lot
  ///   of wrapping in `EitherX<Y,Z>.tX()`.
  TypeBase? _getImprovedType(String interfaceName, String? fieldName) {
    const improvedTypeMappings = <String, Map<String, String>>{
      'Diagnostic': {'code': 'String'},
      'CompletionItem': {'data': 'CompletionItemResolutionInfo'},
      'ParameterInformation': {'label': 'String'},
      'TextDocumentEdit': {'edits': 'TextDocumentEditEdits'},
      'TypeHierarchyItem': {'data': 'TypeHierarchyItemInfo'},
    };

    var interface = improvedTypeMappings[interfaceName];

    var improvedTypeName = interface != null ? interface[fieldName] : null;

    return improvedTypeName != null
        ? improvedTypeName.endsWith('[]')
              ? ArrayType(
                  TypeReference(
                    improvedTypeName.substring(0, improvedTypeName.length - 2),
                  ),
                )
              : improvedTypeName.endsWith('?')
              ? NullableType(
                  TypeReference(
                    improvedTypeName.substring(0, improvedTypeName.length - 1),
                  ),
                )
              : TypeReference(improvedTypeName)
        : null;
  }

  /// Some types are merged together. This method returns the type that [name]s
  /// members should be merged into.
  String? _getMergeTarget(String name) {
    switch (name) {
      // The meta model defines both `LSPErrorCodes` and `ErrorCodes`. The
      // intention was that one is JSONRPC and one is LSP codes, but some codes
      // were defined in the wrong enum with the wrong values, but kept for
      // backwards compatibility. For simplicity, we merge them all into `ErrorCodes`.
      case 'LSPErrorCodes':
        return 'ErrorCodes';
      // In the model, `InitializeParams` is defined as by two classes,
      // `_InitializeParams` and `WorkspaceFoldersInitializeParams`. This
      // split doesn't add anything but makes the types less clear so we
      // merge them into `InitializeParams`.
      case '_InitializeParams':
        return 'InitializeParams';
      case 'WorkspaceFoldersInitializeParams':
        return 'InitializeParams';
      default:
        return null;
    }
  }

  /// Returns whether an entity should be included in the generated code.
  bool _includeEntityInOutput(LspEntity entity) {
    if (!includeProposed && entity.isProposed) {
      return false;
    }

    if (entity is Interface || entity is LspEnum || entity is TypeAlias) {
      if (!_includeTypeInOutput(entity.name)) {
        return false;
      }
    }

    return true;
  }

  /// Returns whether the type with [name] should be included in generated code.
  ///
  /// For [LspEntity]s, [_includeEntityInOutput] should be used instead which
  /// includes this check and others.
  bool _includeTypeInOutput(String name) {
    const ignoredTypes = {
      // InitializeError is not used for v3.0 (Feb 2017) and by dropping it we
      // don't have to handle any cases where both a namespace and interfaces
      // are declared with the same name.
      'InitializeError',
      // Merged into InitializeParams.
      '_InitializeParams',
      'WorkspaceFoldersInitializeParams',
      // We don't use these classes and they weren't in the TS version of the
      // spec so continue to not generate them until required.
      'DidChangeConfigurationRegistrationOptions',
      // LSPAny/LSPObject are used by the LSP spec for unions of basic types.
      // We map these onto Object? and don't use this type (and don't support
      // unions with so many types).
      'LSPAny',
      'LSPObject',
      'LSPUri',
      // The meta model currently includes an unwanted type named 'T' that we
      // don't want to create a class for.
      // TODO(dantup): Remove this once it's gone from the JSON model.
      'T',
    };
    const ignoredPrefixes = {
      // We don't emit MarkedString because it gets mapped to a simple String
      // when getting the .dartType for it.
      'MarkedString',
    };
    var shouldIgnore =
        ignoredTypes.contains(name) ||
        ignoredPrefixes.any((ignore) => name.startsWith(ignore));
    return !shouldIgnore;
  }

  LspEntity _merge(LspEntity source, LspEntity dest) {
    if (source.runtimeType != dest.runtimeType) {
      throw 'Cannot merge ${source.runtimeType} into ${dest.runtimeType}';
    }
    var comment = dest.comment ?? source.comment;
    if (source is LspEnum && dest is LspEnum) {
      return LspEnum(
        name: dest.name,
        comment: comment,
        isProposed: dest.isProposed,
        typeOfValues: dest.typeOfValues,
        flags: dest.flags || source.flags,
        constants: [...dest.constants, ...source.constants],
      );
    } else if (source is Interface && dest is Interface) {
      return Interface(
        name: dest.name,
        comment: comment,
        isProposed: dest.isProposed,
        baseTypes: [...dest.baseTypes, ...source.baseTypes],
        members: [...dest.members, ...source.members],
      );
    }
    throw 'Merging ${source.runtimeType}s is not yet supported';
  }

  List<LspEntity> _mergeTypes(List<LspEntity> types) {
    var typesByName = {for (final type in types) type.name: type};
    assert(types.length == typesByName.length);
    var typeNames = typesByName.keys.toList();
    for (var typeName in typeNames) {
      var targetName = _getMergeTarget(typeName);
      if (targetName != null) {
        var type = typesByName[typeName]!;
        var target = typesByName[targetName]!;
        typesByName[targetName] = _merge(type, target);
        typesByName.remove(typeName);
      }
    }
    return typesByName.values.toList();
  }

  /// Renames types that may have been generated with bad (or long) names.
  Iterable<LspEntity> _renameTypes(List<LspEntity> types) sync* {
    const renames = <String, String>{
      'CompletionClientCapabilitiesCompletionItemInsertTextModeSupport':
          'CompletionItemInsertTextModeSupport',
      'CompletionClientCapabilitiesCompletionItemTagSupport':
          'CompletionItemTagSupport',
      'CompletionClientCapabilitiesCompletionItemResolveSupport':
          'CompletionItemResolveSupport',
      'SignatureHelpClientCapabilitiesSignatureInformationParameterInformation':
          'SignatureInformationParameterInformation',
      'Pattern': 'LspPattern',
      'URI': 'LSPUri',
      // The term "CodeAction" is a bit overloaded. It could mean both an
      // individual result from the textDocument/codeAction request (which is a
      // `Command` or a `CodeAction`), or the `CodeAction` type defined in
      // the spec (which the spec also refers to as a "Code Action literal").
      //
      // To reduce confusion where we have similar APIs that operate on
      // "Code Actions" (CodeAction|Command), we rename `CodeAction` to
      // `CodeActionLiteral` and use the term `CodeAction` to mean either of
      // those types.
      'CodeAction': 'CodeActionLiteral',
      // In LSP 3.18, many types that were previously inline and got generated
      // names have been extracted to their own definitions with hand-written
      // names.
      // To reduce the size of the upcoming diff when this happens, these
      // renames change our 3.17 generated names to those that will be used
      // for 3.18.
      // TODO(dantup): Remove these once LSP 3.18 release and we regenerate
      // using it.
      'TextDocumentFilter2': 'TextDocumentFilterScheme',
      'PrepareRenameResult1': 'PrepareRenamePlaceholder',
      'TextDocumentContentChangeEvent1': 'TextDocumentContentChangePartial',
      'TextDocumentContentChangeEvent2':
          'TextDocumentContentChangeWholeDocument',
      'CompletionListItemDefaults': 'CompletionItemDefaults',
      'InitializeParamsClientInfo': 'ClientInfo',
      'SemanticTokensClientCapabilitiesRequests':
          'ClientSemanticTokensRequestOptions',
      'TraceValues': 'TraceValue',
      'SemanticTokensOptionsFull': 'SemanticTokensFullDelta',
      'CompletionListItemDefaultsEditRange': 'EditRangeWithInsertReplace',
      'ServerCapabilitiesWorkspace': 'WorkspaceOptions',
      'CodeActionClientCapabilitiesCodeActionLiteralSupportCodeActionKind':
          'ClientCodeActionKindOptions',
      'CompletionClientCapabilitiesCompletionItemKind':
          'ClientCompletionItemOptionsKind',
      'CompletionOptionsCompletionItem': 'ServerCompletionItemOptions',
      'InitializeResultServerInfo': 'ServerInfo',
      // End of temporary renames
    };

    // Temporary aliases for old names used in g3
    // TODO(dantup): Remove these once the internal code has been updated.
    var typeNames = types.map((type) => type.name).toSet();
    if (typeNames.contains('TextDocumentContentChangeEvent2')) {
      yield TypeAlias(
        name: 'TextDocumentContentChangeEvent2',
        baseType: TypeReference('TextDocumentContentChangeWholeDocument'),
        renameReferences: true,
        generateTypeDef: true,
      );
    }
    if (typeNames.contains(
      'CodeActionClientCapabilitiesCodeActionLiteralSupportCodeActionKind',
    )) {
      yield TypeAlias(
        name: 'CodeActionLiteralSupportCodeActionKind',
        baseType: TypeReference('ClientCodeActionKindOptions'),
        renameReferences: true,
        generateTypeDef: true,
      );
    }
    // End temporary aliases

    for (var type in types) {
      var newName = renames[type.name];
      if (newName == null) {
        yield type;
        continue;
      }

      // Add a TypeAlias for the old name.
      yield TypeAlias(
        name: type.name,
        comment: type.comment,
        isProposed: type.isProposed,
        baseType: TypeReference(newName),
        renameReferences: true,
      );

      // Replace the type with an equivalent with the same name.
      if (type is Interface) {
        yield Interface(
          name: newName,
          comment: type.comment,
          isProposed: type.isProposed,
          baseTypes: type.baseTypes,
          members: type.members,
        );
      } else if (type is TypeAlias) {
        yield TypeAlias(
          name: newName,
          comment: type.comment,
          isProposed: type.isProposed,
          baseType: type.baseType,
          renameReferences: type.renameReferences,
        );
      } else if (type is LspEnum) {
        yield LspEnum(
          name: newName,
          comment: type.comment,
          isProposed: type.isProposed,
          typeOfValues: type.typeOfValues,
          flags: type.flags,
          constants: type.constants,
        );
      } else {
        throw 'Renaming ${type.runtimeType} is not implemented';
      }
    }
  }
}
