package auth

import (
	"context"
	"encoding/base64"
	"errors"
	"strings"
	"testing"
	"time"

	kernelerrors "github.com/nexuscrm/kernel/pkg/errors"
	"github.com/nexuscrm/kernel/pkg/ports"
	semantic "github.com/pingcap-inc/nexuscrm-contract/pkg/runtime/federatedloginv1"
)

const ssoTestRedirect = "https://app.example.com/oauth/callback"

func ssoService(t *testing.T) (*OAuthService, *oauthRepoStub) {
	t.Helper()
	repo := &oauthRepoStub{}
	return &OAuthService{oauthRepo: repo}, repo
}

// TestSSOLoginStateIsSingleUse is the property the whole binding exists for.
//
// A state that is merely *checked* can be used twice — once by the victim and once by whoever
// captured the provider's redirect. Consumption has to be the store's compare-and-set, not a read
// followed by a decision, so this asserts the second attempt fails rather than that some validation
// ran.
func TestSSOLoginStateIsSingleUse(t *testing.T) {
	svc, _ := ssoService(t)
	ctx := context.Background()

	transaction, err := svc.BeginSSOLoginTransaction(ctx, ssoTestRedirect)
	if err != nil {
		t.Fatalf("BeginSSOLoginTransaction: %v", err)
	}
	if strings.TrimSpace(transaction.State) == "" || transaction.ExpiresAt.IsZero() {
		t.Fatalf("transaction = %+v, want a state and an expiry", transaction)
	}
	if err := semantic.ValidateBeginResponse(semantic.BeginResponse{
		State: transaction.State, ExpiresAtUnixSecond: transaction.ExpiresAt.Unix(),
	}); err != nil {
		t.Fatalf("transaction state is not canonical: %v", err)
	}

	if err := svc.CompleteSSOLoginTransaction(ctx, transaction.State, ssoTestRedirect); err != nil {
		t.Fatalf("first completion: %v", err)
	}
	if err := svc.CompleteSSOLoginTransaction(ctx, transaction.State, ssoTestRedirect); err == nil {
		t.Fatal("the same state completed a second login; a captured redirect could be replayed")
	}
}

// TestSSOLoginStateIsFreshForEveryTransaction keeps state generation at the Runtime owner while
// checking only its public result. Reusing a valid-looking state would let one provider callback
// address a different login transaction even though each stored row remains single-use.
func TestSSOLoginStateIsFreshForEveryTransaction(t *testing.T) {
	svc, _ := ssoService(t)
	ctx := context.Background()

	first, err := svc.BeginSSOLoginTransaction(ctx, ssoTestRedirect)
	if err != nil {
		t.Fatalf("first BeginSSOLoginTransaction: %v", err)
	}
	second, err := svc.BeginSSOLoginTransaction(ctx, ssoTestRedirect)
	if err != nil {
		t.Fatalf("second BeginSSOLoginTransaction: %v", err)
	}
	if first.State == second.State {
		t.Fatal("two login transactions received the same state")
	}
	for _, transaction := range []*SSOLoginTransaction{first, second} {
		if err := semantic.ValidateBeginResponse(semantic.BeginResponse{
			State: transaction.State, ExpiresAtUnixSecond: transaction.ExpiresAt.Unix(),
		}); err != nil {
			t.Fatalf("transaction state is not canonical: %v", err)
		}
	}
}

// TestSSOLoginConsumesAPreUpgradeState preserves the ten-minute rolling-upgrade
// window. The previous release exposed and stored ssologin.<padded-base64> as
// one handle. L1 projects that visible value into the neutral canonical state;
// the durable owner must still address the exact old row.
func TestSSOLoginConsumesAPreUpgradeState(t *testing.T) {
	svc, repo := ssoService(t)
	entropy := make([]byte, semantic.StateEntropyBytes)
	legacyState := ssoLoginStatePrefix + base64.URLEncoding.EncodeToString(entropy)
	canonicalState := base64.RawURLEncoding.EncodeToString(entropy)
	repo.authorizeExpectedHandle = legacyState
	repo.authorizeData = &ports.OAuthAuthorizeRequestData{
		RedirectURI: ssoTestRedirect,
		ExpiresAt:   time.Now().Add(ssoLoginTransactionTTL),
	}

	if err := svc.CompleteSSOLoginTransaction(context.Background(), canonicalState, ssoTestRedirect); err != nil {
		t.Fatalf("pre-upgrade state was not consumable during its TTL: %v", err)
	}
	if repo.authorizeFetchedHandle != legacyState || repo.authorizeConsumedHandle != legacyState {
		t.Fatalf("pre-upgrade state addressed fetch/consume handles %q/%q, want %q exactly",
			repo.authorizeFetchedHandle, repo.authorizeConsumedHandle, legacyState)
	}
}

// TestSSOLoginStateIsBoundToItsRedirect covers most of what "binding" means. A state accepted for any
// target can be replayed into a different one, which is how a login gets completed somewhere the user
// never started it.
func TestSSOLoginStateIsBoundToItsRedirect(t *testing.T) {
	svc, _ := ssoService(t)
	ctx := context.Background()

	transaction, err := svc.BeginSSOLoginTransaction(ctx, ssoTestRedirect)
	if err != nil {
		t.Fatalf("BeginSSOLoginTransaction: %v", err)
	}

	err = svc.CompleteSSOLoginTransaction(ctx, transaction.State, "https://evil.example.com/oauth/callback")
	if err == nil {
		t.Fatal("a state issued for one redirect completed against another")
	}
	var unauthorized *kernelerrors.UnauthorizedError
	if !errors.As(err, &unauthorized) {
		t.Fatalf("err = %v (%T), want a rejection", err, err)
	}
}

// TestAForeignHandleIsRefusedWithoutBeingSpent is the review finding, and the ordering is the whole
// point rather than the refusal.
//
// The browser-visible state is canonical and therefore cannot carry a prefix.
// The owner adds its namespace only to the durable lookup key, so a value from
// another flow cannot address that flow's row even when a lookup is attempted.
func TestAForeignHandleIsRefusedWithoutBeingSpent(t *testing.T) {
	svc, repo := ssoService(t)
	ctx := context.Background()

	// A handle that belongs to the authorize-resume flow, i.e. one without this flow's namespace.
	if err := svc.CompleteSSOLoginTransaction(ctx, "authorize-handle-from-another-flow", ssoTestRedirect); err == nil {
		t.Fatal("a foreign handle completed an SSO login")
	}
	if repo.authorizeConsumed {
		t.Fatal("a foreign handle was consumed; another flow's pending login would be permanently spent")
	}
	if repo.authorizeFetchedHandle != ssoLoginStorageHandle("authorize-handle-from-another-flow") {
		t.Fatalf("foreign handle addressed %q, want only the SSO namespace", repo.authorizeFetchedHandle)
	}
}

// TestARejectedCompletionDoesNotSpendTheState covers the same class from the other side: a state that
// is ours but fails a check must remain usable, or a user who mistypes a redirect — or hits a
// transient rejection — has their login burned by something unrelated to the state.
func TestARejectedCompletionDoesNotSpendTheState(t *testing.T) {
	svc, repo := ssoService(t)
	ctx := context.Background()

	transaction, err := svc.BeginSSOLoginTransaction(ctx, ssoTestRedirect)
	if err != nil {
		t.Fatalf("BeginSSOLoginTransaction: %v", err)
	}

	if err := svc.CompleteSSOLoginTransaction(ctx, transaction.State, "https://evil.example.com/oauth/callback"); err == nil {
		t.Fatal("a mismatched redirect completed the login")
	}
	if repo.authorizeConsumed {
		t.Fatal("a rejected completion spent the state, so the user cannot retry")
	}

	// Still usable, which is what "not spent" has to mean.
	if err := svc.CompleteSSOLoginTransaction(ctx, transaction.State, ssoTestRedirect); err != nil {
		t.Fatalf("the state was unusable after a rejected attempt: %v", err)
	}
}

// TestSSOLoginCompletionAnswersIdenticallyForEveryBadState is the anti-oracle property. Unknown,
// expired and already-spent all mean "start again", so distinguishing them only tells a caller which
// of its guesses came closest.
func TestSSOLoginCompletionAnswersIdenticallyForEveryBadState(t *testing.T) {
	ctx := context.Background()

	var messages []string
	for name, arrange := range map[string]func(*testing.T) (*OAuthService, string){
		"unknown state": func(t *testing.T) (*OAuthService, string) {
			svc, _ := ssoService(t)
			return svc, "a-state-that-was-never-issued"
		},
		"already spent": func(t *testing.T) (*OAuthService, string) {
			svc, _ := ssoService(t)
			transaction, err := svc.BeginSSOLoginTransaction(ctx, ssoTestRedirect)
			if err != nil {
				t.Fatalf("BeginSSOLoginTransaction: %v", err)
			}
			if err := svc.CompleteSSOLoginTransaction(ctx, transaction.State, ssoTestRedirect); err != nil {
				t.Fatalf("first completion: %v", err)
			}
			return svc, transaction.State
		},
		"empty state": func(t *testing.T) (*OAuthService, string) {
			svc, _ := ssoService(t)
			return svc, ""
		},
	} {
		t.Run(name, func(t *testing.T) {
			svc, state := arrange(t)
			err := svc.CompleteSSOLoginTransaction(ctx, state, ssoTestRedirect)
			if err == nil {
				t.Fatal("a bad state completed a login")
			}
			messages = append(messages, err.Error())
		})
	}

	for i := 1; i < len(messages); i++ {
		if messages[i] != messages[0] {
			t.Fatalf("failure messages differ (%q vs %q), which tells a caller why its state was refused", messages[i], messages[0])
		}
	}
}

// TestSSOLoginStateStorageRecordsOnlyWhatTheFlowInvolves checks the row this writes into a shared
// security table. PKCE fields stay empty because NexusCRM issues no authorization code in an SSO
// exchange, and the client is recorded as the built-in browser because that is what the login is for
// — a row claiming a client that took no part would be worse than no row.
func TestSSOLoginStateStorageRecordsOnlyWhatTheFlowInvolves(t *testing.T) {
	svc, repo := ssoService(t)

	transaction, err := svc.BeginSSOLoginTransaction(context.Background(), ssoTestRedirect)
	if err != nil {
		t.Fatalf("BeginSSOLoginTransaction: %v", err)
	}
	stored := repo.authorizeStored
	if stored == nil {
		t.Fatal("nothing was stored, so nothing binds the login")
	}
	if stored.ClientID != BuiltInBrowserClientID {
		t.Fatalf("stored client = %q, want the built-in browser client", stored.ClientID)
	}
	if stored.RedirectURI != ssoTestRedirect {
		t.Fatalf("stored redirect = %q, want the one the login began with", stored.RedirectURI)
	}
	if stored.CodeChallenge != "" || stored.CodeChallengeMethod != "" {
		t.Fatalf("stored PKCE fields = %q/%q, want empty: no authorization code is issued here", stored.CodeChallenge, stored.CodeChallengeMethod)
	}
	if stored.ExpiresAt.IsZero() {
		t.Fatal("the state does not expire")
	}
	// The raw state must not be mistaken for a stored secret elsewhere; the store hashes the
	// handle, and this asserts the service hands it the handle rather than a hash it computed
	// itself, which would give the table two competing formats.
	if stored.Handle != ssoLoginStorageHandle(transaction.State) {
		t.Fatalf("stored handle = %q, want namespaced public state", stored.Handle)
	}
}

// TestSSOLoginRequiresARedirectToBeginWith keeps a state from being issued with nothing to bind it
// to. A state with no redirect is accepted by every redirect, which is the case the binding removes.
func TestSSOLoginRequiresARedirectToBeginWith(t *testing.T) {
	svc, _ := ssoService(t)

	for _, redirect := range []string{"", "   "} {
		transaction, err := svc.BeginSSOLoginTransaction(context.Background(), redirect)
		if err == nil || transaction != nil {
			t.Fatalf("BeginSSOLoginTransaction(%q) = %#v, %v; want a refusal", redirect, transaction, err)
		}
	}
}

// TestSSOLoginTransactionFailsClosedWithoutStorage covers the composition: without the store there is
// no one-time anything, so issuing a state would hand out a binding that binds nothing.
func TestSSOLoginTransactionFailsClosedWithoutStorage(t *testing.T) {
	svc := &OAuthService{}

	if _, err := svc.BeginSSOLoginTransaction(context.Background(), ssoTestRedirect); err == nil {
		t.Fatal("a state was issued with no store to remember it")
	}
	if err := svc.CompleteSSOLoginTransaction(context.Background(), "some-state", ssoTestRedirect); err == nil {
		t.Fatal("a login completed with no store to consume from")
	}
}
