package records

import (
	"context"
	"fmt"
	"time"

	appErrors "github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/kernel/pkg/schemautil"
	"github.com/nexuscrm/kernel/pkg/useridentity"
	"github.com/nexuscrm/shared/pkg/constants"
	"github.com/nexuscrm/shared/pkg/models"
)

// LoadUserSession rebuilds a UserSession from a persisted user ID. Used by
// background workers (durable Bulk ingest, schedulers) that act "on behalf of" a
// user whose HTTP session is gone but whose identity drives permission
// checks. Critically:
//   - Admin capability is never stamped onto the session from a profile name;
//     permission checks re-read the effective permission/capability plane.
//   - Inactive / soft-deleted users are rejected so a disabled account
//     cannot keep executing bulk writes queued before termination.
//     Skipping these checks would be a privilege-escalation vector.
func (ps *PersistenceService) LoadUserSession(ctx context.Context, userID string) (*models.UserSession, error) {
	user, err := ps.cachedFindOne(ctx, constants.TableUser, userID)
	if err != nil {
		return nil, err
	}
	if user == nil {
		return nil, appErrors.NewNotFoundError("User", userID)
	}
	if isDeleted, _ := user["IsDeleted"].(bool); isDeleted {
		return nil, appErrors.NewForbiddenError(fmt.Sprintf("user %s is deleted", userID))
	}
	if v, ok := user["IsDeleted"].(int64); ok && v != 0 {
		return nil, appErrors.NewForbiddenError(fmt.Sprintf("user %s is deleted", userID))
	}
	isActive, _ := user["IsActive"].(bool)
	if !isActive {
		if v, ok := user["IsActive"].(int64); !ok || v == 0 {
			return nil, appErrors.NewForbiddenError(fmt.Sprintf("user %s is inactive", userID))
		}
	}
	// Temporarily-locked accounts are rejected the same way the normal auth
	// path does (auth_service.go:261). Without this, a user whose password
	// was locked by failed-login-rate-limit could still execute background
	// jobs queued pre-lockout — bypassing auth-layer lockout.
	//
	// LockedUntil may arrive as time.Time (hydrated by schema) or as a
	// raw string / []byte (query.ScanRowsToSObjects leaves DATETIME
	// columns as strings because FromStorageRecord doesn't hydrate
	// Date/DateTime). A narrow time.Time-only check would silently
	// no-op in the common case and fail open — a privilege-escalation
	// vector. Accept all three representations.
	if locked, ok := parseLockedUntil(user["LockedUntil"]); ok && time.Now().Before(locked) {
		return nil, appErrors.NewForbiddenError(fmt.Sprintf("user %s is temporarily locked", userID))
	}
	profileID, _ := user[constants.FieldProfileID].(string)
	// Restore RoleID too — sharing-rule evaluation and $UserRole formula
	// context depend on it. Without this a bulk job would get different
	// record-access decisions than the same user's live HTTP session.
	var roleID *string
	if v, ok := user[constants.FieldUserRoleID].(string); ok && v != "" {
		roleID = &v
	}
	// Populate Name / Email from the already-fetched User row, and resolve
	// ProfileName / RoleName via one-shot DB reads at session-load time. These
	// are the same fields the live HTTP login path stamps onto the session
	// (auth_service.go:215-228); without them, formula globals $User.Name,
	// $Profile.Name, $UserRole.Name in bulk-job validation rules and flow
	// formulas would evaluate against empty strings — a regression from the
	// pre-PR behaviour where validation read these from the DB row at
	// evaluation time. Loading them once at session creation preserves
	// Salesforce-parity session-bound semantics (CLAUDE.md §"Salesforce
	// Boundary / Formula context SSoT") while keeping pkg/formula clean.
	//
	// Display name: same construction as live login — auth.ConstructDisplayName
	// over Username / FirstName / LastName. Greenfield User schema has no
	// "Name" column; FirstName + LastName is the displayable form, falling
	// back to Username when both are empty.
	username, _ := user[constants.FieldUsername].(string)
	firstName, _ := user[constants.FieldFirstName].(string)
	lastName, _ := user[constants.FieldLastName].(string)
	name := useridentity.DisplayName(username, firstName, lastName)
	var email *string
	if v, ok := user[constants.FieldEmail].(string); ok && v != "" {
		email = &v
	}
	// DB errors on these reads must bubble — silently degrading to ""
	// ProfileName/RoleName would let a bulk job evaluate $Profile.Name == ""
	// for every record on a transient DB blip without surfacing a failure
	// (Pass B adversarial review on 0720aee, HIGH).
	profileName := ""
	if profileID != "" {
		profile, err := ps.cachedFindOne(ctx, constants.TableProfile, profileID)
		if err != nil {
			return nil, fmt.Errorf("LoadUserSession: read Profile %s: %w", profileID, err)
		}
		if profile != nil {
			profileName, _ = profile[constants.FieldName].(string)
		}
	}
	var roleName *string
	if roleID != nil {
		role, err := ps.cachedFindOne(ctx, constants.TableUserRole, *roleID)
		if err != nil {
			return nil, fmt.Errorf("LoadUserSession: read UserRole %s: %w", *roleID, err)
		}
		if role != nil {
			if v, ok := role[constants.FieldName].(string); ok {
				roleName = &v
			}
		}
	}
	return &models.UserSession{
		ID:          userID,
		Name:        name,
		Email:       email,
		ProfileID:   profileID,
		ProfileName: profileName,
		RoleID:      roleID,
		RoleName:    roleName,
	}, nil
}

// LoadSystemUserSession builds the synthetic platform actor used by internal
// derived writes. Unlike LoadUserSession it intentionally does not require a
// persisted User row for constants.SystemUserID: public-core bootstrap does not
// create one, while audit/system-field attribution still uses that stable ID.
// The actor intentionally has no profile/admin identity: $User.ProfileId,
// $Profile.Id, and $Profile.Name stay aligned with the behavior-bearing session
// fields, and platform-owned derived writes are exposed only through
// $SystemContext.IsDerivedRecompute.
func (ps *PersistenceService) LoadSystemUserSession(ctx context.Context) (*models.UserSession, error) {
	email := constants.SystemUserEmail
	return &models.UserSession{
		ID:    constants.SystemUserID,
		Name:  constants.SystemUserName,
		Email: &email,
	}, nil
}

// parseLockedUntil coerces the various representations a DATETIME
// column can take in an SObject map (time.Time, string, []byte) into
// a time.Time. Returns ok=false for nil / empty / unparseable values
// so the caller can treat "no lock" identically regardless of which
// representation the row hydrator produced.
func parseLockedUntil(v any) (time.Time, bool) {
	switch t := v.(type) {
	case nil:
		return time.Time{}, false
	case time.Time:
		return t, !t.IsZero()
	case string:
		if t == "" {
			return time.Time{}, false
		}
		if parsed, err := schemautil.ParseDBTime([]byte(t)); err == nil {
			return parsed, !parsed.IsZero()
		}
	case []byte:
		if len(t) == 0 {
			return time.Time{}, false
		}
		if parsed, err := schemautil.ParseDBTime(t); err == nil {
			return parsed, !parsed.IsZero()
		}
	}
	return time.Time{}, false
}
