package auth

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"sync"
	"time"

	"github.com/nexuscrm/backend/internal/domain/ports"
	"github.com/nexuscrm/backend/pkg/auth"
	"github.com/nexuscrm/backend/pkg/errors"
	"github.com/nexuscrm/backend/pkg/utils"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
	"github.com/rs/zerolog/log"
)

// permCacheInvalidator is the subset of PermissionService needed for cache invalidation.
type permCacheInvalidator interface {
	InvalidateUserCache(userID string)
}

// activeUserEntry is a single cached IsActive result with an expiry timestamp.
type activeUserEntry struct {
	isActive bool
	cachedAt time.Time
}

// activeUserCache is a short-lived in-memory cache of user IsActive status.
// It reduces redundant DB queries on the ValidateSession hot path.
type activeUserCache struct {
	mu      sync.RWMutex
	entries map[string]activeUserEntry
}

func newActiveUserCache() *activeUserCache {
	return &activeUserCache{entries: make(map[string]activeUserEntry)}
}

// get returns (isActive, ok). ok is false when the entry is absent or expired.
func (c *activeUserCache) get(userID string) (bool, bool) {
	c.mu.RLock()
	e, ok := c.entries[userID]
	c.mu.RUnlock()
	if !ok || time.Since(e.cachedAt) > 60*time.Second {
		return false, false
	}
	return e.isActive, true
}

// set stores an IsActive value for userID.
func (c *activeUserCache) set(userID string, isActive bool) {
	c.mu.Lock()
	c.entries[userID] = activeUserEntry{isActive: isActive, cachedAt: time.Now()}
	c.mu.Unlock()
}

// invalidate removes a userID from the cache (called on deactivation).
func (c *activeUserCache) invalidate(userID string) {
	c.mu.Lock()
	delete(c.entries, userID)
	c.mu.Unlock()
}

// AuthService handles authentication, session management, and password operations
type AuthService struct {
	userRepo            ports.UserRepository
	permissionRepo      ports.PermissionRepository
	sessionRepo         ports.SessionRepository
	permSvc             permCacheInvalidator
	loginHistoryRepo    loginHistoryRecorder
	setupAuditor        *SetupAuditor
	activeCache         *activeUserCache
	connectedAppCache   *activeUserCache
}

// loginHistoryRecorder is the subset of LoginHistoryRepository needed by AuthService.
type loginHistoryRecorder interface {
	RecordLogin(ctx context.Context, userID, loginType, sourceIP, browser, status string) error
}

// SetPermissionService wires the PermissionService for cache invalidation on user changes.
func (s *AuthService) SetPermissionService(ps permCacheInvalidator) {
	s.permSvc = ps
}

// OnUserDeactivated invalidates the active-user cache for defense-in-depth.
func (s *AuthService) OnUserDeactivated(userID string) {
	s.activeCache.invalidate(userID)
}

// NewAuthService creates a new AuthService
func NewAuthService(
	userRepo ports.UserRepository,
	sessionRepo ports.SessionRepository,
	permissionRepo ports.PermissionRepository,
) *AuthService {
	return &AuthService{
		userRepo:          userRepo,
		permissionRepo:    permissionRepo,
		sessionRepo:       sessionRepo,
		activeCache:       newActiveUserCache(),
		connectedAppCache: newActiveUserCache(),
	}
}

// SetLoginHistoryRepo wires the LoginHistoryRepository for recording login events.
func (s *AuthService) SetLoginHistoryRepo(repo loginHistoryRecorder) {
	s.loginHistoryRepo = repo
}

// SetSetupAuditor wires the SetupAuditor for recording admin setup changes.
func (s *AuthService) SetSetupAuditor(a *SetupAuditor) {
	s.setupAuditor = a
}

// SessionOptions holds optional parameters for session creation (e.g. OAuth flows).
type SessionOptions struct {
	ConnectedAppId string
	Scope          string
	GrantType      string
}

// LoginResult contains the result of a successful login
type LoginResult struct {
	Token     string
	User      auth.UserSession
	ExpiresAt time.Time
}

func rejectInactiveUser(user *models.User) error {
	if user == nil || user.IsActive {
		return nil
	}

	log.Warn().
		Str("userID", user.ID).
		Str("email", user.Email).
		Msg("Authentication rejected for inactive user")

	return errors.NewUnauthorizedError("User account is inactive")
}

// recordLoginHistory fires a goroutine to record a login event without blocking the caller.
func (s *AuthService) recordLoginHistory(userID, loginType, sourceIP, userAgent, status string) {
	if s.loginHistoryRepo == nil {
		return
	}
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		if err := s.loginHistoryRepo.RecordLogin(ctx, userID, loginType, sourceIP, userAgent, status); err != nil {
			log.Warn().Err(err).Msg("failed to record login history")
		}
	}()
}

// Login authenticates a user and creates a session.
// opts is optional; pass nil for non-OAuth logins.
func (s *AuthService) Login(ctx context.Context, email, password, ip, userAgent string, opts *SessionOptions) (*LoginResult, error) {
	// 1. Find user by email
	user, err := s.userRepo.FindUserByEmailWithPassword(ctx, email)
	if err != nil {
		return nil, errors.NewInternalError("database error", err)
	}
	if user == nil {
		log.Warn().Str("email", email).Msg("Login failed: user not found")
		return nil, errors.NewUnauthorizedError("Invalid email or password")
	}

	// 2. Check account lockout
	if user.LockedUntil != nil && time.Now().Before(*user.LockedUntil) {
		log.Warn().Str("email", email).Time("lockedUntil", *user.LockedUntil).Msg("Login rejected: account is locked")
		s.recordLoginHistory(user.ID, "Application", ip, userAgent, "Failed")
		return nil, errors.NewUnauthorizedError("Account is temporarily locked due to too many failed login attempts")
	}

	// 3. Verify password
	if user.PasswordHash == "" {
		log.Warn().Str("email", email).Msg("Login failed: password hash is NULL")
		s.recordLoginHistory(user.ID, "Application", ip, userAgent, "Failed")
		return nil, errors.NewUnauthorizedError("Password authentication not configured for this user")
	}

	if !auth.VerifyPassword(password, user.PasswordHash) {
		log.Warn().Str("email", email).Msg("Login failed: invalid password")
		s.recordLoginHistory(user.ID, "Application", ip, userAgent, "Failed")
		// Atomically increment counter and lock if threshold reached
		locked, err := s.userRepo.IncrementFailedAndLockIfNeeded(ctx, user.ID, constants.MaxFailedLoginAttempts, constants.AccountLockoutDuration)
		if err != nil {
			log.Error().Err(err).Str("userID", user.ID).Msg("failed to increment login attempts")
		}
		if locked {
			// Revoke all sessions for the locked account
			if revokeErr := s.sessionRepo.RevokeAllSessionsForUser(ctx, user.ID); revokeErr != nil {
				log.Warn().Err(revokeErr).Str("userID", user.ID).Msg("Failed to revoke sessions on lockout")
			}
			log.Warn().Str("email", email).Msg("Account locked after too many failed login attempts")
		}
		return nil, errors.NewUnauthorizedError("Invalid email or password")
	}

	if err := rejectInactiveUser(user.User); err != nil {
		return nil, err
	}

	// Reset failed login attempts on successful authentication
	if err := s.userRepo.ResetFailedLoginAttempts(ctx, user.ID); err != nil {
		log.Error().Err(err).Str("userID", user.ID).Msg("failed to reset login attempts")
	}

	// 3. Create user session object
	// RoleID is populated by FindUserByEmailWithPassword (Repository Layer)
	displayName := auth.ConstructDisplayName(user.Username, user.FirstName, user.LastName)
	userSession := auth.UserSession{
		ID:          user.ID,
		Name:        displayName,
		Email:       user.Email,
		ProfileId:   user.ProfileID,
		ProfileName: s.resolveProfileName(ctx, user.ProfileID),
		RoleId:      user.UserRoleID,
	}

	// Resolve role name if role is assigned
	if user.UserRoleID != nil {
		if roleName, err := s.resolveRoleName(ctx, *user.UserRoleID); err == nil && roleName != "" {
			userSession.RoleName = &roleName
		}
	}

	token, expiresAt, err := s.createAndPersistSession(ctx, userSession, ip, userAgent, opts)
	if err != nil {
		return nil, err
	}

	s.recordLoginHistory(user.ID, "Application", ip, userAgent, "Success")

	return &LoginResult{
		Token:     token,
		User:      userSession,
		ExpiresAt: expiresAt,
	}, nil
}

// CreateSessionForUser generates a session for a given user ID without password check (for OAuth)
func (s *AuthService) CreateSessionForUser(ctx context.Context, userID, ip, userAgent string, opts *SessionOptions) (*models.SessionToken, error) {
	// 1. Fetch User
	user, err := s.userRepo.GetUserByID(ctx, userID) // Assuming this exists and returns with ProfileID
	if err != nil {
		return nil, err
	}
	if user == nil {
		return nil, errors.NewNotFoundError("User", userID)
	}

	if err := rejectInactiveUser(user); err != nil {
		return nil, err
	}

	if user.LockedUntil != nil && time.Now().Before(*user.LockedUntil) {
		return nil, errors.NewUnauthorizedError("Account is temporarily locked")
	}

	// 2. Fetch Role
	roleID, _ := s.userRepo.GetUserRoleID(ctx, userID)

	// 3. Construct Display Name
	displayName := auth.ConstructDisplayName(user.Username, user.FirstName, user.LastName)

	userSession := auth.UserSession{
		ID:          user.ID,
		Name:        displayName,
		Email:       user.Email,
		ProfileId:   user.ProfileID,
		ProfileName: s.resolveProfileName(ctx, user.ProfileID),
		RoleId:      roleID,
	}

	// Resolve role name if role is assigned
	if roleID != nil {
		if roleName, err := s.resolveRoleName(ctx, *roleID); err == nil && roleName != "" {
			userSession.RoleName = &roleName
		}
	}

	token, expiresAt, err := s.createAndPersistSession(ctx, userSession, ip, userAgent, opts)
	if err != nil {
		return nil, err
	}

	s.recordLoginHistory(userID, "OAuth2", ip, userAgent, "Success")

	// Map to models.SessionToken (which we need to define or use string)
	// We'll return a struct compatible with OAuth response
	return &models.SessionToken{
		AccessToken: token,
		TokenType:   "Bearer",
		ExpiresIn:   int(time.Until(expiresAt).Seconds()),
	}, nil
}

// createAndPersistSession handles token generation and session persistence
func (s *AuthService) createAndPersistSession(ctx context.Context, userSession auth.UserSession, ip, userAgent string, opts *SessionOptions) (string, time.Time, error) {
	// Apply opts to userSession before token generation
	if opts != nil {
		userSession.ConnectedAppId = opts.ConnectedAppId
		userSession.Scope = opts.Scope
	}

	// 1. Generate JWT token
	token, err := auth.GenerateToken(userSession)
	if err != nil {
		return "", time.Time{}, errors.NewInternalError("failed to generate token", err)
	}

	// 2. Decode token to get expiry
	claims, _ := auth.DecodeToken(token)
	expiresAt := time.Unix(claims.ExpiresAt.Unix(), 0)

	// 3. Store session in database using SessionRepository
	var scope *string
	if userSession.Scope != "" {
		scope = utils.StringPtr(userSession.Scope)
	}
	var connectedAppID *string
	if userSession.ConnectedAppId != "" {
		connectedAppID = utils.StringPtr(userSession.ConnectedAppId)
	}
	var grantType *string
	if opts != nil && opts.GrantType != "" {
		grantType = utils.StringPtr(opts.GrantType)
	}
	sessionStruct := &models.SystemSession{
		ID:             claims.RegisteredClaims.ID,
		UserID:         userSession.ID,
		Token:          token,
		ExpiresAt:      expiresAt,
		IPAddress:      ip,
		UserAgent:      userAgent,
		IsRevoked:      false,
		LastActivity:   time.Now(),
		Scope:          scope,
		ConnectedAppID: connectedAppID,
		GrantType:      grantType,
	}

	if err := s.sessionRepo.InsertSession(ctx, sessionStruct); err != nil {
		return "", time.Time{}, errors.NewInternalError("failed to persist session", err)
	}

	return token, expiresAt, nil
}

// ValidateSession checks if a session token is valid and active in the database
func (s *AuthService) ValidateSession(ctx context.Context, tokenString string) (*auth.Claims, error) {
	// 1. Verify JWT signature and claims
	claims, err := auth.ValidateToken(tokenString)
	if err != nil {
		return nil, err
	}

	// 2. Check DB for revocation using SessionRepository
	session, err := s.sessionRepo.GetSession(ctx, claims.RegisteredClaims.ID)
	if err != nil {
		return nil, errors.NewInternalError("database error", err)
	}
	if session == nil {
		return nil, errors.NewUnauthorizedError("Session not found")
	}

	if session.IsRevoked {
		return nil, errors.NewUnauthorizedError("Session has been revoked")
	}

	// Defense-in-depth: also check user.IsActive in case the user was deactivated
	// via a path that did not revoke sessions (e.g. generic CRUD update).
	// Cache results for 60 s to avoid a DB query on every request.
	isActive, cached := s.activeCache.get(session.UserID)
	if !cached {
		user, err := s.userRepo.GetUserByID(ctx, session.UserID)
		if err != nil {
			return nil, errors.NewInternalError("failed to verify user status", err)
		}
		if user == nil {
			return nil, errors.NewUnauthorizedError("User account is inactive")
		}
		isActive = user.IsActive
		s.activeCache.set(session.UserID, isActive)
	}
	if !isActive {
		return nil, errors.NewUnauthorizedError("User account is inactive")
	}

	// If this session was created via a Connected App, verify the app is still active.
	// Cache results for 60 s to avoid a DB query on every request.
	if session.ConnectedAppID != nil && *session.ConnectedAppID != "" {
		appActive, appCached := s.connectedAppCache.get(*session.ConnectedAppID)
		if !appCached {
			var appErr error
			appActive, appErr = s.sessionRepo.IsConnectedAppActive(ctx, *session.ConnectedAppID)
			if appErr != nil {
				return nil, errors.NewInternalError("failed to verify connected app status", appErr)
			}
			s.connectedAppCache.set(*session.ConnectedAppID, appActive)
		}
		if !appActive {
			return nil, errors.NewUnauthorizedError("Connected App has been deactivated")
		}
	}

	// Override JWT scope/appId with DB values (enables immediate scope revocation)
	if session.Scope != nil && *session.Scope != "" {
		claims.User.Scope = *session.Scope
	}
	if session.ConnectedAppID != nil && *session.ConnectedAppID != "" {
		claims.User.ConnectedAppId = *session.ConnectedAppID
	}

	return claims, nil
}

// TouchSession updates the last activity timestamp for a session
func (s *AuthService) TouchSession(sessionID string) {
	// Fire and forget - errors are acceptable for non-critical activity timestamps
	go func() {
		// Use a detached context with timeout to prevent goroutine leaks
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		_ = s.sessionRepo.UpdateLastActivity(ctx, sessionID)
	}()
}

// Logout Revokes a session
func (s *AuthService) Logout(ctx context.Context, tokenString string) error {
	claims, err := auth.DecodeToken(tokenString)
	if err != nil {
		return errors.NewValidationError("token", "Invalid token")
	}

	err = s.sessionRepo.RevokeSession(ctx, claims.RegisteredClaims.ID)
	if err == nil {
		log.Info().Str("user", claims.RegisteredClaims.Subject).Str("session", claims.RegisteredClaims.ID).Msg("User logged out")
	}
	return err
}

// ChangePassword updates a user's password
func (s *AuthService) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error {
	// 1. Validate Strength
	if err := auth.ValidatePasswordStrength(newPassword); err != nil {
		return err
	}

	// 2. Load current password
	user, err := s.userRepo.FindUserByIDWithPassword(ctx, userID)
	if err != nil {
		return errors.NewInternalError("failed to retrieve user", err)
	}
	if user == nil {
		// Should verify if user exists even? Assuming middleware handled it.
		return errors.NewNotFoundError("User", userID)
	}

	if user.PasswordHash == "" {
		return errors.NewValidationError("password", "Password authentication not configured for this user")
	}

	// 2b. Check lockout
	if user.LockedUntil != nil && time.Now().Before(*user.LockedUntil) {
		return errors.NewUnauthorizedError("Account is temporarily locked")
	}

	// 3. Verify
	if !auth.VerifyPassword(currentPassword, user.PasswordHash) {
		return errors.NewUnauthorizedError("Current password is incorrect")
	}

	// 4. Hash New
	newHash, err := auth.HashPassword(newPassword)
	if err != nil {
		return errors.NewInternalError("failed to hash password", err)
	}

	// 5. Update
	err = s.userRepo.UpdatePassword(ctx, userID, newHash)
	if err != nil {
		return err
	}
	log.Info().Str("userID", userID).Msg("Password changed")

	// 6. Revoke all existing sessions so old credentials can't be reused
	if err := s.sessionRepo.RevokeAllSessionsForUser(ctx, userID); err != nil {
		log.Warn().Err(err).Str("userID", userID).Msg("Failed to revoke sessions after password change")
	}
	return nil
}

// passwordResetToken represents a pending password reset request.
type passwordResetToken struct {
	UserID    string
	Token     string
	ExpiresAt time.Time
	Used      bool
}

// resetTokenStore is a simple in-memory store for password reset tokens.
// Sufficient for admin-only, low-volume usage. Tokens expire after 1 hour.
var resetTokenStore = struct {
	mu     sync.Mutex
	tokens map[string]*passwordResetToken
}{tokens: make(map[string]*passwordResetToken)}

// CreatePasswordResetToken generates a single-use reset token for the given user.
func (s *AuthService) CreatePasswordResetToken(ctx context.Context, userID string) (string, time.Time, error) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return "", time.Time{}, errors.NewInternalError("failed to generate reset token", err)
	}
	token := hex.EncodeToString(b)
	expiry := time.Now().Add(1 * time.Hour)

	resetTokenStore.mu.Lock()
	// Clean up expired tokens while we're here
	for k, v := range resetTokenStore.tokens {
		if time.Now().After(v.ExpiresAt) || v.Used {
			delete(resetTokenStore.tokens, k)
		}
	}
	resetTokenStore.tokens[token] = &passwordResetToken{
		UserID:    userID,
		Token:     token,
		ExpiresAt: expiry,
		Used:      false,
	}
	resetTokenStore.mu.Unlock()

	return token, expiry, nil
}

// ResetPasswordWithToken validates a reset token and sets a new password.
func (s *AuthService) ResetPasswordWithToken(ctx context.Context, token, newPassword string) error {
	// 1. Validate password strength
	if err := auth.ValidatePasswordStrength(newPassword); err != nil {
		return err
	}

	// 2. Look up and atomically claim token
	resetTokenStore.mu.Lock()
	entry, ok := resetTokenStore.tokens[token]
	if !ok || entry.Used || time.Now().After(entry.ExpiresAt) {
		resetTokenStore.mu.Unlock()
		return errors.NewUnauthorizedError("Invalid or expired reset token")
	}
	entry.Used = true // Claim immediately to prevent concurrent use
	resetTokenStore.mu.Unlock()

	// 3. Hash new password
	newHash, err := auth.HashPassword(newPassword)
	if err != nil {
		// Rollback: un-claim token so user can retry
		resetTokenStore.mu.Lock()
		entry.Used = false
		resetTokenStore.mu.Unlock()
		return errors.NewInternalError("failed to hash password", err)
	}

	// 4. Update password
	if err := s.userRepo.UpdatePassword(ctx, entry.UserID, newHash); err != nil {
		// Rollback: un-claim token so user can retry
		resetTokenStore.mu.Lock()
		entry.Used = false
		resetTokenStore.mu.Unlock()
		return err
	}
	log.Info().Str("userID", entry.UserID).Msg("Password reset via token")

	// 5. Revoke all sessions
	if err := s.sessionRepo.RevokeAllSessionsForUser(ctx, entry.UserID); err != nil {
		log.Warn().Err(err).Str("userID", entry.UserID).Msg("Failed to revoke sessions after password reset")
	}

	return nil
}

// GetUserByID retrieves a user session object by ID
func (s *AuthService) GetUserByID(ctx context.Context, userID string) (*models.UserSession, error) {
	// Reuse Repo logic
	user, err := s.userRepo.GetUserByID(ctx, userID)
	if err != nil {
		return nil, err
	}
	if user == nil {
		return nil, errors.NewNotFoundError("User", userID)
	}

	// Fetch Role ID separately since GetUserByID might not return it if not cached/joined
	roleID, err := s.userRepo.GetUserRoleID(ctx, userID)
	if err != nil {
		log.Warn().Err(err).Str("userID", userID).Msg("Failed to fetch role for user")
	}

	displayName := auth.ConstructDisplayName(user.Username, user.FirstName, user.LastName)

	session := &models.UserSession{
		ID:          user.ID,
		Name:        displayName,
		Email:       &user.Email,
		ProfileID:   user.ProfileID,
		ProfileName: s.resolveProfileName(ctx, user.ProfileID),
		RoleID:      roleID,
	}
	if roleID != nil {
		if roleName, err := s.resolveRoleName(ctx, *roleID); err == nil && roleName != "" {
			session.RoleName = &roleName
		}
	}
	return session, nil
}

// resolveRoleName looks up the display name of a UserRole by ID
func (s *AuthService) resolveRoleName(ctx context.Context, roleID string) (string, error) {
	return s.userRepo.GetRoleNameByID(ctx, roleID)
}

func (s *AuthService) resolveProfileName(ctx context.Context, profileID string) string {
	if profileName, err := s.userRepo.GetProfileNameByID(ctx, profileID); err == nil && profileName != "" {
		return profileName
	}
	return constants.ProfileDisplayName(profileID)
}

