package oidc

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

	"gitea.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
}

// sessionData is the server-side session record.
type sessionData struct {
	user   web.User
	expiry time.Time
}

// sessionStore is an in-memory session table. Single replica at Stage 0, so an
// in-process map is sufficient; it is safe for concurrent use.
type sessionStore struct {
	mu sync.Mutex
	m  map[string]sessionData
}

func newSessionStore() *sessionStore { return &sessionStore{m: make(map[string]sessionData)} }

func (s *sessionStore) put(id string, d sessionData) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.m[id] = d
}

// get returns the session if present and unexpired; expired entries are evicted.
func (s *sessionStore) get(id string, now time.Time) (sessionData, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	d, ok := s.m[id]
	if !ok {
		return sessionData{}, false
	}
	if !now.Before(d.expiry) {
		delete(s.m, id)
		return sessionData{}, false
	}
	return d, true
}

// refresh slides an existing session's expiry forward; a no-op for unknown ids.
func (s *sessionStore) refresh(id string, expiry time.Time) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if d, ok := s.m[id]; ok {
		d.expiry = expiry
		s.m[id] = d
	}
}

func (s *sessionStore) delete(id string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	delete(s.m, id)
}

// 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
}
