import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;

import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';

import '../api/marianumcloud/nextcloud_ocs.dart';

/// Result of an avatar lookup at render time: [icon] when bytes are available
/// within the fetch budget, otherwise [late] carries the still-running fetch
/// so the caller can re-render once (silently) when it eventually delivers.
typedef AvatarIconLookup = ({Uint8List? icon, Future<Uint8List?>? late});

/// Masks avatar [bytes] into a circular PNG on a square canvas (center-cover
/// crop). Devices do NOT reliably mask Person icons themselves, so the round
/// shape is baked into the bitmap. Runs on `dart:ui`, which is available in
/// engine-backed background isolates (the FCM handler isolate) — no
/// BuildContext involved. Returns null when decoding fails; callers fall back
/// to the raw bytes.
Future<Uint8List?> maskAvatarCircular(Uint8List bytes) async {
  try {
    final codec = await ui.instantiateImageCodec(bytes);
    final frame = await codec.getNextFrame();
    final image = frame.image;
    final size = image.width < image.height ? image.width : image.height;
    final src = ui.Rect.fromLTWH(
      (image.width - size) / 2,
      (image.height - size) / 2,
      size.toDouble(),
      size.toDouble(),
    );
    final dst = ui.Rect.fromLTWH(0, 0, size.toDouble(), size.toDouble());

    final recorder = ui.PictureRecorder();
    final canvas = ui.Canvas(recorder);
    canvas.clipPath(ui.Path()..addOval(dst));
    canvas.drawImageRect(image, src, dst, ui.Paint()..isAntiAlias = true);
    final masked = await recorder.endRecording().toImage(size, size);
    final data = await masked.toByteData(format: ui.ImageByteFormat.png);
    image.dispose();
    masked.dispose();
    return data?.buffer.asUint8List();
  } on Object catch (e) {
    log('Push avatar: circular mask failed: $e');
    return null;
  }
}

/// Loads conversation avatars for Talk push notifications.
///
/// Uses the same Spreed endpoint as the in-app `UserAvatar` widget
/// (`room/{token}/avatar`, no query parameters — for 1:1 chats this is the
/// other person's picture), but with its own disk cache of PRE-MASKED round
/// PNGs: the widget cache is an in-memory LRU that is empty in the FCM
/// background isolate, and masking must not be recomputed per push. Every
/// failure path returns null so a missing avatar can never delay or drop a
/// notification beyond [fetchTimeout].
class PushAvatarStore {
  /// Cached files older than this are treated as stale and pruned.
  static const Duration maxAge = Duration(days: 14);

  static const Duration _defaultFetchTimeout = Duration(seconds: 4);

  // Versioned: `push_avatars` (raw, unmasked) was used before masking landed —
  // reusing it would surface square icons from old cache entries.
  static const _subDirectory = 'push_avatars_masked';

  /// One shared fetch per token and isolate: the bounded render-time lookup
  /// and the late re-render both await the SAME future, and concurrent pushes
  /// don't stampede the endpoint.
  static final Map<String, Future<Uint8List?>> _inflight = {};

  final Future<Directory> Function() _cacheDirProvider;

  /// Fetches the raw avatar response for a chat token. Injectable so tests
  /// never touch the network or the account/endpoint singletons (the default
  /// builds auth headers from them).
  final Future<http.Response> Function(String chatToken) _fetch;
  final Duration fetchTimeout;

  PushAvatarStore({
    Future<Directory> Function()? cacheDirProvider,
    Future<http.Response> Function(String chatToken)? fetch,
    this.fetchTimeout = _defaultFetchTimeout,
  }) : _cacheDirProvider = cacheDirProvider ?? _defaultCacheDir,
       _fetch = fetch ?? _defaultFetch;

  static Future<Directory> _defaultCacheDir() async {
    final base = await getApplicationCacheDirectory();
    return Directory('${base.path}/$_subDirectory');
  }

  static Future<http.Response> _defaultFetch(String chatToken) => http.get(
    NextcloudOcs.uri('apps/spreed/api/v1/room/$chatToken/avatar'),
    headers: {
      ...NextcloudOcs.headers(),
      'Accept': 'image/png,image/jpeg,image/webp',
    },
  );

  /// File-safe cache name for a chat token. Tokens are normally URL-safe
  /// already and used verbatim; anything else is base64url-encoded so exotic
  /// ids can never escape the cache directory.
  static String fileNameForToken(String token) =>
      RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(token)
      ? token
      : base64Url.encode(utf8.encode(token)).replaceAll('=', '');

  /// Whether a cache file written at [modified] is still fresh at [now].
  static bool isFresh(DateTime modified, DateTime now) =>
      now.difference(modified) <= maxAge;

  /// Spreed answers with an SVG placeholder (initials/icon) when a chat has
  /// no real picture — useless for a notification bitmap, so it is skipped.
  static bool looksLikeSvg(Uint8List bytes) {
    final head = utf8
        .decode(
          bytes.sublist(0, bytes.length < 256 ? bytes.length : 256),
          allowMalformed: true,
        )
        .trimLeft();
    return head.startsWith('<?xml') || head.startsWith('<svg');
  }

  /// Returns the (round-masked) conversation avatar for [chatToken]. Order:
  /// fresh disk cache → shared network fetch bounded by [fetchTimeout] →
  /// stale disk cache. On timeout the fetch keeps running and is returned as
  /// [AvatarIconLookup.late] so the caller can re-render once it delivers.
  Future<AvatarIconLookup> roomAvatarIcon(String chatToken) async {
    try {
      final file = await _fileFor(chatToken);
      final fresh = await _readCache(file, allowStale: false);
      if (fresh != null) return (icon: fresh, late: null);

      final fetch = _inflight.putIfAbsent(chatToken, () {
        final future = _fetchProcessAndCache(chatToken, file);
        future.whenComplete(() {
          if (identical(_inflight[chatToken], future)) {
            _inflight.remove(chatToken);
          }
        });
        return future;
      });

      try {
        final fetched = await fetch.timeout(fetchTimeout);
        if (fetched != null) return (icon: fetched, late: null);
        // Definitive miss (no picture / error) — an outdated icon beats none.
        return (icon: await _readCache(file, allowStale: true), late: null);
      } on TimeoutException {
        // Fetch continues in the background; hand it to the caller for the
        // one-time silent re-render.
        return (icon: await _readCache(file, allowStale: true), late: fetch);
      }
    } on Object catch (e) {
      log('Push avatar $chatToken: lookup failed: $e');
      return (icon: null, late: null);
    }
  }

  /// Drops the cached avatar for [chatToken] — called when the app knows the
  /// picture changed (avatar upload/removal in chat settings).
  static Future<void> evict(String chatToken) async {
    try {
      final dir = await _defaultCacheDir();
      final file = File('${dir.path}/${fileNameForToken(chatToken)}');
      if (file.existsSync()) await file.delete();
    } on Object {
      // best effort — the 14-day max age catches it eventually
    }
  }

  Future<File> _fileFor(String chatToken) async {
    final dir = await _cacheDirProvider();
    await dir.create(recursive: true);
    return File('${dir.path}/${fileNameForToken(chatToken)}');
  }

  Future<Uint8List?> _readCache(File file, {required bool allowStale}) async {
    try {
      if (!file.existsSync()) return null;
      if (!allowStale && !isFresh(file.lastModifiedSync(), DateTime.now())) {
        return null;
      }
      final bytes = await file.readAsBytes();
      return bytes.isEmpty ? null : bytes;
    } on Object {
      return null;
    }
  }

  /// Full pipeline: fetch (with outcome diagnostics) → circular mask (raw
  /// fallback when masking fails) → write processed cache. Never throws.
  Future<Uint8List?> _fetchProcessAndCache(String chatToken, File file) async {
    try {
      final raw = await _fetchAvatar(chatToken);
      if (raw == null) return null;
      final icon = await maskAvatarCircular(raw) ?? raw;
      await file.writeAsBytes(icon, flush: true);
      unawaited(_prune(file.parent));
      return icon;
    } on Object catch (e) {
      log('Push avatar $chatToken: fetch/process failed: $e');
      return null;
    }
  }

  Future<Uint8List?> _fetchAvatar(String chatToken) async {
    final response = await _fetch(chatToken);
    final contentType = response.headers['content-type']?.toLowerCase() ?? '';
    final bytes = response.bodyBytes;
    final svgRejected =
        response.statusCode == 200 &&
        bytes.isNotEmpty &&
        (contentType.contains('svg') || looksLikeSvg(bytes));
    // Diagnostics: makes "no icon" cases attributable (status vs. svg
    // placeholder vs. empty body) without a debugger on the device.
    log(
      'Push avatar $chatToken: HTTP ${response.statusCode} '
      'type=$contentType bytes=${bytes.length} svgRejected=$svgRejected',
    );
    if (response.statusCode != 200 || bytes.isEmpty || svgRejected) return null;
    return bytes;
  }

  /// Deletes cache files past [maxAge]. Fire-and-forget after a successful
  /// write — the directory stays small (one file per recently active chat).
  Future<void> _prune(Directory dir) async {
    try {
      final now = DateTime.now();
      await for (final entry in dir.list()) {
        if (entry is! File) continue;
        if (!isFresh(entry.lastModifiedSync(), now)) {
          await entry.delete();
        }
      }
    } on Object {
      // best effort
    }
  }
}
