// Copyright (c) 2024 Zeni Kim <zenik@smarteching.com>
// Use of this source code is governed by MIT-style
// license that can be found in the LICENSE file.

package core

import (
	"bytes"
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"encoding/base64"
	"encoding/gob"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
)

// ErrValueTooLong indicates that the cookie value exceeds the allowed length limit.
// ErrInvalidValue signifies that the cookie value is in an invalid format.
var (
	ErrValueTooLong = errors.New("cookie value too long")
	ErrInvalidValue = errors.New("invalid cookie value")
)

// UserCookie represents a structure to hold user-specific data stored in a cookie, including Email and Token fields.
type UserCookie struct {
	Email string
	Token string
}

// secretcookie is a global variable used to store the decoded secret key for encrypting and decrypting cookies.
var secretcookie []byte

// GetCookie retrieves and decrypts the user cookie from the provided HTTP request and returns it as a UserCookie.
// Returns an error if the cookie retrieval, decryption, or decoding process fails.
func GetCookie(r *http.Request) (UserCookie, error) {

	var err error
	// Create a new instance of a User type.
	var user UserCookie

	// check if template engine is enable
	TemplateEnableStr := os.Getenv("TEMPLATE_ENABLE")
	if TemplateEnableStr == "" {
		TemplateEnableStr = "false"
	}
	TemplateEnable, _ := strconv.ParseBool(TemplateEnableStr)
	// if enabled,
	if TemplateEnable {
		cookie_secret := os.Getenv("COOKIE_SECRET")
		if cookie_secret == "" {
			panic("cookie secret key is not set")
		}
		secretcookie, err = hex.DecodeString(cookie_secret)
		if err != nil {
			return user, err
		}
	} else {
		panic("Templates are disabled")
	}

	gobEncodedValue, err := CookieReadEncrypted(r, "goffee", secretcookie)
	if err != nil {
		return user, err
	}

	// Create an strings.Reader containing the gob-encoded value.
	reader := strings.NewReader(gobEncodedValue)

	// Decode it into the User type. Notice that we need to pass a *pointer* to
	// the Decode() target here?
	if err := gob.NewDecoder(reader).Decode(&user); err != nil {
		return user, err
	}

	return user, nil

}

// SetCookie sets an encrypted cookie with a user's email and token, using gob encoding for data serialization.
func SetCookie(w http.ResponseWriter, email string, token string) error {
	// Initialize a User struct containing the data that we want to store in the
	// cookie.
	var err error

	// check if template engine is enable
	TemplateEnableStr := os.Getenv("TEMPLATE_ENABLE")
	if TemplateEnableStr == "" {
		TemplateEnableStr = "false"
	}
	TemplateEnable, _ := strconv.ParseBool(TemplateEnableStr)
	// if enabled,
	if TemplateEnable {
		cookie_secret := os.Getenv("COOKIE_SECRET")
		if cookie_secret == "" {
			panic("cookie secret key is not set")
		}
		secretcookie, err = hex.DecodeString(cookie_secret)
		if err != nil {
			return err
		}
	} else {
		panic("Templates are disabled")
	}

	if err != nil {
		return err
	}

	user := UserCookie{Email: email, Token: token}

	// Initialize a buffer to hold the gob-encoded data.
	var buf bytes.Buffer

	// Gob-encode the user data, storing the encoded output in the buffer.
	err = gob.NewEncoder(&buf).Encode(&user)
	if err != nil {
		return err
	}

	// Call buf.String() to get the gob-encoded value as a string and set it as
	// the cookie value.
	cookie := http.Cookie{
		Name:     "goffee",
		Value:    buf.String(),
		Path:     "/",
		MaxAge:   3600,
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
	}

	// Write an encrypted cookie containing the gob-encoded data as normal.
	err = CookieWriteEncrypted(w, cookie, secretcookie)
	if err != nil {
		return err
	}

	return nil
}

// CookieWrite writes a secure HTTP cookie to the response writer after base64 encoding its value.
// Returns ErrValueTooLong if the cookie string exceeds the 4096-byte size limit.
func CookieWrite(w http.ResponseWriter, cookie http.Cookie) error {
	// Encode the cookie value using base64.
	cookie.Value = base64.URLEncoding.EncodeToString([]byte(cookie.Value))

	// Check the total length of the cookie contents. Return the ErrValueTooLong
	// error if it's more than 4096 bytes.
	if len(cookie.String()) > 4096 {
		return ErrValueTooLong
	}

	// Write the cookie as normal.
	http.SetCookie(w, &cookie)

	return nil
}

// CookieRead retrieves a base64-encoded cookie value by name from the HTTP request and decodes it.
// Returns the decoded value as a string or an error if the cookie is not found or the value is invalid.
func CookieRead(r *http.Request, name string) (string, error) {
	// Read the cookie as normal.
	cookie, err := r.Cookie(name)
	if err != nil {
		return "", err
	}

	// Decode the base64-encoded cookie value. If the cookie didn't contain a
	// valid base64-encoded value, this operation will fail and we return an
	// ErrInvalidValue error.
	value, err := base64.URLEncoding.DecodeString(cookie.Value)
	if err != nil {
		return "", ErrInvalidValue
	}

	// Return the decoded cookie value.
	return string(value), nil
}

// CookieWriteEncrypted encrypts the cookie's value using AES-GCM and writes it to the HTTP response writer.
// The cookie name is authenticated along with its value.
// It returns an error if encryption fails or the cookie exceeds the maximum allowed length.
func CookieWriteEncrypted(w http.ResponseWriter, cookie http.Cookie, secretKey []byte) error {
	// Create a new AES cipher block from the secret key.
	block, err := aes.NewCipher(secretKey)
	if err != nil {
		return err
	}

	// Wrap the cipher block in Galois Counter Mode.
	aesGCM, err := cipher.NewGCM(block)
	if err != nil {
		return err
	}

	// Create a unique nonce containing 12 random bytes.
	nonce := make([]byte, aesGCM.NonceSize())
	_, err = io.ReadFull(rand.Reader, nonce)
	if err != nil {
		return err
	}

	// Prepare the plaintext input for encryption. Because we want to
	// authenticate the cookie name as well as the value, we make this plaintext
	// in the format "{cookie name}:{cookie value}". We use the : character as a
	// separator because it is an invalid character for cookie names and
	// therefore shouldn't appear in them.
	plaintext := fmt.Sprintf("%s:%s", cookie.Name, cookie.Value)

	// Encrypt the data using aesGCM.Seal(). By passing the nonce as the first
	// parameter, the encrypted data will be appended to the nonce — meaning
	// that the returned encryptedValue variable will be in the format
	// "{nonce}{encrypted plaintext data}".
	encryptedValue := aesGCM.Seal(nonce, nonce, []byte(plaintext), nil)

	// Set the cookie value to the encryptedValue.
	cookie.Value = string(encryptedValue)

	// Write the cookie as normal.
	return CookieWrite(w, cookie)
}

// CookieReadEncrypted reads an encrypted cookie, decrypts its value using AES-GCM, and validates its name before returning.
// Returns the plaintext cookie value or an error if decryption or validation fails.
func CookieReadEncrypted(r *http.Request, name string, secretKey []byte) (string, error) {
	// Read the encrypted value from the cookie as normal.
	encryptedValue, err := CookieRead(r, name)
	if err != nil {
		return "", err
	}

	// Create a new AES cipher block from the secret key.
	block, err := aes.NewCipher(secretKey)
	if err != nil {
		return "", err
	}

	// Wrap the cipher block in Galois Counter Mode.
	aesGCM, err := cipher.NewGCM(block)
	if err != nil {
		return "", err
	}

	// Get the nonce size.
	nonceSize := aesGCM.NonceSize()

	// To avoid a potential 'index out of range' panic in the next step, we
	// check that the length of the encrypted value is at least the nonce
	// size.
	if len(encryptedValue) < nonceSize {
		return "", ErrInvalidValue
	}

	// Split apart the nonce from the actual encrypted data.
	nonce := encryptedValue[:nonceSize]
	ciphertext := encryptedValue[nonceSize:]

	// Use aesGCM.Open() to decrypt and authenticate the data. If this fails,
	// return a ErrInvalidValue error.
	plaintext, err := aesGCM.Open(nil, []byte(nonce), []byte(ciphertext), nil)
	if err != nil {
		return "", ErrInvalidValue
	}

	// The plaintext value is in the format "{cookie name}:{cookie value}". We
	// use strings.Cut() to split it on the first ":" character.
	expectedName, value, ok := strings.Cut(string(plaintext), ":")
	if !ok {
		return "", ErrInvalidValue
	}

	// Check that the cookie name is the expected one and hasn't been changed.
	if expectedName != name {
		return "", ErrInvalidValue
	}

	// Return the plaintext cookie value.
	return value, nil
}