import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';

import 'package:path_provider/path_provider.dart';

/// Persistent disk cache for the in-app [UserAvatar] widget.
///
/// The widget keeps a session-scoped in-memory LRU; this store survives app
/// restarts so a cold start paints the last-known picture on the first frame
/// instead of a blank placeholder while the network request is in flight.
/// Bytes are stored raw (PNG/JPEG/WEBP/SVG) — the widget re-detects SVG from
/// the bytes on read, so no content-type sidecar is needed.
///
/// Distinct from [PushAvatarStore], which caches PRE-MASKED round PNGs for the
/// FCM background isolate and only handles room avatars.
class AvatarDiskCache {
  AvatarDiskCache._();
  static final AvatarDiskCache instance = AvatarDiskCache._();

  static const _subDirectory = 'avatar_cache';

  /// Files older than this are pruned. Only bounds disk for subjects that are
  /// no longer seen — freshness within a session is handled by the widget's
  /// background refresh, which always re-fetches over the network.
  static const Duration maxAge = Duration(days: 30);

  // Memoized so the async directory lookup runs once, and its resolved path is
  // exposed for the synchronous read path (zero-flash on warm sessions).
  Future<Directory>? _dirFuture;
  static String? _dirPath;

  // Prune runs once per session after the first successful write, so a
  // cold-start burst of avatar fetches doesn't re-list the directory per file.
  bool _pruned = false;

  /// Kicks off directory resolution so [readSync] can hit on the very first
  /// avatar of a session. Fire-and-forget from app start; safe to call twice.
  void warmUp() => unawaited(_directory());

  Future<Directory> _directory() {
    return _dirFuture ??= _resolveDirectory();
  }

  Future<Directory> _resolveDirectory() async {
    final base = await getApplicationCacheDirectory();
    final dir = Directory('${base.path}/$_subDirectory');
    await dir.create(recursive: true);
    _dirPath = dir.path;
    return dir;
  }

  /// File-safe, prefix-evictable name. The subject id is hex-encoded so it can
  /// only contain `[0-9a-f]`, which keeps the `_` separator unambiguous: the
  /// user prefix `u_<hex>_` never matches a longer id's file.
  static String fileName({
    required String id,
    required bool isGroup,
    required int size,
  }) {
    final hex = _hex(id);
    // Group avatars are served at one fixed size (no size in the URL).
    return isGroup ? 'g_$hex' : 'u_${hex}_$size';
  }

  static String _hex(String value) {
    final buffer = StringBuffer();
    for (final b in utf8.encode(value)) {
      buffer.write(b.toRadixString(16).padLeft(2, '0'));
    }
    return buffer.toString();
  }

  /// Synchronous read for warm sessions (cache directory already resolved).
  /// Returns null when the directory isn't known yet — the caller falls back
  /// to [read]. Returns null on any error so a corrupt file never throws into
  /// a build.
  Uint8List? readSync({
    required String id,
    required bool isGroup,
    required int size,
  }) {
    final path = _dirPath;
    if (path == null) return null;
    try {
      final file = File(
        '$path/${fileName(id: id, isGroup: isGroup, size: size)}',
      );
      if (!file.existsSync()) return null;
      final bytes = file.readAsBytesSync();
      return bytes.isEmpty ? null : bytes;
    } on Object {
      return null;
    }
  }

  Future<Uint8List?> read({
    required String id,
    required bool isGroup,
    required int size,
  }) async {
    try {
      final dir = await _directory();
      final file = File(
        '${dir.path}/${fileName(id: id, isGroup: isGroup, size: size)}',
      );
      if (!file.existsSync()) return null;
      final bytes = await file.readAsBytes();
      return bytes.isEmpty ? null : bytes;
    } on Object {
      return null;
    }
  }

  Future<void> write({
    required String id,
    required bool isGroup,
    required int size,
    required Uint8List bytes,
  }) async {
    try {
      final dir = await _directory();
      final file = File(
        '${dir.path}/${fileName(id: id, isGroup: isGroup, size: size)}',
      );
      await file.writeAsBytes(bytes, flush: true);
      if (!_pruned) {
        _pruned = true;
        unawaited(_prune(dir));
      }
    } on Object {
      // Best effort — a failed write just means the next launch re-fetches.
    }
  }

  /// Drops every cached size for a user (or the single file for a group).
  /// Called from `invalidateAvatarCache` after an upload/removal or a 404.
  Future<void> evict({required String id, required bool isGroup}) async {
    try {
      final dir = await _directory();
      if (isGroup) {
        final file = File('${dir.path}/${fileName(id: id, isGroup: true, size: 0)}');
        if (file.existsSync()) await file.delete();
        return;
      }
      final prefix = 'u_${_hex(id)}_';
      await for (final entry in dir.list()) {
        if (entry is! File) continue;
        if (entry.uri.pathSegments.last.startsWith(prefix)) {
          await entry.delete();
        }
      }
    } on Object {
      // Best effort — the 30-day max age catches stragglers.
    }
  }

  /// Wipes the whole cache — used by the argument-less `invalidateAvatarCache`
  /// (e.g. on logout).
  Future<void> clear() async {
    try {
      final dir = await _directory();
      if (dir.existsSync()) {
        await for (final entry in dir.list()) {
          if (entry is File) await entry.delete();
        }
      }
    } on Object {
      // Best effort.
    }
  }

  Future<void> _prune(Directory dir) async {
    try {
      final now = DateTime.now();
      await for (final entry in dir.list()) {
        if (entry is! File) continue;
        if (now.difference(entry.lastModifiedSync()) > maxAge) {
          await entry.delete();
        }
      }
    } on Object {
      // Best effort.
    }
  }
}
