package auth

import (
	"crypto/rand"
	"encoding/hex"
	"os"
	"strings"
	"time"

	"github.com/golang-jwt/jwt/v5"
	apperrors "github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/kernel/pkg/recorddisplay"
	"github.com/nexuscrm/kernel/pkg/useridentity"
	"github.com/nexuscrm/kernel/pkg/utils"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
	"github.com/rs/zerolog/log"
)

// UserSession represents the user session data stored in JWT
type UserSession struct {
	ID                string          `json:"id"`
	Name              string          `json:"name"`
	Email             string          `json:"email"`
	ProfileId         string          `json:"profile_id"`                 // Required: User's permissions profile
	ProfileName       string          `json:"profile_name,omitempty"`     // Display name of the profile
	RoleId            *string         `json:"role_id,omitempty"`          // Optional: Role for hierarchy-based data sharing (Salesforce pattern)
	RoleName          *string         `json:"role_name,omitempty"`        // Display name of the role
	ConnectedAppId    string          `json:"connected_app_id,omitempty"` // Which Connected App created this session
	Scope             string          `json:"scope,omitempty"`            // Granted OAuth scope (empty = full access)
	GrantType         string          `json:"grant_type,omitempty"`       // OAuth grant class that created this session
	SessionClass      string          `json:"session_class,omitempty"`    // Generic trust class for handler-level authn decisions
	SystemPermissions map[string]bool `json:"system_permissions,omitempty"`
}

// IsSuperUser is a compatibility helper for older tests and adapters. Runtime
// authorization must prefer the permission/policy plane; this method never
// derives privilege from ProfileId.
func (u UserSession) IsSuperUser() bool {
	return constants.HasSuperUserCapability(u.SystemPermissions)
}

// DisplayName constructs the user's display name from available fields
// Prioritizes First+Last name, falls back to Username
func (u UserSession) DisplayName() string {
	// If First or Last name is present, construct full name
	// This assumes the source struct (SystemUser) had them populated.
	// However, UserSession stores "Name" which is usually pre-calculated
	// or we need to access First/Last from somewhere else.
	// Looking at UserSession struct in this file (lines 14-20):
	// Name string `json:"name"`
	// It doesn't store First/Last name separately in the JWT claims?
	// Wait, line 16 has `Name string`.
	// The problem described in the plan was "Duplicate Business Logic: User display name construction... is repeated in 3 separate places in auth_service.go".
	// In auth_service.go, it builds the name from `user.FirstName` and `user.LastName` (from DB model) and then puts it into `UserSession.Name`.
	// So `UserSession` only holds the result.
	// The logic duplication is converting `models.User` -> `auth.UserSession` (or just display name string).
	// So we should add a helper that takes first/last/username and returns the string.
	// Or add First/Last to UserSession?
	// The plan said: "Add func (u *UserSession) DisplayName() string".
	// But `auth_service.go` logic is building it BEFORE creating UserSession mostly?
	// Line 57 in auth_service: `displayName := user.Username ... if user.FirstName ...`
	// Then `userSession := auth.UserSession{ ... Name: displayName ... }`.
	// So the duplication is in the *generation* of that string.
	// AND in GetUserByID (line 289) it does the same.
	// AND on login/create session.
	// So we need a helper: `func GenerateDisplayName(username, first, last string) string`.
	// OR `func (u *SystemUser) DisplayName() string`? But SystemUser is in `shared/pkg/models`.
	// If I can't touch shared models easily (or don't want to), I can put a helper in this package.
	// `func ConstructDisplayName(username, firstName, lastName string) string`
	return u.Name
}

// ConstructDisplayName helper to standardize name generation
func ConstructDisplayName(username, firstName, lastName string) string {
	return useridentity.DisplayName(username, firstName, lastName)
}

// ResolveLookupDisplayName returns the display label for a referenced record.
// schema MUST be non-nil — callers that cannot resolve the target object's
// metadata must skip display resolution entirely.
//
// Routing (in order):
//
//  1. User branch (Salesforce parity): if schema.APIName == constants.TableUser,
//     compose Username | FirstName + LastName via ConstructDisplayName.
//
//  2. IsNameField branch (the SSoT): scan schema.Fields for IsNameField=true.
//     If found, return record[<that field>] when non-blank; return "" when
//     blank — DO NOT fall through to a literal "Name" column.
//
//  3. Legacy fallback (for schemas that have not yet declared IsNameField):
//     iterate the existing [Name, DeveloperName, Username] probe list.
//     Fires ONLY when no field has IsNameField=true.
func ResolveLookupDisplayName(schema *models.ObjectMetadata, record models.SObject) string {
	return recorddisplay.LookupName(schema, record)
}

// Claims represents JWT claims
type Claims struct {
	User UserSession `json:"user"`
	jwt.RegisteredClaims
}

var jwtSecret = []byte(getJWTSecret())

func getJWTSecret() string {
	secret := os.Getenv("JWT_SECRET")
	if secret == "" {
		env := os.Getenv("NEXUS_ENV")
		if env == "production" || env == "staging" {
			log.Fatal().Msg("SECURITY: JWT_SECRET environment variable is required in production/staging. " +
				"Generate one with: openssl rand -base64 32")
		}
		// Development/test: auto-generate an ephemeral secret and warn.
		// Tokens will not survive restarts, which is acceptable for local dev.
		b := make([]byte, 32)
		if _, err := rand.Read(b); err != nil {
			log.Fatal().Err(err).Msg("Failed to generate ephemeral JWT secret")
		}
		secret = hex.EncodeToString(b)
		log.Warn().Msg("SECURITY: JWT_SECRET not set — using ephemeral random secret (tokens will not survive restarts). " +
			"Set JWT_SECRET for persistent sessions.")
	}
	return secret
}

// resourceIdentifierResolver, when set, returns this deployment's authoritative
// resource identifier — its canonical external base URL. nexuscrm-auth wires it to
// PublicOAuthCanonicalIssuer at startup so the kernel stays decoupled and there is
// a single source of truth for the value. Tokens are stamped with it as the JWT
// audience (aud) at mint and verified against it at validation, binding a session
// or OAuth access token to the environment that issued it — a token minted for one
// deployment (e.g. staging) is rejected by another (e.g. prod), even though all
// environments currently share one signing secret.
var resourceIdentifierResolver func() string

// SetResourceIdentifierResolver wires the deployment resource-identifier source.
// nil (the default) disables audience stamping/verification, which is correct for
// dev/test with no configured issuer. Intended to be called once at startup.
func SetResourceIdentifierResolver(resolver func() string) {
	resourceIdentifierResolver = resolver
}

// resourceIdentifier returns the configured deployment resource identifier, or ""
// when unset (audience stamping/verification then no-ops).
func resourceIdentifier() string {
	if resourceIdentifierResolver == nil {
		return ""
	}
	return strings.TrimSpace(resourceIdentifierResolver())
}

// tokenAudienceMatches reports whether a token's aud claim authorizes use at want.
// An empty want disables the check (unconfigured deployment). A token carrying NO
// audience is grandfathered — the migration window for tokens minted before
// resource binding shipped; once those expire, every live token is bound.
func tokenAudienceMatches(aud jwt.ClaimStrings, want string) bool {
	if want == "" || len(aud) == 0 {
		return true
	}
	for _, a := range aud {
		if strings.TrimSpace(a) == want {
			return true
		}
	}
	return false
}

const DefaultSessionLifetime = 24 * time.Hour

// GenerateToken creates a JWT token for a user session and returns the parsed
// Claims alongside the signed token so callers do not need to re-decode it.
func GenerateToken(session UserSession) (string, *Claims, error) {
	return GenerateTokenForSession(session, utils.GenerateID(), time.Now(), DefaultSessionLifetime)
}

// GenerateTokenForSession signs a token for an existing durable session ID.
func GenerateTokenForSession(session UserSession, sessionID string, issuedAt time.Time, lifetime time.Duration) (string, *Claims, error) {
	return generateTokenForSession(session, sessionID, issuedAt, lifetime, "")
}

// GenerateTokenForSessionWithAudience signs a token for one exact resource.
// It is used by resource-bound OAuth grants; unlike the generic session helper
// it never substitutes the deployment-origin audience.
func GenerateTokenForSessionWithAudience(session UserSession, sessionID string, issuedAt time.Time, lifetime time.Duration, audience string) (string, *Claims, error) {
	if strings.TrimSpace(audience) == "" {
		return "", nil, apperrors.NewUnauthorizedError("resource audience is required")
	}
	return generateTokenForSession(session, sessionID, issuedAt, lifetime, audience)
}

func generateTokenForSession(session UserSession, sessionID string, issuedAt time.Time, lifetime time.Duration, audience string) (string, *Claims, error) {
	if lifetime <= 0 {
		lifetime = DefaultSessionLifetime
	}
	if issuedAt.IsZero() {
		issuedAt = time.Now()
	}
	expirationTime := issuedAt.Add(lifetime)
	claims := &Claims{
		User: session,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(expirationTime),
			IssuedAt:  jwt.NewNumericDate(issuedAt),
			ID:        sessionID,
			Subject:   session.ID,
		},
	}
	// Bind the token to this deployment's resource (its canonical URL) via the
	// standard aud claim, so it cannot be replayed against another environment.
	if aud := strings.TrimSpace(audience); aud != "" {
		claims.RegisteredClaims.Audience = jwt.ClaimStrings{aud}
	} else if aud := resourceIdentifier(); aud != "" {
		claims.RegisteredClaims.Audience = jwt.ClaimStrings{aud}
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	signed, err := token.SignedString(jwtSecret)
	if err != nil {
		return "", nil, err
	}
	return signed, claims, nil
}

// ValidateToken validates and parses a JWT token
func ValidateToken(tokenString string) (*Claims, error) {
	token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
		// Verify signing method
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, apperrors.NewUnauthorizedError("invalid signing method")
		}
		return jwtSecret, nil
	})

	if err != nil {
		return nil, err
	}

	if claims, ok := token.Claims.(*Claims); ok && token.Valid {
		// Ordinary host routes accept only the deployment origin. Tokens issued
		// for a distinct resource such as /mcp must use that resource's own
		// strict admission path; accepting them here would turn a resource-bound
		// credential into a general API bearer. Tokens with no audience remain
		// grandfathered (see tokenAudienceMatches) during the rollout window.
		if !tokenAudienceMatches(claims.Audience, resourceIdentifier()) {
			return nil, apperrors.NewUnauthorizedError("token is not valid for this resource")
		}
		return claims, nil
	}

	return nil, apperrors.NewUnauthorizedError("invalid token")
}

// ValidateTokenForAudience validates a signed JWT for one exact resource.
// It intentionally rejects absent, multi-valued, and mismatched audiences;
// resource routes must not inherit ValidateToken's legacy no-audience grace.
func ValidateTokenForAudience(tokenString, audience string) (*Claims, error) {
	want := strings.TrimSpace(audience)
	if want == "" {
		return nil, apperrors.NewUnauthorizedError("resource audience is required")
	}
	token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, apperrors.NewUnauthorizedError("invalid signing method")
		}
		return jwtSecret, nil
	})
	if err != nil {
		return nil, err
	}
	claims, ok := token.Claims.(*Claims)
	if !ok || !token.Valid || len(claims.Audience) != 1 || strings.TrimSpace(claims.Audience[0]) != want {
		return nil, apperrors.NewUnauthorizedError("token is not valid for this resource")
	}
	return claims, nil
}

// ToMap converts UserSession to a map for formula context
func (u UserSession) ToMap() map[string]interface{} {
	return map[string]interface{}{
		constants.FieldID:   u.ID,
		constants.FieldName: u.Name,
		"email":             u.Email,
		"profile_id":        u.ProfileId,
		// Handle nil pointer safely for optional fields
		"role_id": func() string {
			if u.RoleId != nil {
				return *u.RoleId
			}
			return ""
		}(),
	}
}
