// 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:build/build.dart' show AssetId, BuilderOptions;
import 'package:build_config/build_config.dart';
import 'package:build_runner_core/build_runner_core.dart';
import 'package:code_builder/code_builder.dart';
import 'package:dart_style/dart_style.dart';
import 'package:graphs/graphs.dart';
import 'package:path/path.dart' as p;
import 'package:pub_semver/pub_semver.dart';

import '../package_graph/build_config_overrides.dart';
import 'builder_ordering.dart';

const scriptLocation = '$entryPointDir/build.dart';
const scriptKernelLocation = '$scriptLocation$scriptKernelSuffix';
const scriptKernelSuffix = '.dill';
const scriptKernelCachedLocation =
    '$scriptKernelLocation$scriptKernelCachedSuffix';
const scriptKernelCachedSuffix = '.cached';

final _lastShortFormatDartVersion = Version(3, 6, 0);

Future<String> generateBuildScript() async {
  buildLog.doing('Generating the build script.');
  final info = await findBuildScriptOptions();
  final builders = info.builderApplications;
  final library = Library(
    (b) => b.body.addAll([
      declareFinal('_builders')
          .assign(
            literalList(
              builders,
              refer(
                'BuilderApplication',
                'package:build_runner_core/build_runner_core.dart',
              ),
            ),
          )
          .statement,
      _main(),
    ]),
  );
  final emitter = DartEmitter(
    allocator: Allocator.simplePrefixing(),
    useNullSafetySyntax: true,
  );
  try {
    // The `build_runner` version number is included to force a rebuild if the
    // script was generated by a different version. It only needs updating if
    // the host<->isolate relationship changed in a breaking way, for example
    // if command line args or messages passed via sendports have changed
    // in a breaking way.
    return DartFormatter(languageVersion: _lastShortFormatDartVersion).format(
      '''
// @dart=${_lastShortFormatDartVersion.major}.${_lastShortFormatDartVersion.minor}
// ignore_for_file: directives_ordering
// build_runner >=2.4.16
${library.accept(emitter)}
''',
    );
  } on FormatterException {
    buildLog.error(
      'Generated build script could not be parsed. '
      'Check builder definitions.',
    );
    throw const CannotBuildException();
  }
}

/// Finds expressions to create all the `BuilderApplication` instances that
/// should be applied packages in the build.
///
/// Adds `apply` expressions based on the BuildefDefinitions from any package
/// which has a `build.yaml`.
///
/// Optionally, a custom [packageGraph] and [buildConfigOverrides] can be used
/// to change which packages and build configurations are considered.
/// By default, the [PackageGraph.forThisPackage] will be used and configuration
/// overrides will be loaded from the root package.
Future<Iterable<Expression>> findBuilderApplications({
  PackageGraph? packageGraph,
  Map<String, BuildConfig>? buildConfigOverrides,
}) async {
  final info = await findBuildScriptOptions(
    packageGraph: packageGraph,
    buildConfigOverrides: buildConfigOverrides,
  );
  return info.builderApplications;
}

Future<BuildScriptInfo> findBuildScriptOptions({
  PackageGraph? packageGraph,
  Map<String, BuildConfig>? buildConfigOverrides,
}) async {
  packageGraph ??= await PackageGraph.forThisPackage();
  final orderedPackages = stronglyConnectedComponents<PackageNode>(
    [packageGraph.root],
    (node) => node.dependencies,
    equals: (a, b) => a.name == b.name,
    hashCode: (n) => n.name.hashCode,
  ).expand((c) => c);
  var reader = ReaderWriter(packageGraph);
  var overrides =
      buildConfigOverrides ??= await findBuildConfigOverrides(
        packageGraph,
        reader,
      );
  Future<BuildConfig> packageBuildConfig(PackageNode package) async {
    if (overrides.containsKey(package.name)) {
      return overrides[package.name]!;
    }
    try {
      return await BuildConfig.fromBuildConfigDir(
        package.name,
        package.dependencies.map((n) => n.name),
        package.path,
      );
    } on ArgumentError // ignore: avoid_catching_errors
    catch (_) {
      // During the build an error will be logged.
      return BuildConfig.useDefault(
        package.name,
        package.dependencies.map((n) => n.name),
      );
    }
  }

  // TODO: Remove the dynamic calls here. We don't have a shared interface
  // today and rely on dynamic calls instead which is bad.
  bool isValidDefinition(dynamic definition) {
    // Filter out builderDefinitions with relative imports that aren't
    // from the root package, because they will never work.
    // ignore: avoid_dynamic_calls
    final import = definition.import as String;
    if (import.startsWith('package:')) {
      // Make sure package is known in packageGraph (when present),
      // otherwise PackageNotFoundException will be thrown down the road
      final pkg = AssetId.resolve(Uri.parse(import)).package;
      if (packageGraph?.allPackages.containsKey(pkg) ?? true) {
        return true;
      }
      buildLog.warning(
        'Could not load imported package "$pkg" for definition '
        // ignore: avoid_dynamic_calls
        '"${definition.key}".',
      );
      return false;
    }
    // ignore: avoid_dynamic_calls
    return definition.package == packageGraph!.root.name;
  }

  final orderedConfigs = await Future.wait(
    orderedPackages.map(packageBuildConfig),
  );
  final builderDefinitions = orderedConfigs
      .expand((c) => c.builderDefinitions.values)
      .where(isValidDefinition);

  final rootBuildConfig = orderedConfigs.last;
  final orderedBuilders =
      findBuilderOrder(
        builderDefinitions,
        rootBuildConfig.globalOptions,
      ).toList();

  final postProcessBuilderDefinitions = orderedConfigs
      .expand((c) => c.postProcessBuilderDefinitions.values)
      .where(isValidDefinition);

  // Validate the builder keys in the global builder config, these should always
  // refer to actual builders.
  var allBuilderKeys = {for (var definition in orderedBuilders) definition.key};
  for (var globalBuilderConfig in rootBuildConfig.globalOptions.entries) {
    void checkBuilderKey(String builderKey) {
      if (allBuilderKeys.contains(builderKey)) return;
      buildLog.warning(
        'Invalid builder key `$builderKey` found in global_options config of '
        'build.yaml. This configuration will have no effect.',
      );
    }

    checkBuilderKey(globalBuilderConfig.key);
    globalBuilderConfig.value.runsBefore.forEach(checkBuilderKey);
  }

  final applications = [
    for (var builder in orderedBuilders) _applyBuilder(builder),
    for (var builder in postProcessBuilderDefinitions)
      _applyPostProcessBuilder(builder),
  ];

  return BuildScriptInfo(applications);
}

class BuildScriptInfo {
  final Iterable<Expression> builderApplications;

  BuildScriptInfo(this.builderApplications);
}

/// A method forwarding to `run`.
Method _main() => Method((b) {
  b.name = 'main';
  b.returns = refer('void');
  b.modifier = MethodModifier.async;
  b.requiredParameters.add(
    Parameter((b) {
      b.name = 'args';
      b.type = TypeReference((b) {
        b.symbol = 'List';
        b.types.add(refer('String'));
      });
    }),
  );
  b.optionalParameters.add(
    Parameter((b) {
      b.name = 'sendPort';
      b.type = TypeReference((b) {
        b.symbol = 'SendPort';
        b.url = 'dart:isolate';
        b.isNullable = true;
      });
    }),
  );
  final isolateExitCode = refer(
    'buildProcessState.isolateExitCode',
    'package:build_runner/src/build_script_generate/build_process_state.dart',
  );
  b.body = Block.of([
    refer(
      'buildProcessState.receive',
      'package:build_runner/src/build_script_generate/build_process_state.dart',
    ).call([refer('sendPort')]).awaited.statement,
    isolateExitCode
        .assign(
          refer(
            'run',
            'package:build_runner/build_runner.dart',
          ).call([refer('args'), refer('_builders')]).awaited,
        )
        .statement,
    refer('exitCode', 'dart:io').assign(isolateExitCode).nullChecked.statement,
    refer(
      'buildProcessState.send',
      'package:build_runner/src/build_script_generate/build_process_state.dart',
    ).call([refer('sendPort')]).awaited.statement,
  ]);
});

/// An expression calling `apply` with appropriate setup for a Builder.
Expression _applyBuilder(BuilderDefinition definition) {
  final namedArgs = {
    if (definition.isOptional) 'isOptional': literalTrue,
    if (definition.buildTo == BuildTo.cache)
      'hideOutput': literalTrue
    else
      'hideOutput': literalFalse,
    if (!identical(definition.defaults.generateFor, InputSet.anything))
      'defaultGenerateFor': refer(
        'InputSet',
        'package:build_config/build_config.dart',
      ).constInstance([], {
        if (definition.defaults.generateFor.include != null)
          'include': _rawStringList(definition.defaults.generateFor.include!),
        if (definition.defaults.generateFor.exclude != null)
          'exclude': _rawStringList(definition.defaults.generateFor.exclude!),
      }),
    if (definition.defaults.options.isNotEmpty)
      'defaultOptions': _constructBuilderOptions(definition.defaults.options),
    if (definition.defaults.devOptions.isNotEmpty)
      'defaultDevOptions': _constructBuilderOptions(
        definition.defaults.devOptions,
      ),
    if (definition.defaults.releaseOptions.isNotEmpty)
      'defaultReleaseOptions': _constructBuilderOptions(
        definition.defaults.releaseOptions,
      ),
    if (definition.appliesBuilders.isNotEmpty)
      'appliesBuilders': _rawStringList(definition.appliesBuilders),
  };
  var import = _buildScriptImport(definition.import);
  return refer(
    'apply',
    'package:build_runner_core/build_runner_core.dart',
  ).call([
    literalString(definition.key, raw: true),
    literalList([for (var f in definition.builderFactories) refer(f, import)]),
    _findToExpression(definition),
  ], namedArgs);
}

/// An expression calling `applyPostProcess` with appropriate setup for a
/// PostProcessBuilder.
Expression _applyPostProcessBuilder(PostProcessBuilderDefinition definition) {
  final namedArgs = {
    if (!identical(definition.defaults.generateFor, InputSet.anything))
      'defaultGenerateFor': refer(
        'InputSet',
        'package:build_config/build_config.dart',
      ).constInstance([], {
        if (definition.defaults.generateFor.include != null)
          'include': _rawStringList(definition.defaults.generateFor.include!),
        if (definition.defaults.generateFor.exclude != null)
          'exclude': _rawStringList(definition.defaults.generateFor.exclude!),
      }),
    if (definition.defaults.options.isNotEmpty)
      'defaultOptions': _constructBuilderOptions(definition.defaults.options),
    if (definition.defaults.devOptions.isNotEmpty)
      'defaultDevOptions': _constructBuilderOptions(
        definition.defaults.devOptions,
      ),
    if (definition.defaults.releaseOptions.isNotEmpty)
      'defaultReleaseOptions': _constructBuilderOptions(
        definition.defaults.releaseOptions,
      ),
  };
  var import = _buildScriptImport(definition.import);
  return refer(
    'applyPostProcess',
    'package:build_runner_core/build_runner_core.dart',
  ).call([
    literalString(definition.key, raw: true),
    refer(definition.builderFactory, import),
  ], namedArgs);
}

Expression _rawStringList(List<String> strings) =>
    literalConstList([for (var s in strings) literalString(s, raw: true)]);

/// Returns the actual import to put in the generated script based on an import
/// found in the build.yaml.
String _buildScriptImport(String import) {
  if (import.startsWith('package:')) {
    return import;
  } else if (import.startsWith('../') || import.startsWith('/')) {
    buildLog.warning(
      'The `../` import syntax in build.yaml is now deprecated, '
      'instead do a normal relative import as if it was from the root of '
      'the package. Found `$import` in your `build.yaml` file.',
    );
    return import;
  } else {
    return p.url.relative(import, from: p.url.dirname(scriptLocation));
  }
}

Expression _findToExpression(BuilderDefinition definition) {
  switch (definition.autoApply) {
    case AutoApply.none:
      return refer(
        'toNoneByDefault',
        'package:build_runner_core/build_runner_core.dart',
      ).call([]);
    case AutoApply.dependents:
      return refer(
        'toDependentsOf',
        'package:build_runner_core/build_runner_core.dart',
      ).call([literalString(definition.package, raw: true)]);
    case AutoApply.allPackages:
      return refer(
        'toAllPackages',
        'package:build_runner_core/build_runner_core.dart',
      ).call([]);
    case AutoApply.rootPackage:
      return refer(
        'toRoot',
        'package:build_runner_core/build_runner_core.dart',
      ).call([]);
  }
}

/// An expression creating a [BuilderOptions] from a json string.
Expression _constructBuilderOptions(Map<String, dynamic> options) => refer(
  'BuilderOptions',
  'package:build/build.dart',
).constInstance([options.toExpression()]);

/// Converts a Dart object to a source code representation.
///
/// This is similar to [literal] from `package:code_builder`, except that it
/// always writes raw string literals.
extension ConvertToExpression on Object? {
  Expression toExpression() {
    final $this = this;

    if ($this is Map) {
      return literalMap(
        {
          for (final entry in $this.cast<Object?, Object?>().entries)
            entry.key.toExpression(): entry.value.toExpression(),
        },
        refer('String'),
        refer('dynamic'),
      );
    } else if ($this is List) {
      return literalList([
        for (final entry in $this.cast<Object?>()) entry.toExpression(),
      ], refer('dynamic'));
    } else if ($this is String) {
      return literalString($this, raw: true);
    } else {
      return literal(this);
    }
  }
}
