package services

import (
	config "astrology-api/configs"
	"astrology-api/constants"
	dto "astrology-api/dto/consultation"
	models "astrology-api/models/usermodel"
	"errors"
	"strconv"
	"time"
)

// The only place in this codebase where an Agora token is minted.
//
// Two rules the rest of the API depends on:
//
//   - A token is never returned by anything except an authenticated endpoint
//     the caller is a party to, and never travels in an FCM payload. Push
//     payloads are logged in transit and readable by anything holding the
//     device token; a channel credential does not belong there. The accept
//     notification carries the consultation id and the app fetches the token
//     over TLS.
//
//   - The TTL is derived from the session's own billing cap, never from the
//     request. A leaked token is then bounded by the money that paid for it.
type AgoraTokenService interface {

	// Enabled reports whether credentials are configured. With none, the
	// consultation flow still runs end to end and the apps simply get no
	// Agora block — the same contract the notifications package keeps when
	// Firebase is unconfigured.
	Enabled() bool

	AppID() string

	// CredentialsFor mints one party's credentials for one session.
	CredentialsFor(consultation *models.Consultation, astrologerUserID uint, party string) (*dto.AgoraCredentials, error)
}

// Which side of the session is asking. Both sides get identical privileges —
// a 1:1 consultation is symmetric — but they get different uids, different
// tokens, and each other's account as the peer.
const (
	AgoraPartyCustomer   = "CUSTOMER"
	AgoraPartyAstrologer = "ASTROLOGER"
)

// Floor on a token's lifetime.
//
// A wallet of a few rupees against a high rate yields a cap of a handful of
// seconds, and a six-second token cannot survive the login round trip. In
// practice MinConsultationMinutes keeps the cap above two minutes, so this is
// belt and braces.
const agoraMinTTLSeconds = 900

// Agora's own ceiling.
const agoraMaxTTLSeconds = 86400

type agoraTokenService struct {
	config config.AgoraConfig
}

func NewAgoraTokenService(configuration config.AgoraConfig) AgoraTokenService {
	return &agoraTokenService{config: configuration}
}

func (s *agoraTokenService) Enabled() bool {
	return s.config.Enabled()
}

func (s *agoraTokenService) AppID() string {
	return s.config.AppID
}

func (s *agoraTokenService) CredentialsFor(
	consultation *models.Consultation,
	astrologerUserID uint,
	party string,
) (*dto.AgoraCredentials, error) {

	if consultation == nil {
		return nil, errors.New("consultation is required to mint agora credentials")
	}

	if !s.Enabled() {
		return nil, errors.New("consultation service is not configured")
	}

	//------------------------------------------------
	// Identity
	//------------------------------------------------
	//
	// Both uids are users.id values: the customer's own, and the one the
	// astrologers row points at. They come from the same table, so they are
	// two different primary keys and cannot collide inside one channel.

	if astrologerUserID == 0 {
		return nil, errors.New("astrologer account is not linked, cannot start a session")
	}

	if consultation.UserID == 0 {
		return nil, errors.New("consultation has no customer")
	}

	// Agora reads uid 0 as "let the SDK pick one", which silently unbinds the
	// token from the identity it was minted for. Neither id may be zero.
	if consultation.UserID == astrologerUserID {
		return nil, errors.New("an astrologer cannot consult themselves")
	}

	customerUID := uint32(consultation.UserID)
	astrologerUID := uint32(astrologerUserID)

	var uid, peerUID uint32

	switch party {

	case AgoraPartyAstrologer:
		uid, peerUID = astrologerUID, customerUID

	case AgoraPartyCustomer:
		uid, peerUID = customerUID, astrologerUID

	default:
		return nil, errors.New("agora party must be CUSTOMER or ASTROLOGER")
	}

	channelName := consultation.ChannelName

	if channelName == "" {
		return nil, errors.New("consultation has no channel")
	}

	//------------------------------------------------
	// Lifetime
	//------------------------------------------------

	ttl := s.ttlFor(consultation)

	expireSeconds := uint32(ttl)

	account := strconv.FormatUint(uint64(uid), 10)
	peerAccount := strconv.FormatUint(uint64(peerUID), 10)

	//------------------------------------------------
	// Control Plane
	//------------------------------------------------

	rtmToken, err := buildAgoraRTMToken(
		s.config.AppID,
		s.config.AppCertificate,
		account,
		expireSeconds,
		0,
		0,
	)

	if err != nil {
		return nil, err
	}

	expiresAt := time.Now().Add(time.Duration(ttl) * time.Second)

	credentials := &dto.AgoraCredentials{
		AppID:            s.config.AppID,
		ChannelName:      channelName,
		RTMAccount:       account,
		RTMToken:         rtmToken,
		PeerRTMAccount:   peerAccount,
		ExpiresAt:        expiresAt.Format("2006-01-02 15:04:05"),
		ExpiresAtUnix:    expiresAt.Unix(),
		ExpiresInSeconds: ttl,
	}

	//------------------------------------------------
	// Media Plane
	//------------------------------------------------
	//
	// Chat carries no media, so it gets no RTC token at all.

	if consultation.Medium == constants.MediumChat {
		return credentials, nil
	}

	rtcToken, err := buildAgoraRTCToken(
		s.config.AppID,
		s.config.AppCertificate,
		channelName,
		account,
		rtcPrivilegesFor(consultation.Medium),
		expireSeconds,
		0,
		0,
	)

	if err != nil {
		return nil, err
	}

	credentials.RTCToken = rtcToken
	credentials.RTCUID = uid
	credentials.PeerRTCUID = peerUID
	credentials.RTCRole = "PUBLISHER"

	return credentials, nil
}

// ttlFor sizes the token to the session it belongs to: long enough to outlive
// the billing cap plus however late the sweep runs, short enough that a leaked
// token is not an open channel.
func (s *agoraTokenService) ttlFor(consultation *models.Consultation) int {

	elapsed := 0

	if consultation.StartedAt != nil {
		elapsed = int(time.Since(*consultation.StartedAt).Seconds())
	}

	remaining := consultation.MaxBillableSeconds - elapsed

	ttl := remaining + s.config.TokenGraceSeconds

	if ttl < agoraMinTTLSeconds {
		ttl = agoraMinTTLSeconds
	}

	if s.config.TokenTTLSeconds > 0 && ttl > s.config.TokenTTLSeconds {
		ttl = s.config.TokenTTLSeconds
	}

	if ttl > agoraMaxTTLSeconds {
		ttl = agoraMaxTTLSeconds
	}

	return ttl
}

// rtcPrivilegesFor is a pricing control, not just a capability list.
//
// An AUDIO consultation is billed at audioCallRate and a VIDEO one at
// videoCallRate. Minting an audio token without publish-video means the Agora
// edge rejects a video publish outright, so a modified client cannot upgrade
// itself to the more expensive medium without the server agreeing. Same
// principle as every other price in this layer being server-side.
func rtcPrivilegesFor(medium string) []uint16 {

	if medium == constants.MediumVideo {

		return []uint16{
			agoraPrivJoinChannel,
			agoraPrivPublishAudioStream,
			agoraPrivPublishVideoStream,
			agoraPrivPublishDataStream,
		}
	}

	return []uint16{
		agoraPrivJoinChannel,
		agoraPrivPublishAudioStream,
		agoraPrivPublishDataStream,
	}
}

//////////////////////////////////////////////////////////////
// Diagnostics
//////////////////////////////////////////////////////////////

// MintForDiagnostics mints a pair of tokens without a consultation row, so
// `go run ./cmd/agoratoken` can verify the AccessToken2 packing before any of
// this is wired to a session.
//
// issueTs and salt are pass-through: give them non-zero values to pin the two
// inputs that are otherwise random, and the output becomes reproducible and
// therefore diffable against Agora's own generator. Nothing in the running
// API calls this.
func MintForDiagnostics(
	configuration config.AgoraConfig,
	medium string,
	channelName string,
	uid uint32,
	peerUID uint32,
	ttlSeconds int,
	issueTs uint32,
	salt uint32,
) (*dto.AgoraCredentials, error) {

	if !configuration.Enabled() {
		return nil, errors.New("agora credentials are not configured")
	}

	if ttlSeconds <= 0 {
		ttlSeconds = 3600
	}

	expireSeconds := uint32(ttlSeconds)

	account := strconv.FormatUint(uint64(uid), 10)
	peerAccount := strconv.FormatUint(uint64(peerUID), 10)

	rtmToken, err := buildAgoraRTMToken(
		configuration.AppID,
		configuration.AppCertificate,
		account,
		expireSeconds,
		issueTs,
		salt,
	)

	if err != nil {
		return nil, err
	}

	expiresAt := time.Now().Add(time.Duration(ttlSeconds) * time.Second)

	credentials := &dto.AgoraCredentials{
		AppID:            configuration.AppID,
		ChannelName:      channelName,
		RTMAccount:       account,
		RTMToken:         rtmToken,
		PeerRTMAccount:   peerAccount,
		ExpiresAt:        expiresAt.Format("2006-01-02 15:04:05"),
		ExpiresAtUnix:    expiresAt.Unix(),
		ExpiresInSeconds: ttlSeconds,
	}

	if medium == constants.MediumChat {
		return credentials, nil
	}

	rtcToken, err := buildAgoraRTCToken(
		configuration.AppID,
		configuration.AppCertificate,
		channelName,
		account,
		rtcPrivilegesFor(medium),
		expireSeconds,
		issueTs,
		salt,
	)

	if err != nil {
		return nil, err
	}

	credentials.RTCToken = rtcToken
	credentials.RTCUID = uid
	credentials.PeerRTCUID = peerUID
	credentials.RTCRole = "PUBLISHER"

	return credentials, nil
}
