// Copyright (c) 2020 Nikos Filippakis
// Copyright (c) 2023 Tulir Asokan
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

package crypto

import (
	"context"
	"errors"
	"time"

	"github.com/rs/zerolog"

	"maunium.net/go/mautrix/crypto/olm"
	"maunium.net/go/mautrix/id"

	"maunium.net/go/mautrix"
	"maunium.net/go/mautrix/event"
)

type KeyShareRejection struct {
	Code   event.RoomKeyWithheldCode
	Reason string
}

var (
	// Reject a key request without responding
	KeyShareRejectNoResponse = KeyShareRejection{}

	KeyShareRejectBlacklisted   = KeyShareRejection{event.RoomKeyWithheldBlacklisted, "You have been blacklisted by this device"}
	KeyShareRejectUnverified    = KeyShareRejection{event.RoomKeyWithheldUnverified, "This device does not share keys to unverified devices"}
	KeyShareRejectOtherUser     = KeyShareRejection{event.RoomKeyWithheldUnauthorized, "This device does not share keys to other users"}
	KeyShareRejectUnavailable   = KeyShareRejection{event.RoomKeyWithheldUnavailable, "Requested session ID not found on this device"}
	KeyShareRejectInternalError = KeyShareRejection{event.RoomKeyWithheldUnavailable, "An internal error occurred while trying to share the requested session"}
)

// RequestRoomKey sends a key request for a room to the current user's devices. If the context is cancelled, then so is the key request.
// Returns a bool channel that will get notified either when the key is received or the request is cancelled.
//
// Deprecated: this only supports a single key request target, so the whole automatic cancelling feature isn't very useful.
func (mach *OlmMachine) RequestRoomKey(ctx context.Context, toUser id.UserID, toDevice id.DeviceID,
	roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID) (chan bool, error) {

	requestID := mach.Client.TxnID()
	keyResponseReceived := make(chan struct{})
	mach.roomKeyRequestFilled.Store(sessionID, keyResponseReceived)

	err := mach.SendRoomKeyRequest(roomID, senderKey, sessionID, requestID, map[id.UserID][]id.DeviceID{toUser: {toDevice}})
	if err != nil {
		return nil, err
	}

	resChan := make(chan bool, 1)
	go func() {
		select {
		case <-keyResponseReceived:
			// key request successful
			mach.Log.Debug().Msgf("Key for session %v was received, cancelling other key requests", sessionID)
			resChan <- true
		case <-ctx.Done():
			// if the context is done, key request was unsuccessful
			mach.Log.Debug().Msgf("Context closed (%v) before forwared key for session %v received, sending key request cancellation", ctx.Err(), sessionID)
			resChan <- false
		}

		// send a message to all devices cancelling this key request
		mach.roomKeyRequestFilled.Delete(sessionID)

		cancelEvtContent := &event.Content{
			Parsed: event.RoomKeyRequestEventContent{
				Action:             event.KeyRequestActionCancel,
				RequestID:          requestID,
				RequestingDeviceID: mach.Client.DeviceID,
			},
		}

		toDeviceCancel := &mautrix.ReqSendToDevice{
			Messages: map[id.UserID]map[id.DeviceID]*event.Content{
				toUser: {
					toDevice: cancelEvtContent,
				},
			},
		}

		mach.Client.SendToDevice(event.ToDeviceRoomKeyRequest, toDeviceCancel)
	}()
	return resChan, nil
}

// SendRoomKeyRequest sends a key request for the given key (identified by the room ID, sender key and session ID) to the given users.
//
// The request ID parameter is optional. If it's empty, a random ID will be generated.
//
// This function does not wait for the keys to arrive. You can use WaitForSession to wait for the session to
// arrive (in any way, not just as a reply to this request). There's also RequestRoomKey which waits for a response
// to the specific key request, but currently it only supports a single target device and is therefore deprecated.
// A future function may properly support multiple targets and automatically canceling the other requests when receiving
// the first response.
func (mach *OlmMachine) SendRoomKeyRequest(roomID id.RoomID, senderKey id.SenderKey, sessionID id.SessionID, requestID string, users map[id.UserID][]id.DeviceID) error {
	if len(requestID) == 0 {
		requestID = mach.Client.TxnID()
	}
	requestEvent := &event.Content{
		Parsed: &event.RoomKeyRequestEventContent{
			Action: event.KeyRequestActionRequest,
			Body: event.RequestedKeyInfo{
				Algorithm: id.AlgorithmMegolmV1,
				RoomID:    roomID,
				SenderKey: senderKey,
				SessionID: sessionID,
			},
			RequestID:          requestID,
			RequestingDeviceID: mach.Client.DeviceID,
		},
	}

	toDeviceReq := &mautrix.ReqSendToDevice{
		Messages: make(map[id.UserID]map[id.DeviceID]*event.Content, len(users)),
	}
	for user, devices := range users {
		toDeviceReq.Messages[user] = make(map[id.DeviceID]*event.Content, len(devices))
		for _, device := range devices {
			toDeviceReq.Messages[user][device] = requestEvent
		}
	}
	_, err := mach.Client.SendToDevice(event.ToDeviceRoomKeyRequest, toDeviceReq)
	return err
}

func (mach *OlmMachine) importForwardedRoomKey(ctx context.Context, evt *DecryptedOlmEvent, content *event.ForwardedRoomKeyEventContent) bool {
	log := zerolog.Ctx(ctx).With().
		Str("session_id", content.SessionID.String()).
		Str("room_id", content.RoomID.String()).
		Logger()
	if content.Algorithm != id.AlgorithmMegolmV1 || evt.Keys.Ed25519 == "" {
		log.Debug().
			Str("algorithm", string(content.Algorithm)).
			Msg("Ignoring weird forwarded room key")
		return false
	}

	igsInternal, err := olm.InboundGroupSessionImport([]byte(content.SessionKey))
	if err != nil {
		log.Error().Err(err).Msg("Failed to import inbound group session")
		return false
	} else if igsInternal.ID() != content.SessionID {
		log.Warn().
			Str("actual_session_id", igsInternal.ID().String()).
			Msg("Mismatched session ID while creating inbound group session from forward")
		return false
	}
	config := mach.StateStore.GetEncryptionEvent(content.RoomID)
	var maxAge time.Duration
	var maxMessages int
	if config != nil {
		maxAge = time.Duration(config.RotationPeriodMillis) * time.Millisecond
		maxMessages = config.RotationPeriodMessages
	}
	if content.MaxAge != 0 {
		maxAge = time.Duration(content.MaxAge) * time.Millisecond
	}
	if content.MaxMessages != 0 {
		maxMessages = content.MaxMessages
	}
	igs := &InboundGroupSession{
		Internal:         *igsInternal,
		SigningKey:       evt.Keys.Ed25519,
		SenderKey:        content.SenderKey,
		RoomID:           content.RoomID,
		ForwardingChains: append(content.ForwardingKeyChain, evt.SenderKey.String()),
		id:               content.SessionID,

		ReceivedAt:  time.Now().UTC(),
		MaxAge:      maxAge.Milliseconds(),
		MaxMessages: maxMessages,
		IsScheduled: content.IsScheduled,
	}
	err = mach.CryptoStore.PutGroupSession(content.RoomID, content.SenderKey, content.SessionID, igs)
	if err != nil {
		log.Error().Err(err).Msg("Failed to store new inbound group session")
		return false
	}
	mach.markSessionReceived(content.SessionID)
	log.Debug().Msg("Received forwarded inbound group session")
	return true
}

func (mach *OlmMachine) rejectKeyRequest(rejection KeyShareRejection, device *id.Device, request event.RequestedKeyInfo) {
	if rejection.Code == "" {
		// If the rejection code is empty, it means don't share keys, but also don't tell the requester.
		return
	}
	content := event.RoomKeyWithheldEventContent{
		RoomID:    request.RoomID,
		Algorithm: request.Algorithm,
		SessionID: request.SessionID,
		SenderKey: request.SenderKey,
		Code:      rejection.Code,
		Reason:    rejection.Reason,
	}
	err := mach.sendToOneDevice(device.UserID, device.DeviceID, event.ToDeviceRoomKeyWithheld, &content)
	if err != nil {
		mach.Log.Warn().Err(err).
			Str("code", string(rejection.Code)).
			Str("user_id", device.UserID.String()).
			Str("device_id", device.DeviceID.String()).
			Msg("Failed to send key share rejection")
	}
	err = mach.sendToOneDevice(device.UserID, device.DeviceID, event.ToDeviceOrgMatrixRoomKeyWithheld, &content)
	if err != nil {
		mach.Log.Warn().Err(err).
			Str("code", string(rejection.Code)).
			Str("user_id", device.UserID.String()).
			Str("device_id", device.DeviceID.String()).
			Msg("Failed to send key share rejection (legacy event type)")
	}
}

func (mach *OlmMachine) defaultAllowKeyShare(ctx context.Context, device *id.Device, _ event.RequestedKeyInfo) *KeyShareRejection {
	log := mach.machOrContextLog(ctx)
	if mach.Client.UserID != device.UserID {
		log.Debug().Msg("Rejecting key request from a different user")
		return &KeyShareRejectOtherUser
	} else if mach.Client.DeviceID == device.DeviceID {
		log.Debug().Msg("Ignoring key request from ourselves")
		return &KeyShareRejectNoResponse
	} else if device.Trust == id.TrustStateBlacklisted {
		log.Debug().Msg("Rejecting key request from blacklisted device")
		return &KeyShareRejectBlacklisted
	} else if trustState := mach.ResolveTrust(device); trustState >= mach.ShareKeysMinTrust {
		log.Debug().
			Str("min_trust", mach.SendKeysMinTrust.String()).
			Str("device_trust", trustState.String()).
			Msg("Accepting key request from trusted device")
		return nil
	} else {
		log.Debug().
			Str("min_trust", mach.SendKeysMinTrust.String()).
			Str("device_trust", trustState.String()).
			Msg("Rejecting key request from untrusted device")
		return &KeyShareRejectUnverified
	}
}

func (mach *OlmMachine) handleRoomKeyRequest(ctx context.Context, sender id.UserID, content *event.RoomKeyRequestEventContent) {
	log := zerolog.Ctx(ctx).With().
		Str("request_id", content.RequestID).
		Str("device_id", content.RequestingDeviceID.String()).
		Str("room_id", content.Body.RoomID.String()).
		Str("session_id", content.Body.SessionID.String()).
		Logger()
	ctx = log.WithContext(ctx)
	if content.Action != event.KeyRequestActionRequest {
		return
	} else if content.RequestingDeviceID == mach.Client.DeviceID && sender == mach.Client.UserID {
		log.Debug().Msg("Ignoring key request from ourselves")
		return
	}

	log.Debug().Msg("Received key request")

	device, err := mach.GetOrFetchDevice(ctx, sender, content.RequestingDeviceID)
	if err != nil {
		log.Error().Err(err).Msg("Failed to fetch device that requested keys")
		return
	}

	rejection := mach.AllowKeyShare(ctx, device, content.Body)
	if rejection != nil {
		mach.rejectKeyRequest(*rejection, device, content.Body)
		return
	}

	igs, err := mach.CryptoStore.GetGroupSession(content.Body.RoomID, content.Body.SenderKey, content.Body.SessionID)
	if err != nil {
		if errors.Is(err, ErrGroupSessionWithheld) {
			log.Debug().Err(err).Msg("Requested group session not available")
			mach.rejectKeyRequest(KeyShareRejectUnavailable, device, content.Body)
		} else {
			log.Error().Err(err).Msg("Failed to get group session to forward")
			mach.rejectKeyRequest(KeyShareRejectInternalError, device, content.Body)
		}
		return
	} else if igs == nil {
		log.Error().Msg("Didn't find group session to forward")
		mach.rejectKeyRequest(KeyShareRejectUnavailable, device, content.Body)
		return
	}
	if internalID := igs.ID(); internalID != content.Body.SessionID {
		// Should this be an error?
		log = log.With().Str("unexpected_session_id", internalID.String()).Logger()
	}

	firstKnownIndex := igs.Internal.FirstKnownIndex()
	log = log.With().Uint32("first_known_index", firstKnownIndex).Logger()
	exportedKey, err := igs.Internal.Export(firstKnownIndex)
	if err != nil {
		log.Error().Err(err).Msg("Failed to export group session to forward")
		mach.rejectKeyRequest(KeyShareRejectInternalError, device, content.Body)
		return
	}

	forwardedRoomKey := event.Content{
		Parsed: &event.ForwardedRoomKeyEventContent{
			RoomKeyEventContent: event.RoomKeyEventContent{
				Algorithm:  id.AlgorithmMegolmV1,
				RoomID:     igs.RoomID,
				SessionID:  igs.ID(),
				SessionKey: string(exportedKey),
			},
			SenderKey:          content.Body.SenderKey,
			ForwardingKeyChain: igs.ForwardingChains,
			SenderClaimedKey:   igs.SigningKey,
		},
	}

	if err = mach.SendEncryptedToDevice(ctx, device, event.ToDeviceForwardedRoomKey, forwardedRoomKey); err != nil {
		log.Error().Err(err).Msg("Failed to encrypt and send group session")
	} else {
		log.Debug().Msg("Successfully sent forwarded group session")
	}
}

func (mach *OlmMachine) handleBeeperRoomKeyAck(ctx context.Context, sender id.UserID, content *event.BeeperRoomKeyAckEventContent) {
	log := mach.machOrContextLog(ctx).With().
		Str("room_id", content.RoomID.String()).
		Str("session_id", content.SessionID.String()).
		Int("first_message_index", content.FirstMessageIndex).
		Logger()

	sess, err := mach.CryptoStore.GetGroupSession(content.RoomID, "", content.SessionID)
	if err != nil {
		if errors.Is(err, ErrGroupSessionWithheld) {
			log.Debug().Err(err).Msg("Acked group session was already redacted")
		} else {
			log.Err(err).Msg("Failed to get group session to check if it should be redacted")
		}
		return
	} else if sess == nil {
		log.Warn().Msg("Got key backup ack for unknown session")
		return
	}
	log = log.With().
		Str("sender_key", sess.SenderKey.String()).
		Str("own_identity", mach.OwnIdentity().IdentityKey.String()).
		Logger()

	isInbound := sess.SenderKey == mach.OwnIdentity().IdentityKey
	if isInbound && mach.DeleteOutboundKeysOnAck && content.FirstMessageIndex == 0 {
		log.Debug().Msg("Redacting inbound copy of outbound group session after ack")
		err = mach.CryptoStore.RedactGroupSession(content.RoomID, sess.SenderKey, content.SessionID, "outbound session acked")
		if err != nil {
			log.Err(err).Msg("Failed to redact group session")
		}
	} else {
		log.Debug().Bool("inbound", isInbound).Msg("Received room key ack")
	}
}
