package auth

import (
	"context"
	stderrors "errors"
	"strings"
	"testing"
	"time"

	"github.com/nexuscrm/kernel/pkg/ports"
	"github.com/nexuscrm/shared/pkg/models"
	"github.com/pingcap-inc/nexuscrm-contract/pkg/runtime/permissionstatev1"
)

// fakeApiTokenRepo is an in-memory ports.ApiTokenRepository for service tests.
type fakeApiTokenRepo struct {
	byHash              map[string]*models.SystemApiToken
	byID                map[string]*models.SystemApiToken
	authorizingState    *ports.AuthorizingApiTokenState
	authorizingErr      error
	authorizingCalls    int
	lastAuthorizingHash string
	runtimeCalls        int
	lastRuntimeID       string
	touched             []string
}

func newFakeApiTokenRepo() *fakeApiTokenRepo {
	return &fakeApiTokenRepo{
		byHash: map[string]*models.SystemApiToken{},
		byID:   map[string]*models.SystemApiToken{},
	}
}

func (f *fakeApiTokenRepo) Insert(_ context.Context, t *models.SystemApiToken) error {
	stored := *t
	f.byHash[t.TokenHash] = &stored
	f.byID[t.ID] = &stored
	return nil
}

func (f *fakeApiTokenRepo) GetByHash(_ context.Context, hash string) (*models.SystemApiToken, error) {
	if t, ok := f.byHash[hash]; ok {
		out := *t
		return &out, nil
	}
	return nil, nil
}

func (f *fakeApiTokenRepo) ListByOwner(_ context.Context, ownerID string) ([]models.SystemApiToken, error) {
	var out []models.SystemApiToken
	for _, t := range f.byID {
		if t.OwnerID == ownerID {
			out = append(out, *t)
		}
	}
	return out, nil
}

func (f *fakeApiTokenRepo) Revoke(_ context.Context, id, ownerID string) (bool, error) {
	if t, ok := f.byID[id]; ok && t.OwnerID == ownerID && !t.IsRevoked {
		t.IsRevoked = true
		return true, nil
	}
	return false, nil
}

func (f *fakeApiTokenRepo) RevokeAllNamedForOwnerExcept(_ context.Context, ownerID, name, keepID string) (int64, error) {
	var n int64
	for _, t := range f.byID {
		if t.OwnerID == ownerID && t.Name == name && t.ID != keepID && !t.IsRevoked {
			t.IsRevoked = true
			n++
		}
	}
	return n, nil
}

func (f *fakeApiTokenRepo) TouchLastUsed(_ context.Context, id string) error {
	f.touched = append(f.touched, id)
	return nil
}

func (f *fakeApiTokenRepo) LoadAuthorizingApiTokenState(
	_ context.Context,
	hash string,
) (*ports.AuthorizingApiTokenState, error) {
	f.authorizingCalls++
	f.lastAuthorizingHash = hash
	return f.authorizingState, f.authorizingErr
}

func (f *fakeApiTokenRepo) LoadRuntimeAuthorizingApiTokenState(
	_ context.Context,
	tokenID string,
) (*ports.AuthorizingApiTokenState, error) {
	f.runtimeCalls++
	f.lastRuntimeID = tokenID
	return f.authorizingState, f.authorizingErr
}

type apiTokenRepositoryWithoutAuthorizing struct {
	ports.ApiTokenRepository
}

func TestAuthorizingApiTokenAdmissionReadinessIsCapabilityBased(t *testing.T) {
	if (*ApiTokenService)(nil).AuthorizingApiTokenAdmissionReady() {
		t.Fatal("nil API token service reported strong admission readiness")
	}
	if (*ApiTokenService)(nil).RuntimeAuthorizingApiTokenAdmissionReady() {
		t.Fatal("nil API token service reported runtime admission readiness")
	}
	legacy := NewApiTokenService(
		&apiTokenRepositoryWithoutAuthorizing{},
		nil,
	)
	if legacy.AuthorizingApiTokenAdmissionReady() {
		t.Fatal("legacy API token repository reported strong admission readiness")
	}
	if legacy.RuntimeAuthorizingApiTokenAdmissionReady() {
		t.Fatal("legacy API token repository reported runtime admission readiness")
	}
	strong := NewApiTokenService(newFakeApiTokenRepo(), nil)
	if !strong.AuthorizingApiTokenAdmissionReady() {
		t.Fatal("strong API token repository did not report admission readiness")
	}
	if !strong.RuntimeAuthorizingApiTokenAdmissionReady() {
		t.Fatal("strong API token repository did not report runtime admission readiness")
	}
}

func newTestApiTokenService(user *models.User) (*ApiTokenService, *fakeApiTokenRepo) {
	repo := newFakeApiTokenRepo()
	authSvc := NewAuthService(&userRepoStub{user: user}, &sessionRepoStub{}, nil)
	return NewApiTokenService(repo, authSvc), repo
}

func TestApiTokenServiceClampsScopeToCallerGrant(t *testing.T) {
	svc, _ := newTestApiTokenService(&models.User{ID: "u1", IsActive: true})
	ctx := context.Background()

	// A scoped caller requesting a broader set is clamped to the intersection —
	// the scope it lacks (records.write) must not survive.
	_, token, err := svc.CreateToken(ctx, "u1", "records.read reports.read", "CLI", "records.read records.write reports.read", nil)
	if err != nil {
		t.Fatalf("CreateToken (subset): %v", err)
	}
	if token.Scopes == nil || *token.Scopes == "" {
		t.Fatal("expected a clamped scope, got unrestricted")
	}
	if strings.Contains(*token.Scopes, "records.write") {
		t.Fatalf("token escalated to a scope the caller lacks: %q", *token.Scopes)
	}

	// A blank request is now refused rather than clamped. It used to inherit the caller's scope,
	// which was safe for a scoped caller and full access for an unscoped one — the same input
	// meaning two very different tokens is exactly what design section 10's non-empty requirement
	// removes, so a PAT names what it may do regardless of who minted it.
	if _, _, err := svc.CreateToken(ctx, "u1", "records.read", "CLI", "", nil); err == nil {
		t.Fatal("a blank scope request was accepted; a PAT must declare its own scope")
	}

	// A scoped caller requesting a disjoint scope is rejected outright.
	if _, _, err := svc.CreateToken(ctx, "u1", "records.read", "CLI", "reports.write", nil); err == nil {
		t.Fatal("expected an error when the requested scope is disjoint from the caller's grant")
	}
}

func TestApiTokenServiceRejectsSourceBoundScope(t *testing.T) {
	svc, _ := newTestApiTokenService(&models.User{ID: "u1", IsActive: true})
	svc.SetSourceBoundScopeChecker(func(scope string) bool {
		return scope == "external.records.read"
	})
	ctx := context.Background()

	// A pack-owned (source-backed) scope cannot back a first-party PAT.
	if _, _, err := svc.CreateToken(ctx, "u1", "", "CLI", "external.records.read", nil); err == nil {
		t.Fatal("expected CreateToken to reject a source-backed scope")
	}
	// A non-source-backed scope is still fine.
	if _, _, err := svc.CreateToken(ctx, "u1", "", "CLI", "reports.read", nil); err != nil {
		t.Fatalf("CreateToken (non-source-backed): %v", err)
	}
	// Omitting scopes used to mean full user access and is now refused. This case is kept rather
	// than deleted because it is the one that documents the change: the previous contract is
	// visible here as the thing that no longer holds.
	if _, _, err := svc.CreateToken(ctx, "u1", "", "CLI", "", nil); err == nil {
		t.Fatal("an unscoped token was created; it would have carried the owner's full access")
	}
}

func TestApiTokenServiceCreateAndValidateRoundTrip(t *testing.T) {
	svc, repo := newTestApiTokenService(&models.User{ID: "u1", IsActive: true})
	ctx := context.Background()

	plaintext, token, err := svc.CreateToken(ctx, "u1", "", "My CLI", "records.read reports.read", nil)
	if err != nil {
		t.Fatalf("CreateToken: %v", err)
	}
	if !strings.HasPrefix(plaintext, ApiTokenPlaintextPrefix) {
		t.Fatalf("plaintext missing prefix: %q", plaintext)
	}
	if !LooksLikeApiToken(plaintext) {
		t.Fatalf("LooksLikeApiToken false for %q", plaintext)
	}
	// The stored value must be the hash, never the plaintext.
	if token.TokenHash == plaintext {
		t.Fatal("token hash equals plaintext — plaintext must not be stored")
	}
	if token.TokenHash != HashApiToken(plaintext) {
		t.Fatal("stored hash does not match HashApiToken(plaintext)")
	}
	if got := repo.byHash[HashApiToken(plaintext)]; got == nil {
		t.Fatal("token not persisted under its hash")
	}
	if !strings.HasPrefix(plaintext, token.TokenPrefix) {
		t.Fatalf("display prefix %q is not a prefix of the token", token.TokenPrefix)
	}

	claims, err := svc.Validate(ctx, plaintext)
	if err != nil {
		t.Fatalf("Validate: %v", err)
	}
	if claims.User.ID != "u1" {
		t.Fatalf("claims user = %q, want u1", claims.User.ID)
	}
	if claims.User.Scope != "records.read reports.read" {
		t.Fatalf("claims scope = %q", claims.User.Scope)
	}
	if claims.User.GrantType != ApiTokenGrantType {
		t.Fatalf("claims grant type = %q, want %q", claims.User.GrantType, ApiTokenGrantType)
	}
}

func TestApiTokenServiceValidateRejectsUnknownToken(t *testing.T) {
	svc, _ := newTestApiTokenService(&models.User{ID: "u1", IsActive: true})
	if _, err := svc.Validate(context.Background(), "nxc_does-not-exist"); err == nil {
		t.Fatal("expected error for unknown token")
	}
}

func TestApiTokenServiceValidateRejectsRevokedToken(t *testing.T) {
	svc, _ := newTestApiTokenService(&models.User{ID: "u1", IsActive: true})
	ctx := context.Background()

	plaintext, token, err := svc.CreateToken(ctx, "u1", "", "CLI", "records.read", nil)
	if err != nil {
		t.Fatalf("CreateToken: %v", err)
	}
	if ok, err := svc.Revoke(ctx, token.ID, "u1"); err != nil || !ok {
		t.Fatalf("Revoke = %v, %v", ok, err)
	}
	if _, err := svc.Validate(ctx, plaintext); err == nil {
		t.Fatal("expected error validating a revoked token")
	}
}

func TestApiTokenServiceValidateRejectsExpiredToken(t *testing.T) {
	svc, _ := newTestApiTokenService(&models.User{ID: "u1", IsActive: true})
	ctx := context.Background()

	past := time.Now().Add(-time.Hour)
	plaintext, _, err := svc.CreateToken(ctx, "u1", "", "CLI", "records.read", &past)
	if err != nil {
		t.Fatalf("CreateToken: %v", err)
	}
	if _, err := svc.Validate(ctx, plaintext); err == nil {
		t.Fatal("expected error validating an expired token")
	}
}

func TestAdmitAuthorizingApiTokenReturnsCurrentSubjectAndStrongFloor(t *testing.T) {
	const presented = "nxc_strong-token"
	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",
		FirstName:       "Current",
		LastName:        "User",
		Email:           "current@example.com",
		ProfileName:     "Current Profile",
		UserRoleID:      "role-current",
		UserRoleName:    "Current Role",
		UserAuthorized:  true,
		PermissionFloor: authorizingVersion(t, 37),
	}

	validation, err := NewApiTokenService(repository, nil).
		AdmitAuthorizingApiToken(context.Background(), presented)
	if err != nil {
		t.Fatalf("AdmitAuthorizingApiToken: %v", err)
	}
	if validation == nil || validation.Claims == nil {
		t.Fatal("AdmitAuthorizingApiToken returned empty validation")
	}
	if repository.authorizingCalls != 1 ||
		repository.lastAuthorizingHash != HashApiToken(presented) {
		t.Fatalf(
			"strong read = %d/%q",
			repository.authorizingCalls,
			repository.lastAuthorizingHash,
		)
	}
	if validation.Subject.UserID() != "user-current" ||
		validation.Subject.ProfileID() != "profile-current" ||
		validation.PermissionFloor.Revision() != 37 {
		t.Fatalf("validation = %#v", validation)
	}
	user := validation.Claims.User
	if validation.Claims.ID != "pat-1" ||
		validation.Claims.Subject != "user-current" ||
		user.ID != "user-current" ||
		user.ProfileId != "profile-current" ||
		user.Scope != scope ||
		user.GrantType != ApiTokenGrantType ||
		user.SessionClass != ApiTokenGrantType ||
		user.RoleId == nil ||
		*user.RoleId != "role-current" {
		t.Fatalf("current claims = %#v", validation.Claims)
	}
	if len(repository.touched) != 1 || repository.touched[0] != "pat-1" {
		t.Fatalf("last-used touches = %#v", repository.touched)
	}
}

func TestAdmitRuntimeAuthorizingApiTokenUsesBearerFreeSelectors(t *testing.T) {
	repository := newFakeApiTokenRepo()
	repository.authorizingState = &ports.AuthorizingApiTokenState{
		Token: models.SystemApiToken{
			ID:      "pat-1",
			OwnerID: "user-current",
		},
		Subject:         authorizingSubject(t, "user-current", "profile-current"),
		UserAuthorized:  true,
		PermissionFloor: authorizingVersion(t, 41),
	}

	validation, err := NewApiTokenService(repository, nil).
		AdmitRuntimeAuthorizingApiToken(
			context.Background(),
			"pat-1",
			"user-current",
		)
	if err != nil {
		t.Fatalf("AdmitRuntimeAuthorizingApiToken: %v", err)
	}
	if repository.runtimeCalls != 1 || repository.lastRuntimeID != "pat-1" {
		t.Fatalf(
			"runtime strong read = %d/%q",
			repository.runtimeCalls,
			repository.lastRuntimeID,
		)
	}
	if repository.authorizingCalls != 0 {
		t.Fatalf(
			"runtime admission used presented-token reader %d time(s)",
			repository.authorizingCalls,
		)
	}
	if validation == nil ||
		validation.Claims == nil ||
		validation.Claims.ID != "pat-1" ||
		validation.Subject.UserID() != "user-current" ||
		validation.Subject.ProfileID() != "profile-current" ||
		validation.PermissionFloor.Revision() != 41 {
		t.Fatalf("runtime validation = %#v", validation)
	}
}

func TestAdmitRuntimeAuthorizingApiTokenRejectsWrongPrincipal(t *testing.T) {
	repository := newFakeApiTokenRepo()
	repository.authorizingState = &ports.AuthorizingApiTokenState{
		Token: models.SystemApiToken{
			ID:      "pat-1",
			OwnerID: "user-current",
		},
		Subject:         authorizingSubject(t, "user-current", "profile-current"),
		UserAuthorized:  true,
		PermissionFloor: authorizingVersion(t, 41),
	}

	validation, err := NewApiTokenService(repository, nil).
		AdmitRuntimeAuthorizingApiToken(
			context.Background(),
			"pat-1",
			"other-user",
		)
	if validation != nil || httpStatus(err) != 401 {
		t.Fatalf("wrong principal = %#v, %T(%v)", validation, err, err)
	}
}

func TestAuthorizingApiTokenSeparatesCredentialAndAuthorityFailures(t *testing.T) {
	repository := newFakeApiTokenRepo()
	service := NewApiTokenService(repository, nil)
	if validation, err := service.AdmitAuthorizingApiToken(
		context.Background(),
		"nxc_unknown",
	); validation != nil || httpStatus(err) != 401 {
		t.Fatalf("unknown token = %#v, %T(%v)", validation, err, err)
	}

	legacy := NewApiTokenService(
		apiTokenRepositoryWithoutAuthorizing{ApiTokenRepository: repository},
		nil,
	)
	validation, err := legacy.AdmitAuthorizingApiToken(
		context.Background(),
		"nxc_known",
	)
	var failure *permissionstatev1.Failure
	if validation != nil ||
		!stderrors.As(err, &failure) ||
		failure.Category != permissionstatev1.FailureAuthorityUnavailable {
		t.Fatalf("missing authority = %#v, %T(%v)", validation, err, err)
	}
}

func TestAuthorizingApiTokenDoesNotLeakRepositoryFailure(t *testing.T) {
	internal := stderrors.New("dial tcp secret-db.internal:4000")
	repository := newFakeApiTokenRepo()
	repository.authorizingErr = internal
	validation, err := NewApiTokenService(repository, nil).
		AdmitAuthorizingApiToken(context.Background(), "nxc_known")
	if validation != nil || err == nil {
		t.Fatalf("admission = %#v, %v; want authority failure", validation, err)
	}
	if strings.Contains(err.Error(), "secret-db.internal") {
		t.Fatalf("authority failure leaked repository detail: %v", err)
	}
	caused, ok := err.(interface{ Cause() error })
	if !ok || !stderrors.Is(caused.Cause(), internal) {
		t.Fatalf("internal cause = %T %#v, want original error", err, caused)
	}
}

func TestApiTokenServiceRevokeIsScopedToOwner(t *testing.T) {
	svc, _ := newTestApiTokenService(&models.User{ID: "u1", IsActive: true})
	ctx := context.Background()

	_, token, err := svc.CreateToken(ctx, "u1", "", "CLI", "records.read", nil)
	if err != nil {
		t.Fatalf("CreateToken: %v", err)
	}
	// A different user cannot revoke someone else's token.
	if ok, err := svc.Revoke(ctx, token.ID, "someone-else"); err != nil || ok {
		t.Fatalf("cross-owner revoke = %v, %v; want false, nil", ok, err)
	}
}

func TestHashApiTokenIsDeterministicAndDistinct(t *testing.T) {
	if HashApiToken("nxc_a") != HashApiToken("nxc_a") {
		t.Fatal("hash not deterministic")
	}
	if HashApiToken("nxc_a") == HashApiToken("nxc_b") {
		t.Fatal("distinct tokens collided")
	}
}
