import 'package:drift/drift.dart';
import 'package:recase/recase.dart';
import 'package:sqlparser/sqlparser.dart' hide ResultColumn;

import '../../analysis/options.dart';
import '../../analysis/resolver/queries/explicit_alias_transformer.dart';
import '../../analysis/resolver/queries/nested_queries.dart';
import '../../analysis/results/results.dart';
import '../../utils/string_escaper.dart';
import '../writer.dart';
import 'result_set_writer.dart';
import 'sql_writer.dart';
import 'utils.dart';

const highestAssignedIndexVar = '\$arrayStartIndex';

typedef _ArgumentContext = ({
  // Indicates that the argument is available under a prefix in SQL, probably
  // because it comes from a [NestedResultTable].
  String? sqlPrefix,
  // Indicates that, even if the argument appears to be non-nullable by itself,
  // it comes from a [NestedResultTable] part of an outer join that could make
  // the entire structure nullable.
  bool isNullable,
});

/// Writes the handling code for a query. The code emitted will be a method that
/// should be included in a generated database or dao class.
class QueryWriter {
  final Scope scope;

  late final ExplicitAliasTransformer _transformer;
  final TextEmitter _emitter;

  StringBuffer get _buffer => _emitter.buffer;

  DriftOptions get options => scope.writer.options;

  QueryWriter(this.scope) : _emitter = scope.leaf();

  void write(SqlQuery query) {
    // Note that writing queries can have a result set if they use a RETURNING
    // clause.
    final resultSet = query.resultSet;
    if (resultSet?.needsOwnClass == true) {
      final resultSetScope = scope.root.child();
      ResultSetWriter(query, resultSetScope).write();
    }

    // We generate the Dart string literal for the SQL query by walking the
    // parsed AST. This eliminates unnecessary whitespace and comments in the
    // generated code.
    // In some cases, the whitespace has an impact on the semantic of the
    // query. For instance, `SELECT 1 + 2` has a different column name than
    // `SELECT 1+2`. To work around this, we transform the query to add an
    // explicit alias to every column (since whitespace doesn't matter if the
    // query is written as `SELECT 1+2 AS c0`).
    // We do this transformation so late because it shouldn't have an impact on
    // analysis, Dart getter names stay the same.
    if (resultSet != null) {
      _transformer = ExplicitAliasTransformer();
      _transformer.rewrite(query.root!);

      final nested = query is SqlSelectQuery ? query.nestedContainer : null;
      if (nested != null) {
        addHelperNodes(nested);
      }
    }

    if (query is SqlSelectQuery) {
      _writeSelect(query);
    } else if (query is UpdatingQuery) {
      if (resultSet != null) {
        _writeUpdatingQueryWithReturning(query);
      } else {
        _writeUpdatingQuery(query);
      }
    }
  }

  void _writeSelect(SqlSelectQuery select) {
    _writeSelectStatementCreator(select);
  }

  String _nameOfCreationMethod(SqlSelectQuery select) => select.name;

  /// Writes the function literal that turns a "QueryRow" into the desired
  /// custom return type of a query.
  void _writeMappingLambda(InferredResultSet resultSet, QueryRowType rowClass) {
    final queryRow = _emitter.drift('QueryRow');
    final asyncModifier = rowClass.requiresAsynchronousContext ? 'async' : '';

    // We can write every available mapping as a Dart expression via
    // _writeArgumentExpression. This can be turned into a lambda by appending
    // it with `(QueryRow row) => $expression`. That's also what we're doing,
    // but if we'll just call mapFromRow in there, we can just tear that method
    // off instead. This is just an optimization.
    final singleValue = rowClass.singleValue;
    if (singleValue is MatchingDriftTable && singleValue.effectivelyNoAlias) {
      // Tear-off mapFromRow method on table
      _emitter.write('${singleValue.table.dbGetterName}.mapFromRow');
    } else {
      // In all other cases, we're off to write the expression.
      _emitter.write('($queryRow row) $asyncModifier => ');
      _writeArgumentExpression(
          rowClass, resultSet, (sqlPrefix: null, isNullable: false));
    }
  }

  /// Writes code that will read the [argument] for an existing row type from
  /// the raw `QueryRow`.
  void _writeArgumentExpression(
    ArgumentForQueryRowType argument,
    InferredResultSet resultSet,
    _ArgumentContext context,
  ) {
    switch (argument) {
      case RawQueryRow():
        _buffer.write('row');
      case ScalarResultColumn():
        _readScalar(argument, context);
      case MatchingDriftTable():
        _readMatchingTable(argument, context);
      case StructuredFromNestedColumn():
        final prefix = resultSet.nestedPrefixFor(argument.table);
        _writeArgumentExpression(
          argument.nestedType,
          resultSet,
          (sqlPrefix: prefix, isNullable: argument.nullable),
        );
      case MappedNestedListQuery():
        _buffer.write('await ');
        final query = argument.column.query;
        _writeCustomSelectStatement(query, argument.nestedType);
        _buffer.write('.get()');
      case QueryRowType():
        final singleValue = argument.singleValue;
        if (singleValue != null) {
          return _writeArgumentExpression(singleValue, resultSet, context);
        }

        if (context.isNullable) {
          // If this structed type is nullable, it's coming from an OUTER join
          // which means that, even if the individual components making up the
          // structure are non-nullable, they might all be null in SQL. We
          // detect this case by looking for a non-nullable column and, if it's
          // null, return null directly instead of creating the structured type.
          for (final arg in argument.positionalArguments
              .followedBy(argument.namedArguments.values)
              .whereType<ScalarResultColumn>()) {
            if (!arg.nullable) {
              final keyInMap = context.applyPrefix(arg.name);
              _buffer.write(
                  'row.data[${asDartLiteral(keyInMap)}] == null ? null : ');
            }
          }
        }

        final _ArgumentContext childContext = (
          sqlPrefix: context.sqlPrefix,
          // Individual fields making up this query row type aren't covered by
          // the outer nullability.
          isNullable: false,
        );

        if (!argument.isRecord) {
          // We're writing a constructor, so let's start with the class name.
          _emitter.writeDart(argument.rowType);

          final constructorName = argument.constructorName;
          if (constructorName.isNotEmpty) {
            _emitter
              ..write('.')
              ..write(constructorName);
          }
        }

        _buffer.write('(');
        for (final positional in argument.positionalArguments) {
          _writeArgumentExpression(positional, resultSet, childContext);
          _buffer.write(', ');
        }
        argument.namedArguments.forEach((name, parameter) {
          _buffer.write('$name: ');
          _writeArgumentExpression(parameter, resultSet, childContext);
          _buffer.write(', ');
        });

        _buffer.write(')');
    }
  }

  /// Writes Dart code that, given a variable of type `QueryRow` named `row`
  /// in the same scope, reads the [column] from that row and brings it into a
  /// suitable type.
  void _readScalar(ScalarResultColumn column, _ArgumentContext context) {
    final specialName = _transformer.newNameFor(column.sqlParserColumn!);
    final isNullable = context.isNullable || column.nullable;

    var name = specialName ?? column.name;
    if (context.sqlPrefix != null) {
      name = '${context.sqlPrefix}.$name';
    }

    final dartLiteral = asDartLiteral(name);

    final rawDartType =
        _emitter.dartCode(_emitter.innerColumnType(column.sqlType));
    String code;

    switch (column.sqlType) {
      case ColumnDriftType():
        final method = isNullable ? 'readNullable' : 'read';
        code = 'row.$method<$rawDartType>($dartLiteral)';
      case ColumnCustomType(:final custom):
        final method = isNullable ? 'readNullableWithType' : 'readWithType';
        final typeImpl = _emitter.dartCode(custom.expression);
        code = 'row.$method<$rawDartType>($typeImpl, $dartLiteral)';
    }

    final converter = column.typeConverter;
    if (converter != null) {
      if (converter.canBeSkippedForNulls && isNullable) {
        // The type converter maps non-nullable types, but the column may be
        // nullable in SQL => just map null to null and only invoke the type
        // converter for non-null values.
        final wrapFrom = _emitter.drift('NullAwareTypeConverter.wrapFromSql');
        code = '$wrapFrom(${readConverter(_emitter, converter)}, $code)';
      } else {
        // Just apply the type converter directly.
        code = '${readConverter(_emitter, converter)}.fromSql($code)';
      }
    }

    _emitter.write(code);
  }

  void _readMatchingTable(MatchingDriftTable match, _ArgumentContext context) {
    // note that, even if the result set has a matching table, we can't just
    // use the mapFromRow() function of that table - the column names might
    // be different!
    final table = match.table;

    if (match.effectivelyNoAlias) {
      final mappingMethod =
          context.isNullable ? 'mapFromRowOrNull' : 'mapFromRow';
      final sqlPrefix = context.sqlPrefix;

      _emitter.write('await ${table.dbGetterName}.$mappingMethod(row');
      if (sqlPrefix != null) {
        _emitter.write(', tablePrefix: ${asDartLiteral(sqlPrefix)}');
      }

      _emitter.write(')');
    } else {
      // If the entire table can be nullable, we can check whether a non-nullable
      // column from the table is null. If it is, the entire table is null. This
      // can happen when the table comes from an outer join.
      if (context.isNullable) {
        for (final MapEntry(:key, :value) in match.aliasToColumn.entries) {
          if (!value.nullable) {
            final mapKey = context.applyPrefix(key);

            _emitter
                .write('row.data[${asDartLiteral(mapKey)}] == null ? null : ');
          }
        }
      }

      _emitter.write('${table.dbGetterName}.mapFromRowWithAlias(row, const {');

      for (final alias in match.aliasToColumn.entries) {
        _emitter
          ..write(asDartLiteral(context.applyPrefix(alias.key)))
          ..write(': ')
          ..write(asDartLiteral(alias.value.nameInSql))
          ..write(', ');
      }

      _emitter.write('})');
    }
  }

  /// Writes a method returning a `Selectable<T>`, where `T` is the return type
  /// of the custom query.
  void _writeSelectStatementCreator(SqlSelectQuery select) {
    final returnType = AnnotatedDartCode.build((builder) {
      builder
        ..addSymbol('Selectable', AnnotatedDartCode.drift)
        ..addText('<')
        ..addQueryResultRowType(select)
        ..addText('>');
    });

    final methodName = _nameOfCreationMethod(select);

    _emitter
      ..writeDart(returnType)
      ..write(' $methodName(');
    _writeParameters(select);
    _buffer.write(') {\n');

    _writeExpandedDeclarations(select);
    _buffer.write('return');
    _writeCustomSelectStatement(select);
    _buffer.write(';\n}\n');
  }

  void _writeCustomSelectStatement(SqlSelectQuery select,
      [QueryRowType? resultType]) {
    _buffer.write(' customSelect(${_queryCode(select)}, ');
    _writeVariables(select);
    _buffer.write(', ');
    _writeReadsFrom(select);

    final resultSet = select.resultSet;
    resultType ??= select.queryRowType(options);

    if (resultType.requiresAsynchronousContext) {
      _buffer.write(').asyncMap(');
    } else {
      _buffer.write(').map(');
    }

    _writeMappingLambda(resultSet, resultType);
    _buffer.write(')');
  }

  void _writeUpdatingQueryWithReturning(UpdatingQuery update) {
    final type = AnnotatedDartCode.build((builder) {
      builder
        ..addSymbol('Future', AnnotatedDartCode.dartAsync)
        ..addText('<')
        ..addSymbol('List', AnnotatedDartCode.dartCore)
        ..addText('<')
        ..addQueryResultRowType(update)
        ..addText('>>');
    });

    _emitter
      ..writeDart(type)
      ..write(' ${update.name}(');
    _writeParameters(update);
    _buffer.write(') {\n');

    _writeExpandedDeclarations(update);
    _buffer.write('return customWriteReturning(${_queryCode(update)},');
    _writeCommonUpdateParameters(update);

    _buffer.write(').then((rows) => ');

    final resultSet = update.resultSet!;
    final rowType = update.queryRowType(options);

    if (rowType.requiresAsynchronousContext) {
      _buffer.write('Future.wait(rows.map(');
      _writeMappingLambda(resultSet, rowType);
      _buffer.write('))');
    } else {
      _buffer.write('rows.map(');
      _writeMappingLambda(resultSet, rowType);
      _buffer.write(').toList()');
    }
    _buffer.write(');\n}');
  }

  void _writeUpdatingQuery(UpdatingQuery update) {
    /*
      Future<int> test() {
    return customUpdate('', variables: [], updates: {});
  }
     */
    final implName = update.isInsert ? 'customInsert' : 'customUpdate';

    _buffer.write('Future<int> ${update.name}(');
    _writeParameters(update);
    _buffer.write(') {\n');

    _writeExpandedDeclarations(update);
    _buffer.write('return $implName(${_queryCode(update)},');
    _writeCommonUpdateParameters(update);
    _buffer.write(',);\n}\n');
  }

  void _writeCommonUpdateParameters(UpdatingQuery update) {
    _writeVariables(update);
    _buffer.write(',');
    _writeUpdates(update);
    _writeUpdateKind(update);
  }

  void _writeParameters(SqlQuery query) {
    final namedElements = <FoundElement>[];

    String scopedTypeName(FoundDartPlaceholder element) {
      return '${ReCase(query.name).pascalCase}\$${ReCase(element.name).camelCase}';
    }

    String typeFor(FoundElement element) {
      return _emitter.dartCode(element.dartType(scope));
    }

    String writeScopedTypeFor(FoundDartPlaceholder element) {
      final root = scope.root;
      final type = typeFor(element);
      final scopedType = scopedTypeName(element);

      final args = element.availableResultSets
          .map((e) =>
              '${_emitter.dartCode(scope.entityInfoType(e.entity))} ${e.name}')
          .join(', ');
      root.leaf().write('typedef $scopedType = $type Function($args);');

      return scopedType;
    }

    var needsComma = false;
    for (final element in query.elementsWithNestedQueries()) {
      if (element.hidden) continue;

      // Placeholders with a default value generate optional (and thus, named)
      // parameters. Since moor 4, we have an option to also generate named
      // parameters for named variables.
      final isNamed = (element is FoundDartPlaceholder && element.hasDefault) ||
          (element.hasSqlName && options.generateNamedParameters);

      if (isNamed) {
        namedElements.add(element);
      } else {
        if (needsComma) _buffer.write(', ');

        var type = typeFor(element);
        if (element is FoundDartPlaceholder &&
            element.writeAsScopedFunction(options)) {
          type = writeScopedTypeFor(element);
        }
        _buffer.write('$type ${element.dartParameterName}');
        needsComma = true;
      }
    }

    // Write optional placeholder as named arguments
    if (namedElements.isNotEmpty) {
      if (needsComma) _buffer.write(', ');
      _buffer.write('{');
      needsComma = false;

      for (final optional in namedElements) {
        if (needsComma) _buffer.write(', ');
        needsComma = true;

        String? defaultCode;
        var isNullable = false;
        var type = typeFor(optional);

        if (optional is FoundDartPlaceholder) {
          // If optional Dart placeholders are written as functions, they are
          // generated as nullable parameters. The default is handled with a
          // `??` in the method's body.
          if (optional.writeAsScopedFunction(options)) {
            isNullable = optional.hasDefaultOrImplicitFallback;
            type = writeScopedTypeFor(optional);

            if (isNullable) {
              type += '?';
            }
          } else {
            defaultCode = _defaultForDartPlaceholder(optional, scope);
          }
        }

        // No default value, this element is required if it's not nullable
        var isMarkedAsRequired = false;
        if (optional is FoundVariable) {
          isMarkedAsRequired = optional.isRequired;
          isNullable = optional.nullableInDart;
        }
        final isRequired =
            (!isNullable || isMarkedAsRequired) && defaultCode == null ||
                options.namedParametersAlwaysRequired;
        if (isRequired) {
          _buffer.write('required ');
        }

        _buffer.write('$type ${optional.dartParameterName}');
        if (defaultCode != null && !isRequired) {
          _buffer
            ..write(' =  ')
            ..write(defaultCode);
        }
      }

      _buffer.write('}');
    }
  }

  void _writeExpandedDeclarations(SqlQuery query) {
    _ExpandedDeclarationWriter(query, options, scope, _buffer)
        .writeExpandedDeclarations();
  }

  void _writeVariables(SqlQuery query) {
    _ExpandedVariableWriter(query, _emitter).writeVariables();
  }

  /// Returns a Dart string literal representing the query after variables have
  /// been expanded. For instance, 'SELECT * FROM t WHERE x IN ?' will be turned
  /// into 'SELECT * FROM t WHERE x IN ($expandedVar1)'.
  String _queryCode(SqlQuery query) {
    final dialectForCode = <String, List<SqlDialect>>{};

    for (final dialect in scope.options.supportedDialects) {
      final code =
          SqlWriter(scope.options, dialect: dialect, query: query).write();

      dialectForCode.putIfAbsent(code, () => []).add(dialect);
    }

    if (dialectForCode.length == 1) {
      // All supported dialects use the same SQL syntax, so we can just use that
      return dialectForCode.keys.single;
    } else {
      // Create a switch expression matching over the dialect of the database
      // we're connected to.
      final buffer = StringBuffer('switch (executor.dialect) {');
      final dialectEnum = scope.drift('SqlDialect');

      var index = 0;
      for (final MapEntry(key: code, value: dialects)
          in dialectForCode.entries) {
        index++;

        buffer
            .write(dialects.map((e) => '$dialectEnum.${e.name}').join(' || '));
        if (index == dialectForCode.length) {
          // In the last branch, match all dialects as a fallback
          buffer.write(' || _ ');
        }

        buffer
          ..write(' => ')
          ..write(code)
          ..write(', ');
      }

      buffer.writeln('}');
      return buffer.toString();
    }
  }

  void _writeReadsFrom(SqlSelectQuery select) {
    _buffer.write('readsFrom: {');

    for (final table in select.readsFromTables) {
      _buffer.write('${table.dbGetterName},');
    }

    for (final element in select
        .elementsWithNestedQueries()
        .whereType<FoundDartPlaceholder>()) {
      _buffer.write('...${placeholderContextName(element)}.watchedTables,');
    }

    _buffer.write('}');
  }

  void _writeUpdates(UpdatingQuery update) {
    final from = update.updates.map((t) => t.table.dbGetterName).join(', ');
    _buffer
      ..write('updates: {')
      ..write(from)
      ..write('}');
  }

  void _writeUpdateKind(UpdatingQuery update) {
    if (update.updates.isEmpty) {
      return;
    }
    if (update.isOnlyDelete) {
      _buffer.write(', updateKind: ${_emitter.drift('UpdateKind.delete')}');
    } else if (update.isOnlyUpdate) {
      _buffer.write(', updateKind: ${_emitter.drift('UpdateKind.update')}');
    }
  }
}

/// Returns code to load an instance of the [converter] at runtime.
String readConverter(TextEmitter emitter, AppliedTypeConverter converter) {
  return emitter.dartCode(emitter.readConverter(converter));
}

class _ExpandedDeclarationWriter {
  final SqlQuery query;
  final DriftOptions options;
  final Scope _scope;
  final StringBuffer _buffer;

  bool indexCounterWasDeclared = false;
  bool needsIndexCounter = false;
  int highestIndexBeforeArray = 0;

  _ExpandedDeclarationWriter(
      this.query, this.options, this._scope, this._buffer);

  void writeExpandedDeclarations() {
    // When the SQL query is written to a Dart string, we give each variable an
    // eplixit index (e.g `?2`), regardless of how it was declared in the
    // source.
    // Array variables are converted into multiple variables at runtime, but
    // let's give variables before that an index, all other variables can be
    // turned into explicit indices though. We ensure that array variables have
    // higher indices than other variables.
    var index = 0;
    for (final variable in query.variables) {
      if (!variable.isArray) {
        // Re-assign continous indices to non-array variables
        highestIndexBeforeArray = variable.index = ++index;
      }
    }

    needsIndexCounter = true;
    for (final element in query.elementsWithNestedQueries()) {
      if (element is FoundVariable) {
        if (element.isArray) {
          _writeArrayVariable(element);
        }
      } else if (element is FoundDartPlaceholder) {
        _writeDartPlaceholder(element);
      }
    }
  }

  // Some notes on parameters and generating query code:
  // We expand array parameters to multiple variables at runtime (see the
  // documentation of FoundVariable and SqlQuery for further discussion).
  // To do this. we have to rewrite the sql. Consider this query:
  // SELECT * FROM t WHERE a = ?1 AND b IN :vars OR c IN :vars AND d = ?
  // When expanding an array variable, we write the expanded sql into a local
  // var called "expanded$Name", e.g. when we bind "vars" to [1, 2, 3] in the
  // query, then `expandedVars` would be "(?2, ?3, ?4)".
  // We use explicit indexes when expanding so that we don't have to expand the
  // "vars" variable twice. To do this, a local var called "$currentVarIndex"
  // keeps track of the highest variable number assigned.
  // We can use the same mechanism for runtime Dart placeholders, where we
  // generate a GenerationContext, write the placeholder and finally extract the
  // variables
  //
  // For the new query generation mode, we instead give every regular variable
  // an explicit index and then append arrays and other constructs after these
  // indices have been written.

  void _writeIndexCounterIfNeeded() {
    if (indexCounterWasDeclared || !needsIndexCounter) {
      return; // already written or not necessary at all
    }

    // we only need the index counter when the query contains an expanded
    // element.
    // add +1 because that's going to be the first index of this element.
    final firstVal = highestIndexBeforeArray + 1;
    _buffer.write('var $highestAssignedIndexVar = $firstVal;');
    indexCounterWasDeclared = true;
  }

  void _increaseIndexCounter(String by) {
    if (needsIndexCounter) {
      _buffer
        ..write('$highestAssignedIndexVar += ')
        ..write(by)
        ..write(';\n');
    }
  }

  void _writeDartPlaceholder(FoundDartPlaceholder element) {
    String useExpression() {
      if (element.writeAsScopedFunction(options)) {
        // The parameter is a function type that needs to be evaluated first
        final args = element.availableResultSets.map((e) {
          final table = 'this.${e.entity.dbGetterName}';
          final needsAlias = e.name != e.entity.schemaName;

          if (needsAlias) {
            return 'alias($table, ${asDartLiteral(e.name)})';
          } else {
            return table;
          }
        }).join(', ');

        final defaultValue = _defaultForDartPlaceholder(element, _scope);

        if (defaultValue != null) {
          // Optional elements written as a function are generated as nullable
          // parameters. We need to emit the default if the actual value is
          // null at runtime.
          return '${element.dartParameterName}?.call($args) ?? $defaultValue';
        } else {
          return '${element.dartParameterName}($args)';
        }
      } else {
        // We can just use the parameter directly
        return element.dartParameterName;
      }
    }

    _writeIndexCounterIfNeeded();
    _buffer
      ..write('final ')
      ..write(placeholderContextName(element))
      ..write(' = ');

    final type = element.type;
    if (type is InsertableDartPlaceholderType) {
      final table = type.table;

      _buffer
        ..write(r'$writeInsertable(this.')
        ..write(table?.dbGetterName)
        ..write(', ')
        ..write(useExpression())
        ..write(', startIndex: $highestAssignedIndexVar');

      _buffer.write(');\n');
    } else {
      _buffer
        ..write(r'$write(')
        ..write(useExpression());

      if (element.availableResultSets.length > 1) {
        _buffer.write(', hasMultipleTables: true');
      }

      _buffer
        ..write(', startIndex: $highestAssignedIndexVar')
        ..write(');\n');
    }

    // similar to the case for expanded array variables, we need to
    // increase the index
    _increaseIndexCounter(
        '${placeholderContextName(element)}.amountOfVariables');
  }

  void _writeArrayVariable(FoundVariable element) {
    assert(element.isArray);

    _writeIndexCounterIfNeeded();

    // final expandedvar1 = $expandVar(<startIndex>, <amount>);
    _buffer
      ..write('final ')
      ..write(expandedName(element))
      ..write(' = ')
      ..write(r'$expandVar(')
      ..write(highestAssignedIndexVar)
      ..write(', ')
      ..write(element.dartParameterName)
      ..write('.length);\n');

    // increase highest index for the next expanded element
    _increaseIndexCounter('${element.dartParameterName}.length');
  }
}

class _ExpandedVariableWriter {
  final SqlQuery query;
  final TextEmitter _emitter;

  StringBuffer get _buffer => _emitter.buffer;

  _ExpandedVariableWriter(this.query, this._emitter);

  void writeVariables() {
    _buffer.write('variables: ');

    // Some dialects don't support variables with an explicit index. In that
    // case, we have to desugar them by duplicating variables, e.g. `:a AND :a`
    // would be transformed to `? AND ?` with us binding the value to both
    // variables.
    if (_emitter.writer.options.supportedDialects
            .any((e) => !e.supportsIndexedParameters) &&
        query.referencesAnyElementMoreThanOnce) {
      _buffer.write('executor.dialect.desugarDuplicateVariables([');
      _writeNewVariables();
      _buffer.write('],');

      // Every time a variable is used in the generated SQL text, we have to
      // track the variable's index in the second list
      _buffer.write('[');
      for (final source in query.elementSources) {
        switch (source.referencedElement) {
          case FoundVariable variable:
            _buffer.write('${variable.index}, ');
            break;
          case FoundDartPlaceholder placeholder:
            final context = placeholderContextName(placeholder);
            _buffer.write('...$context.variableIndices');
            break;
        }
      }

      _buffer.write('])');
    } else {
      _buffer.write('[');
      _writeNewVariables();
      _buffer.write(']');
    }
  }

  void _writeNewVariables() {
    // In the new generation mode, we first write all non-array variables in
    // a continuous block, then we proceed to add arrays and other expanded
    // declarations.
    var first = true;

    for (final variable in query.variables) {
      if (!variable.isArray) {
        if (!first) {
          _buffer.write(', ');
        }

        _writeElement(variable);
        first = false;
      }
    }

    for (final element in query.elements) {
      final shouldBeWritten = element is! FoundVariable || element.isArray;

      if (shouldBeWritten) {
        if (!first) {
          _buffer.write(', ');
        }

        _writeElement(element);
        first = false;
      }
    }
  }

  void _writeElement(FoundElement element) {
    if (element is FoundVariable) {
      _writeVariable(element);
    } else if (element is FoundDartPlaceholder) {
      _writeDartPlaceholder(element);
    }
  }

  void _writeVariable(FoundVariable element) {
    // Variables without type converters are written as:
    // `Variable<int>(x)`. When there's a type converter, we instead use
    // `Variable<int>(typeConverter.toSql(x))`.
    // Finally, if we're dealing with a list, we use a collection for to write
    // all the variables sequentially.
    String constructVar(String dartExpr) {
      final capture = element.forCaptured;
      if (capture != null) {
        dartExpr = ('row.read(${asDartLiteral(capture.helperColumn)})');
      }

      final code = _emitter.wrapInVariable(
        element,
        AnnotatedDartCode.text(dartExpr),
        // No longer an array here, we apply a for loop below and run this on
        // individual values only.
        ignoreArray: true,
      );
      return _emitter.dartCode(code);
    }

    final name = element.dartParameterName;

    if (element.isArray) {
      final constructor = constructVar(r'$');
      _buffer.write('for (var \$ in $name) $constructor');
    } else {
      _buffer.write(constructVar(name));
    }
  }

  void _writeDartPlaceholder(FoundDartPlaceholder element) {
    _buffer.write('...${placeholderContextName(element)}.introducedVariables');
  }
}

String? _defaultForDartPlaceholder(
    FoundDartPlaceholder placeholder, Scope scope) {
  final kind = placeholder.type;
  if (kind is ExpressionDartPlaceholderType && kind.defaultValue != null) {
    // Wrap the default expression in parentheses to avoid issues with
    // the surrounding precedence in SQL.
    final (sql, dialectSpecific) =
        scope.sqlByDialect(Parentheses(kind.defaultValue!));

    if (dialectSpecific) {
      return 'const ${scope.drift('CustomExpression')}.dialectSpecific($sql)';
    } else {
      return 'const ${scope.drift('CustomExpression')}($sql)';
    }
  } else if (kind is SimpleDartPlaceholderType &&
      kind.kind == SimpleDartPlaceholderKind.orderBy) {
    return 'const ${scope.drift('OrderBy')}.nothing()';
  } else {
    assert(!placeholder.hasDefaultOrImplicitFallback);
    return null;
  }
}

extension on _ArgumentContext {
  String applyPrefix(String originalName) {
    return switch (sqlPrefix) {
      null => originalName,
      var s => '$s.$originalName',
    };
  }
}
