package auth

import (
	"context"
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"net/mail"
	"strings"
	"time"

	"github.com/golang-jwt/jwt/v5"
	"archivmail/internal/ldapauth"
	ldapcfg "archivmail/internal/ldapconfig"
	"archivmail/internal/userstore"
)

// Session holds the claims extracted from a validated JWT.
type Session struct {
	UserID     int64
	Username   string
	Email      string
	Role       string
	JTI        string // unique JWT ID
	TenantID   *int64
	TenantSlug string
}

// TenantDomainLookup is the interface used by Manager to resolve an email domain
// to a tenant ID. Implemented by tenantstore.Store.
type TenantDomainLookup interface {
	// GetTenantIDByDomain returns the tenant_id for a given domain, or nil if not found.
	GetTenantIDByDomain(ctx context.Context, domain string) (*int64, error)
}

// Manager handles login, token issuance, validation, and logout.
type Manager struct {
	store           *userstore.Store
	ldapStore       *ldapcfg.Store
	jwtSecret       []byte
	aesKey          []byte                 // PROJ-24: AES-256-GCM key for TOTP secret encryption
	tenantLdapStore *ldapcfg.TenantStore   // PROJ-23: per-tenant LDAP config
	tenantLookup    TenantDomainLookup     // PROJ-23: domain -> tenant_id resolution
}

// New creates a new auth Manager.
// ldapStore may be nil; in that case LDAP fallback is disabled.
// aesKey is the hex-encoded AES-256 key for encrypting TOTP secrets.
func New(store *userstore.Store, ldapStore *ldapcfg.Store, jwtSecret string, aesKey string) *Manager {
	aesKeyBytes, _ := hex.DecodeString(aesKey)
	return &Manager{
		store:     store,
		ldapStore: ldapStore,
		jwtSecret: []byte(jwtSecret),
		aesKey:    aesKeyBytes,
	}
}

// SetTenantLDAP wires the per-tenant LDAP config store and tenant domain lookup
// into the auth manager. Both may be nil to disable per-tenant LDAP.
func (m *Manager) SetTenantLDAP(tenantLdapStore *ldapcfg.TenantStore, tenantLookup TenantDomainLookup) {
	m.tenantLdapStore = tenantLdapStore
	m.tenantLookup = tenantLookup
}

// Login verifies credentials and returns a signed JWT token.
// It first attempts a local password check. If that fails and LDAP is
// configured and enabled, it falls back to LDAP authentication.
// If the user has TOTP enabled, totpRequired is true and the token is a
// short-lived pending token that can only be used with ValidateTOTPLogin.
func (m *Manager) Login(username, password string) (token string, user *userstore.User, totpRequired bool, err error) {
	// 1. Try local authentication first.
	// PROJ-46: VerifyLogin lets tenant users authenticate by email and keeps
	// username-login working only for non-tenant users (tenant_id IS NULL).
	user, err = m.store.VerifyLogin(context.Background(), username, password)
	if err == nil {
		if user.TOTPEnabled {
			t, e := m.issuePendingTOTPToken(user)
			return t, user, true, e
		}
		t, u, e := m.issueToken(user)
		return t, u, false, e
	}

	// 2. PROJ-23: Per-tenant LDAP — checked first so tenant config takes priority
	// over global LDAP (spec: tenant_ldap > ldap_config > config.yml).
	// Extract domain from username (user@domain.tld), resolve tenant, try auth.
	if m.tenantLdapStore != nil && m.tenantLookup != nil {
		if domain := extractDomain(username); domain != "" {
			ctx := context.Background()
			tenantID, lookupErr := m.tenantLookup.GetTenantIDByDomain(ctx, domain)
			if lookupErr == nil && tenantID != nil {
				tcfg, tErr := m.tenantLdapStore.GetWithPassword(ctx, *tenantID)
				if tErr == nil && tcfg != nil && tcfg.Enabled && tcfg.URL != "" && tcfg.BindPassword != "" {
					attrs, authErr := ldapauth.Authenticate(ldapauth.Config{
						URL:           tcfg.URL,
						BindDN:        tcfg.BindDN,
						BindPassword:  tcfg.BindPassword,
						BaseDN:        tcfg.BaseDN,
						UserFilter:    tcfg.UserFilter,
						TLS:           tcfg.TLS,
						TLSSkipVerify: tcfg.TLSSkipVerify,
					}, username, password)
						if authErr == nil {
						role := tcfg.DefaultRole
						if role == "" {
							role = userstore.RoleUser
						}
						memberOf := attrs["memberOf"]
						if memberOf != "" {
							for _, gm := range tcfg.GroupMappings {
								if gm.GroupDN != "" && containsGroup(memberOf, gm.GroupDN) {
									role = gm.Role
									break
								}
							}
						}
						email := attrs["mail"]
						if email != "" {
							if _, err := mail.ParseAddress(email); err != nil {
								email = "" // invalid mail attribute — fall back
							}
						}
						if email == "" {
							email = username
						}
						ldapUser, upsertErr := m.store.UpsertLDAPUser(username, email, role, tenantID)
						if upsertErr == nil {
							if ldapUser.TOTPEnabled {
								t, e := m.issuePendingTOTPToken(ldapUser)
								return t, ldapUser, true, e
							}
							t, u, e := m.issueToken(ldapUser)
							return t, u, false, e
						}
					}
				}
			}
		}
	}

	// 3. Global LDAP fallback — only reached if no matching tenant LDAP config found.
	if m.ldapStore != nil {
		cfg, ldapErr := m.ldapStore.GetWithPassword(context.Background())
		if ldapErr == nil && cfg != nil && cfg.Enabled && cfg.URL != "" && cfg.BindPassword != "" {
			attrs, authErr := ldapauth.Authenticate(ldapauth.Config{
				URL:           cfg.URL,
				BindDN:        cfg.BindDN,
				BindPassword:  cfg.BindPassword,
				BaseDN:        cfg.BaseDN,
				UserFilter:    cfg.UserFilter,
				TLS:           cfg.TLS,
				TLSSkipVerify: cfg.TLSSkipVerify,
			}, username, password)
			if authErr == nil {
				role := cfg.DefaultRole
				if role == "" {
					role = userstore.RoleUser
				}
				memberOf := attrs["memberOf"]
				if memberOf != "" {
					for _, gm := range cfg.GroupMappings {
						if gm.GroupDN != "" && containsGroup(memberOf, gm.GroupDN) {
							role = gm.Role
							break
						}
					}
				}
				email := attrs["mail"]
				if email != "" {
					if _, err := mail.ParseAddress(email); err != nil {
						email = "" // invalid mail attribute — fall back
					}
				}
				if email == "" {
					email = username + "@ldap.local"
				}
				ldapUser, upsertErr := m.store.UpsertLDAPUser(username, email, role, nil)
				if upsertErr == nil {
					if ldapUser.TOTPEnabled {
						t, e := m.issuePendingTOTPToken(ldapUser)
						return t, ldapUser, true, e
					}
					t, u, e := m.issueToken(ldapUser)
					return t, u, false, e
				}
			}
		}
	}

	return "", nil, false, fmt.Errorf("auth: login: invalid credentials")
}

// issueToken signs a JWT for the given user and returns the token string.
func (m *Manager) issueToken(user *userstore.User) (string, *userstore.User, error) {
	jti := generateJTI()
	now := time.Now()

	var tenantIDVal int64
	if user.TenantID != nil {
		tenantIDVal = *user.TenantID
	}

	claims := jwt.MapClaims{
		"sub":         user.Username,
		"email":       user.Email,
		"role":        user.Role,
		"uid":         user.ID,
		"jti":         jti,
		"iat":         now.Unix(),
		"exp":         now.Add(8 * time.Hour).Unix(),
		"tenant_id":   tenantIDVal,
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	signed, err := token.SignedString(m.jwtSecret)
	if err != nil {
		return "", nil, fmt.Errorf("auth: sign token: %w", err)
	}

	return signed, user, nil
}

// issuePendingTOTPToken issues a short-lived JWT (5 min) that signals TOTP is required.
// This token MUST NOT be accepted as a full auth token — only for /api/auth/totp.
func (m *Manager) issuePendingTOTPToken(user *userstore.User) (string, error) {
	jti := generateJTI()
	now := time.Now()

	claims := jwt.MapClaims{
		"sub":          user.Username,
		"uid":          user.ID,
		"jti":          jti,
		"iat":          now.Unix(),
		"exp":          now.Add(5 * time.Minute).Unix(),
		"totp_pending": true,
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	signed, err := token.SignedString(m.jwtSecret)
	if err != nil {
		return "", fmt.Errorf("auth: sign pending totp token: %w", err)
	}
	return signed, nil
}

// ValidateTOTPLogin validates a pending TOTP token and TOTP code, then issues a full JWT.
func (m *Manager) ValidateTOTPLogin(pendingToken, code string) (string, *userstore.User, error) {
	// Parse the pending token
	token, err := jwt.Parse(pendingToken, func(t *jwt.Token) (interface{}, error) {
		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("auth: unexpected signing method: %v", t.Header["alg"])
		}
		return m.jwtSecret, nil
	})
	if err != nil {
		return "", nil, fmt.Errorf("auth: invalid pending token: %w", err)
	}
	if !token.Valid {
		return "", nil, errors.New("auth: pending token not valid")
	}

	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok {
		return "", nil, errors.New("auth: bad claims")
	}

	// Verify this is actually a pending TOTP token
	pending, _ := claims["totp_pending"].(bool)
	if !pending {
		return "", nil, errors.New("auth: not a pending TOTP token")
	}

	// Extract user ID
	var userID int64
	switch v := claims["uid"].(type) {
	case float64:
		userID = int64(v)
	case int64:
		userID = v
	default:
		return "", nil, errors.New("auth: missing uid in pending token")
	}

	// Load user from DB
	user, err := m.store.GetByID(userID)
	if err != nil {
		return "", nil, fmt.Errorf("auth: user lookup: %w", err)
	}
	if !user.TOTPEnabled {
		return "", nil, errors.New("auth: TOTP not enabled for this user")
	}

	// Load and decrypt TOTP secret
	encSecret, enabled, err := m.store.GetTOTPSecret(context.Background(), userID)
	if err != nil {
		return "", nil, fmt.Errorf("auth: get totp secret: %w", err)
	}
	if !enabled || len(encSecret) == 0 {
		return "", nil, errors.New("auth: TOTP not configured")
	}

	plainSecret, err := m.decryptAES(encSecret)
	if err != nil {
		return "", nil, fmt.Errorf("auth: decrypt totp secret: %w", err)
	}

	// Validate the TOTP code
	if !ValidateTOTP(string(plainSecret), code) {
		return "", nil, errors.New("auth: invalid TOTP code")
	}

	// Issue a full auth token
	return m.issueToken(user)
}

// ValidateToken parses and validates the token, checking the blacklist.
func (m *Manager) ValidateToken(tokenStr string) (*Session, error) {
	token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("auth: unexpected signing method: %v", t.Header["alg"])
		}
		return m.jwtSecret, nil
	})
	if err != nil {
		return nil, fmt.Errorf("auth: invalid token: %w", err)
	}
	if !token.Valid {
		return nil, errors.New("auth: token not valid")
	}

	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok {
		return nil, errors.New("auth: bad claims")
	}

	// PROJ-24: Reject pending TOTP tokens — they must not be used as full auth tokens.
	if pending, _ := claims["totp_pending"].(bool); pending {
		return nil, errors.New("auth: pending TOTP token cannot be used for authentication")
	}

	jti, _ := claims["jti"].(string)
	blacklisted, err := m.store.IsBlacklisted(jti)
	if err != nil {
		return nil, fmt.Errorf("auth: blacklist check: %w", err)
	}
	if blacklisted {
		return nil, errors.New("auth: token revoked")
	}

	username, _ := claims["sub"].(string)
	email, _ := claims["email"].(string)
	role, _ := claims["role"].(string)

	var userID int64
	switch v := claims["uid"].(type) {
	case float64:
		userID = int64(v)
	case int64:
		userID = v
	}

	var tenantID *int64
	switch v := claims["tenant_id"].(type) {
	case float64:
		if v != 0 {
			id := int64(v)
			tenantID = &id
		}
	case int64:
		if v != 0 {
			tenantID = &v
		}
	}

	// PROJ-64: reject tokens issued before a password change / admin TOTP reset,
	// closing the session-hijack window that a stateless-only JWT leaves open.
	var iat time.Time
	switch v := claims["iat"].(type) {
	case float64:
		iat = time.Unix(int64(v), 0)
	case int64:
		iat = time.Unix(v, 0)
	}
	if validAfter, err := m.store.TokensValidAfter(context.Background(), userID); err == nil && validAfter != nil {
		if iat.Before(*validAfter) {
			return nil, errors.New("auth: token revoked (credentials changed)")
		}
	}

	return &Session{
		UserID:   userID,
		Username: username,
		Email:    email,
		Role:     role,
		JTI:      jti,
		TenantID: tenantID,
	}, nil
}

// Logout revokes the token by adding its JTI to the blacklist.
func (m *Manager) Logout(tokenStr string) error {
	token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("auth: unexpected signing method")
		}
		return m.jwtSecret, nil
	})
	if err != nil {
		return fmt.Errorf("auth: logout parse: %w", err)
	}

	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok {
		return errors.New("auth: bad claims on logout")
	}

	jti, _ := claims["jti"].(string)
	var exp time.Time
	switch v := claims["exp"].(type) {
	case float64:
		exp = time.Unix(int64(v), 0)
	case int64:
		exp = time.Unix(v, 0)
	default:
		exp = time.Now().Add(8 * time.Hour)
	}

	return m.store.BlacklistToken(jti, exp)
}

// HasRole returns true when userRole satisfies the required role level.
// Hierarchy: superadmin > admin > domain_admin > auditor > user
func HasRole(userRole, required string) bool {
	levels := map[string]int{
		userstore.RoleUser:        1,
		userstore.RoleAuditor:     2,
		userstore.RoleDomainAdmin: 3,
		userstore.RoleAdmin:       4,
		userstore.RoleSuperAdmin:  5,
	}
	return levels[userRole] >= levels[required]
}

// containsGroup checks whether a comma-separated memberOf string contains groupDN
// (case-insensitive substring match to handle varying DN formats).
func containsGroup(memberOf, groupDN string) bool {
	for _, dn := range splitMemberOf(memberOf) {
		if dn == groupDN {
			return true
		}
	}
	return false
}

func splitMemberOf(s string) []string {
	var out []string
	for _, part := range splitComma(s) {
		part = trimSpace(part)
		if part != "" {
			out = append(out, part)
		}
	}
	return out
}

// splitComma splits on commas that are not part of DN attribute values.
// For simplicity we split on ", " and "," and let callers match.
func splitComma(s string) []string {
	// Groups returned from LDAP memberOf are newline-separated in the map
	// because we join with "," in ldapauth. Re-split here.
	parts := []string{}
	start := 0
	for i := 0; i < len(s); i++ {
		if s[i] == ',' && (i == 0 || s[i-1] != '\\') {
			parts = append(parts, s[start:i])
			start = i + 1
		}
	}
	parts = append(parts, s[start:])
	return parts
}

func trimSpace(s string) string {
	start, end := 0, len(s)
	for start < end && (s[start] == ' ' || s[start] == '\t') {
		start++
	}
	for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
		end--
	}
	return s[start:end]
}

// extractDomain returns the domain part from a username containing '@'.
// Returns empty string if no '@' is found.
func extractDomain(username string) string {
	idx := strings.LastIndex(username, "@")
	if idx < 0 || idx == len(username)-1 {
		return ""
	}
	return strings.ToLower(username[idx+1:])
}

// generateJTI returns a cryptographically random identifier for a JWT.
func generateJTI() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		// fallback: should never happen on a healthy system
		return fmt.Sprintf("%d", time.Now().UnixNano())
	}
	return hex.EncodeToString(b)
}

// ── PROJ-24: AES-256-GCM encryption helpers for TOTP secrets ─────────────

// EncryptAES encrypts plaintext using AES-256-GCM with the manager's AES key.
// The 12-byte nonce is prepended to the ciphertext.
func (m *Manager) EncryptAES(plaintext []byte) ([]byte, error) {
	return encryptAESGCM(m.aesKey, plaintext)
}

// decryptAES decrypts data encrypted with EncryptAES.
func (m *Manager) decryptAES(data []byte) ([]byte, error) {
	return decryptAESGCM(m.aesKey, data)
}

// DecryptAESForHandler exposes AES decryption for use in API handlers (e.g., TOTP setup confirmation).
func (m *Manager) DecryptAESForHandler(data []byte) ([]byte, error) {
	return decryptAESGCM(m.aesKey, data)
}

func encryptAESGCM(key, plaintext []byte) ([]byte, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, fmt.Errorf("auth: aes cipher: %w", err)
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, fmt.Errorf("auth: gcm: %w", err)
	}
	nonce := make([]byte, gcm.NonceSize())
	if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
		return nil, fmt.Errorf("auth: random nonce: %w", err)
	}
	ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
	return ciphertext, nil
}

func decryptAESGCM(key, data []byte) ([]byte, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, fmt.Errorf("auth: aes cipher: %w", err)
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, fmt.Errorf("auth: gcm: %w", err)
	}
	nonceSize := gcm.NonceSize()
	if len(data) < nonceSize {
		return nil, fmt.Errorf("auth: ciphertext too short")
	}
	nonce, ciphertext := data[:nonceSize], data[nonceSize:]
	plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
	if err != nil {
		return nil, fmt.Errorf("auth: decrypt: %w", err)
	}
	return plaintext, nil
}

// GetUserStore returns the underlying user store (used by TOTP handlers).
func (m *Manager) GetUserStore() *userstore.Store {
	return m.store
}
