import 'dart:async';
import 'dart:collection';

import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/util/int64_extension.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:collection/collection.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:nanoid/nanoid.dart';

import 'chat_entity.dart';
import 'chat_message_listener.dart';
import 'chat_message_service.dart';
import 'chat_message_stream.dart';

part 'chat_bloc.freezed.dart';

class ChatBloc extends Bloc<ChatEvent, ChatState> {
  ChatBloc({
    required this.chatId,
    required this.userId,
  })  : chatController = InMemoryChatController(),
        listener = ChatMessageListener(chatId: chatId),
        selectedSourcesNotifier = ValueNotifier([]),
        super(ChatState.initial()) {
    _startListening();
    _dispatch();
    _loadMessages();
    _loadSetting();
  }

  final String chatId;
  final String userId;
  final ChatMessageListener listener;
  final ValueNotifier<List<String>> selectedSourcesNotifier;
  final ChatController chatController;

  /// The last streaming message id
  String answerStreamMessageId = '';
  String questionStreamMessageId = '';

  ChatMessagePB? lastSentMessage;

  /// Using a temporary map to associate the real message ID with the last streaming message ID.
  ///
  /// When a message is streaming, it does not have a real message ID. To maintain the relationship
  /// between the real message ID and the last streaming message ID, we use this map to store the associations.
  ///
  /// This map will be updated when receiving a message from the server and its author type
  /// is 3 (AI response).
  final HashMap<String, String> temporaryMessageIDMap = HashMap();

  bool isLoadingPreviousMessages = false;
  bool hasMorePreviousMessages = true;
  AnswerStream? answerStream;
  bool isFetchingRelatedQuestions = false;
  bool shouldFetchRelatedQuestions = false;

  @override
  Future<void> close() async {
    await answerStream?.dispose();
    await listener.stop();
    final request = ViewIdPB(value: chatId);
    unawaited(FolderEventCloseView(request).send());
    selectedSourcesNotifier.dispose();
    return super.close();
  }

  void _dispatch() {
    on<ChatEvent>(
      (event, emit) async {
        await event.when(
          // Loading messages
          didLoadLatestMessages: (List<Message> messages) async {
            for (final message in messages) {
              await chatController.insert(message, index: 0);
            }

            switch (state.loadingState) {
              case LoadChatMessageStatus.loading
                  when chatController.messages.isEmpty:
                emit(
                  state.copyWith(
                    loadingState: LoadChatMessageStatus.loadingRemote,
                  ),
                );
                break;
              case LoadChatMessageStatus.loading:
              case LoadChatMessageStatus.loadingRemote:
                emit(
                  state.copyWith(loadingState: LoadChatMessageStatus.ready),
                );
                break;
              default:
                break;
            }
          },
          loadPreviousMessages: () {
            if (isLoadingPreviousMessages) {
              return;
            }

            final oldestMessage = _getOldestMessage();

            if (oldestMessage != null) {
              final oldestMessageId = Int64.tryParseInt(oldestMessage.id);
              if (oldestMessageId == null) {
                Log.error("Failed to parse message_id: ${oldestMessage.id}");
                return;
              }
              isLoadingPreviousMessages = true;
              _loadPreviousMessages(oldestMessageId);
            }
          },
          didLoadPreviousMessages: (messages, hasMore) {
            Log.debug("did load previous messages: ${messages.length}");

            for (final message in messages) {
              chatController.insert(message, index: 0);
            }

            isLoadingPreviousMessages = false;
            hasMorePreviousMessages = hasMore;
          },
          didFinishAnswerStream: () {
            emit(
              state.copyWith(promptResponseState: PromptResponseState.ready),
            );
          },
          didReceiveRelatedQuestions: (List<String> questions) {
            if (questions.isEmpty) {
              return;
            }

            final metadata = {
              onetimeShotType: OnetimeShotType.relatedQuestion,
              'questions': questions,
            };

            final createdAt = DateTime.now();

            final message = TextMessage(
              id: "related_question_$createdAt",
              text: '',
              metadata: metadata,
              author: const User(id: systemUserId),
              createdAt: createdAt,
            );

            chatController.insert(message);
          },
          receiveMessage: (Message message) {
            final oldMessage = chatController.messages
                .firstWhereOrNull((m) => m.id == message.id);
            if (oldMessage == null) {
              chatController.insert(message);
            } else {
              chatController.update(oldMessage, message);
            }
          },
          sendMessage: (
            String message,
            PredefinedFormat? format,
            Map<String, dynamic>? metadata,
          ) {
            _clearErrorMessages(emit);
            _clearRelatedQuestions();
            _startStreamingMessage(message, format, metadata);
            lastSentMessage = null;

            isFetchingRelatedQuestions = false;
            shouldFetchRelatedQuestions =
                format == null || format.imageFormat.hasText;

            emit(
              state.copyWith(
                promptResponseState: PromptResponseState.sendingQuestion,
              ),
            );
          },
          finishSending: () {
            emit(
              state.copyWith(
                promptResponseState: PromptResponseState.streamingAnswer,
              ),
            );
          },
          stopStream: () async {
            if (answerStream == null) {
              return;
            }

            // tell backend to stop
            final payload = StopStreamPB(chatId: chatId);
            await AIEventStopStream(payload).send();

            // allow user input
            emit(
              state.copyWith(
                promptResponseState: PromptResponseState.ready,
              ),
            );

            // no need to remove old message if stream has started already
            if (answerStream!.hasStarted) {
              return;
            }

            // remove the non-started message from the list
            final message = chatController.messages.lastWhereOrNull(
              (e) => e.id == answerStreamMessageId,
            );
            if (message != null) {
              await chatController.remove(message);
            }

            // set answer stream to null
            await answerStream?.dispose();
            answerStream = null;
            answerStreamMessageId = '';
          },
          failedSending: () {
            final lastMessage = chatController.messages.lastOrNull;
            if (lastMessage != null) {
              chatController.remove(lastMessage);
            }
            emit(
              state.copyWith(
                promptResponseState: PromptResponseState.ready,
              ),
            );
          },
          regenerateAnswer: (id, format) {
            _clearRelatedQuestions();
            _regenerateAnswer(id, format);
            lastSentMessage = null;

            isFetchingRelatedQuestions = false;
            shouldFetchRelatedQuestions = false;

            emit(
              state.copyWith(
                promptResponseState: PromptResponseState.sendingQuestion,
              ),
            );
          },
          didReceiveChatSettings: (settings) {
            selectedSourcesNotifier.value = settings.ragIds;
          },
          updateSelectedSources: (selectedSourcesIds) async {
            selectedSourcesNotifier.value = [...selectedSourcesIds];

            final payload = UpdateChatSettingsPB(
              chatId: ChatId(value: chatId),
              ragIds: selectedSourcesIds,
            );
            await AIEventUpdateChatSettings(payload)
                .send()
                .onFailure(Log.error);
          },
          deleteMessage: (mesesage) async {
            await chatController.remove(mesesage);
          },
        );
      },
    );
  }

  void _startListening() {
    listener.start(
      chatMessageCallback: (pb) {
        if (isClosed) {
          return;
        }

        // 3 mean message response from AI
        if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
          temporaryMessageIDMap.putIfAbsent(
            pb.messageId.toString(),
            () => answerStreamMessageId,
          );
          answerStreamMessageId = '';
        }

        // 1 mean message response from User
        if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
          temporaryMessageIDMap.putIfAbsent(
            pb.messageId.toString(),
            () => questionStreamMessageId,
          );
          questionStreamMessageId = '';
        }

        final message = _createTextMessage(pb);
        add(ChatEvent.receiveMessage(message));
      },
      chatErrorMessageCallback: (err) {
        if (!isClosed) {
          Log.error("chat error: ${err.errorMessage}");
          add(const ChatEvent.didFinishAnswerStream());
        }
      },
      latestMessageCallback: (list) {
        if (!isClosed) {
          final messages = list.messages.map(_createTextMessage).toList();
          add(ChatEvent.didLoadLatestMessages(messages));
        }
      },
      prevMessageCallback: (list) {
        if (!isClosed) {
          final messages = list.messages.map(_createTextMessage).toList();
          add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore));
        }
      },
      finishStreamingCallback: () async {
        if (isClosed) {
          return;
        }

        add(const ChatEvent.didFinishAnswerStream());

        // The answer stream will bet set to null after the streaming has
        // finished, got cancelled, or errored. In this case, don't retrieve
        // related questions.
        if (answerStream == null ||
            lastSentMessage == null ||
            !shouldFetchRelatedQuestions) {
          return;
        }

        final payload = ChatMessageIdPB(
          chatId: chatId,
          messageId: lastSentMessage!.messageId,
        );

        isFetchingRelatedQuestions = true;
        await AIEventGetRelatedQuestion(payload).send().fold(
          (list) {
            // while fetching related questions, the user might enter a new
            // question or regenerate a previous response. In such cases, don't
            // display the relatedQuestions
            if (!isClosed && isFetchingRelatedQuestions) {
              add(
                ChatEvent.didReceiveRelatedQuestions(
                  list.items.map((e) => e.content).toList(),
                ),
              );
              isFetchingRelatedQuestions = false;
            }
          },
          (err) => Log.error("Failed to get related questions: $err"),
        );
      },
    );
  }

  void _loadSetting() async {
    final getChatSettingsPayload =
        AIEventGetChatSettings(ChatId(value: chatId));
    await getChatSettingsPayload.send().fold(
      (settings) {
        if (!isClosed) {
          add(ChatEvent.didReceiveChatSettings(settings: settings));
        }
      },
      Log.error,
    );
  }

  void _loadMessages() async {
    final loadMessagesPayload = LoadNextChatMessagePB(
      chatId: chatId,
      limit: Int64(10),
    );
    await AIEventLoadNextMessage(loadMessagesPayload).send().fold(
      (list) {
        if (!isClosed) {
          final messages = list.messages.map(_createTextMessage).toList();
          add(ChatEvent.didLoadLatestMessages(messages));
        }
      },
      (err) => Log.error("Failed to load messages: $err"),
    );
  }

  bool _isOneTimeMessage(Message message) {
    return message.metadata != null &&
        message.metadata!.containsKey(onetimeShotType);
  }

  /// get the last message that is not a one-time message
  Message? _getOldestMessage() {
    return chatController.messages
        .firstWhereOrNull((message) => !_isOneTimeMessage(message));
  }

  void _loadPreviousMessages(Int64? beforeMessageId) {
    final payload = LoadPrevChatMessagePB(
      chatId: chatId,
      limit: Int64(10),
      beforeMessageId: beforeMessageId,
    );
    AIEventLoadPrevMessage(payload).send();
  }

  Future<void> _startStreamingMessage(
    String message,
    PredefinedFormat? format,
    Map<String, dynamic>? metadata,
  ) async {
    await answerStream?.dispose();

    answerStream = AnswerStream();
    final questionStream = QuestionStream();

    // add a streaming question message
    final questionStreamMessage = _createQuestionStreamMessage(
      questionStream,
      metadata,
    );
    add(ChatEvent.receiveMessage(questionStreamMessage));

    final payload = StreamChatPayloadPB(
      chatId: chatId,
      message: message,
      messageType: ChatMessageTypePB.User,
      questionStreamPort: Int64(questionStream.nativePort),
      answerStreamPort: Int64(answerStream!.nativePort),
      metadata: await metadataPBFromMetadata(metadata),
    );
    if (format != null) {
      payload.format = format.toPB();
    }

    // stream the question to the server
    await AIEventStreamMessage(payload).send().fold(
      (question) {
        if (!isClosed) {
          final streamAnswer = _createAnswerStreamMessage(
            stream: answerStream!,
            questionMessageId: question.messageId,
            fakeQuestionMessageId: questionStreamMessage.id,
          );

          lastSentMessage = question;
          add(const ChatEvent.finishSending());
          add(ChatEvent.receiveMessage(streamAnswer));
        }
      },
      (err) {
        if (!isClosed) {
          Log.error("Failed to send message: ${err.msg}");

          final metadata = {
            onetimeShotType: OnetimeShotType.error,
            if (err.code != ErrorCode.Internal) errorMessageTextKey: err.msg,
          };

          final error = TextMessage(
            text: '',
            metadata: metadata,
            author: const User(id: systemUserId),
            id: systemUserId,
            createdAt: DateTime.now(),
          );

          add(const ChatEvent.failedSending());
          add(ChatEvent.receiveMessage(error));
        }
      },
    );
  }

  void _regenerateAnswer(
    String answerMessageIdString,
    PredefinedFormat? format,
  ) async {
    final id = temporaryMessageIDMap.entries
            .firstWhereOrNull((e) => e.value == answerMessageIdString)
            ?.key ??
        answerMessageIdString;
    final answerMessageId = Int64.tryParseInt(id);

    if (answerMessageId == null) {
      return;
    }

    await answerStream?.dispose();
    answerStream = AnswerStream();

    final payload = RegenerateResponsePB(
      chatId: chatId,
      answerMessageId: answerMessageId,
      answerStreamPort: Int64(answerStream!.nativePort),
    );
    if (format != null) {
      payload.format = format.toPB();
    }

    await AIEventRegenerateResponse(payload).send().fold(
      (success) {
        if (!isClosed) {
          final streamAnswer = _createAnswerStreamMessage(
            stream: answerStream!,
            questionMessageId: answerMessageId - 1,
          ).copyWith(id: answerMessageIdString);

          add(ChatEvent.receiveMessage(streamAnswer));
          add(const ChatEvent.finishSending());
        }
      },
      (err) => Log.error("Failed to send message: ${err.msg}"),
    );
  }

  Message _createAnswerStreamMessage({
    required AnswerStream stream,
    required Int64 questionMessageId,
    String? fakeQuestionMessageId,
  }) {
    answerStreamMessageId = fakeQuestionMessageId == null
        ? (questionMessageId + 1).toString()
        : "${fakeQuestionMessageId}_ans";

    return TextMessage(
      id: answerStreamMessageId,
      text: '',
      author: User(id: "streamId:${nanoid()}"),
      metadata: {
        "$AnswerStream": stream,
        messageQuestionIdKey: questionMessageId,
        "chatId": chatId,
      },
      createdAt: DateTime.now(),
    );
  }

  Message _createQuestionStreamMessage(
    QuestionStream stream,
    Map<String, dynamic>? sentMetadata,
  ) {
    final now = DateTime.now();

    questionStreamMessageId = (now.millisecondsSinceEpoch ~/ 1000).toString();

    return TextMessage(
      author: User(id: userId),
      metadata: {
        "$QuestionStream": stream,
        "chatId": chatId,
        messageChatFileListKey: chatFilesFromMessageMetadata(sentMetadata),
      },
      id: questionStreamMessageId,
      createdAt: now,
      text: '',
    );
  }

  Message _createTextMessage(ChatMessagePB message) {
    String messageId = message.messageId.toString();

    /// If the message id is in the temporary map, we will use the previous fake message id
    if (temporaryMessageIDMap.containsKey(messageId)) {
      messageId = temporaryMessageIDMap[messageId]!;
    }

    return TextMessage(
      author: User(id: message.authorId),
      id: messageId,
      text: message.content,
      createdAt: message.createdAt.toDateTime(),
      metadata: {
        messageRefSourceJsonStringKey: message.metadata,
      },
    );
  }

  void _clearErrorMessages(Emitter<ChatState> emit) {
    final errorMessages = chatController.messages
        .where(
          (message) =>
              onetimeMessageTypeFromMeta(message.metadata) ==
              OnetimeShotType.error,
        )
        .toList();

    for (final message in errorMessages) {
      chatController.remove(message);
    }
    emit(state.copyWith(clearErrorMessages: !state.clearErrorMessages));
  }

  void _clearRelatedQuestions() {
    final relatedQuestionMessages = chatController.messages
        .where(
          (message) =>
              onetimeMessageTypeFromMeta(message.metadata) ==
              OnetimeShotType.relatedQuestion,
        )
        .toList();

    for (final message in relatedQuestionMessages) {
      chatController.remove(message);
    }
  }
}

@freezed
class ChatEvent with _$ChatEvent {
  // chat settings
  const factory ChatEvent.didReceiveChatSettings({
    required ChatSettingsPB settings,
  }) = _DidReceiveChatSettings;
  const factory ChatEvent.updateSelectedSources({
    required List<String> selectedSourcesIds,
  }) = _UpdateSelectedSources;

  // send message
  const factory ChatEvent.sendMessage({
    required String message,
    PredefinedFormat? format,
    Map<String, dynamic>? metadata,
  }) = _SendMessage;
  const factory ChatEvent.finishSending() = _FinishSendMessage;
  const factory ChatEvent.failedSending() = _FailSendMessage;

  // regenerate
  const factory ChatEvent.regenerateAnswer(
    String id,
    PredefinedFormat? format,
  ) = _RegenerateAnswer;

  // streaming answer
  const factory ChatEvent.stopStream() = _StopStream;
  const factory ChatEvent.didFinishAnswerStream() = _DidFinishAnswerStream;

  // receive message
  const factory ChatEvent.receiveMessage(Message message) = _ReceiveMessage;

  // loading messages
  const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
      _DidLoadMessages;
  const factory ChatEvent.loadPreviousMessages() = _LoadPreviousMessages;
  const factory ChatEvent.didLoadPreviousMessages(
    List<Message> messages,
    bool hasMore,
  ) = _DidLoadPreviousMessages;

  // related questions
  const factory ChatEvent.didReceiveRelatedQuestions(
    List<String> questions,
  ) = _DidReceiveRelatedQueston;

  const factory ChatEvent.deleteMessage(Message message) = _DeleteMessage;
}

@freezed
class ChatState with _$ChatState {
  const factory ChatState({
    required LoadChatMessageStatus loadingState,
    required PromptResponseState promptResponseState,
    required bool clearErrorMessages,
  }) = _ChatState;

  factory ChatState.initial() => const ChatState(
        loadingState: LoadChatMessageStatus.loading,
        promptResponseState: PromptResponseState.ready,
        clearErrorMessages: false,
      );
}

bool isOtherUserMessage(Message message) {
  return message.author.id != aiResponseUserId &&
      message.author.id != systemUserId &&
      !message.author.id.startsWith("streamId:");
}
