package utils

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"os"
	"strings"
	"time"
)

// Session tokens for the POS terminal.
//
// A till is not a browser. It signs in once when a shop opens and then bills
// for a whole trading day — often on a connection that comes and goes — so the
// thing it carries has to survive a reboot, a lost network, and an hour in a
// drawer. That rules out a server-side session table (a till that cannot reach
// us must still be able to prove who it is when it comes back) and it rules out
// a short expiry.
//
// So: a signed, self-describing token. Everything needed to authorise a request
// is inside it, and the signature is what makes it trustworthy. No database
// round trip on the hot path, and nothing to replicate between pods.
//
// Deliberately not JWT. The backend has no JWT dependency today, and the format
// buys nothing here — there is exactly one issuer, one audience and one
// algorithm, so the header that JWT spends bytes negotiating is a constant. The
// `alg` field is also the source of JWT's worst-known footgun (`alg: none`),
// and a format with no algorithm field cannot have that bug.
//
// Wire format is `base64url(payload).base64url(hmac-sha256)`, and the MAC is
// taken over the encoded payload rather than the raw JSON so that verification
// never has to re-serialise anything to check it.

// PosClaims is what a terminal proves about itself on every request.
//
// Locationid is the load-bearing field. Before this existed the till named its
// own store on the wire and was believed, so changing one number in Settings
// moved a terminal into another tenant's books. Now the location is decided at
// sign-in, from the user's own record, and sealed under the signature.
type PosClaims struct {
	Userid     int    `json:"uid"`
	Tenantid   int    `json:"tid"`
	Locationid int    `json:"lid"`
	Roleid     int    `json:"rid"`
	Configid   int    `json:"cid"`
	Terminalid string `json:"trm,omitempty"`
	Issuedat   int64  `json:"iat"`
	Expiresat  int64  `json:"exp"`
}

// PosTokenTTL is how long a till stays signed in.
//
// Thirty days rather than hours. A shop signs the terminal in once and expects
// it to keep working; forcing a re-login mid-shift would mean a queue of
// customers waiting while somebody finds the manager's password. The exposure
// that buys is bounded by the token naming a single location — a leaked one
// bills into the shop it was already for.
const PosTokenTTL = 30 * 24 * time.Hour

// posTokenSecret is the signing key.
//
// Fails loudly rather than falling back to a baked-in default. A hardcoded
// development secret has a way of reaching production, and a signing key that
// everyone with the source can compute is the same as no signature at all —
// anyone could mint a token for any tenant.
func posTokenSecret() ([]byte, error) {
	secret := strings.TrimSpace(os.Getenv("POS_TOKEN_SECRET"))
	if secret == "" {
		// Falls back to the key the config file already carries, so a
		// deployment that set that one does not need a second variable.
		secret = strings.TrimSpace(os.Getenv("JWT_SECRET_KEY"))
	}
	if secret == "" {
		return nil, fmt.Errorf("POS_TOKEN_SECRET is not set; terminals cannot be issued sessions")
	}
	if len(secret) < 16 {
		return nil, fmt.Errorf("POS_TOKEN_SECRET is too short to sign with; use at least 16 characters")
	}
	return []byte(secret), nil
}

// MintPosToken issues a session for a signed-in terminal.
func MintPosToken(claims PosClaims, now time.Time) (string, time.Time, error) {
	secret, err := posTokenSecret()
	if err != nil {
		return "", time.Time{}, err
	}

	expires := now.Add(PosTokenTTL)
	claims.Issuedat = now.Unix()
	claims.Expiresat = expires.Unix()

	payload, err := json.Marshal(claims)
	if err != nil {
		return "", time.Time{}, err
	}

	encoded := base64.RawURLEncoding.EncodeToString(payload)
	return encoded + "." + sign(encoded, secret), expires, nil
}

// ParsePosToken verifies a token and returns what it claims.
//
// Order matters: the signature is checked before the payload is trusted for
// anything, including expiry. Reading `exp` out of an unverified payload and
// acting on it would be taking the attacker's word for when their own token
// runs out.
func ParsePosToken(token string, now time.Time) (PosClaims, error) {
	secret, err := posTokenSecret()
	if err != nil {
		return PosClaims{}, err
	}

	encoded, signature, found := strings.Cut(strings.TrimSpace(token), ".")
	if !found || encoded == "" || signature == "" {
		return PosClaims{}, fmt.Errorf("malformed session token")
	}

	// Constant time, so a caller cannot learn the right signature one byte at a
	// time from how long the comparison took.
	if !hmac.Equal([]byte(signature), []byte(sign(encoded, secret))) {
		return PosClaims{}, fmt.Errorf("session token signature does not verify")
	}

	payload, err := base64.RawURLEncoding.DecodeString(encoded)
	if err != nil {
		return PosClaims{}, fmt.Errorf("malformed session token")
	}

	var claims PosClaims
	if err := json.Unmarshal(payload, &claims); err != nil {
		return PosClaims{}, fmt.Errorf("malformed session token")
	}

	if claims.Expiresat > 0 && now.Unix() >= claims.Expiresat {
		return PosClaims{}, fmt.Errorf("session has expired; sign in again")
	}

	// A token that verifies but names no outlet would authorise nothing and
	// must not be mistaken for one that authorises everything.
	if claims.Locationid <= 0 || claims.Tenantid <= 0 {
		return PosClaims{}, fmt.Errorf("session token names no outlet")
	}

	return claims, nil
}

func sign(encoded string, secret []byte) string {
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(encoded))
	return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}

// PosTokenConfigured reports whether sessions can be issued at all.
//
// Lets the server say "this deployment has no signing key" once at start-up
// rather than answering every sign-in with a 500.
func PosTokenConfigured() bool {
	_, err := posTokenSecret()
	return err == nil
}
