// Copyright 2014 The Flutter 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 'package:file/file.dart';
import 'package:package_config/package_config.dart';
import 'package:package_config/package_config_types.dart';

import 'base/common.dart';
import 'base/context.dart';
import 'base/file_system.dart';
import 'base/logger.dart';
import 'base/template.dart';
import 'dart/package_map.dart';

/// The Kotlin keywords which are not Java keywords.
/// They are escaped in Kotlin files.
///
/// https://kotlinlang.org/docs/keyword-reference.html
const kReservedKotlinKeywords = <String>['when', 'in', 'is'];

/// Provides the path where templates used by flutter_tools are stored.
class TemplatePathProvider {
  const TemplatePathProvider({required this._flutterRoot});

  final String _flutterRoot;

  /// Returns the directory containing the 'name' template directory.
  Directory directoryInPackage(String name, FileSystem fileSystem) {
    final String templatesDir = fileSystem.path.join(
      _flutterRoot,
      'packages',
      'flutter_tools',
      'templates',
    );
    return fileSystem.directory(fileSystem.path.join(templatesDir, name));
  }

  /// Returns the directory containing the 'name' template directory in
  /// flutter_template_images, to resolve image placeholder against.
  /// if 'name' is null, return the parent template directory.
  Future<Directory> imageDirectory(String? name, FileSystem fileSystem, Logger logger) async {
    final String toolPackagePath = fileSystem.path.join(_flutterRoot, 'packages', 'flutter_tools');
    final String packageFilePath = fileSystem.path.join(
      toolPackagePath,
      '.dart_tool',
      'package_config.json',
    );
    final PackageConfig packageConfig = await loadPackageConfigWithLogging(
      fileSystem.file(packageFilePath),
      logger: logger,
    );
    final Uri? imagePackageLibDir = packageConfig['flutter_template_images']?.packageUriRoot;
    final Directory templateDirectory = fileSystem
        .directory(imagePackageLibDir)
        .parent
        .childDirectory('templates');
    return name == null ? templateDirectory : templateDirectory.childDirectory(name);
  }
}

TemplatePathProvider get templatePathProvider => context.get<TemplatePathProvider>()!;

/// Expands templates in a directory to a destination. All files that must
/// undergo template expansion should end with the `.tmpl` extension. All files
/// that should be replaced with the corresponding image from
/// `flutter_template_images` should end with the `.img.tmpl` extension. All other
/// files are ignored. In case the contents of entire directories must be copied
/// as is, the directory itself can end with `.tmpl` extension. Files within
/// such a directory may also contain the `.tmpl` or `.img.tmpl` extensions and
/// will be considered for expansion. In case certain files need to be copied
/// but without template expansion (data files, etc.), the `.copy.tmpl`
/// extension may be used. Furthermore, templates may contain additional
/// test files intended to run on the CI. Test files must end in `.test.tmpl`
/// and are only included when the `--implementation-tests` flag is enabled.
///
/// Folders with platform/language-specific content must be named
/// `<platform>-<language>.tmpl`.
///
/// Files in the destination will contain none of the `.tmpl`, `.copy.tmpl`,
/// `img.tmpl`, or `-<language>.tmpl` extensions.
class Template {
  factory Template(
    Directory templateSource,
    Directory? imageSourceDir, {
    required FileSystem fileSystem,
    required Logger logger,
    required TemplateRenderer templateRenderer,
    Set<Uri>? templateManifest,
  }) {
    return Template._(
      <Directory>[templateSource],
      imageSourceDir != null ? <Directory>[imageSourceDir] : <Directory>[],
      fileSystem: fileSystem,
      logger: logger,
      templateRenderer: templateRenderer,
      templateManifest: templateManifest,
    );
  }

  Template._(
    List<Directory> templateSources,
    this.imageSourceDirectories, {
    required FileSystem fileSystem,
    required this._logger,
    required this._templateRenderer,
    required Set<Uri>? templateManifest,
  }) : _fileSystem = fileSystem,
       _templateManifest = templateManifest ?? <Uri>{} {
    for (final sourceDirectory in templateSources) {
      if (!sourceDirectory.existsSync()) {
        throwToolExit('Template source directory does not exist: ${sourceDirectory.absolute.path}');
      }
    }

    final templateFiles = <FileSystemEntity, Directory>{
      for (final Directory sourceDirectory in templateSources)
        for (final FileSystemEntity entity in sourceDirectory.listSync(recursive: true))
          entity: sourceDirectory,
    };
    for (final FileSystemEntity entity in templateFiles.keys.whereType<File>()) {
      if (_templateManifest.isNotEmpty &&
          !_templateManifest.contains(Uri.file(entity.absolute.path))) {
        _logger.printTrace('Skipping ${entity.absolute.path}, missing from the template manifest.');
        // Skip stale files in the flutter_tools directory.
        continue;
      }

      final String relativePath = fileSystem.path.relative(
        entity.path,
        from: templateFiles[entity]!.absolute.path,
      );
      if (relativePath.contains(templateExtension)) {
        // If '.tmpl' appears anywhere within the path of this entity, it is
        // a candidate for rendering. This catches cases where the folder
        // itself is a template.
        // When multiple template sources are provided, later directories take
        // precedence and overwrite matching relative destination paths from
        // base dependencies.
        _templateFilePaths[relativePath] = fileSystem.path.absolute(entity.path);
      }
    }
  }

  /// Creates a [Template] by resolving one or more source directories.
  static Future<Template> fromDirectories(
    List<Directory> templateSources, {
    List<Directory> imageSourceDirectories = const <Directory>[],
    required FileSystem fileSystem,
    required Logger logger,
    required TemplateRenderer templateRenderer,
    Set<Uri>? templateManifest,
  }) async {
    return Template._(
      templateSources,
      imageSourceDirectories,
      fileSystem: fileSystem,
      logger: logger,
      templateRenderer: templateRenderer,
      templateManifest: templateManifest,
    );
  }

  static Future<Template> fromName(
    String name, {
    required FileSystem fileSystem,
    required Set<Uri>? templateManifest,
    required Logger logger,
    required TemplateRenderer templateRenderer,
  }) async {
    // All named templates are placed in the 'templates' directory
    final Directory templateDir = templatePathProvider.directoryInPackage(name, fileSystem);
    final Directory imageDir = await templatePathProvider.imageDirectory(name, fileSystem, logger);
    return Template._(
      <Directory>[templateDir],
      <Directory>[imageDir],
      fileSystem: fileSystem,
      logger: logger,
      templateRenderer: templateRenderer,
      templateManifest: templateManifest,
    );
  }

  static Future<Template> merged(
    List<String> names,
    Directory directory, {
    required FileSystem fileSystem,
    required Set<Uri> templateManifest,
    required Logger logger,
    required TemplateRenderer templateRenderer,
  }) async {
    // All named templates are placed in the 'templates' directory
    return Template._(
      <Directory>[
        for (final String name in names) templatePathProvider.directoryInPackage(name, fileSystem),
      ],
      <Directory>[
        for (final String name in names)
          if ((await templatePathProvider.imageDirectory(name, fileSystem, logger)).existsSync())
            await templatePathProvider.imageDirectory(name, fileSystem, logger),
      ],
      fileSystem: fileSystem,
      logger: logger,
      templateRenderer: templateRenderer,
      templateManifest: templateManifest,
    );
  }

  final FileSystem _fileSystem;
  final Logger _logger;
  final Set<Uri> _templateManifest;
  final TemplateRenderer _templateRenderer;

  static const templateExtension = '.tmpl';
  static const copyTemplateExtension = '.copy.tmpl';
  static const imageTemplateExtension = '.img.tmpl';
  static const testTemplateExtension = '.test.tmpl';
  final Pattern _kTemplateLanguageVariant = RegExp(r'(\w+)-(\w+)\.tmpl.*');
  final List<Directory> imageSourceDirectories;

  final _templateFilePaths = <String, String>{};

  /// Render the template into the [destination] directory.
  ///
  /// May throw a [ToolExit] if the directory is not writable.
  int render(
    Directory destination,
    Map<String, Object?> context, {
    bool overwriteExisting = true,
    bool printStatusWhenWriting = true,
  }) {
    try {
      destination.createSync(recursive: true);
    } on FileSystemException catch (err) {
      _logger.printError(err.toString());
      throwToolExit('Failed to flutter create at ${destination.path}.');
    }
    var fileCount = 0;
    final bool implementationTests = (context['implementationTests'] as bool?) ?? false;

    /// Returns the resolved destination path corresponding to the specified
    /// raw destination path, after performing language filtering and template
    /// expansion on the path itself.
    ///
    /// Returns null if the given raw destination path has been filtered.
    String? renderPath(String relativeDestinationPath) {
      final Match? match = _kTemplateLanguageVariant.matchAsPrefix(relativeDestinationPath);
      if (match != null) {
        final String platform = match.group(1)!;
        final language = context['${platform}Language'] as String?;
        if (language != match.group(2)) {
          return null;
        }
        relativeDestinationPath = relativeDestinationPath.replaceAll(
          '$platform-$language.tmpl',
          platform,
        );
      }

      final bool android = (context['android'] as bool?) ?? false;
      if (relativeDestinationPath.contains('android') && !android) {
        return null;
      }

      final bool ios = (context['ios'] as bool?) ?? false;
      if (relativeDestinationPath.contains('ios') && !ios) {
        return null;
      }

      // Only build a web project if explicitly asked.
      final bool web = (context['web'] as bool?) ?? false;
      if (relativeDestinationPath.contains('web') && !web) {
        return null;
      }
      // Only build a Linux project if explicitly asked.
      final bool linux = (context['linux'] as bool?) ?? false;
      if (relativeDestinationPath.startsWith('linux.tmpl') && !linux) {
        return null;
      }
      // Only build a macOS project if explicitly asked.
      final bool macOS = (context['macos'] as bool?) ?? false;
      if (relativeDestinationPath.startsWith('macos.tmpl') && !macOS) {
        return null;
      }
      // Only build a Windows project if explicitly asked.
      final bool windows = (context['windows'] as bool?) ?? false;
      if (relativeDestinationPath.startsWith('windows.tmpl') && !windows) {
        return null;
      }

      final projectName = context['projectName'] as String?;
      final androidIdentifier = context['androidIdentifier'] as String?;
      final pluginClass = context['pluginClass'] as String?;
      final pluginClassSnakeCase = context['pluginClassSnakeCase'] as String?;
      final String destinationDirPath = destination.absolute.path;
      final String pathSeparator = _fileSystem.path.separator;
      String finalDestinationPath = _fileSystem.path
          .join(destinationDirPath, relativeDestinationPath)
          .replaceAll(copyTemplateExtension, '')
          .replaceAll(imageTemplateExtension, '')
          .replaceAll(testTemplateExtension, '')
          .replaceAll(templateExtension, '');

      if (android && androidIdentifier != null) {
        finalDestinationPath = finalDestinationPath.replaceAll(
          'androidIdentifier',
          androidIdentifier.replaceAll('.', pathSeparator),
        );
      }
      if (projectName != null) {
        finalDestinationPath = finalDestinationPath.replaceAll('projectName', projectName);
      }
      // This must be before the pluginClass replacement step.
      if (pluginClassSnakeCase != null) {
        finalDestinationPath = finalDestinationPath.replaceAll(
          'pluginClassSnakeCase',
          pluginClassSnakeCase,
        );
      }
      if (pluginClass != null) {
        finalDestinationPath = finalDestinationPath.replaceAll('pluginClass', pluginClass);
      }
      return finalDestinationPath;
    }

    _templateFilePaths.forEach((String relativeDestinationPath, String absoluteSourcePath) {
      final bool withRootModule = context['withRootModule'] as bool? ?? false;
      if (!withRootModule && absoluteSourcePath.contains('flutter_root')) {
        return;
      }

      if (!implementationTests && absoluteSourcePath.contains(testTemplateExtension)) {
        return;
      }

      final String? finalDestinationPath = renderPath(relativeDestinationPath);
      if (finalDestinationPath == null) {
        return;
      }
      final File finalDestinationFile = _fileSystem.file(finalDestinationPath);
      final String relativePathForLogging = _fileSystem.path.relative(finalDestinationFile.path);

      // Step 1: Check if the file needs to be overwritten.

      if (finalDestinationFile.existsSync()) {
        if (overwriteExisting) {
          finalDestinationFile.deleteSync(recursive: true);
          if (printStatusWhenWriting) {
            _logger.printStatus('  $relativePathForLogging (overwritten)');
          }
        } else {
          // The file exists but we cannot overwrite it, move on.
          if (printStatusWhenWriting) {
            _logger.printTrace('  $relativePathForLogging (existing - skipped)');
          }
          return;
        }
      } else {
        if (printStatusWhenWriting) {
          _logger.printStatus('  $relativePathForLogging (created)');
        }
      }

      fileCount += 1;

      finalDestinationFile.createSync(recursive: true);
      final File sourceFile = _fileSystem.file(absoluteSourcePath);

      // Step 2: If the absolute paths ends with a '.copy.tmpl', this file does
      //         not need mustache rendering but needs to be directly copied.

      if (sourceFile.path.endsWith(copyTemplateExtension)) {
        sourceFile.copySync(finalDestinationFile.path);

        return;
      }

      // Step 3: If the absolute paths ends with a '.img.tmpl', this file needs
      //         to be copied from the template image package.

      if (sourceFile.path.endsWith(imageTemplateExtension)) {
        final potentials = <File>[
          for (final Directory imageSourceDir in imageSourceDirectories)
            _fileSystem.file(
              _fileSystem.path.join(
                imageSourceDir.path,
                relativeDestinationPath.replaceAll(imageTemplateExtension, ''),
              ),
            ),
        ];

        if (potentials.any((File file) => file.existsSync())) {
          final File imageSourceFile = potentials.firstWhere((File file) => file.existsSync());

          imageSourceFile.copySync(finalDestinationFile.path);
        } else {
          throwToolExit('Image File not found ${finalDestinationFile.path}');
        }

        return;
      }

      // Step 4: If the absolute path ends with a '.tmpl', this file needs
      //         rendering via mustache.

      if (sourceFile.path.endsWith(templateExtension)) {
        final String templateContents = sourceFile.readAsStringSync();
        final androidIdentifier = context['androidIdentifier'] as String?;
        if (finalDestinationFile.path.endsWith('.kt') && androidIdentifier != null) {
          context['androidIdentifier'] = _escapeKotlinKeywords(androidIdentifier);
        }

        // Use a copy of the context,
        // since the original is used in rendering other templates.
        final Map<String, Object?> localContext = finalDestinationFile.path.endsWith('.yaml')
            ? _createEscapedContextCopy(context)
            : context;

        final String renderedContents = _templateRenderer.renderString(
          templateContents,
          localContext,
        );

        finalDestinationFile.writeAsStringSync(renderedContents);

        return;
      }

      // Step 5: This file does not end in .tmpl but is in a directory that
      //         does. Directly copy the file to the destination.
      sourceFile.copySync(finalDestinationFile.path);
    });

    return fileCount;
  }
}

/// Create a copy of the given [context], escaping its values when necessary.
///
/// Returns the copied context.
Map<String, Object?> _createEscapedContextCopy(Map<String, Object?> context) {
  final localContext = Map<String, Object?>.of(context);

  final description = localContext['description'] as String?;

  if (description != null && description.isNotEmpty) {
    localContext['description'] = escapeYamlString(description);
  }

  return localContext;
}

String _escapeKotlinKeywords(String androidIdentifier) {
  final List<String> segments = androidIdentifier.split('.');
  final List<String> correctedSegments = segments
      .map((String segment) => kReservedKotlinKeywords.contains(segment) ? '`$segment`' : segment)
      .toList();
  return correctedSegments.join('.');
}

String escapeYamlString(String value) {
  final result = StringBuffer();
  result.write('"');
  for (final int rune in value.runes) {
    result.write(switch (rune) {
      0x00 => r'\0',
      0x09 => r'\t',
      0x0A => r'\n',
      0x0D => r'\r',
      0x22 => r'\"',
      0x5C => r'\\',
      < 0x20 => '\\x${rune.toRadixString(16).padLeft(2, "0")}',
      _ => String.fromCharCode(rune),
    });
  }
  result.write('"');
  return result.toString();
}