// Copyright (c) 2024, 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' hide json;
import 'dart:io';

import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart' show sha256;
import 'package:meta/meta.dart';

import 'api/build_and_link.dart';
import 'encoded_asset.dart';
import 'extension.dart';
import 'hooks/syntax.g.dart';
import 'metadata.dart';
import 'user_defines.dart';
import 'utils/datetime.dart';

/// The input for `hook/build.dart` or `hook/link.dart`.
///
/// The shared properties of a [LinkInput] and a [BuildInput]. This abstraction
/// makes it easier to design APIs intended for both kinds of build hooks,
/// building and linking.
sealed class HookInput {
  /// The underlying json configuration of this [HookInput].
  Map<String, Object?> get json => _syntax.json;

  /// The directory in which output and intermediate artifacts that are unique
  /// to the [config] can be placed.
  ///
  /// This directory is unique per hook and per [config]. The directory is
  /// nested inside [outputDirectoryShared] and has a short checksum to avoid
  /// running out of path length on Winddows.
  ///
  /// Prefer using a sub directory in [outputDirectoryShared] with a
  /// checksum of the fields on the [config] that influence your build. Reusing
  /// a precise subdirectory only dependent on what influences your build avoids
  /// cache misses for [config]s that differ in fields irrelevant for your
  /// build.
  ///
  /// The contents of this directory will not be modified by anything else than
  /// the hook itself.
  ///
  /// The invoker of the the hook will ensure concurrent invocations wait on
  /// each other.
  Uri get outputDirectory {
    if (_cachedOutputDirectory != null) {
      return _cachedOutputDirectory!.uri;
    }
    final checksum = config.computeChecksum();
    final directory = Directory.fromUri(
      outputDirectoryShared.resolve('$checksum/'),
    );
    if (!directory.existsSync()) {
      directory.createSync(recursive: true);
    }
    _cachedOutputDirectory = directory;
    return directory.uri;
  }

  Directory? _cachedOutputDirectory;

  /// The directory in which shared output and intermediate artifacts can be
  /// placed.
  ///
  /// This directory is unique per hook. Use a sub directory of
  /// [outputDirectoryShared] with a checksum of the parts on the [config] that
  /// influence your assets. Ensure your checksum is relatively short to avoid
  /// running out of path lengths on Windows. Reusing a precise subdirectory
  /// only dependent on what influences your build avoids cache misses for
  /// [config]s that differ in fields irrelevant for your build.
  ///
  /// The contents of this directory will not be modified by anything else than
  /// the hook itself.
  ///
  /// The invoker of the the hook will ensure concurrent invocations wait on
  /// each other.
  Uri get outputDirectoryShared => _syntax.outDirShared;

  /// The file to write the [HookOutput] to at the end of a hook invocation.
  Uri get outputFile;

  /// The name of the package the assets are built for.
  String get packageName => _syntax.packageName;

  /// The root of the package the assets are built for.
  ///
  /// Often a package's assets are built because a package is a dependency of
  /// another. For this it is convenient to know the packageRoot.
  Uri get packageRoot => _syntax.packageRoot;

  final HookInputSyntax _syntax;

  HookInput(Map<String, Object?> json)
    : _syntax = HookInputSyntax.fromJson(json) {
    // Trigger validation, remove with cleanup.
    outputDirectory;
    outputDirectoryShared;
  }

  @override
  String toString() => const JsonEncoder.withIndent('  ').convert(json);

  /// The configuration for this hook input.
  HookConfig get config => HookConfig._(this);

  /// The user-defines for this hook input.
  HookInputUserDefines get userDefines => HookInputUserDefines._(this);
}

/// The user-defines in [HookInput.userDefines].
final class HookInputUserDefines {
  final HookInput _input;

  HookInputUserDefines._(this._input);

  /// The value for the user-define for [key] for this package.
  ///
  /// This can be arbitrary JSON/YAML if provided from the SDK from such source.
  /// If it's provided from command-line arguments, it's likely a string.
  Object? operator [](String key) {
    final syntaxNode = _input._syntax.userDefines;
    if (syntaxNode == null) {
      return null;
    }
    final packageUserDefines = PackageUserDefinesSyntaxExtension.fromSyntax(
      syntaxNode,
    );
    final pubspecSource = packageUserDefines.workspacePubspec;
    return pubspecSource?.defines[key];
  }

  /// The absolute path for user-defines for [key] for this package.key
  ///
  /// The relative path passed as user-define is resolved against the base path.
  /// For user-defines originating from a JSON/YAML, the base path is this
  /// JSON/YAML. For user-defines originating from command-line aruments, the
  /// base path is the working directory of the command-line invocation.
  ///
  /// If the user-define is `null` or not a [String], returns `null`.
  Uri? path(String key) {
    final syntaxNode = _input._syntax.userDefines;
    if (syntaxNode == null) {
      return null;
    }
    final packageUserDefines = PackageUserDefinesSyntaxExtension.fromSyntax(
      syntaxNode,
    );
    final pubspecSource = packageUserDefines.workspacePubspec;
    final sources = <PackageUserDefinesSource>[];
    if (pubspecSource != null) {
      sources.add(pubspecSource);
    }
    // TODO(https://github.com/dart-lang/native/issues/2215): Add commandline
    // arguments.
    for (final source in sources) {
      final relativepath = source.defines[key];
      if (relativepath is String) {
        return source.basePath.resolve(relativepath);
      }
    }
    return null;
  }
}

/// The builder for [HookInput].
sealed class HookInputBuilder {
  final _syntax = HookInputSyntax.fromJson({})
    ..config = ConfigSyntax(buildAssetTypes: [], extensions: null);

  /// The JSON representation of this hook input builder.
  Map<String, Object?> get json => _syntax.json;

  /// Sets up the hook input.
  void setupShared({
    required Uri packageRoot,
    required String packageName,
    required Uri outputDirectoryShared,
    required Uri outputFile,
    PackageUserDefines? userDefines,
  }) {
    _syntax.packageRoot = packageRoot;
    _syntax.packageName = packageName;
    _syntax.outDirShared = outputDirectoryShared;
    _syntax.outFile = outputFile;
    _syntax.userDefines = userDefines?.toSyntax();
  }

  /// Constructs a checksum for a [BuildInput].
  ///
  /// This can be used to construct an output directory name specific to the
  /// [HookConfig] being built with this builder. It is therefore assumed the
  /// output directory has not been set yet.
  String computeChecksum() => _jsonChecksum(_syntax.config.json);

  /// The configuration for this hook input.
  HookConfigBuilder get config => HookConfigBuilder._(this);

  /// Adds the protocol extension to this hook input.
  ///
  /// This will [HookConfigBuilder.addBuildAssetTypes] and add the extensions'
  /// config to [config].
  void addExtension(ProtocolExtension extension);
}

String _jsonChecksum(Map<String, Object?> json) {
  final hash = sha256
      .convert(const JsonEncoder().fuse(const Utf8Encoder()).convert(json))
      .toString()
      // 256 bit hashes lead to 64 hex character strings. To avoid overflowing
      // file paths limits on Windows, only use 10. 10 hex characters with 1000
      // different configs leads to a one in a million collision chance. On
      // collision, we'd get a cache miss and rerun the hooks and throw away the
      // cache for the colliding configuration.
      .substring(0, 10);
  return hash;
}

/// The input for `hook/build.dart`.
final class BuildInput extends HookInput {
  @override
  Uri get outputFile => _syntax.outFile;

  final BuildInputSyntax _syntaxBuildInput;

  ///// Creates a [BuildInput] from the given [json].
  BuildInput(super.json) : _syntaxBuildInput = BuildInputSyntax.fromJson(json);

  @override
  BuildConfig get config => BuildConfig._(this);

  /// The assets emitted by `hook/build.dart` of direct dependencies with [ToBuildHooks].
  BuildInputAssets get assets => BuildInputAssets._(this);

  /// The metadata emitted by dependent build hooks.
  BuildInputMetadata get metadata => BuildInputMetadata._(this);
}

/// The metadata in [BuildInput.metadata].
final class BuildInputMetadata {
  final BuildInput _input;

  BuildInputMetadata._(this._input);

  /// The metadata emitted by the build hook of package [packageName].
  PackageMetadata operator [](String packageName) => PackageMetadata._(
    (_input.assets.encodedAssets[packageName] ?? [])
        .where((e) => e.isMetadataAsset)
        .map((e) => e.asMetadataAsset)
        .toList(),
  );
}

/// The metadata from a specific package, available in [BuildInput.metadata].
final class PackageMetadata {
  PackageMetadata._(this._metadata);

  final List<MetadataAsset> _metadata;

  /// Retrieves the metadata value for the given [key].
  Object? operator [](String key) =>
      _metadata.firstWhereOrNull((e) => e.key == key)?.value;
}

/// The assets in [BuildInput.assets].
final class BuildInputAssets {
  final BuildInput _input;

  BuildInputAssets._(this._input);

  /// The encoded assets from direct dependencies.
  Map<String, List<EncodedAsset>> get encodedAssets => {
    for (final MapEntry(:key, :value)
        in (_input._syntaxBuildInput.assets ?? {}).entries)
      key: EncodedAssetSyntax._fromSyntax(value),
  };

  /// The encoded assets from the direct dependency [packageName].
  List<EncodedAsset> operator [](String packageName) =>
      encodedAssets[packageName] ?? [];
}

/// The builder for [BuildInput].
final class BuildInputBuilder extends HookInputBuilder {
  @override
  BuildInputSyntax get _syntax => BuildInputSyntax.fromJson(super._syntax.json);

  /// Sets up the build input with the given [assets].
  void setupBuildInput({Map<String, List<EncodedAsset>>? assets}) {
    _syntax.setup(
      assets: assets == null
          ? null
          : {
              for (final MapEntry(:key, :value) in assets.entries)
                key: [for (final asset in value) asset.toSyntax()],
            },
    );
  }

  @override
  BuildConfigBuilder get config => BuildConfigBuilder._(this);

  @override
  void addExtension(ProtocolExtension extension) =>
      extension.setupBuildInput(this);

  /// Builds the [BuildInput].
  BuildInput build() => BuildInput(json);
}

/// The builder for [HookConfig].
final class HookConfigBuilder {
  ///// The build for the parent (the hook input).
  final HookInputBuilder builder;

  HookConfigBuilder._(this.builder);

  ConfigSyntax get _syntax => builder._syntax.config;

  /// The JSON representation of this config.
  Map<String, Object?> get json => _syntax.json;

  /// Adds asset types to this hook configuration.
  void addBuildAssetTypes(Iterable<String> assetTypes) {
    _syntax.buildAssetTypes.addAll(assetTypes);
  }
}

/// The builder for [BuildConfig].
final class BuildConfigBuilder extends HookConfigBuilder {
  @override
  late final BuildConfigSyntax _syntax = BuildConfigSyntax.fromJson(
    super._syntax.json,
  );

  BuildConfigBuilder._(super.builder) : super._();

  /// Sets up the build configuration.
  void setupBuild({required bool linkingEnabled}) {
    _syntax.setup(linkingEnabled: linkingEnabled);
  }
}

/// The input for a `hook/link.dart`.
final class LinkInput extends HookInput {
  List<EncodedAsset> get _encodedAssets => assets.encodedAssets;

  /// The file containing recorded usages, if any.
  ///
  /// Experimental: The record uses feature needs to be enabled as experiment.
  /// The experiment is only available in the Dart SDK, not in Flutter. We
  /// reserve the right to break this API at any point without respecting
  /// semantic versioning of this package.
  @experimental
  Uri? get recordedUsagesFile => _syntaxLinkInput.resourceIdentifiers;

  @override
  Uri get outputFile => _syntax.outFile;

  final LinkInputSyntax _syntaxLinkInput;

  /// Creates a [LinkInput] from the given [json].
  LinkInput(super.json) : _syntaxLinkInput = LinkInputSyntax.fromJson(json) {
    // Run validation.
    _encodedAssets;
  }

  @override
  LinkConfig get config => LinkConfig._(this);

  /// The assets passed to `hook/link.dart`.
  LinkInputAssets get assets => LinkInputAssets._(this);

  /// The metadata sent to this link hook by dependent link hooks.
  Map<String, Object?> get metadata => Map.fromEntries(
    assets.assetsFromLinking
        .where((e) => e.isMetadataAsset)
        .map((e) => e.asMetadataAsset)
        .map((e) => MapEntry(e.key, e.value)),
  );
}

/// The assets in [LinkInput.assets];
final class LinkInputAssets {
  final LinkInput _input;

  LinkInputAssets._(this._input);

  /// The encoded assets passed to `hook/link.dart`.
  List<EncodedAsset> get encodedAssets =>
      EncodedAssetSyntax._fromSyntax(_input._syntaxLinkInput.assets);

  /// The encoded assets from direct dependencies.
  List<EncodedAsset> get assetsFromLinking =>
      EncodedAssetSyntax._fromSyntax(_input._syntaxLinkInput.assetsFromLinking);
}

/// The builder for [LinkInput].
final class LinkInputBuilder extends HookInputBuilder {
  @override
  LinkInputSyntax get _syntax => LinkInputSyntax.fromJson(super._syntax.json);

  /// Sets up the link input.
  void setupLink({
    required List<EncodedAsset> assets,
    required List<EncodedAsset> assetsFromLinking,
    required Uri? recordedUsesFile,
  }) {
    _syntax.setup(
      assets: [for (final asset in assets) asset.toSyntax()],
      assetsFromLinking: [
        for (final asset in assetsFromLinking) asset.toSyntax(),
      ],
      resourceIdentifiers: recordedUsesFile,
    );
  }

  @override
  void addExtension(ProtocolExtension extension) =>
      extension.setupLinkInput(this);

  @override
  LinkConfigBuilder get config => LinkConfigBuilder._(this);

  /// Builds the [LinkInput].
  LinkInput build() => LinkInput(json);
}

/// The builder for [BuildConfig].
final class LinkConfigBuilder extends HookConfigBuilder {
  @override
  late final BuildConfigSyntax _syntax = BuildConfigSyntax.fromJson(
    super._syntax.json,
  );

  LinkConfigBuilder._(super.builder) : super._();
}

/// Extension methods for [EncodedAsset] to convert to and from the syntax
/// model.
extension EncodedAssetSyntax on List<EncodedAsset> {
  static List<EncodedAsset> _fromSyntax(List<AssetSyntax>? assets) {
    if (assets == null) {
      return [];
    }
    return [
      for (final asset in assets) EncodedAsset.fromJson(asset.json, asset.path),
    ];
  }
}

/// The output from a `hook/build.dart` or `hook/link.dart`.
///
/// The shared properties of a [BuildOutput] and a [LinkOutput]. This
/// abstraction makes it easier to design APIs intended for both kinds of build
/// hooks, building and linking.
sealed class HookOutput {
  /// The underlying json configuration of this [HookOutput].
  Map<String, Object?> get json => _syntax.json;

  /// Start time for the build of this output.
  ///
  /// The [timestamp] is rounded down to whole seconds, because
  /// [File.lastModified] is rounded to whole seconds and caching logic compares
  /// these timestamps.
  DateTime get timestamp => DateTime.parse(_syntax.timestamp);

  /// The files used by this build.
  ///
  /// If any of the files in [dependencies] are modified after [timestamp], the
  /// build will be re-run.
  ///
  /// The (transitive) Dart sources do not have to be added to these
  /// dependencies, only non-Dart files. (Note that old Dart and Flutter SDKs
  /// do not automatically add the Dart sources. So builds get wrongly cached,
  /// try updating to the latest release.)
  List<Uri> get dependencies => _syntax.dependencies ?? [];

  /// The assets produced by this build.
  List<EncodedAsset> get _encodedAssets =>
      EncodedAssetSyntax._fromSyntax(_syntax.assets);

  HookOutputSyntax get _syntax;

  HookOutput._(Map<String, Object?> json);

  @override
  String toString() => const JsonEncoder.withIndent('  ').convert(json);
}

/// The builder for [HookOutput].
sealed class HookOutputBuilder {
  final _syntax = HookOutputSyntax(
    timestamp: DateTime.now().roundDownToSeconds().toString(),
    assets: null,
    dependencies: null,
    status: OutputStatusSyntax.success,
    failureDetails: null,
    assetsForLinking: {},
  );

  /// The JSON representation of this hook output builder.
  Map<String, Object?> get json => _syntax.json;

  HookOutputBuilder();

  /// Adds file used by this build.
  ///
  /// If any of the files are modified after [BuildOutput.timestamp], the
  // build will be  re-run.
  @Deprecated('Use dependencies.add() instead.')
  void addDependency(Uri uri) {
    dependencies.add(uri);
  }

  /// Adds files used by this build.
  ///
  /// If any of the files are modified after [BuildOutput.timestamp], the
  // build will be  re-run.
  @Deprecated('Use dependencies.addAll() instead.')
  void addDependencies(Iterable<Uri> uris) {
    dependencies.addAll(uris);
  }

  /// The dependencies builder for this hook output.
  HookOutputDependenciesBuilder get dependencies =>
      HookOutputDependenciesBuilder._(this);

  /// Sets the failure of this output.
  void setFailure(FailureType value) {
    _syntax.status = OutputStatusSyntax.failure;
    _syntax.failureDetails = FailureSyntax(
      type: switch (value) {
        FailureType.build => FailureTypeSyntax.build,
        FailureType.infra => FailureTypeSyntax.infra,
        FailureType.uncategorized => FailureTypeSyntax.uncategorized,
        _ => FailureTypeSyntax.uncategorized,
      },
    );
  }
}

/// The builder for [HookOutput.dependencies].
class HookOutputDependenciesBuilder {
  final HookOutputBuilder _output;

  HookOutputDependenciesBuilder._(this._output);

  /// Adds file used by this build.
  ///
  /// If any of the files are modified after [BuildOutput.timestamp], the
  // build will be  re-run.
  void add(Uri uri) {
    final dependencies = _output._syntax.dependencies ?? [];
    dependencies.add(uri);
    _output._syntax.dependencies = dependencies;
  }

  /// Adds files used by this build.
  ///
  /// If any of the files are modified after [BuildOutput.timestamp], the
  // build will be  re-run.
  void addAll(Iterable<Uri> uris) {
    final dependencies = _output._syntax.dependencies ?? [];
    dependencies.addAll(uris);
    _output._syntax.dependencies = dependencies;
  }
}

/// The output from a `hook/build.dart` on success.
///
/// See [BuildOutputFailure] for failure.
final class BuildOutput extends HookOutput implements BuildOutputMaybeFailure {
  /// The assets produced by this build which should be linked.
  ///
  /// Every key in the map is a package name. These assets in the values are not
  /// bundled with the application, but are sent to the link hook of the package
  /// specified in the key, which can decide if they are bundled or not.
  Map<String, List<EncodedAsset>> get _encodedAssetsForLinking => {
    for (final MapEntry(:key, :value)
        in (_syntax.assetsForLinking ?? {}).entries)
      key: EncodedAssetSyntax._fromSyntax(value),
  };

  List<EncodedAsset> get _encodedAssetsForBuild =>
      EncodedAssetSyntax._fromSyntax(_syntax.assetsForBuild ?? []);

  @override
  final BuildOutputSyntax _syntax;

  /// Creates a [BuildOutput] from the given [json].
  BuildOutput(super.json)
    : _syntax = BuildOutputSyntax.fromJson(json),
      super._();

  /// The assets produced by this build.
  BuildOutputAssets get assets => BuildOutputAssets._(this);
}

/// The assets in [BuildOutput.assets].
final class BuildOutputAssets {
  final BuildOutput _output;

  BuildOutputAssets._(this._output);

  /// The assets produced by this build.
  List<EncodedAsset> get encodedAssets => _output._encodedAssets;

  /// The assets produced by this build which should be linked.
  ///
  /// Every key in the map is a package name. These assets in the values are not
  /// bundled with the application, but are sent to the link hook of the package
  /// specified in the key, which can decide if they are bundled or not.
  Map<String, List<EncodedAsset>> get encodedAssetsForLinking =>
      _output._encodedAssetsForLinking;

  /// The assets produced by this build which should be available to subsequent
  /// build hooks.
  List<EncodedAsset> get encodedAssetsForBuild =>
      _output._encodedAssetsForBuild;
}

/// The builder for [BuildOutput].
///
/// There are various Dart extensions on this [BuildOutputBuilder] that allow
/// adding specific asset types - which should be used by normal hook authors.
/// For example
///
/// <!-- file://./../../example/api/config_snippet_1.dart -->
/// ```dart
/// import 'package:code_assets/code_assets.dart';
/// import 'package:data_assets/data_assets.dart';
/// import 'package:hooks/hooks.dart';
///
/// void main(List<String> arguments) async {
///   await build(arguments, (input, output) async {
///     output.assets.code.add(
///       CodeAsset(
///         name: 'my_code',
///         file: Uri.file('path/to/file'),
///         package: input.packageName,
///         linkMode: DynamicLoadingBundled(),
///       ),
///     );
///     output.assets.data.add(
///       DataAsset(
///         name: 'my_data',
///         file: Uri.file('path/to/file'),
///         package: input.packageName,
///       ),
///     );
///   });
/// }
/// ```
/// The builder for [BuildOutput].
final class BuildOutputBuilder extends HookOutputBuilder {
  /// The metadata builder for this build output.
  BuildOutputMetadataBuilder get metadata => BuildOutputMetadataBuilder._(this);

  /// The assets builder for this build output.
  BuildOutputAssetsBuilder get assets => BuildOutputAssetsBuilder._(this);

  @override
  BuildOutputSyntax get _syntax =>
      BuildOutputSyntax.fromJson(super._syntax.json);

  /// Builds the [BuildOutput].
  BuildOutput build() => BuildOutput(json);
}

/// The builder for [BuildOutputBuilder.metadata].
final class BuildOutputMetadataBuilder {
  final BuildOutputBuilder _output;

  BuildOutputMetadataBuilder._(this._output);

  /// Sets the metadata [value] for the given [key].
  void operator []=(String key, Object value) {
    _output.assets.addEncodedAsset(
      MetadataAsset(key: key, value: value).encode(),
      routing: const ToBuildHooks(),
    );
  }

  /// Adds all entries from [metadata].
  void addAll(Map<String, Object> metadata) {
    for (final MapEntry(:key, :value) in metadata.entries) {
      this[key] = value;
    }
  }
}

/// The builder for [LinkOutputBuilder.metadata].
final class LinkOutputMetadataBuilder {
  final LinkOutputBuilder _output;

  LinkOutputMetadataBuilder._(this._output);

  /// Sets the metadata [value] for the given [key] to be sent to [packageName].
  void add(String packageName, String key, Object value) {
    _output.assets.addEncodedAsset(
      MetadataAsset(key: key, value: value).encode(),
      routing: ToLinkHook(packageName),
    );
  }

  /// Adds all entries from [metadata] to be sent to [packageName].
  void addAll(String packageName, Map<String, Object> metadata) {
    metadata.forEach((key, value) => add(packageName, key, value));
  }
}

/// The destination for assets in the [BuildOutput].
///
/// Currently supported routings:
///  * [ToBuildHooks]: From build hook to all dependent builds hooks.
///  * [ToLinkHook]: From build hook to a specific link hook.
///  * [ToAppBundle]: From build hook to the application Bundle.
sealed class AssetRouting {}

/// The destination for assets in the [LinkOutput].
///
/// An asset can be either sent to other link hooks with [ToLinkHook] or
/// directly to the application Bundle with [ToAppBundle].
///
/// Currently supported routings:
///  * [ToLinkHook]: From link hook to another depending link hook.
///  * [ToAppBundle]: From link hook to the application Bundle.
sealed class LinkAssetRouting {}

/// Assets with this [AssetRouting] in the [HookOutput] will be sent to the SDK
/// to be bundled with the app.
final class ToAppBundle implements AssetRouting, LinkAssetRouting {
  /// Creates a [ToAppBundle].
  const ToAppBundle();
}

/// Assets with this [AssetRouting] in the [BuildOutput] will be sent to build
/// hooks.
///
/// The assets are only available for build hooks of packages that have a direct
/// dependency on the package emitting the asset with this routing.
///
/// The assets will not be bundled in the final application unless also added
/// with [ToAppBundle]. Prefer bundling the asset in the sending hook, otherwise
/// multiple receivers might try to bundle the asset leading to duplicate assets
/// in the app bundle.
///
/// The receiver will know about sender package (it must be a direct
/// dependency), the sender does not know about the receiver. Hence this routing
/// is a broadcast with 0-N receivers.
final class ToBuildHooks implements AssetRouting {
  /// Creates a [ToBuildHooks].
  const ToBuildHooks();
}

/// Assets with this [AssetRouting] in the [HookOutput] will be sent to the
/// link hook of [packageName].
///
/// The assets are only available to the link hook of [packageName].
///
/// The assets will not be bundled in the final application unless added with
/// [ToAppBundle] in the link hook of [packageName].
///
/// The receiver will not know about the sender package. The sender knows about
/// the receiver package. Hence, the receiver must be specified and there is
/// exactly one receiver.
final class ToLinkHook implements AssetRouting, LinkAssetRouting {
  /// The name of the package that contains the `hook/link.dart` to which assets
  /// should be sent.
  final String packageName;

  /// Creates a [ToLinkHook] with the given [packageName].
  const ToLinkHook(this.packageName);
}

/// The builder for [BuildOutputAssets].
final class BuildOutputAssetsBuilder {
  final BuildOutputBuilder _output;

  BuildOutputAssetsBuilder._(this._output);

  /// Adds [EncodedAsset]s produced by this build.
  ///
  /// The asset is routed according to [routing].
  ///
  /// Note to hook writers. Prefer using the `.add` method on the extension for
  /// the specific asset type being added:
  ///
  /// <!-- file://./../../example/api/config_snippet_1.dart -->
  /// ```dart
  /// import 'package:code_assets/code_assets.dart';
  /// import 'package:data_assets/data_assets.dart';
  /// import 'package:hooks/hooks.dart';
  ///
  /// void main(List<String> arguments) async {
  ///   await build(arguments, (input, output) async {
  ///     output.assets.code.add(
  ///       CodeAsset(
  ///         name: 'my_code',
  ///         file: Uri.file('path/to/file'),
  ///         package: input.packageName,
  ///         linkMode: DynamicLoadingBundled(),
  ///       ),
  ///     );
  ///     output.assets.data.add(
  ///       DataAsset(
  ///         name: 'my_data',
  ///         file: Uri.file('path/to/file'),
  ///         package: input.packageName,
  ///       ),
  ///     );
  ///   });
  /// }
  /// ```
  void addEncodedAsset(
    EncodedAsset asset, {
    AssetRouting routing = const ToAppBundle(),
  }) {
    switch (routing) {
      case ToAppBundle():
        final assets = _syntax.assets ?? [];
        assets.add(asset.toSyntax());
        _syntax.assets = assets;
      case ToBuildHooks():
        final assets = _syntax.assetsForBuild ?? [];
        assets.add(asset.toSyntax());
        _syntax.assetsForBuild = assets;
      case ToLinkHook():
        final packageName = routing.packageName;
        final assetsForLinking = _syntax.assetsForLinking ?? {};
        assetsForLinking[packageName] ??= [];
        assetsForLinking[packageName]!.add(asset.toSyntax());
        _syntax.assetsForLinking = assetsForLinking;
    }
  }

  /// Adds [EncodedAsset]s produced by this build.
  ///
  /// The asset is routed according to [routing].
  ///
  /// Note to hook writers. Prefer using the `.addAll` method on the extension
  /// for the specific asset type being added:
  ///
  /// <!-- file://./../../example/api/config_snippet_3.dart -->
  /// ```dart
  /// import 'package:code_assets/code_assets.dart';
  /// import 'package:data_assets/data_assets.dart';
  /// import 'package:hooks/hooks.dart';
  ///
  /// void main(List<String> arguments) async {
  ///   await build(arguments, (input, output) async {
  ///     output.assets.code.addAll([
  ///       CodeAsset(
  ///         name: 'my_code',
  ///         file: Uri.file('path/to/file'),
  ///         package: input.packageName,
  ///         linkMode: DynamicLoadingBundled(),
  ///       ),
  ///     ]);
  ///     output.assets.data.addAll([
  ///       DataAsset(
  ///         name: 'my_data',
  ///         file: Uri.file('path/to/file'),
  ///         package: input.packageName,
  ///       ),
  ///     ]);
  ///   });
  /// }
  /// ```
  void addEncodedAssets(
    Iterable<EncodedAsset> assets, {
    AssetRouting routing = const ToAppBundle(),
  }) {
    switch (routing) {
      case ToAppBundle():
        final list = _syntax.assets ?? [];
        for (final asset in assets) {
          list.add(asset.toSyntax());
        }
        _syntax.assets = list;
      case ToBuildHooks():
        final list = _syntax.assetsForBuild ?? [];
        for (final asset in assets) {
          list.add(asset.toSyntax());
        }
        _syntax.assetsForBuild = list;
      case ToLinkHook():
        final linkInPackage = routing.packageName;
        final assetsForLinking = _syntax.assetsForLinking ?? {};
        final list = assetsForLinking[linkInPackage] ??= [];
        for (final asset in assets) {
          list.add(asset.toSyntax());
        }
        _syntax.assetsForLinking = assetsForLinking;
    }
  }

  BuildOutputSyntax get _syntax =>
      BuildOutputSyntax.fromJson(_output._syntax.json);
}

/// The output for a `hook/link.dart` on success.
///
/// See [LinkOutputFailure] for failure.
final class LinkOutput extends HookOutput implements LinkOutputMaybeFailure {
  /// The assets produced by this link hook which are routed to link hooks in
  /// other packages.
  ///
  /// These can only be the packages which are direct dependencies of the
  /// current package.
  /// Every key in the map is a package name. These assets in the values are not
  /// bundled with the application, but are sent to the link hook of the package
  /// specified in the key, which can decide what to do with them.
  Map<String, List<EncodedAsset>> get _encodedAssetsForLink => {
    for (final MapEntry(:key, :value)
        in (_syntax.assetsForLinking ?? {}).entries)
      key: EncodedAssetSyntax._fromSyntax(value),
  };

  /// Creates a [LinkOutput] from the given [json].
  LinkOutput(super.json) : _syntax = LinkOutputSyntax.fromJson(json), super._();

  /// The assets produced by this link hook.
  LinkOutputAssets get assets => LinkOutputAssets._(this);

  @override
  final LinkOutputSyntax _syntax;
}

/// The assets in [LinkOutput.assets].
final class LinkOutputAssets {
  final LinkOutput _output;

  LinkOutputAssets._(this._output);

  /// The assets produced by this build.
  List<EncodedAsset> get encodedAssets => _output._encodedAssets;

  /// The assets produced by this link hook sent to a specific link hook.
  ///
  /// The key of the map is the package name of the destination link hook.
  Map<String, List<EncodedAsset>> get encodedAssetsForLink =>
      _output._encodedAssetsForLink;
}

/// The builder for [LinkOutput].
///
/// There are various Dart extensions on this [LinkOutputBuilder] that allow
/// adding specific asset types - which should be used by normal hook authors.
/// For example
///
/// <!-- file://./../../example/api/config_snippet_2.dart -->
/// ```dart
/// import 'package:code_assets/code_assets.dart';
/// import 'package:data_assets/data_assets.dart';
/// import 'package:hooks/hooks.dart';
///
/// void main(List<String> arguments) async {
///   await link(arguments, (input, output) async {
///     output.assets.code.add(
///       CodeAsset(
///         name: 'my_code',
///         file: Uri.file('path/to/file'),
///         package: input.packageName,
///         linkMode: DynamicLoadingBundled(),
///       ),
///     );
///     output.assets.data.add(
///       DataAsset(
///         name: 'my_data',
///         file: Uri.file('path/to/file'),
///         package: input.packageName,
///       ),
///     );
///   });
/// }
/// ```
final class LinkOutputBuilder extends HookOutputBuilder {
  /// The metadata builder for this link output.
  LinkOutputMetadataBuilder get metadata => LinkOutputMetadataBuilder._(this);

  /// The assets builder for this link output.
  LinkOutputAssetsBuilder get assets => LinkOutputAssetsBuilder._(this);

  /// Builds the [LinkOutput].
  LinkOutput build() => LinkOutput(json);
}

/// The builder for [LinkOutput.assets] in [LinkOutputBuilder].
final class LinkOutputAssetsBuilder {
  final LinkOutputBuilder _builder;

  LinkOutputAssetsBuilder._(this._builder);

  /// Adds [EncodedAsset]s produced by this build.
  ///
  /// Note to hook writers. Prefer using the `.add` method on the extension for
  /// the specific asset type being added:
  ///
  /// <!-- file://./../../example/api/config_snippet_2.dart -->
  /// ```dart
  /// import 'package:code_assets/code_assets.dart';
  /// import 'package:data_assets/data_assets.dart';
  /// import 'package:hooks/hooks.dart';
  ///
  /// void main(List<String> arguments) async {
  ///   await link(arguments, (input, output) async {
  ///     output.assets.code.add(
  ///       CodeAsset(
  ///         name: 'my_code',
  ///         file: Uri.file('path/to/file'),
  ///         package: input.packageName,
  ///         linkMode: DynamicLoadingBundled(),
  ///       ),
  ///     );
  ///     output.assets.data.add(
  ///       DataAsset(
  ///         name: 'my_data',
  ///         file: Uri.file('path/to/file'),
  ///         package: input.packageName,
  ///       ),
  ///     );
  ///   });
  /// }
  /// ```
  void addEncodedAsset(
    EncodedAsset asset, {
    LinkAssetRouting routing = const ToAppBundle(),
  }) {
    switch (routing) {
      case ToAppBundle():
        final assets = _syntax.assets ?? [];
        assets.add(asset.toSyntax());
        _syntax.assets = assets;
      case ToLinkHook():
        final packageName = routing.packageName;
        final assetsForLinking = _syntax.assetsForLinking ?? {};
        assetsForLinking[packageName] ??= [];
        assetsForLinking[packageName]!.add(asset.toSyntax());
        _syntax.assetsForLinking = assetsForLinking;
    }
  }

  /// Adds [EncodedAsset]s produced by this build.
  ///
  /// Note to hook writers. Prefer using the `.addAll` method on the extension
  /// for the specific asset type being added:
  ///
  /// <!-- file://./../../example/api/config_snippet_4.dart -->
  /// ```dart
  /// import 'package:code_assets/code_assets.dart';
  /// import 'package:data_assets/data_assets.dart';
  /// import 'package:hooks/hooks.dart';
  ///
  /// void main(List<String> arguments) async {
  ///   await link(arguments, (input, output) async {
  ///     output.assets.code.addAll([
  ///       CodeAsset(
  ///         name: 'my_code',
  ///         file: Uri.file('path/to/file'),
  ///         package: input.packageName,
  ///         linkMode: DynamicLoadingBundled(),
  ///       ),
  ///     ]);
  ///     output.assets.data.addAll([
  ///       DataAsset(
  ///         name: 'my_data',
  ///         file: Uri.file('path/to/file'),
  ///         package: input.packageName,
  ///       ),
  ///     ]);
  ///   });
  /// }
  /// ```
  void addEncodedAssets(
    Iterable<EncodedAsset> assets, {
    LinkAssetRouting routing = const ToAppBundle(),
  }) {
    switch (routing) {
      case ToAppBundle():
        final list = _syntax.assets ?? [];
        for (final asset in assets) {
          list.add(asset.toSyntax());
        }
        _syntax.assets = list;
      case ToLinkHook():
        final linkInPackage = routing.packageName;
        final assetsForLinking = _syntax.assetsForLinking ?? {};
        final list = assetsForLinking[linkInPackage] ??= [];
        for (final asset in assets) {
          list.add(asset.toSyntax());
        }
        _syntax.assetsForLinking = assetsForLinking;
    }
  }

  LinkOutputSyntax get _syntax =>
      LinkOutputSyntax.fromJson(_builder._syntax.json);
}

/// The configuration in [HookInput.config].
final class HookConfig {
  /// The JSON representation of this config.
  Map<String, Object?> get json => _syntax.json;

  /// The JSON path to this config inside the input.
  ///
  /// This is a public member such that [ProtocolExtension]s  can access it.
  List<Object> get path => _syntax.path;

  final ConfigSyntax _syntax;

  /// The asset types that should be built by an invocation of a hook.
  ///
  /// The invoker of a hook may, and in most cases will, invoke the hook
  /// separately for different asset types.
  ///
  /// This means that hooks should be written in a way that they are a no-op if
  /// they are invoked for an asset type that is not emitted by the hook. Most
  /// asset extensions provide a to check [buildAssetTypes] for their own asset
  /// type. For example, `CodeAsset`s can be used as follows:
  ///
  /// <!-- file://./../../example/api/config_snippet_5.dart -->
  /// ```dart
  /// import 'package:code_assets/code_assets.dart';
  /// import 'package:hooks/hooks.dart';
  ///
  /// void main(List<String> arguments) async {
  ///   await build(arguments, (input, output) async {
  ///     if (input.config.buildCodeAssets) {
  ///       output.assets.code.add(
  ///         CodeAsset(
  ///           name: 'my_code',
  ///           file: Uri.file('path/to/file'),
  ///           package: input.packageName,
  ///           linkMode: DynamicLoadingBundled(),
  ///         ),
  ///       );
  ///     }
  ///   });
  /// }
  /// ```
  List<String> get buildAssetTypes => _syntax.buildAssetTypes;

  HookConfig._(HookInput input) : _syntax = input._syntax.config;

  /// Constructs a checksum for this hook config.
  ///
  /// This can be used to construct an output directory name specific to the
  /// [HookConfig] being built with this builder. It is therefore assumed the
  /// output directory has not been set yet.
  String computeChecksum() => _jsonChecksum(_syntax.json);
}

/// The configuration in [BuildInput.config].
final class BuildConfig extends HookConfig {
  @override
  // ignore: overridden_fields
  final BuildConfigSyntax _syntax;

  /// Whether linking is enabled for this build.
  bool get linkingEnabled => _syntax.linkingEnabled;

  BuildConfig._(super.input)
    : _syntax = BuildConfigSyntax.fromJson(
        input._syntax.config.json,
        path: input._syntax.config.path,
      ),
      super._();
}

/// The configuration in [LinkInput.config].
final class LinkConfig extends HookConfig {
  LinkConfig._(super.input) : super._();
}

/// A type of failure that occurred during a hook execution.
class FailureType {
  /// The name of this failure type.
  final String name;

  const FailureType._(this.name);

  /// A failure that occurred due to a problem in the build logic of the hook.
  static const build = FailureType._('build');

  /// A failure that occurred due to an infrastructure issue.
  ///
  /// For example, a network issue, or a tool not being available.
  ///
  /// Typically fixed by investigating infra reliability.
  static const infra = FailureType._('infra');

  /// A failure that is not categorized as [build] or [infra].
  ///
  /// Typically treated as [build].
  static const uncategorized = FailureType._('uncategorized');

  @override
  String toString() => name;
}

/// The output of a hook that has failed.
final class HookOutputFailure {
  /// The JSON representation of this failure.
  Map<String, Object?> get json => _syntax.json;

  final HookOutputSyntax _syntax;

  HookOutputFailure._(Map<String, Object?> json)
    : _syntax = HookOutputSyntax.fromJson(json);

  /// The type of failure.
  ///
  /// This helps in categorizing the error and determining the appropriate
  /// response or fix.
  FailureType get type => switch (_syntax.failureDetails?.type) {
    FailureTypeSyntax.build => FailureType.build,
    FailureTypeSyntax.infra => FailureType.infra,
    FailureTypeSyntax.uncategorized => FailureType.uncategorized,
    _ => FailureType.uncategorized,
  };
}

/// The output from a `hook/build.dart` on failure.
///
/// See [BuildOutput] for success.
final class BuildOutputFailure extends HookOutputFailure
    implements BuildOutputMaybeFailure {
  BuildOutputFailure._(super.json) : super._();
}

/// The output from a `hook/link.dart` on failure.
///
/// See [LinkOutput] for success.
final class LinkOutputFailure extends HookOutputFailure
    implements LinkOutputMaybeFailure {
  LinkOutputFailure._(super.json) : super._();
}

/// Either a successful [BuildOutput] or a [BuildOutputFailure].
sealed class BuildOutputMaybeFailure {
  /// The JSON representation of this output.
  Map<String, Object?> get json;

  factory BuildOutputMaybeFailure(Map<String, Object?> json) {
    final syntax = HookOutputSyntax.fromJson(json);
    final status = syntax.status;
    switch (status) {
      case null: // backwards compatibility.
      case OutputStatusSyntax.success:
        return BuildOutput(json);
      case OutputStatusSyntax.failure:
        return BuildOutputFailure._(json);
    }
    throw StateError('Unknown status: $status.');
  }
}

/// Either a successful [LinkOutput] or a [LinkOutputFailure].
sealed class LinkOutputMaybeFailure {
  /// The JSON representation of this output.
  Map<String, Object?> get json;

  factory LinkOutputMaybeFailure(Map<String, Object?> json) {
    final syntax = HookOutputSyntax.fromJson(json);
    final status = syntax.status;
    switch (status) {
      case null: // backwards compatibility.
      case OutputStatusSyntax.success:
        return LinkOutput(json);
      case OutputStatusSyntax.failure:
        return LinkOutputFailure._(json);
    }
    throw StateError('Unknown status: $status.');
  }
}

/// Base class for errors that can be thrown during a [build] or [link]
/// invocation.
///
/// Throwing these errors in [build] or [link] will automatically set the
/// [HookOutputBuilder.setFailure] and exit the process with the exit code
/// belonging to that error type.
abstract class HookError extends Error {
  /// The error message.
  final String message;

  /// An optional underlying exception that caused this error.
  final Object? wrappedException;

  /// An optional stack trace associated with the [wrappedException].
  final StackTrace? wrappedTrace;

  /// Creates a [HookError] with the given [message].
  HookError({required this.message, this.wrappedException, this.wrappedTrace});

  /// The exit code that should be used if the process terminates due to this
  /// error.
  int get exitCode;

  /// The [FailureType] associated with this error.
  FailureType get failureType;
}

/// An error indicating a problem with the build logic within a hook.
///
/// Throwing this error in [build] or [link] will automatically set the
/// [HookOutputBuilder.setFailure] and exit the process with [exitCode].
///
/// This typically means something went wrong during the asset generation or
/// transformation process.
final class BuildError extends HookError {
  /// Creates a [BuildError] with the given [message].
  BuildError({
    required super.message,
    super.wrappedException,
    super.wrappedTrace,
  });

  @override
  int get exitCode => 1;

  /// The failure type for build errors is [FailureType.build].
  @override
  FailureType get failureType => FailureType.build;
}

/// An error indicating an infrastructure-related problem during hook execution.
///
/// Throwing this error in [build] or [link] will automatically set the
/// [HookOutputBuilder.setFailure] and exit the process with [exitCode].
///
/// This could be due to issues like network problems.
final class InfraError extends HookError {
  /// Creates a [InfraError] with the given [message].
  InfraError({
    required super.message,
    super.wrappedException,
    super.wrappedTrace,
  });

  @override
  int get exitCode => 2;

  /// The failure type for infrastructure errors is [FailureType.infra].
  @override
  FailureType get failureType => FailureType.infra;
}
