import 'dart:async';

import 'package:aves/geo/countries.dart';
import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/video/metadata.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/geocoding_service.dart';
import 'package:aves/services/service_policy.dart';
import 'package:aves/services/services.dart';
import 'package:aves/services/svg_metadata_service.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:collection/collection.dart';
import 'package:country_code/country_code.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';

class AvesEntry {
  String uri;
  String? _path, _directory, _filename, _extension;
  int? pageId, contentId;
  final String sourceMimeType;
  int width;
  int height;
  int sourceRotationDegrees;
  final int? sizeBytes;
  String? _sourceTitle;

  // `dateModifiedSecs` can be missing in viewer mode
  int? _dateModifiedSecs;
  final int? sourceDateTakenMillis;
  int? _durationMillis;
  int? _catalogDateMillis;
  CatalogMetadata? _catalogMetadata;
  AddressDetails? _addressDetails;

  final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();

  // TODO TLAD make it dynamic if it depends on OS/lib versions
  static const List<String> undecodable = [
    MimeTypes.art,
    MimeTypes.crw,
    MimeTypes.djvu,
    MimeTypes.psdVnd,
    MimeTypes.psdX,
  ];

  AvesEntry({
    required this.uri,
    required String? path,
    required this.contentId,
    required this.pageId,
    required this.sourceMimeType,
    required this.width,
    required this.height,
    required this.sourceRotationDegrees,
    required this.sizeBytes,
    required String? sourceTitle,
    required int? dateModifiedSecs,
    required this.sourceDateTakenMillis,
    required int? durationMillis,
  }) {
    this.path = path;
    this.sourceTitle = sourceTitle;
    this.dateModifiedSecs = dateModifiedSecs;
    this.durationMillis = durationMillis;
  }

  bool get canDecode => !undecodable.contains(mimeType);

  bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);

  AvesEntry copyWith({
    String? uri,
    String? path,
    int? contentId,
    int? dateModifiedSecs,
  }) {
    final copyContentId = contentId ?? this.contentId;
    final copied = AvesEntry(
      uri: uri ?? this.uri,
      path: path ?? this.path,
      contentId: copyContentId,
      pageId: null,
      sourceMimeType: sourceMimeType,
      width: width,
      height: height,
      sourceRotationDegrees: sourceRotationDegrees,
      sizeBytes: sizeBytes,
      sourceTitle: sourceTitle,
      dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs,
      sourceDateTakenMillis: sourceDateTakenMillis,
      durationMillis: durationMillis,
    )
      ..catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId)
      ..addressDetails = _addressDetails?.copyWith(contentId: copyContentId);

    return copied;
  }

  // from DB or platform source entry
  factory AvesEntry.fromMap(Map map) {
    return AvesEntry(
      uri: map['uri'] as String,
      path: map['path'] as String?,
      pageId: null,
      contentId: map['contentId'] as int?,
      sourceMimeType: map['sourceMimeType'] as String,
      width: map['width'] as int? ?? 0,
      height: map['height'] as int? ?? 0,
      sourceRotationDegrees: map['sourceRotationDegrees'] as int? ?? 0,
      sizeBytes: map['sizeBytes'] as int?,
      sourceTitle: map['title'] as String?,
      dateModifiedSecs: map['dateModifiedSecs'] as int?,
      sourceDateTakenMillis: map['sourceDateTakenMillis'] as int?,
      durationMillis: map['durationMillis'] as int?,
    );
  }

  // for DB only
  Map<String, dynamic> toMap() {
    return {
      'uri': uri,
      'path': path,
      'contentId': contentId,
      'sourceMimeType': sourceMimeType,
      'width': width,
      'height': height,
      'sourceRotationDegrees': sourceRotationDegrees,
      'sizeBytes': sizeBytes,
      'title': sourceTitle,
      'dateModifiedSecs': dateModifiedSecs,
      'sourceDateTakenMillis': sourceDateTakenMillis,
      'durationMillis': durationMillis,
    };
  }

  void dispose() {
    imageChangeNotifier.dispose();
    metadataChangeNotifier.dispose();
    addressChangeNotifier.dispose();
  }

  // do not implement [Object.==] and [Object.hashCode] using mutable attributes (e.g. `uri`)
  // so that we can reliably use instances in a `Set`, which requires consistent hash codes over time

  @override
  String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path, pageId=$pageId}';

  set path(String? path) {
    _path = path;
    _directory = null;
    _filename = null;
    _extension = null;
  }

  String? get path => _path;

  String? get directory {
    _directory ??= path != null ? pContext.dirname(path!) : null;
    return _directory;
  }

  String? get filenameWithoutExtension {
    _filename ??= path != null ? pContext.basenameWithoutExtension(path!) : null;
    return _filename;
  }

  String? get extension {
    _extension ??= path != null ? pContext.extension(path!) : null;
    return _extension;
  }

  // the MIME type reported by the Media Store is unreliable
  // so we use the one found during cataloguing if possible
  String get mimeType => _catalogMetadata?.mimeType ?? sourceMimeType;

  String get mimeTypeAnySubtype => mimeType.replaceAll(RegExp('/.*'), '/*');

  bool get isFavourite => favourites.isFavourite(this);

  bool get isSvg => mimeType == MimeTypes.svg;

  // guess whether this is a photo, according to file type (used as a hint to e.g. display megapixels)
  bool get isPhoto => [MimeTypes.heic, MimeTypes.heif, MimeTypes.jpeg, MimeTypes.tiff].contains(mimeType) || isRaw;

  // Android's `BitmapRegionDecoder` documentation states that "only the JPEG and PNG formats are supported"
  // but in practice (tested on API 25, 27, 29), it successfully decodes the formats listed below,
  // and it actually fails to decode GIF, DNG and animated WEBP. Other formats were not tested.
  bool get _supportedByBitmapRegionDecoder =>
      [
        MimeTypes.heic,
        MimeTypes.heif,
        MimeTypes.jpeg,
        MimeTypes.webp,
        MimeTypes.arw,
        MimeTypes.cr2,
        MimeTypes.nef,
        MimeTypes.nrw,
        MimeTypes.orf,
        MimeTypes.pef,
        MimeTypes.raf,
        MimeTypes.rw2,
        MimeTypes.srw,
      ].contains(mimeType) &&
      !isAnimated;

  bool get supportTiling => _supportedByBitmapRegionDecoder || mimeType == MimeTypes.tiff;

  bool get useTiles => supportTiling && (width > 4096 || height > 4096);

  bool get isRaw => MimeTypes.rawImages.contains(mimeType);

  bool get isImage => MimeTypes.isImage(mimeType);

  bool get isVideo => MimeTypes.isVideo(mimeType);

  bool get isCatalogued => _catalogMetadata != null;

  bool get isAnimated => _catalogMetadata?.isAnimated ?? false;

  bool get isGeotiff => _catalogMetadata?.isGeotiff ?? false;

  bool get is360 => _catalogMetadata?.is360 ?? false;

  bool get isMultiPage => _catalogMetadata?.isMultiPage ?? false;

  bool get isMotionPhoto => isMultiPage && mimeType == MimeTypes.jpeg;

  bool get canEdit => path != null;

  bool get canRotateAndFlip => canEdit && canEditExif;

  // support for writing EXIF
  // as of androidx.exifinterface:exifinterface:1.3.0
  bool get canEditExif {
    switch (mimeType.toLowerCase()) {
      case MimeTypes.jpeg:
      case MimeTypes.png:
      case MimeTypes.webp:
        return true;
      default:
        return false;
    }
  }

  // Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata,
  // so it should be registered as width=1920, height=1080, orientation=90,
  // but is incorrectly registered as width=1080, height=1920, orientation=0.
  // Double-checking the width/height during loading or cataloguing is the proper solution, but it would take space and time.
  // Comparing width and height can help with the portrait FHD video example,
  // but it fails for a portrait screenshot rotated, which is landscape with width=1080, height=1920, orientation=90
  bool get isRotated => rotationDegrees % 180 == 90;

  static const ratioSeparator = '\u2236';
  static const resolutionSeparator = ' \u00D7 ';

  bool get isSized => width > 0 && height > 0;

  String get resolutionText {
    final ws = width;
    final hs = height;
    return isRotated ? '$hs$resolutionSeparator$ws' : '$ws$resolutionSeparator$hs';
  }

  String get aspectRatioText {
    if (width > 0 && height > 0) {
      final gcd = width.gcd(height);
      final w = width ~/ gcd;
      final h = height ~/ gcd;
      return isRotated ? '$h$ratioSeparator$w' : '$w$ratioSeparator$h';
    } else {
      return '?$ratioSeparator?';
    }
  }

  double get displayAspectRatio {
    if (width == 0 || height == 0) return 1;
    return isRotated ? height / width : width / height;
  }

  Size get displaySize {
    final w = width.toDouble();
    final h = height.toDouble();
    return isRotated ? Size(h, w) : Size(w, h);
  }

  Size videoDisplaySize(double sar) {
    final size = displaySize;
    if (sar != 1) {
      final dar = displayAspectRatio * sar;
      final w = size.width;
      final h = size.height;
      if (w >= h) return Size(w, w / dar);
      if (h > w) return Size(h * dar, h);
    }
    return size;
  }

  int get megaPixels => (width * height / 1000000).round();

  DateTime? _bestDate;

  DateTime? get bestDate {
    if (_bestDate == null) {
      if ((_catalogDateMillis ?? 0) > 0) {
        _bestDate = DateTime.fromMillisecondsSinceEpoch(_catalogDateMillis!);
      } else if ((sourceDateTakenMillis ?? 0) > 0) {
        _bestDate = DateTime.fromMillisecondsSinceEpoch(sourceDateTakenMillis!);
      } else if ((dateModifiedSecs ?? 0) > 0) {
        _bestDate = DateTime.fromMillisecondsSinceEpoch(dateModifiedSecs! * 1000);
      }
    }
    return _bestDate;
  }

  int get rotationDegrees => _catalogMetadata?.rotationDegrees ?? sourceRotationDegrees;

  set rotationDegrees(int rotationDegrees) {
    sourceRotationDegrees = rotationDegrees;
    _catalogMetadata?.rotationDegrees = rotationDegrees;
  }

  bool get isFlipped => _catalogMetadata?.isFlipped ?? false;

  set isFlipped(bool isFlipped) => _catalogMetadata?.isFlipped = isFlipped;

  String? get sourceTitle => _sourceTitle;

  set sourceTitle(String? sourceTitle) {
    _sourceTitle = sourceTitle;
    _bestTitle = null;
  }

  int? get dateModifiedSecs => _dateModifiedSecs;

  set dateModifiedSecs(int? dateModifiedSecs) {
    _dateModifiedSecs = dateModifiedSecs;
    _bestDate = null;
  }

  DateTime? get monthTaken {
    final d = bestDate;
    return d == null ? null : DateTime(d.year, d.month);
  }

  DateTime? get dayTaken {
    final d = bestDate;
    return d == null ? null : DateTime(d.year, d.month, d.day);
  }

  int? get durationMillis => _durationMillis;

  set durationMillis(int? durationMillis) {
    _durationMillis = durationMillis;
    _durationText = null;
  }

  String? _durationText;

  String get durationText {
    _durationText ??= formatFriendlyDuration(Duration(milliseconds: durationMillis ?? 0));
    return _durationText!;
  }

  // returns whether this entry has GPS coordinates
  // (0, 0) coordinates are considered invalid, as it is likely a default value
  bool get hasGps => (_catalogMetadata?.latitude ?? 0) != 0 || (_catalogMetadata?.longitude ?? 0) != 0;

  bool get hasAddress => _addressDetails != null;

  // has a place, or at least the full country name
  // derived from Google reverse geocoding addresses
  bool get hasFineAddress => _addressDetails?.place?.isNotEmpty == true || (_addressDetails?.countryName?.length ?? 0) > 3;

  LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null;

  String? get geoUri {
    if (!hasGps) return null;
    final latitude = roundToPrecision(_catalogMetadata!.latitude!, decimals: 6);
    final longitude = roundToPrecision(_catalogMetadata!.longitude!, decimals: 6);
    return 'geo:$latitude,$longitude?q=$latitude,$longitude';
  }

  List<String>? _xmpSubjects;

  List<String> get xmpSubjects {
    _xmpSubjects ??= _catalogMetadata?.xmpSubjects?.split(';').where((tag) => tag.isNotEmpty).toList() ?? [];
    return _xmpSubjects!;
  }

  String? _bestTitle;

  String? get bestTitle {
    _bestTitle ??= _catalogMetadata?.xmpTitleDescription?.isNotEmpty == true ? _catalogMetadata!.xmpTitleDescription : sourceTitle;
    return _bestTitle;
  }

  CatalogMetadata? get catalogMetadata => _catalogMetadata;

  set catalogDateMillis(int? dateMillis) {
    _catalogDateMillis = dateMillis;
    _bestDate = null;
  }

  set catalogMetadata(CatalogMetadata? newMetadata) {
    final oldDateModifiedSecs = dateModifiedSecs;
    final oldRotationDegrees = rotationDegrees;
    final oldIsFlipped = isFlipped;

    catalogDateMillis = newMetadata?.dateMillis;
    _catalogMetadata = newMetadata;
    _bestTitle = null;
    _xmpSubjects = null;
    metadataChangeNotifier.notifyListeners();

    _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
  }

  void clearMetadata() {
    catalogMetadata = null;
    addressDetails = null;
  }

  Future<void> catalog({bool background = false, bool persist = true}) async {
    if (isCatalogued) return;
    if (isSvg) {
      // vector image sizing is not essential, so we should not spend time for it during loading
      // but it is useful anyway (for aspect ratios etc.) so we size them during cataloguing
      final size = await SvgMetadataService.getSize(this);
      if (size != null) {
        await _applyNewFields({
          'width': size.width.ceil(),
          'height': size.height.ceil(),
        }, persist: persist);
      }
      catalogMetadata = CatalogMetadata(contentId: contentId);
    } else {
      if (isVideo && (!isSized || durationMillis == 0)) {
        // exotic video that is not sized during loading
        final fields = await VideoMetadataFormatter.getCatalogMetadata(this);
        await _applyNewFields(fields, persist: persist);
      }
      catalogMetadata = await metadataService.getCatalogMetadata(this, background: background);
    }
  }

  AddressDetails? get addressDetails => _addressDetails;

  set addressDetails(AddressDetails? newAddress) {
    _addressDetails = newAddress;
    addressChangeNotifier.notifyListeners();
  }

  Future<void> locate({required bool background}) async {
    if (!hasGps) return;
    await _locateCountry();
    if (await availability.canLocatePlaces) {
      await locatePlace(background: background);
    }
  }

  // quick reverse geocoding to find the country, using an offline asset
  Future<void> _locateCountry() async {
    if (!hasGps || hasAddress) return;
    final countryCode = await countryTopology.countryCode(latLng!);
    setCountry(countryCode);
  }

  void setCountry(CountryCode? countryCode) {
    if (hasFineAddress || countryCode == null) return;
    addressDetails = AddressDetails(
      contentId: contentId,
      countryCode: countryCode.alpha2,
      countryName: countryCode.alpha3,
    );
  }

  String? _geocoderLocale;

  String get geocoderLocale {
    _geocoderLocale ??= (settings.locale ?? WidgetsBinding.instance!.window.locale).toString();
    return _geocoderLocale!;
  }

  // full reverse geocoding, requiring Play Services and some connectivity
  Future<void> locatePlace({required bool background}) async {
    if (!hasGps || hasFineAddress) return;
    try {
      Future<List<Address>> call() => GeocodingService.getAddress(latLng!, geocoderLocale);
      final addresses = await (background
          ? servicePolicy.call(
              call,
              priority: ServiceCallPriority.getLocation,
            )
          : call());
      if (addresses.isNotEmpty) {
        final address = addresses.first;
        final cc = address.countryCode;
        final cn = address.countryName;
        final aa = address.adminArea;
        addressDetails = AddressDetails(
          contentId: contentId,
          countryCode: cc,
          countryName: cn,
          adminArea: aa,
          // if country & admin fields are null, it is likely the ocean,
          // which is identified by `featureName` but we default to the address line anyway
          locality: address.locality ?? (cc == null && cn == null && aa == null ? address.addressLine : null),
        );
      }
    } catch (error, stack) {
      debugPrint('$runtimeType locate failed with path=$path coordinates=$latLng error=$error\n$stack');
    }
  }

  Future<String?> findAddressLine() async {
    if (!hasGps) return null;

    try {
      final addresses = await GeocodingService.getAddress(latLng!, geocoderLocale);
      if (addresses.isNotEmpty) {
        final address = addresses.first;
        return address.addressLine;
      }
    } catch (error, stack) {
      debugPrint('$runtimeType findAddressLine failed with path=$path coordinates=$latLng error=$error\n$stack');
    }
    return null;
  }

  String get shortAddress {
    // `admin area` examples: Seoul, Geneva, null
    // `locality` examples: Mapo-gu, Geneva, Annecy
    return {
      _addressDetails?.countryName,
      _addressDetails?.adminArea,
      _addressDetails?.locality,
    }.whereNotNull().where((v) => v.isNotEmpty).join(', ');
  }

  bool search(String query) => {
        bestTitle,
        _catalogMetadata?.xmpSubjects,
        _addressDetails?.countryName,
        _addressDetails?.adminArea,
        _addressDetails?.locality,
      }.any((s) => s != null && s.toUpperCase().contains(query));

  Future<void> _applyNewFields(Map newFields, {required bool persist}) async {
    final uri = newFields['uri'];
    if (uri is String) this.uri = uri;
    final path = newFields['path'];
    if (path is String) this.path = path;
    final contentId = newFields['contentId'];
    if (contentId is int) this.contentId = contentId;
    final sourceTitle = newFields['title'];
    if (sourceTitle is String) this.sourceTitle = sourceTitle;

    final width = newFields['width'];
    if (width is int) this.width = width;
    final height = newFields['height'];
    if (height is int) this.height = height;
    final durationMillis = newFields['durationMillis'];
    if (durationMillis is int) this.durationMillis = durationMillis;

    final dateModifiedSecs = newFields['dateModifiedSecs'];
    if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs;
    final rotationDegrees = newFields['rotationDegrees'];
    if (rotationDegrees is int) this.rotationDegrees = rotationDegrees;
    final isFlipped = newFields['isFlipped'];
    if (isFlipped is bool) this.isFlipped = isFlipped;

    if (persist) {
      await metadataDb.saveEntries({this});
      if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!});
    }

    metadataChangeNotifier.notifyListeners();
  }

  Future<bool> rotate({required bool clockwise, required bool persist}) async {
    final newFields = await imageFileService.rotate(this, clockwise: clockwise);
    if (newFields.isEmpty) return false;

    final oldDateModifiedSecs = dateModifiedSecs;
    final oldRotationDegrees = rotationDegrees;
    final oldIsFlipped = isFlipped;
    await _applyNewFields(newFields, persist: persist);
    await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
    return true;
  }

  Future<bool> flip({required bool persist}) async {
    final newFields = await imageFileService.flip(this);
    if (newFields.isEmpty) return false;

    final oldDateModifiedSecs = dateModifiedSecs;
    final oldRotationDegrees = rotationDegrees;
    final oldIsFlipped = isFlipped;
    await _applyNewFields(newFields, persist: persist);
    await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
    return true;
  }

  Future<bool> delete() {
    final completer = Completer<bool>();
    imageFileService.delete([this]).listen(
      (event) => completer.complete(event.success),
      onError: completer.completeError,
      onDone: () {
        if (!completer.isCompleted) {
          completer.complete(false);
        }
      },
    );
    return completer.future;
  }

  // when the entry image itself changed (e.g. after rotation)
  Future<void> _onImageChanged(int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async {
    if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
      await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
      imageChangeNotifier.notifyListeners();
    }
  }

  // favourites

  Future<void> toggleFavourite() async {
    if (isFavourite) {
      await removeFromFavourites();
    } else {
      await addToFavourites();
    }
  }

  Future<void> addToFavourites() async {
    if (!isFavourite) {
      await favourites.add([this]);
    }
  }

  Future<void> removeFromFavourites() async {
    if (isFavourite) {
      await favourites.remove([this]);
    }
  }

  // compare by:
  // 1) title ascending
  // 2) extension ascending
  static int compareByName(AvesEntry a, AvesEntry b) {
    final c = compareAsciiUpperCase(a.bestTitle ?? '', b.bestTitle ?? '');
    return c != 0 ? c : compareAsciiUpperCase(a.extension ?? '', b.extension ?? '');
  }

  // compare by:
  // 1) size descending
  // 2) name ascending
  static int compareBySize(AvesEntry a, AvesEntry b) {
    final c = (b.sizeBytes ?? 0).compareTo(a.sizeBytes ?? 0);
    return c != 0 ? c : compareByName(a, b);
  }

  static final _epoch = DateTime.fromMillisecondsSinceEpoch(0);

  // compare by:
  // 1) date descending
  // 2) name descending
  static int compareByDate(AvesEntry a, AvesEntry b) {
    var c = (b.bestDate ?? _epoch).compareTo(a.bestDate ?? _epoch);
    if (c != 0) return c;
    return compareByName(b, a);
  }
}
