package oidc

import (
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"strings"
	"sync"
	"time"

	"git.d-ma.be/mathias/tapir/internal/web"
)

// randToken returns a URL-safe 256-bit random string for session IDs, OIDC
// state, and nonces.
func randToken() (string, error) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return "", fmt.Errorf("oidc: read random: %w", err)
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

// sign appends an HMAC-SHA256 (HS256) tag so a tampered cookie value is
// rejected. The opaque session ID — not any token — is what travels in the
// cookie.
func (d *DexAuth) sign(value string) string {
	mac := hmac.New(sha256.New, d.secret)
	mac.Write([]byte(value))
	return value + "." + hex.EncodeToString(mac.Sum(nil))
}

// unsign verifies the HMAC tag and returns the value. It uses a constant-time
// comparison so a bad tag cannot be probed byte by byte.
func (d *DexAuth) unsign(signed string) (string, bool) {
	i := strings.LastIndex(signed, ".")
	if i < 0 {
		return "", false
	}
	value, tag := signed[:i], signed[i+1:]
	want, err := hex.DecodeString(tag)
	if err != nil {
		return "", false
	}
	mac := hmac.New(sha256.New, d.secret)
	mac.Write([]byte(value))
	if !hmac.Equal(want, mac.Sum(nil)) {
		return "", false
	}
	return value, true
}

// sessionClaims is the self-contained session payload carried INSIDE the signed
// cookie — there is no server-side session table. This is deliberate (ADR-029):
// an in-memory store was wiped on every pod restart, logging every user out on
// each deploy, and a stateless cookie also survives browser-close and works
// across replicas. It holds only the identity (subject + email, not secret) and
// an absolute expiry; the HMAC tag (sign/unsign) makes it tamper-proof.
type sessionClaims struct {
	Sub   string `json:"s"`
	Email string `json:"e"`
	Exp   int64  `json:"x"` // unix seconds; absolute expiry
}

// encodeSession produces the signed cookie value for a user with the given expiry.
func (d *DexAuth) encodeSession(u web.User, exp time.Time) string {
	b, _ := json.Marshal(sessionClaims{Sub: u.Subject, Email: u.Email, Exp: exp.Unix()})
	return d.sign(base64.RawURLEncoding.EncodeToString(b))
}

// decodeSession verifies the cookie's HMAC, parses the claims, and checks expiry.
// It returns the user and the absolute expiry on success.
func (d *DexAuth) decodeSession(cookieValue string, now time.Time) (web.User, time.Time, bool) {
	payload, ok := d.unsign(cookieValue)
	if !ok {
		return web.User{}, time.Time{}, false
	}
	raw, err := base64.RawURLEncoding.DecodeString(payload)
	if err != nil {
		return web.User{}, time.Time{}, false
	}
	var c sessionClaims
	if err := json.Unmarshal(raw, &c); err != nil {
		return web.User{}, time.Time{}, false
	}
	exp := time.Unix(c.Exp, 0)
	if !now.Before(exp) {
		return web.User{}, time.Time{}, false // expired
	}
	return web.User{Subject: c.Sub, Email: c.Email}, exp, true
}

// pendingData holds the nonce bound to an in-flight authorization request.
type pendingData struct {
	nonce  string
	expiry time.Time
}

// pendingStore maps OIDC state to its nonce between /auth/login and the
// callback. Entries are one-time (take deletes) and short-lived, defeating
// replay and CSRF on the callback.
type pendingStore struct {
	mu sync.Mutex
	m  map[string]pendingData
}

func newPendingStore() *pendingStore { return &pendingStore{m: make(map[string]pendingData)} }

func (p *pendingStore) put(state, nonce string, expiry time.Time) {
	p.mu.Lock()
	defer p.mu.Unlock()
	p.m[state] = pendingData{nonce: nonce, expiry: expiry}
}

// take consumes the nonce for state, returning false if absent or expired.
func (p *pendingStore) take(state string, now time.Time) (string, bool) {
	p.mu.Lock()
	defer p.mu.Unlock()
	d, ok := p.m[state]
	if !ok {
		return "", false
	}
	delete(p.m, state)
	if !now.Before(d.expiry) {
		return "", false
	}
	return d.nonce, true
}
