import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/dart/element/type.dart';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart' show DriftSqlType;
import 'package:sqlparser/sqlparser.dart' show ReferenceAction;
import 'package:sqlparser/sqlparser.dart' as sql;

import '../../driver/error.dart';
import '../../results/results.dart';
import '../resolver.dart';
import '../shared/dart_types.dart';
import 'helper.dart';
import 'table.dart';

const String _startInt = 'integer';
const String _startInt64 = 'int64';
const String _startIntEnum = 'intEnum';
const String _startTextEnum = 'textEnum';
const String _startString = 'text';
const String _startBool = 'boolean';
const String _startDateTime = 'dateTime';
const String _startBlob = 'blob';
const String _startReal = 'real';
const String _startCustom = 'customType';

const Set<String> _starters = {
  _startInt,
  _startInt64,
  _startIntEnum,
  _startTextEnum,
  _startString,
  _startBool,
  _startDateTime,
  _startBlob,
  _startReal,
  _startCustom,
};

const String _methodNamed = 'named';
const String _methodReferences = 'references';
const String _methodAutoIncrement = 'autoIncrement';
const String _methodWithLength = 'withLength';
const String _methodNullable = 'nullable';
const String _methodUnique = 'unique';
const String _methodCustomConstraint = 'customConstraint';
const String _methodDefault = 'withDefault';
const String _methodClientDefault = 'clientDefault';
const String _methodMap = 'map';
const String _methodGenerated = 'generatedAs';
const String _methodCheck = 'check';
const Set<String> _addsSqlConstraint = {
  _methodReferences,
  _methodAutoIncrement,
  _methodUnique,
  _methodDefault,
  _methodGenerated,
  _methodCheck,
};

const String _errorMessage = 'This getter does not create a valid column that '
    'can be parsed by drift. Please refer to the readme from drift to see how '
    'columns are formed. If you have any questions, feel free to raise an '
    'issue.';

/// Parses a single column defined in a Dart table. These columns are a chain
/// or [MethodInvocation]s. An example getter might look like this:
/// ```dart
/// IntColumn get id => integer().autoIncrement()();
/// ```
/// The last call `()` is a [FunctionExpressionInvocation], the entries for
/// before that (in this case `autoIncrement()` and `integer()` are a)
/// [MethodInvocation]. We work our way through that syntax until we hit a
/// method that starts the chain (contained in [starters]). By visiting all
/// the invocations on our way, we can extract the constraint for the column
/// (e.g. its name, whether it has auto increment, is a primary key and so on).
class ColumnParser {
  final DartTableResolver _resolver;

  ColumnParser(this._resolver);

  Future<PendingColumnInformation?> parse(
      MethodDeclaration getter, Element element) async {
    final expr = returnExpressionOfMethod(getter);

    if (expr is! FunctionExpressionInvocation) {
      _resolver.reportError(
          DriftAnalysisError.forDartElement(element, _errorMessage));
      return null;
    }

    var remainingExpr = expr.function as MethodInvocation;

    String? foundStartMethod;
    String? foundExplicitName;
    String? foundCustomConstraint;
    Expression? customConstraintSource;
    AnnotatedDartCode? foundDefaultExpression;
    AnnotatedDartCode? clientDefaultExpression;
    Expression? mappedAs;
    String? referencesColumnInSameTable;

    var nullable = false;
    var hasDefaultConstraints = false;

    final foundConstraints = <DriftColumnConstraint>[];

    while (true) {
      final methodName = remainingExpr.methodName.name;

      if (_starters.contains(methodName)) {
        foundStartMethod = methodName;
        break;
      }

      if (_addsSqlConstraint.contains(methodName)) {
        hasDefaultConstraints = true;
      }

      switch (methodName) {
        case _methodNamed:
          if (foundExplicitName != null) {
            _resolver.reportError(
              DriftAnalysisError.forDartElement(
                element,
                "You're setting more than one name here, the first will "
                'be used',
              ),
            );
          }

          foundExplicitName =
              readStringLiteral(remainingExpr.argumentList.arguments.first);
          if (foundExplicitName == null) {
            _resolver.reportError(DriftAnalysisError.inDartAst(
                element,
                remainingExpr.argumentList,
                'This table name is cannot be resolved! Please only use '
                'a constant string as parameter for .named().'));
          }
          break;
        case _methodReferences:
          final args = remainingExpr.argumentList.arguments;
          final first = args.first;

          if (first is! Identifier) {
            _resolver.reportError(DriftAnalysisError.inDartAst(
              element,
              first,
              'This parameter should be a simple class name',
            ));
            break;
          }

          final staticElement = first.staticElement;
          if (staticElement is! ClassElement) {
            _resolver.reportError(DriftAnalysisError.inDartAst(
              element,
              first,
              '`${first.name}` is not a class!',
            ));
            break;
          }

          final columnNameNode = args[1];
          if (columnNameNode is! SymbolLiteral) {
            _resolver.reportError(DriftAnalysisError.inDartAst(
              element,
              columnNameNode,
              'This should be a symbol literal (`#columnName`)',
            ));
            break;
          }

          final columnName =
              columnNameNode.components.map((token) => token.lexeme).join('.');

          ReferenceAction? onUpdate, onDelete;

          ReferenceAction? parseAction(Expression expr) {
            if (expr is! PrefixedIdentifier) {
              _resolver.reportError(DriftAnalysisError.inDartAst(element, expr,
                  'Should be a direct enum reference (`KeyAction.cascade`)'));
              return null;
            }

            final name = expr.identifier.name;
            switch (name) {
              case 'setNull':
                return ReferenceAction.setNull;
              case 'setDefault':
                return ReferenceAction.setDefault;
              case 'cascade':
                return ReferenceAction.cascade;
              case 'restrict':
                return ReferenceAction.restrict;
              case 'noAction':
              default:
                return ReferenceAction.noAction;
            }
          }

          for (final expr in args) {
            if (expr is! NamedExpression) continue;

            final name = expr.name.label.name;
            final value = expr.expression;
            if (name == 'onUpdate') {
              onUpdate = parseAction(value);
            } else if (name == 'onDelete') {
              onDelete = parseAction(value);
            }
          }

          final referencedTable = await _resolver.resolver
              .resolveDartReference(_resolver.discovered.ownId, staticElement);

          if (referencedTable is ReferencesItself) {
            // "Foreign" key to a column in the same table.
            foundConstraints
                .add(ForeignKeyReference.unresolved(onUpdate, onDelete));
            referencesColumnInSameTable = columnName;
          } else if (referencedTable is ResolvedReferenceFound) {
            final driftElement = referencedTable.element;

            if (driftElement is DriftTable) {
              final column = driftElement.columns.firstWhereOrNull(
                  (element) => element.nameInDart == columnName);

              if (column == null) {
                _resolver.reportError(DriftAnalysisError.inDartAst(
                  element,
                  columnNameNode,
                  'The referenced table `${driftElement.schemaName}` has no '
                  'column named `$columnName` in Dart.',
                ));
              } else {
                foundConstraints
                    .add(ForeignKeyReference(column, onUpdate, onDelete));
              }
            } else {
              _resolver.reportError(
                  DriftAnalysisError.inDartAst(element, first, 'Not a table'));
            }
          } else {
            // Could not resolve foreign table, emit warning
            _resolver.reportErrorForUnresolvedReference(referencedTable,
                (msg) => DriftAnalysisError.inDartAst(element, first, msg));
          }

          break;
        case _methodWithLength:
          final args = remainingExpr.argumentList;
          final minArg = findNamedArgument(args, 'min');
          final maxArg = findNamedArgument(args, 'max');

          foundConstraints.add(LimitingTextLength(
            minLength: minArg != null ? readIntLiteral(minArg) : null,
            maxLength: maxArg != null ? readIntLiteral(maxArg) : null,
          ));
          break;
        case _methodAutoIncrement:
          foundConstraints.add(PrimaryKeyColumn(true));
          break;
        case _methodNullable:
          nullable = true;
          break;
        case _methodUnique:
          foundConstraints.add(const UniqueColumn());
          break;
        case _methodCustomConstraint:
          if (foundCustomConstraint != null) {
            _resolver.reportError(
              DriftAnalysisError.inDartAst(
                element,
                remainingExpr.methodName,
                "You've already set custom constraints on this column, "
                'they will be overriden by this call.',
              ),
            );
          }

          final stringLiteral = customConstraintSource =
              remainingExpr.argumentList.arguments.first;
          foundCustomConstraint = readStringLiteral(stringLiteral);

          if (foundCustomConstraint == null) {
            _resolver.reportError(DriftAnalysisError.forDartElement(
              element,
              'This constraint is cannot be resolved! Please only use '
              'a constant string as parameter for .customConstraint().',
            ));
          }
          break;
        case _methodDefault:
          final args = remainingExpr.argumentList;
          final expression = args.arguments.single;
          foundDefaultExpression = AnnotatedDartCode.ast(expression);
          break;
        case _methodClientDefault:
          clientDefaultExpression = AnnotatedDartCode.ast(
              remainingExpr.argumentList.arguments.single);
          break;
        case _methodMap:
          final args = remainingExpr.argumentList;
          mappedAs = args.arguments.single;
          break;
        case _methodGenerated:
          Expression? generatedExpression;
          var stored = false;

          for (final expr in remainingExpr.argumentList.arguments) {
            if (expr is NamedExpression && expr.name.label.name == 'stored') {
              final storedValue = expr.expression;
              if (storedValue is BooleanLiteral) {
                stored = storedValue.value;
              } else {
                _resolver.reportError(DriftAnalysisError.inDartAst(
                    element, expr, 'Must be a boolean literal'));
              }
            } else {
              generatedExpression = expr;
            }
          }

          if (generatedExpression != null) {
            final code = AnnotatedDartCode.ast(generatedExpression);
            foundConstraints.add(ColumnGeneratedAs(code, stored));
          }
          break;
        case _methodCheck:
          final expr = remainingExpr.argumentList.arguments.first;
          foundConstraints
              .add(DartCheckExpression(AnnotatedDartCode.ast(expr)));
      }

      // We're not at a starting method yet, so we need to go deeper!
      final inner = remainingExpr.target as MethodInvocation;
      remainingExpr = inner;
    }

    final sqlName = foundExplicitName ??
        _resolver.resolver.driver.options.caseFromDartToSql
            .apply(getter.name.lexeme);
    ColumnType columnType;

    final helper = _resolver.resolver.driver.knownTypes;

    if (foundStartMethod == _startCustom) {
      final expression = remainingExpr.argumentList.arguments.single;

      final custom = readCustomType(
        element.library!,
        expression,
        helper,
        (message) => _resolver.reportError(
          DriftAnalysisError.inDartAst(element, mappedAs!, message),
        ),
      );
      columnType = custom != null
          ? ColumnType.custom(custom)
          // Fallback if we fail to read the custom type - we'll also emit an
          // error int that case.
          : ColumnType.drift(DriftSqlType.any);
    } else {
      columnType =
          ColumnType.drift(_startMethodToBuiltinColumnType(foundStartMethod));
    }

    AppliedTypeConverter? converter;
    if (mappedAs != null) {
      converter = readTypeConverter(
        element.library!,
        mappedAs,
        columnType,
        nullable,
        (message) => _resolver.reportError(
            DriftAnalysisError.inDartAst(element, mappedAs!, message)),
        helper,
      );
    }

    if (foundStartMethod == _startIntEnum) {
      if (converter != null) {
        _resolver.reportError(DriftAnalysisError.forDartElement(
          element,
          'Using $_startIntEnum will apply a custom converter by default, '
          "so you can't add an additional converter",
        ));
      }

      final enumType = remainingExpr.typeArgumentTypes!.first;
      converter = readEnumConverter(
        (msg) => _resolver.reportError(DriftAnalysisError.inDartAst(element,
            remainingExpr.typeArguments ?? remainingExpr.methodName, msg)),
        enumType,
        EnumType.intEnum,
        helper,
      );
    } else if (foundStartMethod == _startTextEnum) {
      if (converter != null) {
        _resolver.reportError(DriftAnalysisError.forDartElement(
          element,
          'Using $_startTextEnum will apply a custom converter by default, '
          "so you can't add an additional converter",
        ));
      }

      final enumType = remainingExpr.typeArgumentTypes!.first;
      converter = readEnumConverter(
        (msg) => _resolver.reportError(DriftAnalysisError.inDartAst(element,
            remainingExpr.typeArguments ?? remainingExpr.methodName, msg)),
        enumType,
        EnumType.textEnum,
        helper,
      );
    }

    if (foundDefaultExpression != null && clientDefaultExpression != null) {
      _resolver.reportError(
        DriftAnalysisError.forDartElement(
          element,
          'clientDefault() and withDefault() are mutually exclusive, '
          "they can't both be used. Use clientDefault() for values that "
          'are different for each row and withDefault() otherwise.',
        ),
      );
    }

    if (foundConstraints.contains(const UniqueColumn()) &&
        foundConstraints.any((e) => e is PrimaryKeyColumn)) {
      _resolver.reportError(
        DriftAnalysisError.forDartElement(
          element,
          'Primary key column cannot have UNIQUE constraint',
        ),
      );
    }

    if (hasDefaultConstraints && foundCustomConstraint != null) {
      _resolver.reportError(
        DriftAnalysisError.forDartElement(
          element,
          'This column definition is using both drift-defined '
          'constraints (like references, autoIncrement, ...) and a '
          'customConstraint(). Only the custom constraint will be added '
          'to the column in SQL!',
        ),
      );
    }

    final docString =
        getter.documentationComment?.tokens.map((t) => t.toString()).join('\n');

    foundConstraints.addAll(await _driftConstraintsFromCustomConstraints(
      isNullable: nullable,
      customConstraints: foundCustomConstraint,
      sourceForCustomConstraints: customConstraintSource,
    ));
    return PendingColumnInformation(
      DriftColumn(
        sqlType: columnType,
        nullable: nullable,
        nameInSql: sqlName,
        nameInDart: element.name!,
        declaration: DriftDeclaration.dartElement(element),
        typeConverter: converter,
        clientDefaultCode: clientDefaultExpression,
        defaultArgument: foundDefaultExpression,
        overriddenJsonName: _readJsonKey(element),
        documentationComment: docString,
        constraints: foundConstraints,
        customConstraints: foundCustomConstraint,
        referenceName: _readReferenceName(element),
      ),
      referencesColumnInSameTable: referencesColumnInSameTable,
    );
  }

  DriftSqlType _startMethodToBuiltinColumnType(String name) {
    return const {
      _startBool: DriftSqlType.bool,
      _startString: DriftSqlType.string,
      _startInt: DriftSqlType.int,
      _startInt64: DriftSqlType.bigInt,
      _startIntEnum: DriftSqlType.int,
      _startTextEnum: DriftSqlType.string,
      _startDateTime: DriftSqlType.dateTime,
      _startBlob: DriftSqlType.blob,
      _startReal: DriftSqlType.double,
    }[name]!;
  }

  String? _readJsonKey(Element getter) {
    final annotations = getter.metadata;
    final object = annotations.firstWhereOrNull((e) {
      final value = e.computeConstantValue();
      final valueType = value?.type;

      return valueType is InterfaceType &&
          isFromDrift(valueType) &&
          valueType.element.name == 'JsonKey';
    });

    if (object == null) return null;

    return object.computeConstantValue()!.getField('key')!.toStringValue();
  }

  String? _readReferenceName(Element getter) {
    final annotations = getter.metadata;
    final object = annotations.firstWhereOrNull((e) {
      final value = e.computeConstantValue();
      final valueType = value?.type;

      return valueType is InterfaceType &&
          isFromDrift(valueType) &&
          valueType.element.name == 'ReferenceName';
    });

    if (object == null) return null;

    return object.computeConstantValue()!.getField('name')!.toStringValue();
  }

  Future<List<DriftColumnConstraint>> _driftConstraintsFromCustomConstraints({
    required bool isNullable,
    String? customConstraints,
    AstNode? sourceForCustomConstraints,
  }) async {
    if (customConstraints == null) return const [];

    final engine = _resolver.resolver.driver.newSqlEngine();
    final parseResult = engine.parseColumnConstraints(customConstraints);
    final constraints =
        (parseResult.rootNode as sql.ColumnDefinition).constraints;

    for (final error in parseResult.errors) {
      _resolver.reportError(DriftAnalysisError(error.token.span,
          'Parse error in customConstraint(): ${error.message}'));
    }

    // Constraints override all constraints that drift will add. So if the
    // column is non-nullable, there should be a `NON NULL` constraint.
    if (!isNullable && !constraints.any((e) => e is sql.NotNull)) {
      _resolver.reportError(DriftAnalysisError.inDartAst(
        _resolver.discovered.dartElement,
        sourceForCustomConstraints!,
        "This column is not declared to be `.nullable()`, but also doesn't "
        'have `NOT NULL` in its custom constraints. Please explicitly declare '
        'the column to be nullable in Dart, or add a `NOT NULL` constraint for '
        'consistency.',
      ));
    }

    final parsedConstraints = <DriftColumnConstraint>[];

    for (final constraint in constraints) {
      if (constraint is sql.GeneratedAs) {
        parsedConstraints.add(ColumnGeneratedAs.fromParser(constraint));
      } else if (constraint is sql.PrimaryKeyColumn) {
        parsedConstraints.add(PrimaryKeyColumn(constraint.autoIncrement));
      } else if (constraint is sql.UniqueColumn) {
        parsedConstraints.add(UniqueColumn());
      } else if (constraint is sql.ForeignKeyColumnConstraint) {
        final clause = constraint.clause;

        final table =
            await _resolver.resolveSqlReferenceOrReportError<DriftTable>(
          clause.foreignTable.tableName,
          (msg) => DriftAnalysisError.inDartAst(
            _resolver.discovered.dartElement,
            sourceForCustomConstraints!,
            msg,
          ),
        );

        if (table != null) {
          final columnName = clause.columnNames.first;
          final column =
              table.columnBySqlName[clause.columnNames.first.columnName];

          if (column == null) {
            _resolver.reportError(DriftAnalysisError.inDartAst(
              _resolver.discovered.dartElement,
              sourceForCustomConstraints!,
              'The referenced table has no column named `$columnName`',
            ));
          } else {
            parsedConstraints.add(ForeignKeyReference(
              column,
              constraint.clause.onUpdate,
              constraint.clause.onDelete,
            ));
          }
        }
      }
    }

    return parsedConstraints;
  }
}

class PendingColumnInformation {
  final DriftColumn column;

  /// If the returned column references another column in the same table, its
  /// [ForeignKeyReference] is still unresolved when the local column resolver
  /// returns.
  ///
  /// It is the responsibility of the table resolver to patch the reference for
  /// this column in that case.
  final String? referencesColumnInSameTable;

  PendingColumnInformation(this.column, {this.referencesColumnInSameTable});
}
