// Copyright 2019 The Chromium Authors. 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_runner_core/build_runner_core.dart';
import 'package:build/build.dart' show BuilderOptions;
import 'package:build_config/build_config.dart';
import 'package:code_builder/code_builder.dart';
import 'package:dart_style/dart_style.dart';
import 'package:graphs/graphs.dart';

import '../base/common.dart';
import '../base/file_system.dart';
import '../project.dart';

class BuildScriptGeneratorFactory {
  const BuildScriptGeneratorFactory();

  /// Creates a [BuildScriptGenerator] for the current flutter project.
  BuildScriptGenerator create(FlutterProject flutterProject, PackageGraph packageGraph) {
    return BuildScriptGenerator(flutterProject, packageGraph);
  }
}

/// Generates a build_script for the current flutter project.
class BuildScriptGenerator {
  const BuildScriptGenerator(this.flutterProject, this.packageGraph);

  final FlutterProject flutterProject;
  final PackageGraph packageGraph;

  /// Generate a build script for the curent flutter project.
  ///
  /// Requires the project to have a pubspec.yaml.
  Future<void> generateBuildScript() async {
    final Iterable<Expression> builders = await _findBuilderApplications();
    final Library library = Library((LibraryBuilder libraryBuilder) => libraryBuilder.body.addAll(<Spec>[
      literalList(builders, refer('BuilderApplication', 'package:build_runner_core/build_runner_core.dart'))
        .assignFinal('_builders')
        .statement,
      _createMain(),
    ]));
    final DartEmitter emitter = DartEmitter(Allocator.simplePrefixing());
    try {
      final String location = fs.path.join(flutterProject.dartTool.path, 'build', 'entrypoint', 'build.dart');
      final String result = DartFormatter().format('''
        // ignore_for_file: directives_ordering
        ${library.accept(emitter)}''');
      final File output = fs.file(location);
      await output.create(recursive: true);
      await fs.file(location).writeAsString(result);
    } on FormatterException {
      throwToolExit('Generated build script could not be parsed. '
        'This is likely caused by a misconfigured builder definition.');
    }
  }

  /// 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`.
  Future<Iterable<Expression>> _findBuilderApplications() async {
    final List<Expression> builderApplications = <Expression>[];
    final Iterable<PackageNode> orderedPackages = stronglyConnectedComponents<PackageNode>(
      <PackageNode>[packageGraph.root],
      (PackageNode node) => node.dependencies,
      equals: (PackageNode a, PackageNode b) => a.name == b.name,
      hashCode: (PackageNode n) => n.name.hashCode,
    ).expand((List<PackageNode> nodes) => nodes);
    Future<BuildConfig> _packageBuildConfig(PackageNode package) async {
      try {
        return await BuildConfig.fromBuildConfigDir(package.name, package.dependencies.map((PackageNode node) => node.name), package.path);
      } on ArgumentError catch (_) {
        // During the build an error will be logged.
        return BuildConfig.useDefault(package.name, package.dependencies.map((PackageNode node) => node.name));
      }
    }

    final Iterable<BuildConfig> orderedConfigs = await Future.wait(orderedPackages.map(_packageBuildConfig));
    final List<BuilderDefinition> builderDefinitions = orderedConfigs
      .expand((BuildConfig buildConfig) => buildConfig.builderDefinitions.values)
      .where((BuilderDefinition builderDefinition) {
        if (builderDefinition.import.startsWith('package:')) {
          return true;
        }
        return builderDefinition.package == packageGraph.root.name;
      })
      .toList();

    final List<BuilderDefinition> orderedBuilders = _findBuilderOrder(builderDefinitions).toList();
    builderApplications.addAll(orderedBuilders.map(_applyBuilder));

    final List<PostProcessBuilderDefinition> postProcessBuilderDefinitions = orderedConfigs
      .expand((BuildConfig buildConfig) => buildConfig.postProcessBuilderDefinitions.values)
      .where((PostProcessBuilderDefinition builderDefinition) {
        if (builderDefinition.import.startsWith('package:')) {
          return true;
        }
        return builderDefinition.package == packageGraph.root.name;
      })
      .toList();
    builderApplications.addAll(postProcessBuilderDefinitions.map(_applyPostProcessBuilder));

    return builderApplications;
  }

  /// A method forwarding to `run`.
  Method _createMain() {
    return Method((MethodBuilder b) => b
    ..name = 'main'
    ..modifier = MethodModifier.async
    ..requiredParameters.add(Parameter((ParameterBuilder parameterBuilder) => parameterBuilder
      ..name = 'args'
      ..type = TypeReference((TypeReferenceBuilder typeReferenceBuilder) => typeReferenceBuilder
        ..symbol = 'List'
        ..types.add(refer('String')))))
    ..optionalParameters.add(Parameter((ParameterBuilder parameterBuilder) => parameterBuilder
      ..name = 'sendPort'
      ..type = refer('SendPort', 'dart:isolate')))
    ..body = Block.of(<Code>[
      refer('run', 'package:build_runner/build_runner.dart')
          .call(<Expression>[refer('args'), refer('_builders')])
          .awaited
          .assignVar('result')
          .statement,
      refer('sendPort')
          .nullSafeProperty('send')
          .call(<Expression>[refer('result')]).statement,
    ]));
  }

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

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

  /// 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;
    }
    throwToolExit('non-package import syntax in build.yaml is not supported');
    return null;
  }

  Expression _findToExpression(BuilderDefinition definition) {
    switch (definition.autoApply) {
      case AutoApply.none:
        return refer('toNoneByDefault',
                'package:build_runner_core/build_runner_core.dart')
            .call(<Expression>[]);
      // TODO(jonahwilliams): re-enabled when we have the builders strategy fleshed out.
      // case AutoApply.dependents:
      //   return refer('toDependentsOf',
      //           'package:build_runner_core/build_runner_core.dart')
      //       .call(<Expression>[literalString(definition.package)]);
      case AutoApply.allPackages:
        return refer('toAllPackages',
                'package:build_runner_core/build_runner_core.dart')
            .call(<Expression>[]);
      case AutoApply.dependents:
      case AutoApply.rootPackage:
        return refer('toRoot', 'package:build_runner_core/build_runner_core.dart')
            .call(<Expression>[]);
    }
    throw ArgumentError('Unhandled AutoApply type: ${definition.autoApply}');
  }

  /// An expression creating a [BuilderOptions] from a json string.
  Expression _constructBuilderOptions(BuilderOptions options) {
    return refer('BuilderOptions', 'package:build/build.dart').newInstance(<Expression>[literalMap(options.config)]);
  }

  /// Put [builders] into an order such that any builder which specifies
  /// [BuilderDefinition.requiredInputs] will come after any builder which
  /// produces a desired output.
  ///
  /// Builders will be ordered such that their `required_inputs` and `runs_before`
  /// constraints are met, but the rest of the ordering is arbitrary.
  Iterable<BuilderDefinition> _findBuilderOrder(Iterable<BuilderDefinition> builders) {
    Iterable<BuilderDefinition> dependencies(BuilderDefinition parent) {
      return builders.where((BuilderDefinition child) => _hasInputDependency(parent, child) || _mustRunBefore(parent, child));
    }
    final List<List<BuilderDefinition>> components = stronglyConnectedComponents<BuilderDefinition>(
      builders,
      dependencies,
      equals: (BuilderDefinition a, BuilderDefinition b) => a.key == b.key,
      hashCode: (BuilderDefinition b) => b.key.hashCode,
    );
    return components.map((List<BuilderDefinition> component) {
      if (component.length > 1) {
        throw ArgumentError('Required input cycle for ${component.toList()}');
      }
      return component.single;
    }).toList();
  }

  /// Whether [parent] has a `required_input` that wants to read outputs produced
  /// by [child].
  bool _hasInputDependency(BuilderDefinition parent, BuilderDefinition child) {
    final Set<String> childOutputs = child.buildExtensions.values.expand((List<String> values) => values).toSet();
    return parent.requiredInputs.any((String input) => childOutputs.any((String output) => output.endsWith(input)));
  }

  /// Whether [child] specifies that it wants to run before [parent].
  bool _mustRunBefore(BuilderDefinition parent, BuilderDefinition child) => child.runsBefore.contains(parent.key);
}
