// Copyright (c) 2019, 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:analyzer/dart/element/element.dart';
import 'package:analyzer/file_system/file_system.dart';
import 'package:dartdoc/src/dartdoc_options.dart';
import 'package:dartdoc/src/model/kind.dart';
import 'package:dartdoc/src/model/model.dart';
import 'package:dartdoc/src/model_utils.dart';
import 'package:dartdoc/src/package_meta.dart';
import 'package:dartdoc/src/warnings.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p show Context;
import 'package:pub_semver/pub_semver.dart';

// All hrefs are emitted as relative paths from the output root. We are unable
// to compute them from the page we are generating, and many properties computed
// using hrefs are memoized anyway. To build complete relative hrefs, we emit
// the href with this placeholder, and then replace it with the current page's
// base href afterwards.
// See https://github.com/dart-lang/dartdoc/issues/2090 for further context.
// TODO: Find an approach that doesn't require doing this.
// Unlikely to be mistaken for an identifier, html tag, or something else that
// might reasonably exist normally.
@internal
const String htmlBasePlaceholder = r'%%__HTMLBASE_dartdoc_internal__%%';

/// A [LibraryContainer] that contains [Library] objects related to a particular
/// package.
final class Package extends LibraryContainer
    with Referable, Warnable, MarkdownFileDocumentation {
  @override
  final String name;

  @override
  final PackageGraph packageGraph;

  // Creates a package, if necessary, and adds it to the [packageGraph].
  factory Package.fromPackageMeta(
      PackageMeta packageMeta, PackageGraph packageGraph) {
    var packageName = packageMeta.name;
    var expectNonLocal = !packageGraph.packageMap.containsKey(packageName) &&
        packageGraph.allLibrariesAdded;
    var packagePath = packageGraph.resourceProvider.pathContext
        .canonicalize(packageMeta.dir.path);
    var package = packageGraph.packageMap.putIfAbsent(
      packageMeta.name,
      () => Package._(
        packageMeta.name,
        packageGraph,
        packageMeta,
        packagePath,
      ),
    );
    // Verify that we don't somehow decide to document locally a package picked
    // up after all documented libraries are added, because that breaks the
    // assumption that we've picked up all documented libraries and packages
    // before allLibrariesAdded is true.
    assert(
        !(expectNonLocal && package.documentedWhere == DocumentLocation.local),
        "Found more libraries to document after 'allLibrariesAdded' was set to "
        'true');
    return package;
  }

  Package._(this.name, this.packageGraph, this.packageMeta, this.packagePath)
      : config = DartdocOptionContext.fromContext(
          packageGraph.config,
          packageGraph.resourceProvider.getFolder(packagePath),
          packageGraph.resourceProvider,
        ),
        super(
            isSdk: packageMeta.isSdk,
            enclosingName: packageGraph.defaultPackageName);

  @override
  bool get isCanonical => true;

  /// Number of times we have invoked a tool for this package.
  int toolInvocationIndex = 0;

  // The animation IDs that have already been used, indexed by the [href] of the
  // object that contains them.
  Map<String?, Set<String>> usedAnimationIdsByHref = {};

  /// Holds all libraries added to this package.  May include non-documented
  /// libraries, but is not guaranteed to include a complete list of
  /// non-documented libraries unless they are all referenced by documented ones.
  final Set<Library> allLibraries = {};

  bool get hasHomepage => packageMeta.homepage.isNotEmpty;

  String get homepage => packageMeta.homepage;

  @override
  Kind get kind => isSdk ? Kind.sdk : Kind.package;

  @override
  String? get aboveSidebarPath => null;

  @override
  String? get belowSidebarPath => null;

  /// Return true if the code has defined non-default categories for libraries
  /// in this package.
  bool get hasCategories => categories.isNotEmpty;

  @override
  File? get documentationFile => packageMeta.getReadmeContents();

  @override
  bool get isDocumented =>
      isFirstPackage || documentedWhere != DocumentLocation.missing;

  /// If we have public libraries, this is the default package, or if we are
  /// auto-including dependencies, then this package is public.
  @override
  bool get isPublic =>
      _isLocalPublicByDefault || libraries.any((l) => l.isPublic);

  /// Whether this package is local.
  ///
  /// A package can be local in three ways:
  /// * this is the default package,
  /// * this is part of an embedder SDK, or
  /// * [DartdocOptionContext.autoIncludeDependencies] is true, and this
  ///   package is not excluded with `exclude-packages`.
  bool get isLocal {
    // Do not document as local if we excluded this package by name.
    if (_isExcluded) return false;
    // Document as local if this is the default package.
    if (_isLocalPublicByDefault) return true;
    // Assume we want to document an embedded SDK as local if it has libraries
    // defined in the default package.
    // TODO(jcollins-g): Handle case where embedder SDKs can be assembled from
    // multiple locations?
    if (!packageGraph.hasEmbedderSdk) return false;
    if (!packageMeta.isSdk) return false;
    final packagePath = packageGraph.packageMeta.dir.path;
    return libraries.any((l) => _pathContext.isWithin(
        packagePath, l.element.firstFragment.source.fullName));
  }

  /// True if the global config excludes this package by name.
  bool get _isExcluded => packageGraph.config.isPackageExcluded(name);

  /// True if this is the package being documented by default, or the
  /// global config indicates we are auto-including dependencies.
  bool get _isLocalPublicByDefault =>
      packageMeta == packageGraph.packageMeta ||
      packageGraph.config.autoIncludeDependencies;

  /// Returns the location of documentation for this package, for making
  /// decisions regarding the 'linkToRemote' option and canonicalization.
  @override
  late DocumentLocation documentedWhere = () {
    if (isLocal && isPublic) {
      return DocumentLocation.local;
    }
    if (config.linkToRemote &&
        config.linkToUrl.isNotEmpty &&
        isPublic &&
        !packageGraph.config.isPackageExcluded(name)) {
      return DocumentLocation.remote;
    }
    return DocumentLocation.missing;
  }();

  String get filePath => 'index.html';

  @override
  String get fullyQualifiedName => 'package:$name';

  late final String baseHref = documentedWhere == DocumentLocation.remote
      ? (_remoteBaseHref.endsWith('/') ? _remoteBaseHref : '$_remoteBaseHref/')
      : (config.useBaseHref ? '' : htmlBasePlaceholder);

  String get _remoteBaseHref {
    return config.linkToUrl.replaceAllMapped(_substituteNameVersion, (m) {
      switch (m.group(1)) {
        // Return the prerelease tag of the release if a prerelease, or 'stable'
        // otherwise.  Mostly coded around the Dart SDK's use of dev/stable, but
        // theoretically applicable elsewhere.
        case 'b':
          {
            var version = Version.parse(packageMeta.version);
            var tag = 'stable';
            if (version.isPreRelease) {
              // `version.preRelease` is a `List<dynamic>` with a mix of
              // integers and strings.  Given this, handle
              // "2.8.0-dev.1.0, 2.9.0-1.0.dev", and similar variations.
              tag = version.preRelease.whereType<String>().first;
              // Who knows about non-SDK packages, but SDKs must conform to the
              // known format.
              assert(!packageMeta.isSdk || int.tryParse(tag) == null,
                  'Got an integer as string instead of the expected "dev" tag');
            }
            return tag;
          }
        case 'n':
          return name;
        // The full version string of the package.
        case 'v':
          return packageMeta.version;
        default:
          assert(false, 'Unsupported case: ${m.group(1)}');
          return '';
      }
    });
  }

  static final _substituteNameVersion = RegExp(r'%([bnv])%');

  @override
  String get href => '$baseHref$filePath';

  @override
  String get location => _pathContext.toUri(packageMeta.resolvedDir).toString();

  @override
  Package get package => this;

  /// The default, unnamed category.
  ///
  /// This is initialized by [initializeCategories].
  late final Category defaultCategory;

  /// A map of category name to the category itself.
  ///
  /// This is initialized by [initializeCategories].
  late final Map<String?, Category> nameToCategory;

  /// Adds [categorization] to one or more categories in the [nameToCategory]
  /// map, if it is annotated with `{@category}`, or `[defaultCategory], if not,
  /// via the [addTo] callback.
  void addToCategories(
      ModelElement categorization, void Function(Category) addTo) {
    if (!categorization.isCanonical) {
      return;
    }
    var categoryNames = categorization.categoryNames;
    if (categoryNames.isEmpty) {
      addTo(defaultCategory);
      return;
    }
    for (var category in categoryNames) {
      addTo(nameToCategory.putIfAbsent(
          category, () => Category(category, this, config)));
    }
  }

  /// Initializes the [defaultCategory] and the [nameToCategory] map, with all
  /// appropriate elements.
  void initializeCategories() {
    defaultCategory = Category(null, this, config);
    nameToCategory = {};
    for (var library in libraries.wherePublic) {
      addToCategories(library, (c) => c.libraries.add(library));
      for (var constant in library.constants) {
        addToCategories(constant, (c) => c.constants.add(constant));
      }
      for (var function in library.functions) {
        addToCategories(function, (c) => c.functions.add(function));
      }
      for (var property in library.properties) {
        addToCategories(property, (c) => c.properties.add(property));
      }
      for (var typedef_ in library.typedefs) {
        addToCategories(typedef_, (c) => c.typedefs.add(typedef_));
      }
      for (var extension in library.extensions) {
        addToCategories(extension, (c) => c.extensions.add(extension));
      }
      for (var extensionType in library.extensionTypes) {
        addToCategories(
            extensionType, (c) => c.extensionTypes.add(extensionType));
      }
      for (var class_ in library.classesAndExceptions) {
        addToCategories(class_, (c) => c.addClass(class_));
      }
      for (var enum_ in library.enums) {
        addToCategories(enum_, (c) => c.enums.add(enum_));
      }
      for (var mixin in library.mixins) {
        addToCategories(mixin, (c) => c.mixins.add(mixin));
      }
    }
  }

  /// The categories, sorted alphabetically by name.
  late final List<Category> categories = [
    defaultCategory,
    ...nameToCategory.values,
  ].where((c) => c.name.isNotEmpty).toList(growable: false)
    ..sort();

  Iterable<Category> get categoriesWithPublicLibraries =>
      categories.where((c) => c.libraries.any((e) => e.isPublic));

  /// The documented categories, sorted either by the 'categoryOrder' option, or
  /// by name.
  ///
  /// In the case that 'categoryOrder' is given, any documented categories which
  /// are not found in 'categoryOrder' are listed after the ones which are,
  /// ordered by name.
  Iterable<Category> get documentedCategoriesSorted {
    if (config.categoryOrder.isEmpty) {
      // Category display order is configurable; leave the category order
      // as defined if the order is specified.
      return categories.whereDocumented;
    }

    var documentedCategories =
        categories.whereDocumented.toList(growable: false);
    return documentedCategories
      ..sort((a, b) {
        var aIndex = config.categoryOrder.indexOf(a.name);
        var bIndex = config.categoryOrder.indexOf(b.name);
        if (aIndex >= 0 && bIndex >= 0) {
          return aIndex.compareTo(bIndex);
        } else if (aIndex < 0 && bIndex >= 0) {
          // `a` is not found in the category order, but `b` is.
          return 1;
        } else if (bIndex < 0 && aIndex >= 0) {
          // `b` is not found in the category order, but `a` is.
          return -1;
        } else {
          // Neither is found in the category order.
          return documentedCategories
              .indexOf(a)
              .compareTo(documentedCategories.indexOf(b));
        }
      });
  }

  bool get hasDocumentedCategories => categories.any((e) => e.isDocumented);

  @override
  final DartdocOptionContext config;

  /// Is this the package at the top of the list?  We display the first
  /// package specially (with "Libraries" rather than the package name).
  bool get isFirstPackage =>
      packageGraph.localPackages.isNotEmpty &&
      identical(packageGraph.localPackages.first, this);

  final String packagePath;

  String get version => packageMeta.version;

  final PackageMeta packageMeta;

  @override
  Element? get element => null;

  @override
  List<String> get containerOrder => config.packageOrder;

  @override
  String get sortKey => name;

  @override
  late final Map<String, Referable> referenceChildren = {
    // Do not override any preexisting data, and insert based on the public
    // library sort order.
    // TODO(jcollins-g): warn when results require package-global lookups like
    // this.
    for (var library in publicLibrariesSorted) ...{
      library.referenceName: library,
      ...library.referenceChildren,
    }
  };

  @override
  Iterable<Referable> get referenceParents => [packageGraph];

  p.Context get _pathContext => packageGraph.resourceProvider.pathContext;

  @override
  String get referenceName => 'package:$name';
}
