package auth

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	stderrors "errors"
	"strings"
	"time"

	"github.com/golang-jwt/jwt/v5"
	"github.com/nexuscrm/kernel/pkg/auth"
	"github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/kernel/pkg/ports"
	"github.com/nexuscrm/kernel/pkg/utils"
	"github.com/nexuscrm/shared/pkg/models"
)

const (
	// ApiTokenPlaintextPrefix marks a personal access token so the request-auth
	// middleware can route it to PAT validation instead of JWT verification.
	ApiTokenPlaintextPrefix = "nxc_"
	// ApiTokenGrantType labels the grant/session class carried by PAT-authenticated requests.
	ApiTokenGrantType = "personal_access_token"

	apiTokenRandomBytes      = 30
	apiTokenDisplayPrefixLen = 12
)

// ApiTokenService issues and validates self-service personal access tokens.
// Only a token's hash is persisted; the plaintext is returned to its owner once.
type ApiTokenService struct {
	repo                     ports.ApiTokenRepository
	authorizingTokens        ports.AuthorizingApiTokenRepository
	runtimeAuthorizingTokens ports.RuntimeAuthorizingApiTokenRepository
	auth                     *AuthService
	scopeIsSourceBound       func(scope string) bool
}

// NewApiTokenService constructs an ApiTokenService. authSvc supplies the shared
// user-session assembly so a PAT authenticates as exactly the user a login would.
func NewApiTokenService(repo ports.ApiTokenRepository, authSvc *AuthService) *ApiTokenService {
	authorizingTokens, _ := repo.(ports.AuthorizingApiTokenRepository)
	runtimeAuthorizingTokens, _ := repo.(ports.RuntimeAuthorizingApiTokenRepository)
	return &ApiTokenService{
		repo:                     repo,
		authorizingTokens:        authorizingTokens,
		runtimeAuthorizingTokens: runtimeAuthorizingTokens,
		auth:                     authSvc,
	}
}

// AuthorizingApiTokenAdmissionReady reports whether this service owns the
// additive strong repository capability. Production authorizing compositions
// use it to reject construction around the legacy credential-only reader.
func (s *ApiTokenService) AuthorizingApiTokenAdmissionReady() bool {
	return s != nil && s.authorizingTokens != nil
}

// RuntimeAuthorizingApiTokenAdmissionReady reports whether bearer-free
// background PAT re-admission is available.
func (s *ApiTokenService) RuntimeAuthorizingApiTokenAdmissionReady() bool {
	return s != nil && s.runtimeAuthorizingTokens != nil
}

// SetSourceBoundScopeChecker installs a predicate that reports whether a scope is
// owned by an application pack (source-backed). Such scopes are delegated grants
// tied to a Connected App owner, so a personal access token — a first-party
// credential with no Connected App — can never satisfy their route policy and
// must not carry them (it would be accepted at creation yet 403 on every use).
// Nil (the default) disables the check, which suits unit tests with no registry.
func (s *ApiTokenService) SetSourceBoundScopeChecker(fn func(scope string) bool) {
	s.scopeIsSourceBound = fn
}

// HashApiToken derives the stored, unique lookup key for a plaintext token.
// sha256 (not bcrypt) is used deliberately: the presented bearer carries no
// separate locator, so validation must find the row by a deterministic hash.
func HashApiToken(token string) string {
	sum := sha256.Sum256([]byte(token))
	return base64.RawURLEncoding.EncodeToString(sum[:])
}

// LooksLikeApiToken reports whether a bearer value is a personal access token.
func LooksLikeApiToken(bearer string) bool {
	return strings.HasPrefix(bearer, ApiTokenPlaintextPrefix)
}

// CreateToken generates a new personal access token for ownerID, persists only
// its hash, and returns the plaintext value exactly once. scope is a
// space-delimited OAuth-style scope string; expiresAt nil means no expiry.
func (s *ApiTokenService) CreateToken(ctx context.Context, ownerID, callerScope, name, scope string, expiresAt *time.Time) (string, *models.SystemApiToken, error) {
	ownerID = strings.TrimSpace(ownerID)
	name = strings.TrimSpace(name)
	if ownerID == "" {
		return "", nil, errors.NewUnauthorizedError("missing token owner")
	}
	if name == "" {
		return "", nil, errors.NewValidationError("name", "Token name is required")
	}

	buf := make([]byte, apiTokenRandomBytes)
	if _, err := rand.Read(buf); err != nil {
		return "", nil, errors.NewInternalError("failed to generate token", err)
	}
	plaintext := ApiTokenPlaintextPrefix + base64.RawURLEncoding.EncodeToString(buf)

	displayPrefix := plaintext
	if len(displayPrefix) > apiTokenDisplayPrefixLen {
		displayPrefix = displayPrefix[:apiTokenDisplayPrefixLen]
	}

	// A personal access token must name what it may do. Design section 10 requires a non-empty
	// scope and an explicit credential class for a PAT, and this is the non-empty half.
	//
	// It is enforced at creation rather than at admission, and the difference matters. Changing
	// how an *existing* empty scope is interpreted would break credentials that are in use, which
	// section 11 refuses to do before consumers migrate. Refusing to mint a *new* unscoped one
	// breaks nothing that exists and stops the population growing while that migration runs —
	// otherwise the inventory's backlog is being added to faster than it is worked down.
	//
	// A PAT is the worst credential to leave unscoped: long-lived, user-minted, and today an empty
	// scope means full access, so a token created to read one object can do everything its owner
	// can, forever.
	if strings.TrimSpace(scope) == "" {
		return "", nil, errors.NewValidationError(
			"scopes",
			"a personal access token must declare at least one scope; an unscoped token would carry your full access",
		)
	}

	// Clamp the new token's scope to the caller's current session scope so a
	// narrowly-scoped OAuth session cannot mint a broader PAT and thereby escape its
	// Connected App grant. An empty caller scope is a full (unrestricted) session and may
	// grant any scope the requester named.
	effectiveScope := strings.TrimSpace(scope)
	if cs := strings.TrimSpace(callerScope); cs != "" {
		clamped, err := ComputeEffectiveScope(&cs, effectiveScope)
		if err != nil {
			return "", nil, errors.NewValidationError("scopes", "requested scopes exceed your current session grant")
		}
		effectiveScope = clamped
	}
	// A PAT is a first-party user credential with no Connected App, so it can
	// never satisfy the route policy of a pack-owned (source-backed) scope. Refuse
	// such scopes up front instead of issuing a token that would 403 on every
	// request — those integrations must use the OAuth flow.
	if s.scopeIsSourceBound != nil {
		for _, sc := range strings.Fields(effectiveScope) {
			if s.scopeIsSourceBound(sc) {
				return "", nil, errors.NewValidationError("scopes", "scope "+sc+" is issued only through OAuth Connected Apps and cannot back a personal access token")
			}
		}
	}
	var scopePtr *string
	if effectiveScope != "" {
		scopePtr = &effectiveScope
	}

	token := &models.SystemApiToken{
		ID:          utils.GenerateID(),
		OwnerID:     ownerID,
		Name:        name,
		TokenHash:   HashApiToken(plaintext),
		TokenPrefix: displayPrefix,
		Scopes:      scopePtr,
		ExpiresAt:   expiresAt,
		IsRevoked:   false,
	}

	if err := s.repo.Insert(ctx, token); err != nil {
		return "", nil, err
	}
	return plaintext, token, nil
}

// ListForOwner returns the caller's tokens (metadata only; the hash is never
// exposed to a client by the handler layer).
func (s *ApiTokenService) ListForOwner(ctx context.Context, ownerID string) ([]models.SystemApiToken, error) {
	return s.repo.ListByOwner(ctx, ownerID)
}

// Revoke revokes one of the caller's tokens, scoped to ownerID.
func (s *ApiTokenService) Revoke(ctx context.Context, id, ownerID string) (bool, error) {
	return s.repo.Revoke(ctx, id, ownerID)
}

// RevokeAllNamedForOwnerExcept revokes every active token of ownerID whose
// Name equals name, except keepID, in one atomic statement (token-rotation
// support; the name filter keeps rotation from touching the owner's unrelated
// tokens).
func (s *ApiTokenService) RevokeAllNamedForOwnerExcept(ctx context.Context, ownerID, name, keepID string) (int64, error) {
	return s.repo.RevokeAllNamedForOwnerExcept(ctx, ownerID, name, keepID)
}

// Validate authenticates a presented personal access token and returns claims
// for its owner, mirroring the shape produced by JWT session validation so the
// same authorization plane applies. It fails closed on unknown, revoked, or
// expired tokens and on inactive/locked owners.
func (s *ApiTokenService) Validate(ctx context.Context, presented string) (*auth.Claims, error) {
	presented = strings.TrimSpace(presented)
	if presented == "" {
		return nil, errors.NewUnauthorizedError("missing token")
	}

	row, err := s.repo.GetByHash(ctx, HashApiToken(presented))
	if err != nil {
		return nil, err
	}
	if row == nil || row.IsRevoked {
		return nil, errors.NewUnauthorizedError("invalid or revoked API token")
	}
	if row.ExpiresAt != nil && time.Now().After(*row.ExpiresAt) {
		return nil, errors.NewUnauthorizedError("API token has expired")
	}

	userSession, err := s.auth.buildUserSessionForUser(ctx, row.OwnerID)
	if err != nil {
		return nil, err
	}
	if row.Scopes != nil {
		userSession.Scope = *row.Scopes
	}
	userSession.GrantType = ApiTokenGrantType
	userSession.SessionClass = ApiTokenGrantType

	// Best-effort last-used bookkeeping; never fail an authenticated request on it.
	_ = s.repo.TouchLastUsed(ctx, row.ID)

	return &auth.Claims{
		User: userSession,
		RegisteredClaims: jwt.RegisteredClaims{
			ID:      row.ID,
			Subject: userSession.ID,
		},
	}, nil
}

// AdmitAuthorizingApiToken validates a PAT through one strong read of the
// credential, current subject, and Permission State floor. The legacy Validate
// method remains unchanged for source compatibility and cannot satisfy the new
// authorizing path.
func (s *ApiTokenService) AdmitAuthorizingApiToken(
	ctx context.Context,
	presented string,
) (*AuthorizingSessionValidation, error) {
	presented = strings.TrimSpace(presented)
	if presented == "" {
		return nil, errors.NewUnauthorizedError("missing token")
	}
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	if s == nil || s.authorizingTokens == nil {
		return nil, newSecurityAuthorityUnavailable(
			stderrors.New("authorizing API token repository is not configured"),
		)
	}

	tokenHash := HashApiToken(presented)
	state, err := s.authorizingTokens.LoadAuthorizingApiTokenState(ctx, tokenHash)
	if err != nil {
		if ctxErr := ctx.Err(); ctxErr != nil {
			return nil, ctxErr
		}
		return nil, newSecurityAuthorityUnavailable(err)
	}
	return s.admitAuthorizingApiTokenState(
		ctx,
		state,
		tokenHash,
		"",
		"",
	)
}

// AdmitRuntimeAuthorizingApiToken performs one bearer-free strong admission
// using the durable PAT and principal selectors captured by an already
// admitted request.
func (s *ApiTokenService) AdmitRuntimeAuthorizingApiToken(
	ctx context.Context,
	tokenID string,
	principalID string,
) (*AuthorizingSessionValidation, error) {
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	tokenID = strings.TrimSpace(tokenID)
	principalID = strings.TrimSpace(principalID)
	if tokenID == "" || principalID == "" {
		return nil, errors.NewUnauthorizedError("invalid or revoked API token")
	}
	if s == nil || s.runtimeAuthorizingTokens == nil {
		return nil, newSecurityAuthorityUnavailable(
			stderrors.New(
				"runtime authorizing API token repository is not configured",
			),
		)
	}
	state, err := s.runtimeAuthorizingTokens.LoadRuntimeAuthorizingApiTokenState(
		ctx,
		tokenID,
	)
	if err != nil {
		if ctxErr := ctx.Err(); ctxErr != nil {
			return nil, ctxErr
		}
		return nil, newSecurityAuthorityUnavailable(err)
	}
	return s.admitAuthorizingApiTokenState(
		ctx,
		state,
		"",
		tokenID,
		principalID,
	)
}

func (s *ApiTokenService) admitAuthorizingApiTokenState(
	ctx context.Context,
	state *ports.AuthorizingApiTokenState,
	expectedTokenHash string,
	expectedTokenID string,
	expectedPrincipalID string,
) (*AuthorizingSessionValidation, error) {
	if state == nil ||
		state.Token.IsRevoked ||
		(state.Token.ExpiresAt != nil && !time.Now().Before(*state.Token.ExpiresAt)) ||
		!state.UserAuthorized {
		return nil, errors.NewUnauthorizedError("invalid or revoked API token")
	}
	if err := state.PermissionFloor.Validate(); err != nil {
		return nil, newSecurityAuthorityUnavailable(err)
	}
	if expectedPrincipalID != "" &&
		state.Subject.UserID() != expectedPrincipalID {
		return nil, errors.NewUnauthorizedError(
			"invalid or revoked API token",
		)
	}
	if err := state.Subject.Validate(); err != nil ||
		expectedTokenHash != "" && state.Token.TokenHash != expectedTokenHash ||
		expectedTokenID != "" && state.Token.ID != expectedTokenID ||
		state.Token.OwnerID != state.Subject.UserID() {
		return nil, newSecurityAuthorityUnavailable(
			stderrors.New("authorizing API token subject is inconsistent"),
		)
	}

	userSession := auth.UserSession{
		ID:           state.Subject.UserID(),
		Name:         auth.ConstructDisplayName(state.Username, state.FirstName, state.LastName),
		Email:        state.Email,
		ProfileId:    state.Subject.ProfileID(),
		ProfileName:  state.ProfileName,
		RoleId:       authorizingOptionalString(state.UserRoleID),
		RoleName:     authorizingOptionalString(state.UserRoleName),
		GrantType:    ApiTokenGrantType,
		SessionClass: ApiTokenGrantType,
	}
	if state.Token.Scopes != nil {
		userSession.Scope = *state.Token.Scopes
	}

	// Last-used telemetry is not authorization authority and remains
	// best-effort after successful strong admission.
	if s.repo != nil {
		_ = s.repo.TouchLastUsed(ctx, state.Token.ID)
	}

	// Tokens do not pass through the durable-session validator, so the scope inventory is
	// reported here — the other place an admission's scope becomes known. A PAT is the most
	// urgent part of the empty-scope backlog: long-lived, user-minted, and today an empty scope
	// on one means full access.
	s.auth.observeCredentialScope(CredentialScopeClassAPIToken, userSession.Scope)
	return &AuthorizingSessionValidation{
		Claims: &auth.Claims{
			User: userSession,
			RegisteredClaims: jwt.RegisteredClaims{
				ID:      state.Token.ID,
				Subject: state.Subject.UserID(),
			},
		},
		Subject:         state.Subject,
		PermissionFloor: state.PermissionFloor,
	}, nil
}
