package auth

import (
	"context"
	"strings"
	"testing"

	"github.com/nexuscrm/kernel/pkg/ports"
	"github.com/nexuscrm/shared/pkg/models"
)

// TestPersonalAccessTokensMustDeclareAScope is design section 10's PAT rule: a non-empty scope and
// an explicit credential class.
//
// The non-empty half is enforced at creation, not at admission, and the difference is the whole
// reason it can land now. 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 backlog is being added to faster than it is worked down.
func TestPersonalAccessTokensMustDeclareAScope(t *testing.T) {
	ctx := context.Background()

	for name, requested := range map[string]string{
		"empty":           "",
		"whitespace only": "   ",
		"tabs":            "\t\t",
	} {
		t.Run(name, func(t *testing.T) {
			svc, _ := newTestApiTokenService(&models.User{ID: "u1", IsActive: true})
			plaintext, token, err := svc.CreateToken(ctx, "u1", "", "CLI", requested, nil)
			if err == nil {
				t.Fatal("an unscoped token was created; it would carry the owner's full access")
			}
			if plaintext != "" || token != nil {
				t.Fatalf("a refused creation returned a credential: %q / %#v", plaintext, token)
			}
			// The message has to say what to do. A bare "invalid scopes" leaves the operator
			// guessing whether the value was malformed or simply absent.
			if !strings.Contains(err.Error(), "at least one scope") {
				t.Fatalf("error does not name the requirement: %v", err)
			}
		})
	}

	t.Run("a declared scope is accepted", func(t *testing.T) {
		// Anti-vacuity: the refusals above would also pass if creation were broken outright.
		svc, _ := newTestApiTokenService(&models.User{ID: "u1", IsActive: true})
		_, token, err := svc.CreateToken(ctx, "u1", "", "CLI", "records.read", nil)
		if err != nil {
			t.Fatalf("CreateToken with a scope: %v", err)
		}
		if token.Scopes == nil || *token.Scopes != "records.read" {
			t.Fatalf("stored scopes = %v, want the declared scope", token.Scopes)
		}
	})
}

// TestAdmittedPersonalAccessTokenCarriesItsExplicitClass is the other half of the same rule.
// Section 8.1 permits a PAT to omit Connected App identity *only* when its explicit credential
// class is present, so a PAT admission that looked like an ordinary session would take the
// historical direct-login branch the design forbids.
//
// This asserts the fact rather than trusting the comment that used to state it.
func TestAdmittedPersonalAccessTokenCarriesItsExplicitClass(t *testing.T) {
	const presented = "nxc_explicit-class"
	scope := "records.read"
	repository := newFakeApiTokenRepo()
	repository.authorizingState = &ports.AuthorizingApiTokenState{
		Token: models.SystemApiToken{
			ID:        "pat-1",
			OwnerID:   "user-current",
			TokenHash: HashApiToken(presented),
			Scopes:    &scope,
		},
		Subject:         authorizingSubject(t, "user-current", "profile-current"),
		Username:        "current.user",
		UserAuthorized:  true,
		PermissionFloor: authorizingVersion(t, 37),
	}

	validation, err := NewApiTokenService(repository, nil).
		AdmitAuthorizingApiToken(context.Background(), presented)
	if err != nil {
		t.Fatalf("AdmitAuthorizingApiToken: %v", err)
	}
	if got := validation.Claims.User.SessionClass; got != ApiTokenGrantType {
		t.Fatalf("session class = %q, want the explicit PAT class %q", got, ApiTokenGrantType)
	}
	if got := validation.Claims.User.GrantType; got != ApiTokenGrantType {
		t.Fatalf("grant type = %q, want the explicit PAT class %q", got, ApiTokenGrantType)
	}
	if validation.Claims.User.Scope != "records.read" {
		t.Fatalf("admitted scope = %q, want the token's declared scope", validation.Claims.User.Scope)
	}
}
