// Package auth implements Phase 2 — WebAuthn passkey authentication, session
// cookies, recovery codes, rate limiting, and the auth-enforcement middleware.
package auth

import (
	"context"
	"crypto/rand"
	"database/sql"
	"encoding/base64"
	"errors"
	"net/http"
	"time"
)

// ErrSession is returned by Validate when the session token is missing,
// malformed, expired, or unknown. All four cases map to a 401 from the
// caller's perspective; the middleware does not distinguish further.
var ErrSession = errors.New("auth: invalid session")

// Session is the row shape persisted in the SQLite `sessions` table.
type Session struct {
	Token      string
	CreatedAt  time.Time
	ExpiresAt  time.Time
	LastSeenAt time.Time
	UserAgent  string
	IPAddress  string
}

// SessionStore wraps the sessions table. All methods are safe for concurrent
// use through the underlying *sql.DB.
type SessionStore struct {
	db          *sql.DB
	cookieName  string
	lifetime    time.Duration
	rngOverride func([]byte) (int, error) // for tests
}

// NewSessionStore constructs the store. cookieName defaults to "cc_session"
// when empty; lifetime defaults to 30 days when zero.
func NewSessionStore(db *sql.DB, cookieName string, lifetime time.Duration) *SessionStore {
	if cookieName == "" {
		cookieName = "cc_session"
	}
	if lifetime <= 0 {
		lifetime = 30 * 24 * time.Hour
	}
	return &SessionStore{db: db, cookieName: cookieName, lifetime: lifetime}
}

// CookieName returns the configured session cookie name. Useful for tests
// and for the logout handler that needs to write an expired cookie with the
// matching name.
func (s *SessionStore) CookieName() string { return s.cookieName }

// Lifetime returns the configured session lifetime.
func (s *SessionStore) Lifetime() time.Duration { return s.lifetime }

// Issue creates a new session and returns it. The caller writes the token to
// the response cookie via SetCookie.
func (s *SessionStore) Issue(ctx context.Context, userAgent, ipAddress string) (*Session, error) {
	token, err := s.randomToken(32)
	if err != nil {
		return nil, err
	}
	now := time.Now()
	expires := now.Add(s.lifetime)
	if _, err := s.db.ExecContext(ctx,
		`INSERT INTO sessions(token, created_at, expires_at, last_seen_at, user_agent, ip_address)
		 VALUES (?, ?, ?, ?, ?, ?)`,
		token, now.Unix(), expires.Unix(), now.Unix(),
		nullableText(userAgent), nullableText(ipAddress),
	); err != nil {
		return nil, err
	}
	return &Session{
		Token:      token,
		CreatedAt:  now,
		ExpiresAt:  expires,
		LastSeenAt: now,
		UserAgent:  userAgent,
		IPAddress:  ipAddress,
	}, nil
}

// Validate looks up the cookie token, returns the session if valid, and
// touches last_seen_at to renew. An expired session is deleted as a side
// effect of validation so the table doesn't accumulate dead rows.
func (s *SessionStore) Validate(ctx context.Context, r *http.Request) (*Session, error) {
	c, err := r.Cookie(s.cookieName)
	if err != nil || c.Value == "" {
		return nil, ErrSession
	}
	if !looksLikeToken(c.Value) {
		return nil, ErrSession
	}

	row := s.db.QueryRowContext(ctx,
		`SELECT token, created_at, expires_at, last_seen_at, user_agent, ip_address
		 FROM sessions WHERE token = ?`, c.Value)

	var (
		sess                          Session
		created, expires, lastSeen    int64
		ua, ip                        sql.NullString
	)
	if err := row.Scan(&sess.Token, &created, &expires, &lastSeen, &ua, &ip); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrSession
		}
		return nil, err
	}
	sess.CreatedAt = time.Unix(created, 0)
	sess.ExpiresAt = time.Unix(expires, 0)
	sess.LastSeenAt = time.Unix(lastSeen, 0)
	sess.UserAgent = ua.String
	sess.IPAddress = ip.String

	now := time.Now()
	if !sess.ExpiresAt.After(now) {
		_, _ = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = ?`, sess.Token)
		return nil, ErrSession
	}
	// Renew last_seen_at AND slide expires forward by full lifetime on activity.
	newExpires := now.Add(s.lifetime)
	_, _ = s.db.ExecContext(ctx,
		`UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE token = ?`,
		now.Unix(), newExpires.Unix(), sess.Token)
	sess.LastSeenAt = now
	sess.ExpiresAt = newExpires
	return &sess, nil
}

// Revoke deletes the session row. Idempotent.
func (s *SessionStore) Revoke(ctx context.Context, token string) error {
	_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = ?`, token)
	return err
}

// PurgeExpired runs a cheap cleanup pass. Operators don't need to call this;
// the middleware deletes expired tokens on access. This is for a periodic
// sweep against the long tail of "issued but never visited again".
func (s *SessionStore) PurgeExpired(ctx context.Context) (int64, error) {
	res, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`, time.Now().Unix())
	if err != nil {
		return 0, err
	}
	return res.RowsAffected()
}

// SetCookie writes the session token to w as an HTTP-only, Secure,
// SameSite=Strict cookie at "/".
//
// Secure is always set, including in dev_mode: modern browsers treat
// localhost / 127.0.0.1 as a secure context and accept Secure cookies over
// plain HTTP from those origins, so dev still works.
func (s *SessionStore) SetCookie(w http.ResponseWriter, sess *Session) {
	http.SetCookie(w, &http.Cookie{
		Name:     s.cookieName,
		Value:    sess.Token,
		Path:     "/",
		Expires:  sess.ExpiresAt,
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteStrictMode,
	})
}

// ClearCookie writes an immediately-expired cookie with the matching name
// so the browser drops its copy. The session row should also be Revoke()d.
func (s *SessionStore) ClearCookie(w http.ResponseWriter) {
	http.SetCookie(w, &http.Cookie{
		Name:     s.cookieName,
		Value:    "",
		Path:     "/",
		Expires:  time.Unix(0, 0),
		MaxAge:   -1,
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteStrictMode,
	})
}

func (s *SessionStore) randomToken(n int) (string, error) {
	buf := make([]byte, n)
	if s.rngOverride != nil {
		if _, err := s.rngOverride(buf); err != nil {
			return "", err
		}
	} else {
		if _, err := rand.Read(buf); err != nil {
			return "", err
		}
	}
	return base64.RawURLEncoding.EncodeToString(buf), nil
}

func looksLikeToken(s string) bool {
	// Base64-url of 32 bytes is 43 chars. Be tolerant ±a couple chars.
	if len(s) < 40 || len(s) > 96 {
		return false
	}
	for _, r := range s {
		switch {
		case r >= 'A' && r <= 'Z',
			r >= 'a' && r <= 'z',
			r >= '0' && r <= '9',
			r == '-', r == '_':
		default:
			return false
		}
	}
	return true
}

func nullableText(s string) any {
	if s == "" {
		return nil
	}
	return s
}
