// 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:async';

import 'package:analysis_server/protocol/protocol_generated.dart';
import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/dart/analysis/uri_converter.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/ast_factory.dart';
import 'package:analyzer/dart/ast/token.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/file_system/file_system.dart';
import 'package:analyzer/src/dart/ast/ast_factory.dart';
import 'package:analyzer/src/dart/ast/token.dart';
import 'package:analyzer/src/dart/resolver/scope.dart';
import 'package:analyzer/src/generated/source.dart';
import 'package:analyzer_plugin/protocol/protocol_common.dart' hide Element;
import 'package:analyzer_plugin/utilities/change_builder/change_builder_dart.dart';
import 'package:analyzer_plugin/utilities/range_factory.dart';
import 'package:front_end/src/base/syntactic_entity.dart';
import 'package:path/src/context.dart';

/**
 * An object used to compute a set of edits to add imports to a given library in
 * order to make a given set of elements visible.
 *
 * This is used to implement the `edit.importElements` request.
 */
class ImportElementsComputer {
  /**
   * The resource provider used to access the file system.
   */
  final ResourceProvider resourceProvider;

  /**
   * The resolution result associated with the defining compilation unit of the
   * library to which imports might be added.
   */
  final ResolveResult libraryResult;

  /**
   * Initialize a newly created builder.
   */
  ImportElementsComputer(this.resourceProvider, this.libraryResult);

  /**
   * Create the edits that will cause the list of [importedElements] to be
   * imported into the library at the given [path].
   */
  Future<SourceChange> createEdits(
      List<ImportedElements> importedElementsList) async {
    // TODO(brianwilkerson) Determine whether this await is necessary.
    await null;
    List<ImportedElements> filteredImportedElements =
        _filterImportedElements(importedElementsList);
    LibraryElement libraryElement = libraryResult.libraryElement;
    UriConverter uriConverter = libraryResult.session.uriConverter;
    List<ImportDirective> existingImports = <ImportDirective>[];
    for (var directive in libraryResult.unit.directives) {
      if (directive is ImportDirective) {
        existingImports.add(directive);
      }
    }

    DartChangeBuilder builder = new DartChangeBuilder(libraryResult.session);
    await builder.addFileEdit(libraryResult.path,
        (DartFileEditBuilder builder) {
      for (ImportedElements importedElements in filteredImportedElements) {
        List<ImportDirective> matchingImports =
            _findMatchingImports(existingImports, importedElements);
        if (matchingImports.isEmpty) {
          //
          // The required library is not being imported with a matching prefix,
          // so we need to add an import.
          //
          File importedFile = resourceProvider.getFile(importedElements.path);
          Uri uri = uriConverter.pathToUri(importedFile.path);
          Source importedSource = importedFile.createSource(uri);
          String importUri =
              _getLibrarySourceUri(libraryElement, importedSource);
          _InsertionDescription description =
              _getInsertionDescription(importUri);
          builder.addInsertion(description.offset, (DartEditBuilder builder) {
            for (int i = 0; i < description.newLinesBefore; i++) {
              builder.writeln();
            }
            builder.write("import '");
            builder.write(importUri);
            builder.write("'");
            if (importedElements.prefix.isNotEmpty) {
              builder.write(' as ');
              builder.write(importedElements.prefix);
            }
            builder.write(';');
            for (int i = 0; i < description.newLinesAfter; i++) {
              builder.writeln();
            }
          });
        } else {
          //
          // There are some imports of the library with a matching prefix. We
          // need to determine whether the names are already visible or whether
          // we need to make edits to make them visible.
          //
          // Compute the edits that need to be made.
          //
          Map<ImportDirective, _ImportUpdate> updateMap =
              <ImportDirective, _ImportUpdate>{};
          for (String requiredName in importedElements.elements) {
            _computeUpdate(updateMap, matchingImports, requiredName);
          }
          //
          // Apply the edits.
          //
          for (ImportDirective directive in updateMap.keys) {
            _ImportUpdate update = updateMap[directive];
            List<String> namesToUnhide = update.namesToUnhide;
            List<String> namesToShow = update.namesToShow;
            namesToShow.sort();
            NodeList<Combinator> combinators = directive.combinators;
            int combinatorCount = combinators.length;
            for (int combinatorIndex = 0;
                combinatorIndex < combinatorCount;
                combinatorIndex++) {
              Combinator combinator = combinators[combinatorIndex];
              if (combinator is HideCombinator && namesToUnhide.isNotEmpty) {
                NodeList<SimpleIdentifier> hiddenNames = combinator.hiddenNames;
                int nameCount = hiddenNames.length;
                int first = -1;
                for (int nameIndex = 0; nameIndex < nameCount; nameIndex++) {
                  if (namesToUnhide.contains(hiddenNames[nameIndex].name)) {
                    if (first < 0) {
                      first = nameIndex;
                    }
                  } else {
                    if (first >= 0) {
                      // Remove a range of names.
                      builder.addDeletion(range.startStart(
                          hiddenNames[first], hiddenNames[nameIndex]));
                      first = -1;
                    }
                  }
                }
                if (first == 0) {
                  // Remove the whole combinator.
                  if (combinatorIndex == 0) {
                    if (combinatorCount > 1) {
                      builder.addDeletion(range.startStart(
                          combinator, combinators[combinatorIndex + 1]));
                    } else {
                      SyntacticEntity precedingNode = directive.prefix ??
                          directive.deferredKeyword ??
                          directive.uri;
                      if (precedingNode == null) {
                        builder.addDeletion(range.node(combinator));
                      } else {
                        builder.addDeletion(
                            range.endEnd(precedingNode, combinator));
                      }
                    }
                  } else {
                    builder.addDeletion(range.endEnd(
                        combinators[combinatorIndex - 1], combinator));
                  }
                } else if (first > 0) {
                  // Remove a range of names that includes the last name.
                  builder.addDeletion(range.endEnd(
                      hiddenNames[first - 1], hiddenNames[nameCount - 1]));
                }
              } else if (combinator is ShowCombinator &&
                  namesToShow.isNotEmpty) {
                // TODO(brianwilkerson) Add the names in alphabetic order.
                builder.addInsertion(combinator.shownNames.last.end,
                    (DartEditBuilder builder) {
                  for (String nameToShow in namesToShow) {
                    builder.write(', ');
                    builder.write(nameToShow);
                  }
                });
              }
            }
          }
        }
      }
    });
    return builder.sourceChange;
  }

  /**
   * Choose the import for which the least amount of work is required,
   * preferring to do no work in there is an import that already makes the name
   * visible, and preferring to remove hide combinators rather than add show
   * combinators.
   *
   * The name is visible without needing any changes if:
   * - there is an import with no combinators,
   * - there is an import with only hide combinators and none of them hide the
   *   name,
   * - there is an import that shows the name and doesn't subsequently hide the
   *   name.
   */
  void _computeUpdate(Map<ImportDirective, _ImportUpdate> updateMap,
      List<ImportDirective> matchingImports, String requiredName) {
    /**
     * Return `true` if the [requiredName] is in the given list of [names].
     */
    bool nameIn(NodeList<SimpleIdentifier> names) {
      for (SimpleIdentifier name in names) {
        if (name.name == requiredName) {
          return true;
        }
      }
      return false;
    }

    ImportDirective preferredDirective = null;
    int bestEditCount = -1;
    bool deleteHide = false;
    bool addShow = false;

    for (ImportDirective directive in matchingImports) {
      NodeList<Combinator> combinators = directive.combinators;
      if (combinators.isEmpty) {
        return;
      }
      bool hasHide = false;
      bool needsShow = false;
      int editCount = 0;
      for (Combinator combinator in combinators) {
        if (combinator is HideCombinator) {
          if (nameIn(combinator.hiddenNames)) {
            hasHide = true;
            editCount++;
          }
        } else if (combinator is ShowCombinator) {
          if (needsShow || !nameIn(combinator.shownNames)) {
            needsShow = true;
            editCount++;
          }
        }
      }
      if (editCount == 0) {
        return;
      } else if (bestEditCount < 0 || editCount < bestEditCount) {
        preferredDirective = directive;
        bestEditCount = editCount;
        deleteHide = hasHide;
        addShow = needsShow;
      }
    }

    _ImportUpdate update = updateMap.putIfAbsent(
        preferredDirective, () => new _ImportUpdate(preferredDirective));
    if (deleteHide) {
      update.unhide(requiredName);
    }
    if (addShow) {
      update.show(requiredName);
    }
  }

  /**
   * Filter the given list of imported elements ([originalList]) so that only
   * the names that are not already defined still remain. Names that are already
   * defined are removed even if they might not resolve to the same name as in
   * the original source.
   */
  List<ImportedElements> _filterImportedElements(
      List<ImportedElements> originalList) {
    LibraryElement libraryElement = libraryResult.libraryElement;
    LibraryScope libraryScope = new LibraryScope(libraryElement);
    AstFactory factory = new AstFactoryImpl();
    List<ImportedElements> filteredList = <ImportedElements>[];
    for (ImportedElements elements in originalList) {
      List<String> originalElements = elements.elements;
      List<String> filteredElements = originalElements.toList();
      for (String name in originalElements) {
        Identifier identifier = factory
            .simpleIdentifier(new StringToken(TokenType.IDENTIFIER, name, -1));
        if (elements.prefix.isNotEmpty) {
          SimpleIdentifier prefix = factory.simpleIdentifier(
              new StringToken(TokenType.IDENTIFIER, elements.prefix, -1));
          Token period = new SimpleToken(TokenType.PERIOD, -1);
          identifier = factory.prefixedIdentifier(prefix, period, identifier);
        }
        Element element = libraryScope.lookup(identifier, libraryElement);
        if (element != null) {
          filteredElements.remove(name);
        }
      }
      if (originalElements.length == filteredElements.length) {
        filteredList.add(elements);
      } else if (filteredElements.isNotEmpty) {
        filteredList.add(new ImportedElements(
            elements.path, elements.prefix, filteredElements));
      }
    }
    return filteredList;
  }

  /**
   * Return all of the import elements in the list of [existingImports] that
   * match the given specification of [importedElements], or an empty list if
   * there are no such imports.
   */
  List<ImportDirective> _findMatchingImports(
      List<ImportDirective> existingImports,
      ImportedElements importedElements) {
    List<ImportDirective> matchingImports = <ImportDirective>[];
    for (ImportDirective existingImport in existingImports) {
      if (_matches(existingImport, importedElements)) {
        matchingImports.add(existingImport);
      }
    }
    return matchingImports;
  }

  /**
   * Return the offset at which an import of the given [importUri] should be
   * inserted.
   *
   * Partially copied from DartFileEditBuilderImpl.
   */
  _InsertionDescription _getInsertionDescription(String importUri) {
    CompilationUnit unit = libraryResult.unit;
    LibraryDirective libraryDirective;
    List<ImportDirective> importDirectives = <ImportDirective>[];
    List<Directive> otherDirectives = <Directive>[];
    for (Directive directive in unit.directives) {
      if (directive is LibraryDirective) {
        libraryDirective = directive;
      } else if (directive is ImportDirective) {
        importDirectives.add(directive);
      } else {
        otherDirectives.add(directive);
      }
    }
    if (importDirectives.isEmpty) {
      if (libraryDirective == null) {
        if (otherDirectives.isEmpty) {
          // TODO(brianwilkerson) Insert after any non-doc comments.
          return new _InsertionDescription(0, after: 2);
        }
        return new _InsertionDescription(otherDirectives[0].offset, after: 2);
      }
      return new _InsertionDescription(libraryDirective.end, before: 2);
    }
    // TODO(brianwilkerson) Fix this to find the right location.
    // See DartFileEditBuilderImpl._addLibraryImports for inspiration.
    return new _InsertionDescription(importDirectives.last.end, before: 1);
  }

  /**
   * Computes the best URI to import [what] into [from].
   *
   * Copied from DartFileEditBuilderImpl.
   */
  String _getLibrarySourceUri(LibraryElement from, Source what) {
    String whatPath = what.fullName;
    // check if an absolute URI (such as 'dart:' or 'package:')
    Uri whatUri = what.uri;
    String whatUriScheme = whatUri.scheme;
    if (whatUriScheme != '' && whatUriScheme != 'file') {
      return whatUri.toString();
    }
    // compute a relative URI
    Context context = resourceProvider.pathContext;
    String fromFolder = context.dirname(from.source.fullName);
    String relativeFile = context.relative(whatPath, from: fromFolder);
    return context.split(relativeFile).join('/');
  }

  /**
   * Return `true` if the given [import] matches the given specification of
   * [importedElements]. They will match if they import the same library using
   * the same prefix.
   */
  bool _matches(ImportDirective import, ImportedElements importedElements) {
    LibraryElement library = (import.element as ImportElement).importedLibrary;
    return library != null &&
        library.source.fullName == importedElements.path &&
        (import.prefix?.name ?? '') == importedElements.prefix;
  }
}

/**
 * Information about how a given import directive needs to be updated in order
 * to make the required names visible.
 */
class _ImportUpdate {
  /**
   * The import directive to be updated.
   */
  final ImportDirective import;

  /**
   * The list of names that are currently hidden that need to not be hidden.
   */
  final List<String> namesToUnhide = <String>[];

  /**
   * The list of names that need to be added to show clauses.
   */
  final List<String> namesToShow = <String>[];

  /**
   * Initialize a newly created information holder to hold information about
   * updates to the given [import].
   */
  _ImportUpdate(this.import);

  /**
   * Record that the given [name] needs to be added to show combinators.
   */
  void show(String name) {
    namesToShow.add(name);
  }

  /**
   * Record that the given [name] needs to be removed from hide combinators.
   */
  void unhide(String name) {
    namesToUnhide.add(name);
  }
}

class _InsertionDescription {
  final int newLinesBefore;
  final int offset;
  final int newLinesAfter;

  _InsertionDescription(this.offset, {int before: 0, int after: 0})
      : this.newLinesBefore = before,
        this.newLinesAfter = after;
}
