// 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 'dart:convert';
import 'dart:io';

import 'package:analysis_server/src/utilities/strings.dart';
import 'package:collection/collection.dart';

import 'meta_model.dart';

/// Reads the LSP 'meta_model.json' file and returns its types.
class LspMetaModelReader {
  final _types = <LspEntity>[];

  /// A set of names already used (or reserved) by types that have been read.
  final Set<String> _typeNames = {};

  /// Characters to strip from member names.
  final _memberNameInvalidCharPattern = RegExp(r'\$_?');

  /// Patterns to replace with '_' in member names.
  final _memberNameSeparatorPattern = RegExp(r'/');

  /// Gets all types that have been read from the model JSON.
  List<LspEntity> get types => _types.toList();

  /// Reads all spec types from [file].
  LspMetaModel readFile(File file) {
    var modelJson = file.readAsStringSync();
    var model = jsonDecode(modelJson) as Map<String, Object?>;
    return readMap(model);
  }

  /// Reads all spec types from [model].
  LspMetaModel readMap(Map<String, dynamic> model) {
    var requests = model['requests'] as List?;
    var notifications = model['notifications'] as List?;
    var structures = model['structures'] as List?;
    var enums = model['enumerations'] as List?;
    var typeAliases = model['typeAliases'] as List?;
    var methods = [...?requests, ...?notifications].toList();
    [
      ...?structures?.map(_readStructure),
      ...?enums?.map((e) => _readEnum(e)),
      ...?typeAliases?.map(_readTypeAlias),
    ].forEach(_addType);

    // Requests and notifications may have inline union types as their
    // params/result. We can create TypeAliases for those using sensible
    // names to simplify their use in the handlers.
    requests?.forEach(_readRequest);
    notifications?.forEach(_readNotification);

    var methodsEnum = _createMethodsEnum(methods);
    if (methodsEnum != null) {
      _addType(methodsEnum);
    }

    return LspMetaModel(
      types: types,
      methods: methodsEnum?.constants.toList() ?? [],
    );
  }

  /// Adds [type] to the current list and prevents its name from being used
  /// by generated interfaces.
  void _addType(LspEntity type) {
    _typeNames.add(type.name);
    _types.add(type);
  }

  String _camelCase(String str) =>
      str.substring(0, 1).toLowerCase() + str.substring(1);

  /// Creates an enum for all LSP method names.
  LspEnum? _createMethodsEnum(List<Object?> methods) {
    Constant toConstant(Map<String, Object?> item) {
      var name = item['method'] as String;
      // We use documentation from the request/notification for things like
      // proposed check, but we don't put the full request/notification docs
      // on the method enum member.
      var documentation = item['documentation'] as String?;
      var comment = '''Constant for the '$name' method.''';
      return Constant(
        name: _generateMemberName(name, camelCase: true),
        comment: comment,
        isProposed: _isProposed(documentation),
        type: TypeReference.string,
        value: name,
      );
    }

    var methodConstants = methods
        .cast<Map<String, Object?>>()
        .map(toConstant)
        .toList();

    if (methodConstants.isEmpty) {
      return null;
    }

    var comment = 'All standard LSP Methods read from the JSON spec.';
    return LspEnum(
      name: 'Method',
      comment: comment,
      typeOfValues: TypeReference.string,
      constants: methodConstants,
    );
  }

  /// Creates a type alias for a top-level union, such as those used for
  /// request parameters/results that don't have named types in the spec.
  void _createUnionAlias(String name, dynamic model, String? documentation) {
    if (model == null) {
      return;
    }

    // We don't currently support reading the two top-level intersection types.
    // These can just be skipped because the types we're generating here are
    // just for convenience (to produce better names for use in handlers rather
    // than referencing `EitherX<Y>` everywhere).
    if (model['kind'] == 'and') {
      return;
    }

    var type = _extractType(name, '', model);
    if (type is UnionType) {
      _addType(
        TypeAlias(
          name: name,
          comment: documentation,
          isProposed: _isProposed(documentation),
          baseType: type,
          renameReferences: false,
        ),
      );
    }
  }

  Constant _extractEnumValue(TypeBase parentType, dynamic model) {
    var name = model['name'] as String;
    var documentation = model['documentation'] as String?;
    return Constant(
      name: _generateMemberName(name),
      comment: documentation,
      isProposed: _isProposed(documentation),
      type: parentType,
      value: model['value'].toString(),
    );
  }

  Member _extractMember(String parentName, dynamic model) {
    var name = model['name'] as String;
    var documentation = model['documentation'] as String?;
    var type = _extractType(parentName, name, model['type']);

    // Unions may contain `null` types which we promote up to the field.
    var allowsNull = false;
    if (type is UnionType) {
      var types = type.types;

      // Extract and strip `null`s from the union.
      if (types.any(isNullType)) {
        allowsNull = true;
        type = UnionType(types.whereNot(isNullType).toList());
      }
    }

    return Field(
      name: _generateMemberName(name),
      comment: documentation,
      isProposed: _isProposed(documentation),
      type: type,
      allowsNull: allowsNull,
      allowsUndefined: model['optional'] == true,
    );
  }

  /// Reads the type of [model].
  TypeBase _extractType(String parentName, String? fieldName, dynamic model) {
    if (model['kind'] == 'reference' || model['kind'] == 'base') {
      // Reference kinds are other named interfaces defined in the spec, base are
      // other named types defined elsewhere.
      return TypeReference(model['name'] as String);
    } else if (model['kind'] == 'array') {
      return ArrayType(_extractType(parentName, fieldName, model['element']!));
    } else if (model['kind'] == 'map') {
      var name = fieldName ?? '';
      return MapType(
        _extractType(parentName, '${name}Key', model['key']!),
        _extractType(parentName, '${name}Value', model['value']!),
      );
    } else if (model['kind'] == 'literal') {
      // "Literal" here means an inline/anonymous type.
      var inlineTypeName = _generateTypeName(parentName, fieldName ?? '');

      // First record the definition of the anonymous type itself.
      var members = (model['value']['properties'] as List)
          .map((p) => _extractMember(inlineTypeName, p))
          .toList();
      _addType(Interface.inline(inlineTypeName, members));

      // Then return its name.
      return TypeReference(inlineTypeName);
    } else if (model['kind'] == 'stringLiteral') {
      return LiteralType(TypeReference.string, model['value'] as String);
    } else if (model['kind'] == 'or') {
      // Ensure the parent name is reserved so we don't try to reuse its name
      // if we're parsing something without a field name.
      _typeNames.add(parentName);

      var itemTypes = model['items'] as List;
      var types = itemTypes.map((item) {
        var generatedName = _generateAvailableTypeName(parentName, fieldName);
        return _extractType(generatedName, null, item);
      }).toList();

      return UnionType(types);
    } else if (model['kind'] == 'tuple') {
      // We currently just map tuples to an array of any of the types. The
      // LSP 3.17 spec only has one tuple which is `[number, number]`.
      var itemTypes = model['items'] as List;
      var types = itemTypes.mapIndexed((index, item) {
        var suffix = index + 1;
        var name = fieldName ?? '';
        var thisName = '$name$suffix';
        return _extractType(parentName, thisName, item);
      }).toList();
      return ArrayType(UnionType(types));
    } else {
      throw 'Unable to extract type from $model';
    }
  }

  /// Generates an available name for a node.
  ///
  /// If the computed name is already used, a number will be appended to the
  /// end.
  String _generateAvailableTypeName(String containerName, String? fieldName) {
    var name = _generateTypeName(containerName, fieldName ?? '');
    var requiresSuffix = fieldName == null;
    // If the name has already been taken, try appending a number and try
    // again.
    String generatedName;
    var suffixIndex = 1;
    do {
      if (suffixIndex > 20) {
        throw 'Failed to generate an available name for $name';
      }
      generatedName = requiresSuffix || suffixIndex > 1
          ? '$name$suffixIndex'
          : name;
      suffixIndex++;
    } while (_typeNames.contains(generatedName));
    return generatedName;
  }

  /// Generates a valid name for a member.
  String _generateMemberName(String name, {bool camelCase = false}) {
    // Replace any separators like `/` with `_`.
    name = name.replaceAll(_memberNameSeparatorPattern, '_');

    // Replace out any characters we don't want in member names.
    name = name.replaceAll(_memberNameInvalidCharPattern, '');

    // TODO(dantup): Remove this condition and always do camelCase in a future
    //   CL to reduce the migration diff.
    if (camelCase) {
      name = _camelCase(name);
    }
    return name;
  }

  /// Generates a valid name for a type.
  String _generateTypeName(String parent, String child) {
    // Some classes are private (`_InitializeParams`) but still exposed via
    // other classes (`InitializeParams`) but the child types still need to be
    // exposed, so remove any leading underscores.
    if (parent.startsWith('_')) {
      parent = parent.substring(1);
    }
    return '${capitalize(parent)}${capitalize(child)}';
  }

  bool _isProposed(String? documentation) {
    return documentation?.contains('@proposed') ?? false;
  }

  LspEnum _readEnum(dynamic model) {
    var name = model['name'] as String;
    var type = TypeReference(name);
    var baseType = _extractType(name, null, model['type']);
    var documentation = model['documentation'] as String?;

    return LspEnum(
      name: name,
      comment: documentation,
      isProposed: _isProposed(documentation),
      typeOfValues: baseType,
      constants: [
        ...?(model['values'] as List?)?.map((p) => _extractEnumValue(type, p)),
      ],
    );
  }

  void _readNotification(dynamic model) {
    var method = model['method'] as String;
    var namePrefix = method.split('/').map(capitalize).join();
    var documentation = model['documentation'] as String?;

    var paramsDoc = documentation != null
        ? 'Parameters for ${_camelCase(documentation)}'
        : null;

    _createUnionAlias('${namePrefix}Params', model['params'], paramsDoc);
  }

  void _readRequest(dynamic model) {
    var method = model['method'] as String;
    var namePrefix = method.split('/').map(capitalize).join();
    var documentation = model['documentation'] as String?;

    var paramsDoc = documentation != null
        ? 'Parameters for ${_camelCase(documentation)}'
        : null;

    var resultDoc = documentation != null
        ? 'Result for ${_camelCase(documentation)}'
        : null;

    _createUnionAlias('${namePrefix}Params', model['params'], paramsDoc);
    _createUnionAlias('${namePrefix}Result', model['result'], resultDoc);
  }

  LspEntity _readStructure(dynamic model) {
    var name = model['name'] as String;
    var documentation = model['documentation'] as String?;
    return Interface(
      name: name,
      comment: documentation,
      isProposed: _isProposed(documentation),
      baseTypes: [
        ...?(model['extends'] as List?)?.map(
          (e) => TypeReference(e['name'] as String),
        ),
        ...?(model['mixins'] as List?)?.map(
          (e) => TypeReference(e['name'] as String),
        ),
      ],
      members: [
        ...?(model['properties'] as List?)?.map((p) => _extractMember(name, p)),
      ],
    );
  }

  TypeAlias _readTypeAlias(dynamic model) {
    var name = model['name'] as String;
    var documentation = model['documentation'] as String?;
    return TypeAlias(
      name: name,
      comment: documentation,
      isProposed: _isProposed(documentation),
      baseType: _extractType(name, null, model['type']),
      renameReferences: false,
    );
  }
}
