// Package auth implements login, TOTP verification, and credential management.
//
// Security design:
//   - All credential comparisons use constant-time operations to resist timing
//     side-channels. crypto/subtle.ConstantTimeCompare is used wherever secrets
//     are compared.
//   - On any login failure the error returned to the caller is always generic
//     ("invalid credentials"), regardless of which step failed, to prevent
//     user enumeration.
//   - TOTP uses a ±1 time-step window (±30s) per RFC 6238 recommendation.
//   - PHC string format is used for password hashes, enabling transparent
//     parameter upgrades without re-migration.
package auth

import (
	"crypto/hmac"
	"crypto/sha1" //nolint:gosec // SHA-1 is required by RFC 6238 for TOTP; not used for collision resistance.
	"crypto/subtle"
	"encoding/base32"
	encodingbase64 "encoding/base64"
	"encoding/binary"
	"errors"
	"fmt"
	"math"
	"strconv"
	"strings"
	"sync"
	"time"

	"golang.org/x/crypto/argon2"

	"git.wntrmute.dev/mc/mcias/internal/crypto"
)

// ErrInvalidCredentials is returned for any authentication failure.
// It intentionally does not distinguish between wrong password, wrong TOTP,
// or unknown user — to prevent information leakage to the caller.
var ErrInvalidCredentials = errors.New("auth: invalid credentials")

// ArgonParams holds Argon2id hashing parameters embedded in PHC strings.
type ArgonParams struct {
	Time    uint32
	Memory  uint32 // KiB
	Threads uint8
}

// DefaultArgonParams returns OWASP-2023-compliant parameters.
// Security: These meet the OWASP minimum (time=2, memory=64MiB) and provide
// additional margin with time=3.
func DefaultArgonParams() ArgonParams {
	return ArgonParams{
		Time:    3,
		Memory:  64 * 1024, // 64 MiB in KiB
		Threads: 4,
	}
}

// dummyHashOnce ensures the dummy hash is computed exactly once at first use.
// Security: computing it lazily (rather than at init time) keeps startup fast;
// using sync.Once makes it safe to call from concurrent goroutines.
var (
	dummyHashOnce sync.Once
	dummyHashVal  string
)

// DummyHash returns a pre-computed Argon2id PHC hash of a fixed dummy password
// using DefaultArgonParams. It is computed once on first call and cached.
//
// Security (F-07): using a real hash with the exact same parameters as
// production password verification ensures that dummy operations (run for
// unknown users or inactive accounts to prevent timing-based enumeration)
// take as long as real verifications, regardless of parameter changes.
// The previous hardcoded string used a 6-byte salt and 6-byte hash which
// was faster to verify than a real 16-byte-salt / 32-byte-hash record.
func DummyHash() string {
	dummyHashOnce.Do(func() {
		h, err := HashPassword("dummy-password-for-timing-only", DefaultArgonParams())
		if err != nil {
			// This should be unreachable in production — HashPassword only fails
			// if crypto/rand fails or the password is empty, neither of which
			// applies here. Panic so the misconfiguration surfaces immediately
			// rather than silently degrading security.
			panic("auth: DummyHash: failed to pre-compute dummy hash: " + err.Error())
		}
		dummyHashVal = h
	})
	return dummyHashVal
}

// HashPassword hashes a password using Argon2id and returns a PHC-format string.
// A random 16-byte salt is generated via crypto/rand for each call.
//
// Security: Argon2id is selected per OWASP recommendation; it resists both
// side-channel and GPU brute-force attacks. The random salt ensures each hash
// is unique even for identical passwords.
func HashPassword(password string, params ArgonParams) (string, error) {
	if password == "" {
		return "", errors.New("auth: password must not be empty")
	}

	// Generate a cryptographically-random 16-byte salt.
	salt, err := crypto.RandomBytes(16)
	if err != nil {
		return "", fmt.Errorf("auth: generate salt: %w", err)
	}

	hash := argon2.IDKey(
		[]byte(password),
		salt,
		params.Time,
		params.Memory,
		params.Threads,
		32, // 256-bit output
	)

	// PHC format: $argon2id$v=19$m=<M>,t=<T>,p=<P>$<salt-b64>$<hash-b64>
	saltB64 := encodingbase64.RawStdEncoding.EncodeToString(salt)
	hashB64 := encodingbase64.RawStdEncoding.EncodeToString(hash)
	phc := fmt.Sprintf(
		"$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
		params.Memory, params.Time, params.Threads,
		saltB64, hashB64,
	)
	return phc, nil
}

// VerifyPassword checks a plaintext password against a PHC-format Argon2id hash.
// Returns true if the password matches.
//
// Security: Comparison uses crypto/subtle.ConstantTimeCompare after computing
// the candidate hash with identical parameters and the stored salt. This
// prevents timing attacks that could reveal whether a password is "closer" to
// the correct value.
func VerifyPassword(password, phcHash string) (bool, error) {
	params, salt, expectedHash, err := parsePHC(phcHash)
	if err != nil {
		return false, fmt.Errorf("auth: parse PHC hash: %w", err)
	}

	candidateHash := argon2.IDKey(
		[]byte(password),
		salt,
		params.Time,
		params.Memory,
		params.Threads,
		uint32(len(expectedHash)), //nolint:gosec // G115: hash buffer length is always small and fits uint32
	)

	// Security: constant-time comparison prevents timing side-channels.
	if subtle.ConstantTimeCompare(candidateHash, expectedHash) != 1 {
		return false, nil
	}
	return true, nil
}

// parsePHC parses a PHC-format Argon2id hash string.
// Expected format: $argon2id$v=19$m=<M>,t=<T>,p=<P>$<salt-b64>$<hash-b64>
func parsePHC(phc string) (ArgonParams, []byte, []byte, error) {
	parts := strings.Split(phc, "$")
	// Expected: ["", "argon2id", "v=19", "m=M,t=T,p=P", "salt", "hash"]
	if len(parts) != 6 {
		return ArgonParams{}, nil, nil, fmt.Errorf("auth: invalid PHC format: %d parts", len(parts))
	}
	if parts[1] != "argon2id" {
		return ArgonParams{}, nil, nil, fmt.Errorf("auth: unsupported algorithm %q", parts[1])
	}

	var params ArgonParams
	for _, kv := range strings.Split(parts[3], ",") {
		eq := strings.IndexByte(kv, '=')
		if eq < 0 {
			return ArgonParams{}, nil, nil, fmt.Errorf("auth: invalid PHC param %q", kv)
		}
		k, v := kv[:eq], kv[eq+1:]
		n, err := strconv.ParseUint(v, 10, 32)
		if err != nil {
			return ArgonParams{}, nil, nil, fmt.Errorf("auth: parse PHC param %q: %w", kv, err)
		}
		switch k {
		case "m":
			params.Memory = uint32(n)
		case "t":
			params.Time = uint32(n)
		case "p":
			params.Threads = uint8(n) //nolint:gosec // G115: thread count is validated to be <= 255 by config
		}
	}

	salt, err := encodingbase64.RawStdEncoding.DecodeString(parts[4])
	if err != nil {
		return ArgonParams{}, nil, nil, fmt.Errorf("auth: decode salt: %w", err)
	}
	hash, err := encodingbase64.RawStdEncoding.DecodeString(parts[5])
	if err != nil {
		return ArgonParams{}, nil, nil, fmt.Errorf("auth: decode hash: %w", err)
	}
	return params, salt, hash, nil
}

// ValidateTOTP checks a 6-digit TOTP code against a raw TOTP secret (bytes).
// A ±1 time-step window (±30s) is allowed to accommodate clock skew.
//
// Returns (true, counter, nil) on a valid code where counter is the HOTP
// counter value that matched.  The caller MUST pass this counter to
// db.CheckAndUpdateTOTPCounter to prevent replay attacks within the validity
// window (CRIT-01).
//
// Security:
//   - Comparison uses crypto/subtle.ConstantTimeCompare to resist timing attacks.
//   - Only RFC 6238-compliant HOTP (HMAC-SHA1) is implemented; no custom crypto.
//   - A ±1 window is the RFC 6238 recommendation; wider windows increase
//     exposure to code interception between generation and submission.
//   - The returned counter enables replay prevention: callers store it and
//     reject any future code that does not advance past it (RFC 6238 §5.2).
func ValidateTOTP(secret []byte, code string) (bool, int64, error) {
	if len(code) != 6 {
		return false, 0, nil
	}

	now := time.Now().Unix()
	step := int64(30) // RFC 6238 default time step in seconds

	// Security: evaluate all three counters with constant-time comparisons
	// before returning.  Early-exit would leak which counter matched via
	// timing; we instead record the match and continue, returning at the end.
	var matched bool
	var matchedCounter int64
	for _, counter := range []int64{
		now/step - 1,
		now / step,
		now/step + 1,
	} {
		expected, err := hotp(secret, uint64(counter)) //nolint:gosec // G115: counter is Unix time / step, always non-negative
		if err != nil {
			return false, 0, fmt.Errorf("auth: compute TOTP: %w", err)
		}
		// Security: constant-time comparison to prevent timing attack.
		// We deliberately do NOT break early so that all three comparisons
		// always execute, preventing a timing side-channel on which counter
		// slot matched.
		if subtle.ConstantTimeCompare([]byte(code), []byte(expected)) == 1 {
			matched = true
			matchedCounter = counter
		}
	}
	if matched {
		return true, matchedCounter, nil
	}
	return false, 0, nil
}

// hotp computes an HMAC-SHA1-based OTP for a given counter value.
// Implements RFC 4226 §5, which is the base algorithm for RFC 6238 TOTP.
//
// Security: SHA-1 is used as required by RFC 4226/6238. It is used here in
// an HMAC construction for OTP purposes — not for collision-resistant hashing.
// The HMAC-SHA1 construction is still cryptographically sound for this use case.
func hotp(key []byte, counter uint64) (string, error) {
	counterBytes := make([]byte, 8)
	binary.BigEndian.PutUint64(counterBytes, counter)

	mac := hmac.New(sha1.New, key)
	if _, err := mac.Write(counterBytes); err != nil {
		return "", fmt.Errorf("auth: HMAC-SHA1 write: %w", err)
	}
	h := mac.Sum(nil)

	// Dynamic truncation per RFC 4226 §5.3.
	offset := h[len(h)-1] & 0x0F
	binCode := (int(h[offset]&0x7F)<<24 |
		int(h[offset+1])<<16 |
		int(h[offset+2])<<8 |
		int(h[offset+3])) % int(math.Pow10(6))

	return fmt.Sprintf("%06d", binCode), nil
}

// DecodeTOTPSecret decodes a base32-encoded TOTP secret string to raw bytes.
// TOTP authenticator apps present secrets in base32 for display; this function
// converts them to the raw byte form stored (encrypted) in the database.
func DecodeTOTPSecret(base32Secret string) ([]byte, error) {
	normalised := strings.ToUpper(strings.ReplaceAll(base32Secret, " ", ""))
	decoded, err := base32.StdEncoding.DecodeString(normalised)
	if err != nil {
		decoded, err = base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(normalised)
		if err != nil {
			return nil, fmt.Errorf("auth: decode base32 TOTP secret: %w", err)
		}
	}
	return decoded, nil
}

// GenerateTOTPSecret generates a random 20-byte TOTP shared secret and returns
// both the raw bytes and their base32 representation for display to the user.
func GenerateTOTPSecret() (rawBytes []byte, base32Encoded string, err error) {
	rawBytes, err = crypto.RandomBytes(20)
	if err != nil {
		return nil, "", fmt.Errorf("auth: generate TOTP secret: %w", err)
	}
	base32Encoded = base32.StdEncoding.EncodeToString(rawBytes)
	return rawBytes, base32Encoded, nil
}
