// Package authd implements the in-house multi-tenant auth IdP that
// backs the M13 admin UI. It issues short-lived access JWTs (HS256,
// 15m by default) and long-lived refresh tokens (opaque random
// strings, 7d, stored server-side with rotation + family re-use
// detection).
//
// The refresh-token side of things is mostly SQL functions
// (migrations/009_auth.up.sql). This package owns:
//
//   - JWT signing and verification (HS256, shared secret v1).
//   - Magic-link token generation, hashing, and consumption.
//   - Password hashing (bcrypt).
//   - Server-side session validation for incoming requests
//     (used by other services via the VerifyAccessToken call).
//   - Audit log writes for auth.* events.
//
// Out of scope for v1 (deferred to v2): JWKS, RS256, SSO mapping,
// MFA, OAuth2 flows. The HS256 shared secret is the only auth
// material — it MUST be rotated before any multi-instance deploy.
//
// Threading: Authd is safe for concurrent use. The store is
// stateless; all state lives in Postgres.
package authd

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/hex"
	"errors"
	"fmt"
	"time"

	"github.com/golang-jwt/jwt/v5"
	"golang.org/x/crypto/bcrypt"

	"git3.techno-world.net/lrosales/broad-announce/internal/postgres"
)

// Config is the authd runtime config. Loaded from env in main.
type Config struct {
	// JWTSecret is the HS256 signing key. Must be at least 32 bytes.
	// Generated by scripts/gen-jwt-secret.sh on first install.
	JWTSecret []byte

	// Issuer is the `iss` claim. Should match across all services
	// that need to verify tokens.
	Issuer string

	// AccessTokenTTL is how long access JWTs are valid. 15m default.
	AccessTokenTTL time.Duration

	// RefreshTokenTTL is how long refresh tokens are valid. 7d default.
	RefreshTokenTTL time.Duration

	// MagicLinkTTL is how long a magic link is valid. 24h default.
	MagicLinkTTL time.Duration

	// BcryptCost is the bcrypt work factor. 12 default.
	BcryptCost int
}

// DefaultConfig returns Config with safe defaults. JWTSecret is
// zero — main() must load it from env.
func DefaultConfig() Config {
	return Config{
		Issuer:          "broad-announce",
		AccessTokenTTL:  15 * time.Minute,
		RefreshTokenTTL: 7 * 24 * time.Hour,
		MagicLinkTTL:    24 * time.Hour,
		BcryptCost:      12,
	}
}

// Store returns the underlying Store. Used by HTTP handlers that
// need direct access to user/tenant lookups not on the high-level
// API.
func (a *Authd) Store() *Store { return a.store }

// Authd is the service object. Construct once at startup, pass to
// the HTTP handlers.
type Authd struct {
	cfg   Config
	store *Store
}

// New constructs an Authd. pool is the pgx pool; cfg must have a
// non-zero JWTSecret (caller validates). pool may be nil for
// tests that only exercise the pure-Go paths (bcrypt, JWT); any
// call that hits the DB will return an error in that mode.
func New(pool *postgres.Pool, cfg Config) (*Authd, error) {
	if len(cfg.JWTSecret) < 32 {
		return nil, fmt.Errorf("authd: JWTSecret must be at least 32 bytes (got %d)", len(cfg.JWTSecret))
	}
	if cfg.AccessTokenTTL == 0 {
		cfg.AccessTokenTTL = 15 * time.Minute
	}
	if cfg.RefreshTokenTTL == 0 {
		cfg.RefreshTokenTTL = 7 * 24 * time.Hour
	}
	if cfg.MagicLinkTTL == 0 {
		cfg.MagicLinkTTL = 24 * time.Hour
	}
	if cfg.BcryptCost == 0 {
		cfg.BcryptCost = 12
	}
	return &Authd{cfg: cfg, store: NewStore(pool)}, nil
}

// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------

// ErrInvalidCredentials is returned when login fails. The HTTP
// handler maps this to 401 with a generic message — we never
// disclose whether the email or the password was wrong.
var ErrInvalidCredentials = errors.New("authd: invalid credentials")

// ErrUserNotFound is the underlying cause. Handlers should NOT
// surface this to clients.
var ErrUserNotFound = errors.New("authd: user not found")

// ErrUserDisabled is returned when the user exists but is in
// 'pending' (no password set yet, must use magic link) or 'disabled'
// (admin-blocked).
var ErrUserDisabled = errors.New("authd: user not active")

// ErrMagicLinkInvalid is returned for unknown / expired / consumed
// magic links.
var ErrMagicLinkInvalid = errors.New("authd: magic link invalid or expired")

// ErrTokenReuse is returned when a refresh token is used after it's
// been rotated. The whole family has been killed.
var ErrTokenReuse = errors.New("authd: refresh token re-use detected, session killed")

// ---------------------------------------------------------------------------
// Passwords
// ---------------------------------------------------------------------------

// HashPassword returns a bcrypt hash of the plaintext password. Cost
// is taken from cfg.BcryptCost.
func (a *Authd) HashPassword(ctx context.Context, plaintext string) (string, error) {
	hash, err := bcrypt.GenerateFromPassword([]byte(plaintext), a.cfg.BcryptCost)
	if err != nil {
		return "", fmt.Errorf("bcrypt: %w", err)
	}
	return string(hash), nil
}

// VerifyPassword reports whether plaintext matches the stored hash.
// Returns nil on match, bcrypt.ErrMismatchedHashAndPassword on
// mismatch, or another error if the hash is malformed.
func (a *Authd) VerifyPassword(hash, plaintext string) error {
	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext))
}

// ---------------------------------------------------------------------------
// Magic links
// ---------------------------------------------------------------------------

// IssueMagicLink generates a magic link token for a user, hashes it,
// stores the hash, and returns the PLAINTEXT token (the caller
// emails this — it is never stored).
//
// Returns the token (hex, 64 chars), its expiry, and any error.
func (a *Authd) IssueMagicLink(ctx context.Context, userID, purpose string) (token string, expiresAt time.Time, err error) {
	if purpose != "invite" && purpose != "password_reset" && purpose != "mfa_reset" {
		return "", time.Time{}, fmt.Errorf("authd: invalid magic link purpose %q", purpose)
	}
	// 32 random bytes → 64 hex chars
	raw := make([]byte, 32)
	if _, err := rand.Read(raw); err != nil {
		return "", time.Time{}, fmt.Errorf("rand: %w", err)
	}
	token = hex.EncodeToString(raw)

	hash := sha256.Sum256(raw) // hash the RAW bytes, not the hex string
	expiresAt = time.Now().Add(a.cfg.MagicLinkTTL)

	if err := a.store.InsertMagicLink(ctx, userID, hash[:], purpose, expiresAt); err != nil {
		return "", time.Time{}, err
	}
	return token, expiresAt, nil
}

// ConsumeMagicLink validates a magic link token and returns the
// associated user_id. Marks the link as consumed. Caller should
// treat ErrMagicLinkInvalid as 401.
func (a *Authd) ConsumeMagicLink(ctx context.Context, token, ip, ua string) (string, error) {
	raw, err := hex.DecodeString(token)
	if err != nil || len(raw) != 32 {
		return "", ErrMagicLinkInvalid
	}
	hash := sha256.Sum256(raw)
	userID, err := a.store.ConsumeMagicLink(ctx, hash[:], ip, ua)
	if err != nil {
		return "", err
	}
	return userID, nil
}

// ---------------------------------------------------------------------------
// Sessions (refresh tokens, SQL-side)
// ---------------------------------------------------------------------------

// LoginResult is what Login + MagicLinkConsume return.
type LoginResult struct {
	AccessToken  string
	RefreshToken string
	UserID       string
	TenantID     string
	Role         string
	ExpiresAt    time.Time
}

// Login authenticates with email+password, returns a fresh session.
// On failure, returns ErrInvalidCredentials (or ErrUserDisabled)
// and writes an audit_log row regardless of outcome.
func (a *Authd) Login(ctx context.Context, email, password, ip, ua string) (*LoginResult, error) {
	user, err := a.store.GetUserByEmail(ctx, email)
	if err != nil {
		if errors.Is(err, ErrUserNotFound) {
			// Audit the failed attempt. Don't disclose existence.
			_ = a.store.WriteAudit(ctx, "auth.login", "", ip, ua, "", "",
				map[string]any{"email": email, "success": false, "reason": "not_found"})
			return nil, ErrInvalidCredentials
		}
		return nil, err
	}
	if user.Status != "active" {
		_ = a.store.WriteAudit(ctx, "auth.login", user.ID, ip, ua, "", user.TenantID,
			map[string]any{"email": email, "success": false, "reason": "not_active"})
		return nil, ErrUserDisabled
	}
	if err := a.VerifyPassword(user.PasswordHash, password); err != nil {
		_ = a.store.WriteAudit(ctx, "auth.login", user.ID, ip, ua, "", user.TenantID,
			map[string]any{"email": email, "success": false, "reason": "bad_password"})
		return nil, ErrInvalidCredentials
	}
	res, err := a.issueSession(ctx, user, ip, ua)
	if err != nil {
		return nil, err
	}
	_ = a.store.WriteAudit(ctx, "auth.login", user.ID, ip, ua, "", user.TenantID,
		map[string]any{"email": email, "success": true})
	_ = a.store.TouchUserLogin(ctx, user.ID)
	return res, nil
}

// issueSession is the shared path: mint JWT + ask Postgres for a
// refresh token (via the SQL function).
func (a *Authd) issueSession(ctx context.Context, user *User, ip, ua string) (*LoginResult, error) {
	accessJWT, jti, expiresAt, err := a.mintAccessToken(user)
	if err != nil {
		return nil, err
	}
	refreshToken, err := a.store.IssueRefreshToken(ctx, user.ID, jti, int(a.cfg.RefreshTokenTTL.Seconds()), ip, ua)
	if err != nil {
		return nil, err
	}
	return &LoginResult{
		AccessToken:  accessJWT,
		RefreshToken: refreshToken,
		UserID:       user.ID,
		TenantID:     user.TenantID,
		Role:         user.Role,
		ExpiresAt:    expiresAt,
	}, nil
}

// Refresh swaps a refresh token for a new access+refresh pair. The
// old refresh is revoked; if the old refresh was already consumed,
// the WHOLE family is killed (returns ErrTokenReuse).
func (a *Authd) Refresh(ctx context.Context, presentedToken, ip, ua string) (*LoginResult, error) {
	user, newRefresh, newJTI, err := a.store.RotateRefreshToken(ctx, presentedToken, int(a.cfg.RefreshTokenTTL.Seconds()), ip, ua)
	if err != nil {
		return nil, err
	}
	accessJWT, expiresAt, err := a.mintAccessTokenWithJTI(user, newJTI)
	if err != nil {
		return nil, err
	}
	_ = a.store.WriteAudit(ctx, "auth.refresh", user.ID, ip, ua, "", user.TenantID,
		map[string]any{"success": true})
	return &LoginResult{
		AccessToken:  accessJWT,
		RefreshToken: newRefresh,
		UserID:       user.ID,
		TenantID:     user.TenantID,
		Role:         user.Role,
		ExpiresAt:    expiresAt,
	}, nil
}

// Logout revokes the refresh token. Idempotent — a missing token
// returns nil (no error) so logout can't be used to probe token
// validity.
func (a *Authd) Logout(ctx context.Context, refreshToken, userID, ip, ua string) error {
	if err := a.store.RevokeRefreshToken(ctx, refreshToken); err != nil {
		return err
	}
	_ = a.store.WriteAudit(ctx, "auth.logout", userID, ip, ua, "", "", nil)
	return nil
}

// ---------------------------------------------------------------------------
// JWT
// ---------------------------------------------------------------------------

// AccessClaims is what we sign into the access JWT. It carries the
// minimum to authorize a request, NOT a session token.
type AccessClaims struct {
	UserID    string `json:"sub"`
	TenantID  string `json:"tid,omitempty"`
	Role      string `json:"role"`
	TokenType string `json:"typ"` // always "access"
	jwt.RegisteredClaims
}

// mintAccessToken signs an access JWT for the given user. The JTI
// is generated internally.
func (a *Authd) mintAccessToken(user *User) (token string, jti string, expiresAt time.Time, err error) {
	now := time.Now()
	expiresAt = now.Add(a.cfg.AccessTokenTTL)
	jti = newJTI()
	claims := AccessClaims{
		UserID:    user.ID,
		TenantID:  user.TenantID,
		Role:      user.Role,
		TokenType: "access",
		RegisteredClaims: jwt.RegisteredClaims{
			Issuer:    a.cfg.Issuer,
			Subject:   user.ID,
			ExpiresAt: jwt.NewNumericDate(expiresAt),
			IssuedAt:  jwt.NewNumericDate(now),
			NotBefore: jwt.NewNumericDate(now),
			ID:        jti,
		},
	}
	t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	signed, err := t.SignedString(a.cfg.JWTSecret)
	if err != nil {
		return "", "", time.Time{}, fmt.Errorf("sign jwt: %w", err)
	}
	return signed, jti, expiresAt, nil
}

// mintAccessTokenWithJTI signs an access JWT using the caller-supplied
// JTI. Used by Refresh so the new refresh's access_jti matches the
// row we just inserted.
func (a *Authd) mintAccessTokenWithJTI(user *User, jti string) (string, time.Time, error) {
	now := time.Now()
	expiresAt := now.Add(a.cfg.AccessTokenTTL)
	claims := AccessClaims{
		UserID:    user.ID,
		TenantID:  user.TenantID,
		Role:      user.Role,
		TokenType: "access",
		RegisteredClaims: jwt.RegisteredClaims{
			Issuer:    a.cfg.Issuer,
			Subject:   user.ID,
			ExpiresAt: jwt.NewNumericDate(expiresAt),
			IssuedAt:  jwt.NewNumericDate(now),
			NotBefore: jwt.NewNumericDate(now),
			ID:        jti,
		},
	}
	t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	signed, err := t.SignedString(a.cfg.JWTSecret)
	if err != nil {
		return "", time.Time{}, fmt.Errorf("sign jwt: %w", err)
	}
	return signed, expiresAt, nil
}

// VerifyAccessToken parses and validates an access JWT. Returns the
// claims on success. Used by other services that want to authorize
// a request without going through authd.
//
// The signing method is enforced to be HMAC (not 'none', not RS256
// with a confused-deputy attack). The expiry is checked.
func (a *Authd) VerifyAccessToken(raw string) (*AccessClaims, error) {
	claims := &AccessClaims{}
	tok, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) {
		// Reject anything that isn't HMAC. See
		// https://github.com/golang-jwt/jwt#security-considerations
		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
		}
		return a.cfg.JWTSecret, nil
	})
	if err != nil {
		return nil, fmt.Errorf("verify jwt: %w", err)
	}
	if !tok.Valid {
		return nil, errors.New("verify jwt: token invalid")
	}
	if claims.TokenType != "access" {
		return nil, fmt.Errorf("verify jwt: wrong token type %q", claims.TokenType)
	}
	return claims, nil
}

// ---------------------------------------------------------------------------
// Invites
// ---------------------------------------------------------------------------

// InviteUser creates a pending user in a tenant and issues a magic
// link token for the invitation. The token is returned to the
// caller (the HTTP handler) which emails it.
func (a *Authd) InviteUser(ctx context.Context, tenantID, email, role, inviterUserID, ip, ua string) (magicLinkToken string, userID string, err error) {
	if role != "tenant_admin" && role != "viewer" {
		return "", "", fmt.Errorf("authd: invalid invite role %q (super_admin is not invitable)", role)
	}
	userID, err = a.store.CreateUser(ctx, tenantID, email, role)
	if err != nil {
		return "", "", err
	}
	token, _, err := a.IssueMagicLink(ctx, userID, "invite")
	if err != nil {
		return "", "", err
	}
	_ = a.store.WriteAudit(ctx, "auth.invite", inviterUserID, ip, ua, userID, tenantID,
		map[string]any{"email": email, "role": role})
	return token, userID, nil
}

// SetPassword updates the user's password (bcrypt hash). Used both
// for first-time setup via magic link and for password resets.
func (a *Authd) SetPassword(ctx context.Context, userID, plaintext string) error {
	hash, err := a.HashPassword(ctx, plaintext)
	if err != nil {
		return err
	}
	return a.store.SetUserPassword(ctx, userID, hash, "active")
}

// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------

// newJTI returns a 128-bit random ID encoded as hex. Used as the
// `jti` claim on access tokens.
func newJTI() string {
	var b [16]byte
	_, _ = rand.Read(b[:])
	return hex.EncodeToString(b[:])
}

// SecureEqual is a constant-time compare. Use it for any byte slice
// equality that could be timing-attacked (e.g. token prefix checks
// in tests, never in prod hot paths where Postgres handles equality).
func SecureEqual(a, b []byte) bool {
	return subtle.ConstantTimeCompare(a, b) == 1
}
