import 'dart:convert';
import 'dart:io';

import 'package:drift_dev/src/analysis/results/results.dart';
import 'package:drift_dev/src/cli/cli.dart';
import 'package:drift_dev/src/cli/commands/schema.dart';
import 'package:drift_dev/src/cli/commands/schema/generate_utils.dart';
import 'package:drift_dev/src/cli/commands/schema/steps.dart';
import 'package:collection/collection.dart';

import 'package:drift_dev/src/services/schema/schema_files.dart';
import 'package:drift_dev/src/services/schema/schema_isolate.dart';
import 'package:io/ansi.dart';
import 'package:path/path.dart' as p;
import 'package:recase/recase.dart';

class MakeMigrationCommand extends DriftCommand {
  MakeMigrationCommand(super.cli) {
    argParser.registerExportSchemaStartupCodeOption();
  }

  @override
  String get description => """
Generates migrations utilities for drift databases

${styleBold.wrap("Usage")}:

Run this command to manage database migrations:

1. After initially defining your database to save the schema.
2. After modifying the database schema and incrementing the version.

This will generate the following:

1. A steps file which contains a helper function to write a migration from one version to another.

  Example:
  ${blue.wrap("class")} ${green.wrap("Database")} ${blue.wrap("extends")} ${green.wrap("_\$Database")} ${yellow.wrap("{")}

    ...

    ${lightCyan.wrap("@override")}
    ${green.wrap("MigrationStrategy")} ${blue.wrap("get")} ${lightCyan.wrap("migration")} ${magenta.wrap("{")}
      ${magenta.wrap("return")} ${green.wrap("MigrationStrategy")}${blue.wrap("(")}
        ${lightCyan.wrap("onUpgrade")}: ${yellow.wrap("stepByStep(")}
          ${lightCyan.wrap("from1To2")}: ${magenta.wrap("(")}${lightCyan.wrap("m")}, ${lightCyan.wrap("schema")}${magenta.wrap(")")} ${magenta.wrap("async {")}
            ${magenta.wrap("await")} ${lightCyan.wrap("m")}.${yellow.wrap("stepByStep")}${blue.wrap("(")}${lightCyan.wrap("schema.todoEntries")}, ${lightCyan.wrap("schema.todoEntries.dueDate")}${blue.wrap(")")};
          ${magenta.wrap("}")}${yellow.wrap(")")},
      ${blue.wrap(")")};
    ${yellow.wrap("}")}

2. A test file containing:
   a) Automated tests to validate the correctness of migrations.

   b) A sample data integrity test for the first migration. This test ensures that the initial schema is created correctly and that basic data operations work as expected.
      This sample test should be adapted for subsequent migrations, especially those involving complex modifications to existing tables.

${styleBold.wrap("Configuration")}:

This tool requires the database be defined in the build.yaml file.

Example:

${blue.wrap("""
targets:
  \$default:
    builders:
      drift_dev:
        options:""")}
          ${green.wrap("# Required: The name of the database and the path to the database file")}
          ${blue.wrap("databases")}:
            ${blue.wrap("my_database")}: ${lightRed.wrap("lib/database.dart")}

            ${green.wrap("# Optional: Add more databases")}
            ${blue.wrap("another_db")}: ${lightRed.wrap("lib/database2.dart")}


          ${green.wrap("# Optional: The directory where the test files are stored")}:
          ${blue.wrap("test_dir")}: ${lightRed.wrap("test/drift/")} ${green.wrap("# (default)")}

          ${green.wrap("# Optional: The directory where the schema files are stored")}:
          ${blue.wrap("schema_dir")}: ${lightRed.wrap("drift_schemas/")}  ${green.wrap("# (default)")}

""";

  @override
  String get name => "make-migrations";

  @override
  Future<void> run() async {
    if (p.isAbsolute(cli.project.options.schemaDir)) {
      cli.exit(
          '`schema_dir` must be a relative path. Remove the leading slash');
    }
    if (p.isAbsolute(cli.project.options.testDir)) {
      cli.exit('`test_dir` must be a relative path. Remove the leading slash');
    }

    final dumpGeneratedSchemaCode = argResults?.exportSchemaStartupCode;

    /// The root directory where test files for all databases are stored
    /// e.g /test/drift/
    final rootSchemaDir = Directory(
        p.join(cli.project.directory.path, cli.project.options.schemaDir))
      ..createSync(recursive: true);

    /// The root directory where schema files for all databases are stored
    /// e.g /drift_schemas/
    final rootTestDir = Directory(
        p.join(cli.project.directory.path, cli.project.options.testDir))
      ..createSync(recursive: true);

    if (cli.project.options.databases.isEmpty) {
      cli.exit(
          'No databases found in the build.yaml file. Run `drift_dev make-migrations --help` or check the documentation for more information: https://drift.simonbinder.eu/Migrations/');
    }

    final databaseMigrationsWriters =
        await Future.wait(cli.project.options.databases.entries.map(
      (entry) async {
        final writer = await _MigrationTestEmitter.create(
          cli: cli,
          rootSchemaDir: rootSchemaDir,
          rootTestDir: rootTestDir,
          dbName: entry.key,
          relativeDbClassPath: entry.value,
          dumpGeneratedSchemaCode: dumpGeneratedSchemaCode,
        );
        return writer;
      },
    ));

    for (var writer in databaseMigrationsWriters) {
      // Dump the schema files for all databases
      await writer.writeSchemaFile();
      if (writer.schemas.length == 1) {
        continue;
      }
      // Write the step by step migration files for all databases
      // This is done after all the schema files have been written to the disk
      // to ensure that the schema files are up to date
      await writer.writeStepsFile();
      // Write the generated test databases
      await writer.writeTestDatabases();
      // Write the generated test
      await writer.writeTest();
      await writer.flush();
      writer.suggestDataMigrationTest();
    }
  }
}

class _MigrationTestEmitter {
  final DriftDevCli cli;
  final Directory rootSchemaDir;
  final Directory rootTestDir;
  final String dbName;
  late final File dbClassFile;

  /// The directory where the schema files for this database are stored
  /// e.g /drift_schemas/my_database/
  final Directory schemaDir;

  /// The directory where the tests for this database are stored
  /// e.g /test/drift/my_database/
  final Directory testDir;

  /// The directory where the generated test utils are stored
  /// e.g /test/drift/my_database/generated/
  final Directory testDatabasesDir;

  /// Current schema version of the database
  final int schemaVersion;

  /// The name of the database class
  final String dbClassName;

  /// The parsed database class
  final DriftDatabase db;

  /// The parsed drift elements
  final List<DriftElement> driftElements;

  /// Whether the core test package is unavailable and needs to be replaced with
  /// `flutter_test`.
  final bool shouldUseFlutterTest;

  final File? dumpGeneratedSchemaCode;

  /// Stores the tempoarary files to be written to the disk
  /// Only is written to the disk once the entire generation process completes without errors
  final writeTasks = <File, String>{};

  /// Write all the files to the disk
  Future<void> flush() async {
    for (var MapEntry(key: file, value: content) in writeTasks.entries) {
      // Note: Content is formatted Dart code at this point.
      await file.writeAsString(content);
    }
    writeTasks.clear();
  }

  /// All the schema files for this database
  Map<int, ExportedSchema> schemas = const {};

  /// Migration writer for each migration
  List<_MigrationTestWriter> migrations = const [];

  _MigrationTestEmitter(
      {required this.cli,
      required this.rootSchemaDir,
      required this.rootTestDir,
      required this.dbName,
      required this.dbClassFile,
      required this.schemaDir,
      required this.testDir,
      required this.testDatabasesDir,
      required this.schemaVersion,
      required this.dbClassName,
      required this.db,
      required this.driftElements,
      required this.dumpGeneratedSchemaCode,
      required this.shouldUseFlutterTest});

  static Future<_MigrationTestEmitter> create({
    required DriftDevCli cli,
    required Directory rootSchemaDir,
    required Directory rootTestDir,
    required String dbName,
    required String relativeDbClassPath,
    required File? dumpGeneratedSchemaCode,
  }) async {
    if (p.isAbsolute(relativeDbClassPath)) {
      cli.exit(
          'The path for the "$dbName" database must be a relative path. Remove the leading slash');
    }
    if (!relativeDbClassPath.endsWith(".dart")) {
      if (dbName == "schema_dir" || dbName == "test_dir") {
        cli.exit(
            "The path for the $dbName must be a dart file. It seems you have $dbName under the `options` section in the build.yaml file instead of under the `databases` section");
      } else {
        cli.exit('The path for the "$dbName" database must be a dart file');
      }
    }

    final dbClassFile =
        File(p.join(cli.project.directory.path, relativeDbClassPath));
    final schemaDir = Directory(p.join(rootSchemaDir.path, dbName))
      ..createSync(recursive: true);
    final testDir = Directory(p.join(rootTestDir.path, dbName))
      ..createSync(recursive: true);
    final testDatabasesDir = Directory(p.join(testDir.path, 'generated'))
      ..createSync(recursive: true);
    final (:db, :elements, :schemaVersion) =
        await cli.readElementsFromSource(dbClassFile.absolute);
    if (schemaVersion == null) {
      cli.exit('Could not read schema version from the "$dbName" database.');
    }
    if (db == null) {
      cli.exit('Could not read database class from the "$dbName" database.');
    }

    final config = await cli.project.packageConfig;
    final hasFlutter = config?.packages.any((e) => e.name == 'flutter') == true;
    final hasTest = config?.packages.any((e) => e.name == 'test') == true;
    final hasFlutterTest =
        config?.packages.any((e) => e.name == 'flutter_test') == true;

    if (!hasTest && !hasFlutterTest) {
      cli.logger.warning('No test package found for project, please add a'
          'dependency on flutter_test or test.');
    }

    final emitter = _MigrationTestEmitter(
      cli: cli,
      rootSchemaDir: rootSchemaDir,
      rootTestDir: rootTestDir,
      dbName: dbName,
      dbClassFile: dbClassFile,
      schemaDir: schemaDir,
      testDir: testDir,
      db: db,
      driftElements: elements,
      dbClassName: db.definingDartClass.toString(),
      testDatabasesDir: testDatabasesDir,
      schemaVersion: schemaVersion,
      dumpGeneratedSchemaCode: dumpGeneratedSchemaCode,
      // For packages depending on flutter, suggest running tests with `flutter
      // test` instead of `dart test`.
      shouldUseFlutterTest: hasFlutter,
    );
    await emitter._readSchemas();
    return emitter;
  }

  Future<void> _readSchemas() async {
    schemas = await parseSchema(schemaDir);
    migrations = _MigrationTestWriter.fromSchema(schemas)
        .sorted((a, b) => a.from.compareTo(b.from));
  }

  /// Create a .json dump of the current schema
  /// This file is written instantly to the disk
  Future<void> writeSchemaFile() async {
    // If the latest schema file version is larger than the current schema version
    // then something is wrong
    if (schemas.keys.any((v) => v > schemaVersion)) {
      cli.exit(
          'The version of your $dbName database ($schemaVersion) is lower than the latest schema version. '
          'The schema version in the database should never be decreased. ');
    }

    final writer = SchemaWriter(driftElements, options: cli.project.options);
    final schemaFile = driftSchemaFile(schemaVersion);
    final content = json.encode(await writer.createSchemaJson(
        dumpStartupCode: dumpGeneratedSchemaCode));
    if (!schemaFile.existsSync()) {
      cli.logger
          .info('$dbName: Creating schema file for version $schemaVersion');
      schemaFile.writeAsStringSync(content);
      // Re-parse the schema to include the newly created schema file
      await _readSchemas();
    } else if (schemaFile.readAsStringSync() != content) {
      cli.exit(
          "A schema for version $schemaVersion of the $dbName database already exists and differs from the current schema."
          " Either delete the existing schema file or update the schema version in the database file.");
    }
  }

  /// Create a step by step migration file
  Future<void> writeStepsFile() async {
    if (!stepsFile.existsSync()) {
      cli.logger.info(
          """$dbName: Generated step by step migration helper in ${blue.wrap(p.relative(stepsFile.path))}
Use this generated `${yellow.wrap("stepByStep")}` to write your migrations.
Example:

${blue.wrap("class")} ${green.wrap(dbClassName)} ${blue.wrap("extends")} ${green.wrap("_\$$dbClassName")} ${yellow.wrap("{")}

  ...

  ${lightCyan.wrap("@override")}
  ${green.wrap("MigrationStrategy")} ${blue.wrap("get")} ${lightCyan.wrap("migration")} ${magenta.wrap("{")}
    ${magenta.wrap("return")} ${green.wrap("MigrationStrategy")}${blue.wrap("(")}
      ${lightCyan.wrap("onUpgrade")}: ${yellow.wrap("stepByStep(")}
        ${lightCyan.wrap("from1To2")}: ${magenta.wrap("(")}${lightCyan.wrap("m")}, ${lightCyan.wrap("schema")}${magenta.wrap(")")} ${magenta.wrap("async {")}
          ${backgroundGreen.wrap("// Write your migrations here")}
        ${magenta.wrap("}")}${yellow.wrap(")")},
    ${blue.wrap(")")};
  ${yellow.wrap("}")}
""");
    } else {
      cli.logger.fine(
          "$dbName: Updating step by step migration helper in ${blue.wrap(p.relative(stepsFile.path))}");
    }
    writeTasks[stepsFile] =
        await StepsGenerationUtil.generateStepByStepMigration(cli, schemas);
  }

  /// Generate a built database for each schema version
  /// This will be used to test the migrations
  Future<void> writeTestDatabases() async {
    for (final versionAndEntities in schemas.entries) {
      final version = versionAndEntities.key;
      final entities = versionAndEntities.value;
      writeTasks[testUtilityFile(version)] =
          await GenerateUtils.generateSchemaCode(
              cli, version, entities, true, true);
    }
    writeTasks[File(p.join(testDatabasesDir.path, 'schema.dart'))] =
        await GenerateUtils.generateLibraryCode(
            cli, schemas.keys.sorted((a, b) => a.compareTo(b)));
  }

  Future<void> writeTest() async {
    final testFile = File(p.join(testDir.path, 'migration_test.dart'));
    if (testFile.existsSync()) {
      return;
    }
    if (migrations.isEmpty) {
      return;
    }

    final firstMigration = migrations.first;

    final packageName = cli.project.buildConfig.packageName;
    final relativeDbPath = p.posix.relative(dbClassFile.path,
        from: p.join(cli.project.directory.path, 'lib'));
    final testPackageName = shouldUseFlutterTest ? 'flutter_test' : 'test';

    final code = """
// ignore_for_file: unused_local_variable, unused_import
import 'package:drift/drift.dart';
import 'package:drift_dev/api/migrations_native.dart';
import 'package:$packageName/$relativeDbPath';
import 'package:$testPackageName/$testPackageName.dart';
import 'generated/schema.dart';

${firstMigration.schemaImports().join('\n')}

void main() {
  driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
  late SchemaVerifier verifier;

  setUpAll(() {
    verifier = SchemaVerifier(GeneratedHelper());
  });

  group('simple database migrations', () {
    // These simple tests verify all possible schema updates with a simple (no
    // data) migration. This is a quick way to ensure that written database
    // migrations properly alter the schema.
    const versions = GeneratedHelper.versions;
    for (final (i, fromVersion) in versions.indexed) {
      group('from \$fromVersion', () {
        for (final toVersion in versions.skip(i + 1)) {
          test('to \$toVersion', () async {
            final schema = await verifier.schemaAt(fromVersion);
            final db = $dbClassName(schema.newConnection());
            await verifier.migrateAndValidate(db, toVersion);
            await db.close();
          });
        }
      });
    }
  });


  // The following template shows how to write tests ensuring your migrations
  // preserve existing data.
  // Testing this can be useful for migrations that change existing columns
  // (e.g. by alterating their type or constraints). Migrations that only add
  // tables or columns typically don't need these advanced tests. For more
  // information, see https://drift.simonbinder.eu/migrations/tests/#verifying-data-integrity
  // TODO: This generated template shows how these tests could be written. Adopt
  // it to your own needs when testing migrations with data integrity.
  ${firstMigration.testStepByStepMigrationCode(dbName, dbClassName)}
}
""";

    final testCommand = shouldUseFlutterTest ? 'flutter' : 'dart';
    cli.logger.info(
        '$dbName: Generated test in ${blue.wrap(p.relative(testFile.path))}.\n'
        'Run this test to validate that your migrations are written correctly. ${yellow.wrap("$testCommand test ${p.relative(testFile.path)}")}');

    if (!db.hasConstructorArgumentForConnection) {
      cli.logger.info(
        'Running this test requires changes to your database class! '
        'The database needs to support being opened against custom databases'
        'for testing. To do that, change the constructor of your database '
        'from e.g. \n'
        '  ${red.wrap('$dbClassName(): super(_openConnection());')}\n'
        'to something like:\n'
        '  ${green.wrap('$dbClassName([QueryExecutor? e]): super(e ?? _openConnection());')}\n',
      );
    }

    writeTasks[testFile] = await cli.project.formatSource(code);
  }

  /// The json file where the schema for the current version of the database is stored
  File driftSchemaFile(int version) {
    return File(p.join(schemaDir.path, 'drift_schema_v$version.json'));
  }

  File testUtilityFile(int version) {
    return File(p.join(testDatabasesDir.path, 'schema_v$version.dart'));
  }

  /// Generated file where the step by step migration code is stored
  File get stepsFile {
    return File(dbClassFile.absolute.path
        .replaceFirst(RegExp(r'\.dart$'), '.steps.dart'));
  }

  void suggestDataMigrationTest() {
    final lastMigration = migrations.last;
    final schemaBefore = schemas[lastMigration.from]!;
    final schemaAfter = schemas[lastMigration.to]!;

    // Find changes that suggest a test with data (instead of the default tests
    // only using empty tables) might be useful.
    final reasons = <String>[];

    for (final elementAfter in schemaAfter.schema) {
      final elementBefore = schemaBefore.schema
          .firstWhereOrNull((e) => e.id.name == elementAfter.id.name);

      if (elementBefore == null) {
        continue;
      }

      if (elementAfter is DriftTable && elementBefore is DriftTable) {
        for (final columnAfter in elementAfter.columns) {
          final columnBefore =
              elementBefore.columnBySqlName[columnAfter.nameInSql];

          if (columnBefore == null &&
              elementAfter.isColumnRequiredForInsert(columnAfter)) {
            reasons.add(
                'Added column "${columnAfter.nameInSql}" in "${elementAfter.schemaName}" without a default.');
          }
        }
      }
    }

    if (reasons.isNotEmpty) {
      cli.logger.info(
        '${cyan.wrap('Hint')}: Your latest migration might benefit from a test '
        'with data, as it might need special setup to transform existing data. '
        'Drit has detected the following reasons for that: ',
      );
      for (final reason in reasons) {
        cli.logger.info(' - $reason');
      }

      cli.logger.info(
          'For more information on writing these tests, see ${yellow.wrap('https://drift.simonbinder.eu/migrations/tests/#verifying-data-integrity')}');
    }
  }
}

/// A writer that generates example code for a migration test with data
/// integrity.
class _MigrationTestWriter {
  final List<DriftTable> tables;
  final int from;
  final int to;

  _MigrationTestWriter(this.tables, {required this.from, required this.to});

  /// Create  list of migration writers from a map of schema versions
  /// A migration writer is created for each pair of schema versions
  /// e.g (1,2), (2,3), (3,4) etc
  static List<_MigrationTestWriter> fromSchema(
      Map<int, ExportedSchema> schemas) {
    final result = <_MigrationTestWriter>[];
    if (schemas.length < 2) {
      return result;
    }
    final versions = schemas.keys.toList()..sort();
    for (var i = 0; i < versions.length - 1; i++) {
      final (from, fromSchema) = (versions[i], schemas[versions[i]]!);
      final (to, toSchema) = (versions[i + 1], schemas[versions[i + 1]]!);
      final fromTables = fromSchema.schema.whereType<DriftTable>();
      final toTables = toSchema.schema.whereType<DriftTable>();
      final commonTables = fromTables.where(
          (table) => toTables.any((t) => t.schemaName == table.schemaName));
      result
          .add(_MigrationTestWriter(commonTables.toList(), from: from, to: to));
    }
    return result;
  }

  List<String> schemaImports() {
    return [
      "import 'generated/schema_v$from.dart' as v$from;",
      "import 'generated/schema_v$to.dart' as v$to;"
    ];
  }

  /// Generate a step by step migration test
  /// This test will test the migration from version [from] to version [to]
  /// It will also import the validation models to test data integrity
  String testStepByStepMigrationCode(String dbName, String dbClassName) {
    return """
test('migration from v$from to v$to does not corrupt data',
      () async {
  // Add data to insert into the old database, and the expected rows after the
  // migration.
  // TODO: Fill these lists
    ${tables.map((table) {
      return """
final old${table.dbGetterName.pascalCase}Data = <v$from.${table.nameOfRowClass}>[];
final expectedNew${table.dbGetterName.pascalCase}Data = <v$to.${table.nameOfRowClass}>[];
""";
    }).join('\n')}

    await verifier.testWithDataIntegrity(
      oldVersion: $from,
      newVersion: $to,
      createOld: v$from.DatabaseAtV$from.new,
      createNew: v$to.DatabaseAtV$to.new,
      openTestedDatabase: $dbClassName.new,
      createItems: (batch, oldDb) {
        ${tables.map(
      (table) {
        return "batch.insertAll(oldDb.${table.dbGetterName}, old${table.dbGetterName.pascalCase}Data);";
      },
    ).join('\n')}
      },
      validateItems: (newDb) async {
        ${tables.map(
      (table) {
        return "expect(expectedNew${table.dbGetterName.pascalCase}Data, await newDb.select(newDb.${table.dbGetterName}).get());";
      },
    ).join('\n')}
      },
    );
  });
""";
  }
}
