enum TextEntityKind { mention, phone, card }

class TextEntity {
  final TextEntityKind kind;
  final int start;
  final int end;
  final String value;

  const TextEntity({
    required this.kind,
    required this.start,
    required this.end,
    required this.value,
  });

  int get length => end - start;
}

typedef TextSpanRange = ({int start, int end});

final RegExp _cardPattern = RegExp(
  r'(?<![\d.,])\d(?:[ -]?\d){12,18}(?![\d.,])',
);
final RegExp _phonePattern = RegExp(
  r'(?<![\d.,])(?:\+\d(?:[ ()-]{0,3}\d){9,14}|[78](?:[ ()-]{0,3}\d){10})(?![\d.,])',
);
final RegExp _mentionPattern = RegExp(r'(?<![\w@/])@([A-Za-z0-9_]{2,32})');

const Map<String, String> _cardBrands = {
  'MIR': 'МИР',
  'VISA': 'Visa',
  'MASTERCARD': 'Mastercard',
  'MAESTRO': 'Maestro',
  'AMEX': 'American Express',
  'UNIONPAY': 'UnionPay',
  'JCB': 'JCB',
  'DINERS': 'Diners Club',
  'DISCOVER': 'Discover',
};

String? cardBrand(String digits) {
  if (digits.length < 13) return null;
  int prefix(int length) => int.parse(digits.substring(0, length));

  final p1 = prefix(1);
  final p2 = prefix(2);
  final p3 = prefix(3);
  final p4 = prefix(4);
  final p6 = digits.length >= 6 ? prefix(6) : 0;

  if (p4 >= 2200 && p4 <= 2204) return 'MIR';
  if (p1 == 4) return 'VISA';
  if (p2 >= 51 && p2 <= 55) return 'MASTERCARD';
  if (p4 >= 2221 && p4 <= 2720) return 'MASTERCARD';
  if (p2 == 34 || p2 == 37) return 'AMEX';
  if (p2 == 62) return 'UNIONPAY';
  if (p4 >= 3528 && p4 <= 3589) return 'JCB';
  if (p3 >= 300 && p3 <= 305) return 'DINERS';
  if (p2 == 36 || p2 == 38 || p2 == 39) return 'DINERS';
  if (p4 == 6011 || p2 == 65) return 'DISCOVER';
  if (p3 >= 644 && p3 <= 649) return 'DISCOVER';
  if (p6 >= 622126 && p6 <= 622925) return 'DISCOVER';
  if (p4 == 5018 || p4 == 5020 || p4 == 5038 || p4 == 6304) return 'MAESTRO';
  if (p4 == 6759 || (p4 >= 6761 && p4 <= 6763)) return 'MAESTRO';
  return null;
}

String? cardBrandTitle(String digits) {
  final brand = cardBrand(digits);
  return brand == null ? null : _cardBrands[brand];
}

String cardMask(String digits) {
  final brand = cardBrand(digits) ?? 'CARD';
  final tail = digits.length >= 4
      ? digits.substring(digits.length - 4)
      : digits;
  return '$brand*$tail';
}

String formatCardNumber(String digits) {
  final buffer = StringBuffer();
  for (var i = 0; i < digits.length; i++) {
    if (i > 0 && i % 4 == 0) buffer.write(' ');
    buffer.write(digits[i]);
  }
  return buffer.toString();
}

bool isLuhnValid(String digits) {
  if (digits.length < 12) return false;
  var sum = 0;
  var double = false;
  for (var i = digits.length - 1; i >= 0; i--) {
    var value = digits.codeUnitAt(i) - 0x30;
    if (value < 0 || value > 9) return false;
    if (double) {
      value *= 2;
      if (value > 9) value -= 9;
    }
    sum += value;
    double = !double;
  }
  return sum % 10 == 0;
}

String _digitsOf(String raw) {
  final buffer = StringBuffer();
  for (var i = 0; i < raw.length; i++) {
    final code = raw.codeUnitAt(i);
    if (code >= 0x30 && code <= 0x39) buffer.writeCharCode(code);
  }
  return buffer.toString();
}

bool _mayContainEntities(String text) {
  for (var i = 0; i < text.length; i++) {
    final code = text.codeUnitAt(i);
    if (code == 0x40) return true;
    if (code >= 0x30 && code <= 0x39) return true;
  }
  return false;
}

List<TextEntity> detectTextEntities(
  String text, {
  Iterable<TextSpanRange> skip = const [],
}) {
  if (text.isEmpty || !_mayContainEntities(text)) return const [];

  final taken = <TextSpanRange>[...skip];
  bool free(int start, int end) =>
      !taken.any((r) => start < r.end && end > r.start);

  final found = <TextEntity>[];

  void collect(
    RegExp pattern,
    TextEntityKind kind,
    String? Function(RegExpMatch match) valueOf,
  ) {
    for (final match in pattern.allMatches(text)) {
      if (!free(match.start, match.end)) continue;
      final value = valueOf(match);
      if (value == null) continue;
      taken.add((start: match.start, end: match.end));
      found.add(
        TextEntity(
          kind: kind,
          start: match.start,
          end: match.end,
          value: value,
        ),
      );
    }
  }

  collect(_cardPattern, TextEntityKind.card, (match) {
    final digits = _digitsOf(match.group(0)!);
    if (digits.length < 13 || digits.length > 19) return null;
    if (cardBrand(digits) == null) return null;
    if (!isLuhnValid(digits)) return null;
    return digits;
  });

  collect(_phonePattern, TextEntityKind.phone, (match) {
    final digits = _digitsOf(match.group(0)!);
    if (digits.length < 10 || digits.length > 15) return null;
    return '+$digits';
  });

  collect(_mentionPattern, TextEntityKind.mention, (match) => match.group(1));

  found.sort((a, b) => a.start.compareTo(b.start));
  return found;
}

bool hasTextEntities(String text, {Iterable<TextSpanRange> skip = const []}) =>
    detectTextEntities(text, skip: skip).isNotEmpty;
