import 'dart:convert';
import 'dart:core';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:synchronized/synchronized.dart';

/// Add a leading zero, if required, to ensure that an integer is rendered
/// as a two "digit" string.
///
/// NOTE: This function assumes that 0 <= i <= 99
String padInt(int i) {
  if (i <= 9) {
    return '0$i';
  }

  return i.toString();
}

/// Format the timestamp of a conversation change into a nice string.
/// timestamp and now are both in millisecondsSinceEpoch.
/// Ensures that now >= timestamp
String formatConversationTimestamp(int timestamp, int now) {
  final difference = now - timestamp;

  // NOTE: Just to make sure
  assert(difference >= 0, 'Timestamp lies in the future');

  if (difference >= 60 * Duration.millisecondsPerMinute) {
    final hourDifference = (difference / Duration.millisecondsPerHour).floor();
    if (hourDifference >= 24) {
      final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
      final suffix = difference >= 364.5 * Duration.millisecondsPerDay
          ? dt.year.toString()
          : '';
      return '${dt.day}.${dt.month}.$suffix';
    } else {
      return '${hourDifference}h';
    }
  } else if (difference <= Duration.millisecondsPerMinute) {
    return t.dateTime.justNow;
  }

  return '${(difference / Duration.millisecondsPerMinute).floor()}min';
}

/// Same as [formatConversationTimestamp] but for messages
String formatMessageTimestamp(int timestamp, int now) {
  final difference = now - timestamp;

  // NOTE: Just to make sure
  assert(difference >= 0, 'Timestamp lies in the future');

  if (difference >= 15 * Duration.millisecondsPerMinute) {
    final dt = DateTime.fromMillisecondsSinceEpoch(timestamp);
    return '${dt.hour}:${padInt(dt.minute)}';
  } else {
    if (difference < Duration.millisecondsPerMinute) {
      return t.dateTime.justNow;
    } else {
      final diff = (difference / Duration.millisecondsPerMinute).floor();
      return t.dateTime.nMinutesAgo(min: diff);
    }
  }
}

/// Turn [day], which is an integer between 1 and 7, into the name of the day of the week.
String weekdayToStringAbbrev(int day) {
  switch (day) {
    case DateTime.monday:
      return t.dateTime.mondayAbbrev;
    case DateTime.tuesday:
      return t.dateTime.tuesdayAbbrev;
    case DateTime.wednesday:
      return t.dateTime.wednessdayAbbrev;
    case DateTime.thursday:
      return t.dateTime.thursdayAbbrev;
    case DateTime.friday:
      return t.dateTime.fridayAbbrev;
    case DateTime.saturday:
      return t.dateTime.saturdayAbbrev;
    case DateTime.sunday:
      return t.dateTime.sundayAbbrev;
  }

  // Should not happen
  throw Exception();
}

/// Turn [month], which is an integer between 1 and 12, into the name of the month.
String monthToString(int month) {
  switch (month) {
    case DateTime.january:
      return t.dateTime.january;
    case DateTime.february:
      return t.dateTime.february;
    case DateTime.march:
      return t.dateTime.march;
    case DateTime.april:
      return t.dateTime.april;
    case DateTime.may:
      return t.dateTime.may;
    case DateTime.june:
      return t.dateTime.june;
    case DateTime.july:
      return t.dateTime.july;
    case DateTime.august:
      return t.dateTime.august;
    case DateTime.september:
      return t.dateTime.september;
    case DateTime.october:
      return t.dateTime.october;
    case DateTime.november:
      return t.dateTime.november;
    case DateTime.december:
      return t.dateTime.december;
  }

  // Should not happen
  throw Exception();
}

/// Format both the timestamp [dt] of the message and the current timestamp into a string
/// like 'Today', 'Yesterday', 'Fri, 7. August' or '6. August 2022'.
String formatDateBubble(DateTime dt, DateTime now) {
  if (dt.day == now.day && dt.month == now.month && dt.year == now.year) {
    return t.dateTime.today;
  } else if (now.subtract(const Duration(days: 1)).day == dt.day) {
    return t.dateTime.yesterday;
  } else if (dt.year == now.year) {
    return '${weekdayToStringAbbrev(dt.weekday)}., ${dt.day}. ${monthToString(dt.month)}';
  } else {
    return '${dt.day}. ${monthToString(dt.month)} ${dt.year}';
  }
}

enum JidFormatError {
  none,
  empty,
  noLocalpart,
  noSeparator,
  tooManySeparators,
  noDomain
}

/// Validate a JID and return why it is invalid.
JidFormatError validateJid(String jid) {
  if (jid.isEmpty) {
    return JidFormatError.empty;
  }

  if (!jid.contains('@')) {
    return JidFormatError.noSeparator;
  }

  final parts = jid.split('@');
  if (parts.length != 2) {
    return JidFormatError.tooManySeparators;
  }

  if (parts[0].isEmpty) {
    return JidFormatError.noLocalpart;
  }

  if (parts[1].isEmpty) {
    return JidFormatError.noDomain;
  }

  return JidFormatError.none;
}

/// Returns an error string if [jid] is not a valid JID. Returns null if everything
/// appears okay.
String? validateJidString(String jid) {
  switch (validateJid(jid)) {
    case JidFormatError.empty:
      return 'XMPP-Address cannot be empty';
    case JidFormatError.noSeparator:
    case JidFormatError.tooManySeparators:
      return 'XMPP-Address must contain exactly one @';
    // TODO(Unknown): Find a better text
    case JidFormatError.noDomain:
      return 'A domain must follow the @';
    case JidFormatError.noLocalpart:
      return 'Your username must preceed the @';
    case JidFormatError.none:
      return null;
  }
}

/// Returns the first element in [items] which is non null.
/// Returns null if they all are null.
T? firstNotNull<T>(List<T?> items) {
  for (final item in items) {
    if (item != null) return item;
  }

  return null;
}

/// Attempt to guess a mimetype from its file extension
String? guessMimeTypeFromExtension(String ext) {
  switch (ext) {
    case 'png':
      return 'image/png';
    case 'jpg':
    case 'jpeg':
      return 'image/jpeg';
    case 'webp':
      return 'image/webp';
    case 'mp4':
      return 'video/mp4';
  }

  return null;
}

/// Return the translated name describing the MIME type [mime]. If [mime] is null or
/// the MIME type is neither image, video or audio, then it falls back to the
/// translation of "file".
String mimeTypeToName(String? mime) {
  if (mime != null) {
    if (mime.startsWith('image')) {
      return t.messages.image;
    } else if (mime.startsWith('audio')) {
      return t.messages.audio;
    } else if (mime.startsWith('video')) {
      return t.messages.video;
    }
  }

  return t.messages.file;
}

/// Return an emoji for the MIME type [mime]. If [addTypeName] id true, then a human readable
/// name for the MIME type will be appended.
String mimeTypeToEmoji(String? mime, {bool addTypeName = true}) {
  String value;
  if (mime != null) {
    if (mime.startsWith('image')) {
      value = '🖼️';
    } else if (mime.startsWith('audio')) {
      value = '🎙';
    } else if (mime.startsWith('video')) {
      value = '🎬';
    } else {
      value = '📁';
    }
  } else {
    value = '📁';
  }

  if (addTypeName) {
    value += ' ${mimeTypeToName(mime)}';
  }

  return value;
}

/// Parse an Uri and return the "filename".
String filenameFromUrl(String url) {
  return Uri.parse(url).pathSegments.last;
}

/// Attempts to escape [filename] such that it cannot be expanded into another path, i.e.
/// make "../" not dangerous.
String escapeFilename(String filename) {
  return filename
      .replaceAll('/', '%2F')
      // ignore: use_raw_strings
      .replaceAll('\\', '%5C')
      .replaceAll('../', '..%2F');
}

/// Return a version of the filename [filename] with [suffix] attached to the file's
/// name while keeping the extension in [filename] intact.
String filenameWithSuffix(String filename, String suffix) {
  final parts = filename.split('.');

  // Handle the special case of no "." in filename
  if (parts.length == 1) {
    return '$filename$suffix';
  }

  final filenameWithoutExtension = parts.take(parts.length - 1).join('.');
  return '$filenameWithoutExtension$suffix.${parts.last}';
}

extension ExceptionSafeLock on Lock {
  /// Throwing an exception with synchronized is not safe as it will cause the lock to
  /// not get released. This function wraps the call to [criticalSection], making sure
  /// that it cannot deadlock everything depending on the lock. Throws the exception again
  /// after the lock has been released.
  /// With [log], one can control how the stack trace gets displayed. Defaults to print.
  Future<void> safeSynchronized(
    Future<void> Function() criticalSection, {
    void Function(String) log = print,
  }) async {
    Object? ex;

    await synchronized(() async {
      try {
        await criticalSection();
      } catch (err, stackTrace) {
        ex = err;
        log(stackTrace.toString());
      }
    });

    if (ex != null) {
      // ignore: only_throw_errors
      throw ex!;
    }
  }
}

/// Returns true if the message [message] was sent by us ([jid]). If not, returns false.
bool isSent(Message message, String jid) {
  // TODO(PapaTutuWawa): Does this work?
  return message.sender.split('/').first == jid.split('/').first;
}

/// Convert the file size [size] in bytes to a human readable string. This is what
/// Conversations does.
String fileSizeToString(int size) {
  // See https://github.com/iNPUTmice/Conversations/blob/d435c1f2aef1454141d4f5099224b5a03d579dba/src/main/java/eu/siacs/conversations/utils/UIHelper.java#L605
  if (size > (1.5 * 1024 * 1024)) {
    return '${(size * 1.0 / (1024 * 1024)).round()} MiB';
  } else if (size >= 1024) {
    return '${(size * 1.0 / 1024).round()} KiB';
  } else {
    return '$size B';
  }
}

/// Load [path] into memory and determine its width and height. Returns null in case
/// of an error.
Future<Size?> getImageSizeFromPath(String path) async {
  final bytes = await File(path).readAsBytes();
  return getImageSizeFromData(bytes);
}

/// Like getImageSizeFromPath but taking the image's bytes directly.
Future<Size?> getImageSizeFromData(Uint8List bytes) async {
  try {
    final dartCodec = await instantiateImageCodec(bytes);
    final dartFrame = await dartCodec.getNextFrame();

    final size = Size(
      dartFrame.image.width.toDouble(),
      dartFrame.image.height.toDouble(),
    );

    dartFrame.image.dispose();
    dartCodec.dispose();

    return size;
  } catch (_) {
    // TODO(PapaTutuWawa): Log error
    return null;
  }
}

/// Returns true if we can generate a video thumbnail of mime type [mime]. If not, returns
/// false.
bool canGenerateVideoThumbnail(String mime) {
  return ![
    // Ignore mime types that may be wacky
    'video/webm',
  ].contains(mime);
}

Future<String> getContactProfilePicturePath(String id) async {
  final tempDir = await getTemporaryDirectory();
  final avatarDir = p.join(
    tempDir.path,
    'contacts',
    'avatars',
  );
  final dir = Directory(avatarDir);
  if (!dir.existsSync()) await dir.create(recursive: true);

  return p.join(avatarDir, id);
}

/// Prepend [item] to [list], but ensure that the resulting list's size is
/// smaller than or equal to [maxSize].
List<T> clampedListPrepend<T>(List<T> list, T item, int maxSize) {
  return clampedListPrependAll(
    list,
    [item],
    maxSize,
  );
}

/// Prepend [items] to [list], but ensure that the resulting list has a size
/// that is smaller than or equal to [maxSize].
List<T> clampedListPrependAll<T>(List<T> list, List<T> items, int maxSize) {
  if (items.length >= maxSize) {
    return items.sublist(0, maxSize);
  }

  if (list.length + items.length <= maxSize) {
    return [
      ...items,
      ...list,
    ];
  }

  return [
    ...items,
    ...list,
  ].sublist(0, maxSize);
}

extension StringJsonHelper on String {
  /// Converts the Map into a JSON-encoded String. Helper function for working with nullable maps.
  Map<String, dynamic> fromJson() {
    return (jsonDecode(this) as Map<dynamic, dynamic>).cast<String, dynamic>();
  }
}

extension MapJsonHelper on Map<String, dynamic> {
  /// Converts the map into a String. Helper function for working with nullable Strings.
  String toJson() => jsonEncode(this);
}
