////////////////////////////////////////////////////////////////////////////////
// Copyright © 2022 xx foundation                                             //
//                                                                            //
// Use of this source code is governed by a license that can be found in the  //
// LICENSE file.                                                              //
////////////////////////////////////////////////////////////////////////////////

//go:build js && wasm

package main

import (
	"bytes"
	"crypto/ed25519"
	"encoding/json"
	"strings"
	"syscall/js"
	"time"

	"github.com/hack-pad/go-indexeddb/idb"
	"github.com/pkg/errors"
	jww "github.com/spf13/jwalterweatherman"

	"gitlab.com/elixxir/client/v4/cmix/rounds"
	"gitlab.com/elixxir/client/v4/dm"
	cryptoChannel "gitlab.com/elixxir/crypto/channel"
	"gitlab.com/elixxir/crypto/message"
	"gitlab.com/elixxir/xxdk-wasm/indexedDb/impl"
	"gitlab.com/elixxir/xxdk-wasm/utils"
	"gitlab.com/xx_network/primitives/id"
)

// wasmModel implements dm.EventModel interface backed by IndexedDb.
// NOTE: This model is NOT thread safe - it is the responsibility of the
// caller to ensure that its methods are called sequentially.
type wasmModel struct {
	db                *idb.Database
	cipher            cryptoChannel.Cipher
	receivedMessageCB MessageReceivedCallback
}

// upsertConversation is used for joining or updating a Conversation.
func (w *wasmModel) upsertConversation(nickname string,
	pubKey ed25519.PublicKey, partnerToken uint32, codeset uint8,
	blocked bool) error {
	parentErr := errors.New("[DM indexedDB] failed to upsertConversation")

	// Build object
	newConvo := Conversation{
		Pubkey:         pubKey,
		Nickname:       nickname,
		Token:          partnerToken,
		CodesetVersion: codeset,
		Blocked:        blocked,
	}

	// Convert to jsObject
	newConvoJson, err := json.Marshal(&newConvo)
	if err != nil {
		return errors.WithMessagef(parentErr,
			"Unable to marshal Conversation: %+v", err)
	}
	convoObj, err := utils.JsonToJS(newConvoJson)
	if err != nil {
		return errors.WithMessagef(parentErr,
			"Unable to marshal Conversation: %+v", err)
	}

	_, err = impl.Put(w.db, conversationStoreName, convoObj)
	if err != nil {
		return errors.WithMessagef(parentErr,
			"Unable to put Conversation: %+v", err)
	}
	return nil
}

// buildMessage is a private helper that converts typical dm.EventModel inputs
// into a basic Message structure for insertion into storage.
//
// NOTE: ID is not set inside this function because we want to use the
// autoincrement key by default. If you are trying to overwrite an existing
// message, then you need to set it manually yourself.
func buildMessage(messageID, parentID, text []byte, partnerKey,
	senderKey ed25519.PublicKey, timestamp time.Time, round id.Round,
	mType dm.MessageType, codeset uint8, status dm.Status) *Message {
	return &Message{
		MessageID:          messageID,
		ConversationPubKey: partnerKey[:],
		ParentMessageID:    parentID,
		Timestamp:          timestamp,
		SenderPubKey:       senderKey[:],
		Status:             uint8(status),
		CodesetVersion:     codeset,
		Text:               text,
		Type:               uint16(mType),
		Round:              uint64(round),
	}
}

func (w *wasmModel) Receive(messageID message.ID, nickname string, text []byte,
	partnerKey, senderKey ed25519.PublicKey, dmToken uint32, codeset uint8, timestamp time.Time,
	round rounds.Round, mType dm.MessageType, status dm.Status) uint64 {
	parentErr := "[DM indexedDB] failed to Receive"
	jww.TRACE.Printf("[DM indexedDB] Receive(%s)", messageID)

	uuid, err := w.receiveWrapper(messageID, nil, nickname, string(text),
		partnerKey, senderKey, dmToken, codeset, timestamp, round, mType, status)
	if err != nil {
		jww.ERROR.Printf("%+v", errors.WithMessagef(err, parentErr))
		return 0
	}
	return uuid
}

func (w *wasmModel) ReceiveText(messageID message.ID, nickname, text string,
	partnerKey, senderKey ed25519.PublicKey, dmToken uint32, codeset uint8,
	timestamp time.Time, round rounds.Round, status dm.Status) uint64 {
	parentErr := "[DM indexedDB] failed to ReceiveText"
	jww.TRACE.Printf("[DM indexedDB] ReceiveText(%s)", messageID)

	uuid, err := w.receiveWrapper(messageID, nil, nickname, text,
		partnerKey, senderKey, dmToken, codeset, timestamp, round,
		dm.TextType, status)
	if err != nil {
		jww.ERROR.Printf("%+v", errors.WithMessagef(err, parentErr))
		return 0
	}
	return uuid
}

func (w *wasmModel) ReceiveReply(messageID, reactionTo message.ID, nickname,
	text string, partnerKey, senderKey ed25519.PublicKey, dmToken uint32, codeset uint8,
	timestamp time.Time, round rounds.Round, status dm.Status) uint64 {
	parentErr := "[DM indexedDB] failed to ReceiveReply"
	jww.TRACE.Printf("[DM indexedDB] ReceiveReply(%s)", messageID)

	uuid, err := w.receiveWrapper(messageID, &reactionTo, nickname, text,
		partnerKey, senderKey, dmToken, codeset, timestamp, round,
		dm.ReplyType, status)
	if err != nil {
		jww.ERROR.Printf("%+v", errors.WithMessagef(err, parentErr))
		return 0
	}
	return uuid
}

func (w *wasmModel) ReceiveReaction(messageID, reactionTo message.ID, nickname,
	reaction string, partnerKey, senderKey ed25519.PublicKey, dmToken uint32, codeset uint8,
	timestamp time.Time, round rounds.Round, status dm.Status) uint64 {
	parentErr := "[DM indexedDB] failed to ReceiveReaction"
	jww.TRACE.Printf("[DM indexedDB] ReceiveReaction(%s)", messageID)

	uuid, err := w.receiveWrapper(messageID, &reactionTo, nickname, reaction,
		partnerKey, senderKey, dmToken, codeset, timestamp, round,
		dm.ReactionType, status)
	if err != nil {
		jww.ERROR.Printf("%+v", errors.WithMessagef(err, parentErr))
		return 0
	}
	return uuid
}

func (w *wasmModel) UpdateSentStatus(uuid uint64, messageID message.ID,
	timestamp time.Time, round rounds.Round, status dm.Status) {
	parentErr := errors.New("[DM indexedDB] failed to UpdateSentStatus")
	jww.TRACE.Printf(
		"[DM indexedDB] UpdateSentStatus(%d, %s, ...)", uuid, messageID)

	// Convert messageID to the key generated by json.Marshal
	key := js.ValueOf(uuid)

	// Use the key to get the existing Message
	currentMsg, err := impl.Get(w.db, messageStoreName, key)
	if err != nil {
		jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr,
			"Unable to get message: %+v", err))
		return
	}

	// Extract the existing Message and update the Status
	newMessage := &Message{}
	err = json.Unmarshal([]byte(utils.JsToJson(currentMsg)), newMessage)
	if err != nil {
		jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr,
			"Could not JSON unmarshal message: %+v", err))
		return
	}

	newMessage.Status = uint8(status)
	if !messageID.Equals(message.ID{}) {
		newMessage.MessageID = messageID.Bytes()
	}

	if round.ID != 0 {
		newMessage.Round = uint64(round.ID)
	}

	if !timestamp.Equal(time.Time{}) {
		newMessage.Timestamp = timestamp
	}

	// Store the updated Message
	_, err = w.upsertMessage(newMessage)
	if err != nil {
		jww.ERROR.Printf("%+v", errors.Wrap(parentErr, err.Error()))
		return
	}

	jww.TRACE.Printf("[DM indexedDB] Calling ReceiveMessageCB(%v, %v, t, f)",
		uuid, newMessage.ConversationPubKey)
	go w.receivedMessageCB(uuid, newMessage.ConversationPubKey,
		true, false)
}

// receiveWrapper is a higher-level wrapper of upsertMessage.
func (w *wasmModel) receiveWrapper(messageID message.ID, parentID *message.ID, nickname,
	data string, partnerKey, senderKey ed25519.PublicKey, partnerToken uint32, codeset uint8,
	timestamp time.Time, round rounds.Round, mType dm.MessageType, status dm.Status) (uint64, error) {

	// Keep track of whether a Conversation was altered
	var convoToUpdate *Conversation

	// Determine whether Conversation needs to be created
	result, err := w.getConversation(partnerKey)
	if err != nil {
		if !strings.Contains(err.Error(), impl.ErrDoesNotExist) {
			return 0, err
		} else {
			// If there is no extant Conversation, create one.
			jww.DEBUG.Printf(
				"[DM indexedDB] Joining conversation with %s", nickname)

			convoToUpdate = &Conversation{
				Pubkey:         partnerKey,
				Nickname:       nickname,
				Token:          partnerToken,
				CodesetVersion: codeset,
				Blocked:        false,
			}
		}
	} else {
		jww.DEBUG.Printf(
			"[DM indexedDB] Conversation with %s already joined", nickname)

		// Update Conversation if nickname was altered
		isFromPartner := bytes.Equal(result.Pubkey, senderKey)
		nicknameChanged := result.Nickname != nickname
		if isFromPartner && nicknameChanged {
			jww.DEBUG.Printf(
				"[DM indexedDB] Updating from nickname %s to %s",
				result.Nickname, nickname)
			convoToUpdate = result
			convoToUpdate.Nickname = nickname
		}

		// Fix conversation if dmToken is altered
		dmTokenChanged := result.Token != partnerToken
		if isFromPartner && dmTokenChanged {
			jww.WARN.Printf(
				"[DM indexedDB] Updating from dmToken %d to %d",
				result.Token, partnerToken)
			convoToUpdate = result
			convoToUpdate.Token = partnerToken
		}
	}

	// Update the conversation in storage, if needed
	conversationUpdated := convoToUpdate != nil
	if conversationUpdated {
		err = w.upsertConversation(convoToUpdate.Nickname, convoToUpdate.Pubkey,
			convoToUpdate.Token, convoToUpdate.CodesetVersion, convoToUpdate.Blocked)
		if err != nil {
			return 0, err
		}
	}

	// Handle encryption, if it is present
	textBytes := []byte(data)
	if w.cipher != nil {
		textBytes, err = w.cipher.Encrypt(textBytes)
		if err != nil {
			return 0, err
		}
	}

	var parentIdBytes []byte
	if parentID != nil {
		parentIdBytes = parentID.Marshal()
	}

	msgToInsert := buildMessage(messageID.Bytes(), parentIdBytes, textBytes,
		partnerKey, senderKey, timestamp, round.ID, mType, codeset, status)

	uuid, err := w.upsertMessage(msgToInsert)
	if err != nil {
		return 0, err
	}

	jww.TRACE.Printf("[DM indexedDB] Calling ReceiveMessageCB(%v, %v, f, %t)",
		uuid, partnerKey, conversationUpdated)
	go w.receivedMessageCB(uuid, partnerKey, false, conversationUpdated)
	return uuid, nil
}

// upsertMessage is a helper function that will update an existing record
// if Message.ID is specified. Otherwise, it will perform an insert.
func (w *wasmModel) upsertMessage(msg *Message) (uint64, error) {
	// Convert to jsObject
	newMessageJson, err := json.Marshal(msg)
	if err != nil {
		return 0, errors.Errorf("Unable to marshal Message: %+v", err)
	}
	messageObj, err := utils.JsonToJS(newMessageJson)
	if err != nil {
		return 0, errors.Errorf("Unable to marshal Message: %+v", err)
	}

	// Store message to database
	msgIdObj, err := impl.Put(w.db, messageStoreName, messageObj)
	if err != nil {
		return 0, errors.Errorf("Unable to put Message: %+v", err)
	}

	uuid := msgIdObj.Int()
	jww.DEBUG.Printf("[DM indexedDB] Successfully stored message %d", uuid)
	return uint64(uuid), nil
}

// BlockSender silences messages sent by the indicated sender
// public key.
func (w *wasmModel) BlockSender(senderPubKey ed25519.PublicKey) {
	parentErr := "failed to BlockSender"
	err := w.setBlocked(senderPubKey, true)
	if err != nil {
		jww.ERROR.Printf("%+v", errors.WithMessage(err, parentErr))
	}
}

// UnblockSender allows messages sent by the indicated sender
// public key.
func (w *wasmModel) UnblockSender(senderPubKey ed25519.PublicKey) {
	parentErr := "failed to UnblockSender"
	err := w.setBlocked(senderPubKey, false)
	if err != nil {
		jww.ERROR.Printf("%+v", errors.WithMessage(err, parentErr))
	}
}

// setBlocked is a helper for blocking/unblocking a given Conversation.
func (w *wasmModel) setBlocked(senderPubKey ed25519.PublicKey, isBlocked bool) error {
	// Get current Conversation and set blocked
	resultConvo, err := w.getConversation(senderPubKey)
	if err != nil {
		return err
	}

	return w.upsertConversation(resultConvo.Nickname, resultConvo.Pubkey,
		resultConvo.Token, resultConvo.CodesetVersion, isBlocked)
}

// GetConversation returns the conversation held by the model (receiver).
func (w *wasmModel) GetConversation(senderPubKey ed25519.PublicKey) *dm.ModelConversation {
	parentErr := "failed to GetConversation"
	resultConvo, err := w.getConversation(senderPubKey)
	if err != nil {
		jww.ERROR.Printf("%+v", errors.WithMessage(err, parentErr))
		return nil
	}

	return &dm.ModelConversation{
		Pubkey:         resultConvo.Pubkey,
		Nickname:       resultConvo.Nickname,
		Token:          resultConvo.Token,
		CodesetVersion: resultConvo.CodesetVersion,
		Blocked:        resultConvo.Blocked,
	}
}

// getConversation is a helper that returns the Conversation with the given senderPubKey.
func (w *wasmModel) getConversation(senderPubKey ed25519.PublicKey) (*Conversation, error) {
	resultObj, err := impl.Get(w.db, conversationStoreName, impl.EncodeBytes(senderPubKey))
	if err != nil {
		return nil, err
	}

	resultConvo := &Conversation{}
	err = json.Unmarshal([]byte(utils.JsToJson(resultObj)), resultConvo)
	if err != nil {
		return nil, err
	}
	return resultConvo, nil
}

// GetConversations returns any conversations held by the model (receiver).
func (w *wasmModel) GetConversations() []dm.ModelConversation {
	parentErr := "failed to GetConversations"

	results, err := impl.GetAll(w.db, conversationStoreName)
	if err != nil {
		jww.ERROR.Printf("%+v", errors.WithMessage(err, parentErr))
		return nil
	}

	conversations := make([]dm.ModelConversation, len(results))
	for i := range results {
		resultConvo := &Conversation{}
		err = json.Unmarshal([]byte(utils.JsToJson(results[i])), resultConvo)
		if err != nil {
			jww.ERROR.Printf("%+v", errors.WithMessage(err, parentErr))
			return nil
		}
		conversations[i] = dm.ModelConversation{
			Pubkey:         resultConvo.Pubkey,
			Nickname:       resultConvo.Nickname,
			Token:          resultConvo.Token,
			CodesetVersion: resultConvo.CodesetVersion,
			Blocked:        resultConvo.Blocked,
		}
	}
	return conversations
}