import 'dart:convert';

import 'package:localstore/localstore.dart';

/// Maximum messages kept per chat thread — enough for a messenger-style
/// notification, small enough to stay cheap in the background isolate.
const int kChatThreadCap = 15;

/// Canonical per-chat notification tag (kept in sync with
/// `NotificationTasks.chatTag`, which delegates here).
String chatNotificationTag(String chatToken) => 'talk_$chatToken';

/// Deterministic non-negative 31-bit notification id per chat, so every
/// message of the same conversation updates ONE stacked notification instead
/// of adding a new card per push.
int stableChatNotificationId(String chatToken) {
  var hash = 0;
  for (final unit in chatToken.codeUnits) {
    hash = (hash * 31 + unit) & 0x7fffffff;
  }
  return hash;
}

/// One message inside a chat's notification history.
class ThreadMessage {
  final int nid;
  final String sender;
  final String text;
  final int timestampMs;

  /// Room name parsed from a group-chat subject (`<sender> in <room>`);
  /// null for 1:1 chats and for history entries written before this field
  /// existed (tolerated on read — they simply carry no room information).
  final String? roomName;

  const ThreadMessage({
    required this.nid,
    required this.sender,
    required this.text,
    required this.timestampMs,
    this.roomName,
  });

  Map<String, dynamic> toJson() => {
    'nid': nid,
    'sender': sender,
    'text': text,
    'timestampMs': timestampMs,
    'roomName': ?roomName,
  };

  factory ThreadMessage.fromJson(Map<String, dynamic> json) => ThreadMessage(
    nid: (json['nid'] as num).toInt(),
    sender: json['sender'] as String? ?? '',
    text: json['text'] as String? ?? '',
    timestampMs: (json['timestampMs'] as num?)?.toInt() ?? 0,
    roomName: json['roomName'] as String?,
  );
}

/// Appends [message] to [messages], replacing an entry with the same nid
/// (redelivered push) and capping the history at [cap] (oldest dropped).
/// Pure so the stacking behavior is unit-testable.
List<ThreadMessage> appendThreadMessage(
  List<ThreadMessage> messages,
  ThreadMessage message, {
  int cap = kChatThreadCap,
}) {
  final result = [...messages.where((m) => m.nid != message.nid), message];
  return result.length > cap ? result.sublist(result.length - cap) : result;
}

/// Removes the message with [nid]; the caller cancels the notification when
/// the returned list is empty and re-renders it silently otherwise.
List<ThreadMessage> removeThreadNid(List<ThreadMessage> messages, int nid) =>
    messages.where((m) => m.nid != nid).toList();

/// History for the next notification after [message] arrives, given whether
/// the chat's previous notification is still on screen ([isActive]):
/// `true` → stacks onto existing history; `false` → notification gone
/// (dismissed/read), thread restarts with only [message]; `null` → probe
/// failed/unsupported, keep stacking (degraded stacking never loses a message).
///
/// Android has no reliable "notification dismissed" callback, so the
/// visible-state probe at append time is the substitute.
List<ThreadMessage> threadAfterIncoming(
  List<ThreadMessage> existing,
  ThreadMessage message,
  bool? isActive, {
  int cap = kChatThreadCap,
}) {
  final base = isActive == false ? const <ThreadMessage>[] : existing;
  return appendThreadMessage(base, message, cap: cap);
}

/// MessagingStyle header per Android convention: a 1:1 chat gets NO
/// `conversationTitle` (setting one would repeat the person's name on every
/// row); groups use the room name as title, falling back to the last sender
/// when there is >1 distinct sender but no room name is known.
({String? conversationTitle, bool groupConversation}) conversationHeader(
  List<ThreadMessage> messages,
) {
  final roomName = messages
      .lastWhere(
        (m) => m.roomName?.isNotEmpty ?? false,
        orElse: () =>
            const ThreadMessage(nid: 0, sender: '', text: '', timestampMs: 0),
      )
      .roomName;
  if (roomName != null && roomName.isNotEmpty) {
    return (conversationTitle: roomName, groupConversation: true);
  }
  final distinctSenders = messages.map((m) => m.sender).toSet().length;
  if (distinctSenders <= 1) {
    return (conversationTitle: null, groupConversation: false);
  }
  return (conversationTitle: messages.last.sender, groupConversation: true);
}

/// Persists the per-chat message history via [Localstore] so both the
/// foreground and the FCM background isolate build the same stacked
/// notification. Complements [NidStore], which keeps the nid → chat mapping
/// needed to resolve delete-pushes.
class ChatThreadStore {
  static const _collection = 'push_chat_threads';

  final Localstore _db;

  ChatThreadStore({Localstore? db}) : _db = db ?? Localstore.instance;

  /// Localstore doc ids become file names — tokens are normally URL-safe and
  /// used verbatim, anything else is base64url-encoded.
  static String docIdForToken(String chatToken) =>
      RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(chatToken)
      ? chatToken
      : base64Url.encode(utf8.encode(chatToken)).replaceAll('=', '');

  Future<List<ThreadMessage>> messages(String chatToken) async {
    final data = await _doc(chatToken).get();
    final raw = data?['messages'];
    if (raw is! List) return const [];
    return raw
        .whereType<Map<String, dynamic>>()
        .map(ThreadMessage.fromJson)
        .toList();
  }

  /// Appends [message] and returns the updated (capped) history.
  Future<List<ThreadMessage>> append(
    String chatToken,
    ThreadMessage message,
  ) async {
    final updated = appendThreadMessage(await messages(chatToken), message);
    await _write(chatToken, updated);
    return updated;
  }

  /// Appends [message], but restarts the thread first when the chat's previous
  /// notification is no longer active ([isActive] == false — dismissed/read).
  /// See [threadAfterIncoming] for the tri-state semantics.
  Future<List<ThreadMessage>> appendConsideringActive(
    String chatToken,
    ThreadMessage message,
    bool? isActive,
  ) async {
    final updated = threadAfterIncoming(
      await messages(chatToken),
      message,
      isActive,
    );
    await _write(chatToken, updated);
    return updated;
  }

  /// Removes the message with [nid] and returns the remaining history.
  Future<List<ThreadMessage>> removeNid(String chatToken, int nid) async {
    final remaining = removeThreadNid(await messages(chatToken), nid);
    if (remaining.isEmpty) {
      await clearChat(chatToken);
    } else {
      await _write(chatToken, remaining);
    }
    return remaining;
  }

  Future<void> clearChat(String chatToken) => _doc(chatToken).delete();

  Future<void> clearAll() async {
    final docs = await _db.collection(_collection).get();
    if (docs == null) return;
    for (final id in docs.keys) {
      // Localstore keys are full document paths (/push_chat_threads/<id>).
      await _db.collection(_collection).doc(id.split('/').last).delete();
    }
  }

  DocumentRef _doc(String chatToken) =>
      _db.collection(_collection).doc(docIdForToken(chatToken));

  Future<void> _write(String chatToken, List<ThreadMessage> messages) =>
      _doc(chatToken).set({
        'messages': [for (final m in messages) m.toJson()],
      });
}
