package services

// Agora AccessToken2, the "007" token format, implemented against Agora's
// published reference so this repository does not take a dependency on a
// nested, untagged Go module inside a tools monorepo.
//
// The same precedent is already set by notifications/firebase.go, which signs
// FCM HTTP v1 requests by hand rather than pulling the Firebase SDK.
//
// The format is frozen and small, but it is also unforgiving: every field is
// little-endian, every string is length-prefixed, and the signing key is
// derived in two HMAC rounds before the body is signed. Get any one of those
// wrong and the result is a structurally valid token that Agora rejects with
// no diagnostic. Verify with `go run ./cmd/agoratoken` against Agora's own
// generator before wiring any of this to an app.
//
// Wire format:
//
//	"007" + base64( zlib( packString(signature) + body ) )
//
//	body      = packString(appID) + packUint32(issueTs) + packUint32(expire)
//	            + packUint32(salt) + packUint16(serviceCount) + services...
//
//	signing   = HMAC-SHA256(key = packUint32(issueTs), msg = appCertificate)
//	signing   = HMAC-SHA256(key = packUint32(salt),    msg = signing)
//	signature = HMAC-SHA256(key = signing,             msg = body)

import (
	"bytes"
	"compress/zlib"
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/binary"
	"errors"
	"fmt"
	"math/big"
	"sort"
	"time"
)

const agoraTokenVersion = "007"

// Service types inside the token envelope. One envelope can carry several;
// this codebase mints one service per token so RTM and RTC credentials can be
// renewed and reasoned about independently.
const (
	agoraServiceTypeRTC = uint16(1)
	agoraServiceTypeRTM = uint16(2)
)

// RTC privileges. Withholding one is a real enforcement point: an AUDIO
// consultation is billed at audioCallRate, so its token deliberately does not
// carry publish-video. The Agora edge then rejects a video publish and a
// modified client cannot silently upgrade itself to the video rate.
const (
	agoraPrivJoinChannel        = uint16(1)
	agoraPrivPublishAudioStream = uint16(2)
	agoraPrivPublishVideoStream = uint16(3)
	agoraPrivPublishDataStream  = uint16(4)
)

// The RTM service has exactly one privilege.
const agoraPrivRTMLogin = uint16(1)

//////////////////////////////////////////////////////////////
// Pack helpers
//////////////////////////////////////////////////////////////

func agoraPackUint16(buf *bytes.Buffer, value uint16) {
	_ = binary.Write(buf, binary.LittleEndian, value)
}

func agoraPackUint32(buf *bytes.Buffer, value uint32) {
	_ = binary.Write(buf, binary.LittleEndian, value)
}

func agoraPackString(buf *bytes.Buffer, value string) {
	agoraPackUint16(buf, uint16(len(value)))
	buf.WriteString(value)
}

func agoraPackBytes(buf *bytes.Buffer, value []byte) {
	agoraPackUint16(buf, uint16(len(value)))
	buf.Write(value)
}

// agoraPackPrivileges writes the privilege map in ascending key order.
//
// Go randomises map iteration, and the signature covers these bytes, so an
// unsorted write would produce a different token on every call for the same
// inputs. They would all still verify — but a non-deterministic token is
// impossible to diff against a reference implementation, which is exactly
// what has to happen before this ships.
func agoraPackPrivileges(buf *bytes.Buffer, privileges map[uint16]uint32) {

	agoraPackUint16(buf, uint16(len(privileges)))

	keys := make([]uint16, 0, len(privileges))

	for key := range privileges {
		keys = append(keys, key)
	}

	sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })

	for _, key := range keys {
		agoraPackUint16(buf, key)
		agoraPackUint32(buf, privileges[key])
	}
}

//////////////////////////////////////////////////////////////
// Services
//////////////////////////////////////////////////////////////

type agoraService interface {
	pack(buf *bytes.Buffer)
}

type agoraServiceRTC struct {
	privileges  map[uint16]uint32
	channelName string
	uid         string
}

func (s agoraServiceRTC) pack(buf *bytes.Buffer) {
	agoraPackUint16(buf, agoraServiceTypeRTC)
	agoraPackPrivileges(buf, s.privileges)
	agoraPackString(buf, s.channelName)
	agoraPackString(buf, s.uid)
}

type agoraServiceRTM struct {
	privileges map[uint16]uint32
	userID     string
}

func (s agoraServiceRTM) pack(buf *bytes.Buffer) {
	agoraPackUint16(buf, agoraServiceTypeRTM)
	agoraPackPrivileges(buf, s.privileges)
	agoraPackString(buf, s.userID)
}

//////////////////////////////////////////////////////////////
// Build
//////////////////////////////////////////////////////////////

// buildAgoraToken assembles, signs and encodes one token.
//
// issueTs and salt are parameters rather than being generated inside, so the
// verification CLI can pin them and diff the output byte-for-byte against
// Agora's generator. Production callers pass 0 for both and get a real
// timestamp and a cryptographically random salt.
func buildAgoraToken(
	appID string,
	appCertificate string,
	expireSeconds uint32,
	issueTs uint32,
	salt uint32,
	services []agoraService,
) (string, error) {

	if !isAgoraCredential(appID) {
		return "", errors.New("AGORA_APP_ID must be 32 hexadecimal characters")
	}

	if !isAgoraCredential(appCertificate) {
		return "", errors.New("AGORA_APP_CERTIFICATE must be 32 hexadecimal characters")
	}

	if len(services) == 0 {
		return "", errors.New("agora token must carry at least one service")
	}

	if issueTs == 0 {
		issueTs = uint32(time.Now().Unix())
	}

	if salt == 0 {
		generated, err := agoraSalt()

		if err != nil {
			return "", err
		}

		salt = generated
	}

	//------------------------------------------------
	// Body
	//------------------------------------------------

	body := new(bytes.Buffer)

	agoraPackString(body, appID)
	agoraPackUint32(body, issueTs)
	agoraPackUint32(body, expireSeconds)
	agoraPackUint32(body, salt)
	agoraPackUint16(body, uint16(len(services)))

	for _, service := range services {
		service.pack(body)
	}

	//------------------------------------------------
	// Signature
	//------------------------------------------------

	signature := agoraSign(agoraSigningKey(appCertificate, issueTs, salt), body.Bytes())

	//------------------------------------------------
	// Envelope
	//------------------------------------------------

	content := new(bytes.Buffer)

	agoraPackBytes(content, signature)
	content.Write(body.Bytes())

	compressed, err := agoraCompress(content.Bytes())

	if err != nil {
		return "", err
	}

	return agoraTokenVersion + base64.StdEncoding.EncodeToString(compressed), nil
}

// agoraSigningKey derives the key the body is signed with: the certificate is
// folded through the issue timestamp and then the salt, so two tokens issued
// in the same second with different salts share no key material.
func agoraSigningKey(appCertificate string, issueTs uint32, salt uint32) []byte {

	issueBuf := new(bytes.Buffer)
	agoraPackUint32(issueBuf, issueTs)

	signing := agoraSign(issueBuf.Bytes(), []byte(appCertificate))

	saltBuf := new(bytes.Buffer)
	agoraPackUint32(saltBuf, salt)

	return agoraSign(saltBuf.Bytes(), signing)
}

func agoraSign(key []byte, message []byte) []byte {

	mac := hmac.New(sha256.New, key)
	mac.Write(message)

	return mac.Sum(nil)
}

func agoraCompress(payload []byte) ([]byte, error) {

	out := new(bytes.Buffer)

	writer := zlib.NewWriter(out)

	if _, err := writer.Write(payload); err != nil {
		writer.Close()
		return nil, err
	}

	if err := writer.Close(); err != nil {
		return nil, err
	}

	return out.Bytes(), nil
}

// agoraSalt uses crypto/rand rather than math/rand, which Agora's reference
// seeds from the clock. The salt is key material for the signature; two
// processes starting in the same second must not derive the same one.
func agoraSalt() (uint32, error) {

	limit := big.NewInt(int64(^uint32(0)))

	value, err := rand.Int(rand.Reader, limit)

	if err != nil {
		return 0, fmt.Errorf("agora: could not generate a token salt: %w", err)
	}

	return uint32(value.Uint64()) + 1, nil
}

// isAgoraCredential checks the 32-hex-character shape Agora uses for both the
// app id and the certificate.
//
// The reference implementation returns an empty token and a nil error when
// this fails, which surfaces as an unexplained login failure in the app days
// later. Failing loudly here instead is the single most valuable deviation in
// this file.
func isAgoraCredential(value string) bool {

	if len(value) != 32 {
		return false
	}

	for _, char := range value {

		switch {
		case char >= '0' && char <= '9':
		case char >= 'a' && char <= 'f':
		case char >= 'A' && char <= 'F':
		default:
			return false
		}
	}

	return true
}

//////////////////////////////////////////////////////////////
// The two builders the rest of the codebase calls
//////////////////////////////////////////////////////////////

// buildAgoraRTCToken mints a media-plane token bound to one channel and one
// uid. privileges decides what the holder may publish.
func buildAgoraRTCToken(
	appID string,
	appCertificate string,
	channelName string,
	uid string,
	privileges []uint16,
	expireSeconds uint32,
	issueTs uint32,
	salt uint32,
) (string, error) {

	granted := make(map[uint16]uint32, len(privileges))

	for _, privilege := range privileges {
		granted[privilege] = expireSeconds
	}

	service := agoraServiceRTC{
		privileges:  granted,
		channelName: channelName,
		uid:         uid,
	}

	return buildAgoraToken(appID, appCertificate, expireSeconds, issueTs, salt, []agoraService{service})
}

// buildAgoraRTMToken mints a control-plane token for one account. Every
// medium gets one of these: the accept handshake, the hangup signal and the
// "session is ending" warning all ride RTM, whether or not there is also an
// RTC channel carrying media.
func buildAgoraRTMToken(
	appID string,
	appCertificate string,
	account string,
	expireSeconds uint32,
	issueTs uint32,
	salt uint32,
) (string, error) {

	service := agoraServiceRTM{
		privileges: map[uint16]uint32{agoraPrivRTMLogin: expireSeconds},
		userID:     account,
	}

	return buildAgoraToken(appID, appCertificate, expireSeconds, issueTs, salt, []agoraService{service})
}
