package auth

import (
	"context"
	"testing"
	"time"

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

func observedScopes(t *testing.T, svc *AuthService) *[]CredentialScopeObservation {
	t.Helper()
	seen := []CredentialScopeObservation{}
	svc.SetCredentialScopeObserver(func(observation CredentialScopeObservation) {
		seen = append(seen, observation)
	})
	return &seen
}

// TestEverySessionAdmissionReportsItsScope is the property that replaced a hand-kept ledger.
//
// An earlier attempt instrumented each transport and classified five call sites by hand; three
// of those classifications were wrong, because one file can hold both a client-presented
// admission and the server verifying its own work. Reporting from the single validating owner
// means a new admission path is covered the moment it is written, and nothing has to be
// remembered.
//
// Both admission entry points are exercised here rather than the owner directly, because what
// would rot is the *reaching* of it: a path that stopped going through the owner would still pass
// a test of the owner alone.
func TestEverySessionAdmissionReportsItsScope(t *testing.T) {
	const sessionID, principalID = "SESSION_A", "USER_A"

	t.Run("runtime delegated admission", func(t *testing.T) {
		svc, repo := browserAdmissionService(t, browserSessionRow(sessionID, principalID, "verifier"))
		repo.state.Session.AuthEpoch = 7
		scope := "api"
		repo.state.Session.Scope = &scope
		seen := observedScopes(t, svc)

		if _, err := svc.AdmitRuntimeAuthorizingSession(context.Background(), sessionID, principalID); err != nil {
			t.Fatalf("AdmitRuntimeAuthorizingSession: %v", err)
		}
		if len(*seen) != 1 || (*seen)[0].Scope != "api" {
			t.Fatalf("observations = %#v, want one carrying the durable scope", *seen)
		}
	})

	t.Run("browser admission", func(t *testing.T) {
		proof, verifier, err := newSessionControlProof()
		if err != nil {
			t.Fatalf("newSessionControlProof: %v", err)
		}
		svc, _ := browserAdmissionService(t, browserSessionRow(sessionID, principalID, verifier))
		seen := observedScopes(t, svc)

		if _, _, err := svc.AdmitAuthorizingBrowserSession(context.Background(), proof); err != nil {
			t.Fatalf("AdmitAuthorizingBrowserSession: %v", err)
		}
		if len(*seen) != 1 {
			t.Fatalf("observations = %#v, want exactly one", *seen)
		}
	})
}

// TestTheClassSeparatesTheLegacyBrowserBearer is the finding that made the class come from how the
// credential was presented rather than from what the session row carries.
//
// A legacy browser bearer is a *bearer* admission on a session that also has a proof cookie — the
// exact population the readable-credential removal exists to eliminate. Deriving the class from the
// row put it in the cookie-only "browser" bucket, which an operator reads as the expected end state,
// so migration readiness could be misread while every one of those admissions was still happening.
//
// It gets its own value rather than being folded into programmatic, because it is the number that
// has to reach zero and mixing it with ordinary API traffic would make that number unreadable.
func TestTheClassSeparatesTheLegacyBrowserBearer(t *testing.T) {
	for name, tc := range map[string]struct {
		verifier  *string
		wantClass string
	}{
		"bearer on a browser-shaped session": {stringPointer("a-verifier"), CredentialScopeClassBrowserBearer},
		"bearer on an ordinary session":      {nil, CredentialScopeClassProgrammatic},
		"blank verifier is not a browser":    {stringPointer("   "), CredentialScopeClassProgrammatic},
	} {
		t.Run(name, func(t *testing.T) {
			session := models.SystemSession{ID: "SESSION_A", ControlProofHash: tc.verifier}
			if got := bearerAdmissionCredentialClass(session); got != tc.wantClass {
				t.Fatalf("class = %q, want %q", got, tc.wantClass)
			}
		})
	}

	// And the cookie-only lane is the one that reports plain "browser": the end state, reported
	// only where no bearer was presented at all.
	if CredentialScopeClassBrowser == CredentialScopeClassBrowserBearer {
		t.Fatal("the transitional and end-state classes are the same value, so the migration signal is invisible")
	}
}

// TestRefusedAdmissionReportsNothing keeps the number meaning reliance. Counting refusals fills it
// with probes and expired credentials, so a finished migration looks stalled.
func TestRefusedAdmissionReportsNothing(t *testing.T) {
	const sessionID, principalID = "SESSION_A", "USER_A"
	svc, repo := browserAdmissionService(t, browserSessionRow(sessionID, principalID, "verifier"))
	repo.state.Session.AuthEpoch = 7
	seen := observedScopes(t, svc)

	// Admits first, so the refusal below is the only difference.
	if _, err := svc.AdmitRuntimeAuthorizingSession(context.Background(), sessionID, principalID); err != nil {
		t.Fatalf("harness cannot admit before the fault is injected: %v", err)
	}
	admitted := len(*seen)

	repo.state.Session.IsRevoked = true
	if _, err := svc.AdmitRuntimeAuthorizingSession(context.Background(), sessionID, principalID); err == nil {
		t.Fatal("a revoked session was admitted")
	}
	if len(*seen) != admitted {
		t.Fatalf("a refused admission was reported: %#v", (*seen)[admitted:])
	}
}

// TestScopeObservationCannotBreakAdmission is the safety property of an inventory: it observes.
// A panicking or absent observer must not change what a request gets, or the metric becomes a
// way to take the system down.
func TestScopeObservationCannotBreakAdmission(t *testing.T) {
	const sessionID, principalID = "SESSION_A", "USER_A"

	t.Run("no observer wired", func(t *testing.T) {
		svc, repo := browserAdmissionService(t, browserSessionRow(sessionID, principalID, "verifier"))
		repo.state.Session.AuthEpoch = 7
		if _, err := svc.AdmitRuntimeAuthorizingSession(context.Background(), sessionID, principalID); err != nil {
			t.Fatalf("admission failed with no observer: %v", err)
		}
	})

	t.Run("state carries no scope", func(t *testing.T) {
		svc, repo := browserAdmissionService(t, browserSessionRow(sessionID, principalID, "verifier"))
		repo.state.Session.AuthEpoch = 7
		repo.state.Session.Scope = nil
		seen := observedScopes(t, svc)

		if _, err := svc.AdmitRuntimeAuthorizingSession(context.Background(), sessionID, principalID); err != nil {
			t.Fatalf("AdmitRuntimeAuthorizingSession: %v", err)
		}
		if len(*seen) != 1 || (*seen)[0].Scope != "" {
			t.Fatalf("observations = %#v, want one empty scope — this is the case being inventoried", *seen)
		}
	})
}

var _ = ports.AuthorizingSessionState{}
var _ = time.Time{}

// TestPersonalAccessTokenAdmissionReportsItsScope covers the second reporting point. Tokens do
// not pass through the durable-session validator, so nothing about the session path guarantees
// this one — and a mutation removing it passed until this test existed, which is exactly the
// gap the previous ledger-based attempt had at a larger scale.
//
// 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.
func TestPersonalAccessTokenAdmissionReportsItsScope(t *testing.T) {
	const presented = "nxc_inventory-token"

	admit := func(t *testing.T, scopes *string) []CredentialScopeObservation {
		t.Helper()
		repository := newFakeApiTokenRepo()
		repository.authorizingState = &ports.AuthorizingApiTokenState{
			Token: models.SystemApiToken{
				ID:        "pat-1",
				OwnerID:   "user-current",
				TokenHash: HashApiToken(presented),
				Scopes:    scopes,
			},
			Subject:         authorizingSubject(t, "user-current", "profile-current"),
			Username:        "current.user",
			UserAuthorized:  true,
			PermissionFloor: authorizingVersion(t, 37),
		}

		authSvc := &AuthService{}
		seen := observedScopes(t, authSvc)
		service := NewApiTokenService(repository, authSvc)

		if _, err := service.AdmitAuthorizingApiToken(context.Background(), presented); err != nil {
			t.Fatalf("AdmitAuthorizingApiToken: %v", err)
		}
		return *seen
	}

	t.Run("unscoped token is reported as such", func(t *testing.T) {
		seen := admit(t, nil)
		if len(seen) != 1 {
			t.Fatalf("observations = %#v, want exactly one", seen)
		}
		if seen[0].Class != CredentialScopeClassAPIToken || seen[0].Scope != "" {
			t.Fatalf("observation = %#v, want an unscoped api_token", seen[0])
		}
	})

	t.Run("scoped token carries its scope", func(t *testing.T) {
		scope := "records.read"
		seen := admit(t, &scope)
		if len(seen) != 1 || seen[0].Scope != "records.read" {
			t.Fatalf("observations = %#v, want the token's declared scope", seen)
		}
	})

	t.Run("a refused token reports nothing", func(t *testing.T) {
		repository := newFakeApiTokenRepo()
		authSvc := &AuthService{}
		seen := observedScopes(t, authSvc)

		if _, err := NewApiTokenService(repository, authSvc).
			AdmitAuthorizingApiToken(context.Background(), "nxc_unknown"); err == nil {
			t.Fatal("an unknown token was admitted")
		}
		if len(*seen) != 0 {
			t.Fatalf("a refused token was reported: %#v", *seen)
		}
	})
}

// TestNoAdmissionPathCountsWhatItGoesOnToRefuse is the review finding, and it is the property the
// whole inventory rests on.
//
// The observation used to be made inside the shared state validator, which looked like the ideal
// single point — but each caller still has refusals of its own after it returns: a wrong principal
// on the runtime path, a bearer that is no longer the session's current one on the policy path,
// a projection that disagrees with the authoritative state on the browser path. Reporting before
// those ran counted rejections, and an inventory that counts rejections does not mean "credentials
// a deployment relies on", which is the only thing it is for.
//
// So each path reports at its own end, and each is checked here against a refusal that only it can
// produce. A single point that cannot see its callers' checks is not the right point.
func TestNoAdmissionPathCountsWhatItGoesOnToRefuse(t *testing.T) {
	const sessionID, principalID = "SESSION_A", "USER_A"

	t.Run("runtime path: wrong principal", func(t *testing.T) {
		svc, repo := browserAdmissionService(t, browserSessionRow(sessionID, principalID, "verifier"))
		repo.state.Session.AuthEpoch = 7
		seen := observedScopes(t, svc)

		// Admits first, so the difference below is only the refusal.
		if _, err := svc.AdmitRuntimeAuthorizingSession(context.Background(), sessionID, principalID); err != nil {
			t.Fatalf("harness cannot admit before the refusal is arranged: %v", err)
		}
		admitted := len(*seen)

		if _, err := svc.AdmitRuntimeAuthorizingSession(context.Background(), sessionID, "SOMEONE_ELSE"); err == nil {
			t.Fatal("a session was admitted for a principal it does not belong to")
		}
		if len(*seen) != admitted {
			t.Fatalf("a refused runtime admission was counted: %#v", (*seen)[admitted:])
		}
	})

	t.Run("browser path: identity drift", func(t *testing.T) {
		proof, verifier, err := newSessionControlProof()
		if err != nil {
			t.Fatalf("newSessionControlProof: %v", err)
		}
		svc, repo := browserAdmissionService(t, browserSessionRow(sessionID, principalID, verifier))
		seen := observedScopes(t, svc)

		if _, _, err := svc.AdmitAuthorizingBrowserSession(context.Background(), proof); err != nil {
			t.Fatalf("harness cannot admit before the drift is introduced: %v", err)
		}
		admitted := len(*seen)

		// Internally consistent state that describes a different profile than the projection.
		repo.state.Subject = authorizingSubject(t, principalID, "PROFILE_B")
		if _, _, err := svc.AdmitAuthorizingBrowserSession(context.Background(), proof); err == nil {
			t.Fatal("a drifted browser admission succeeded")
		}
		if len(*seen) != admitted {
			t.Fatalf("a refused browser admission was counted: %#v", (*seen)[admitted:])
		}
	})
}

// TestTokenAdmissionSurvivesAWithoutAuthServiceComposition settles a review concern with evidence
// rather than reasoning. NewApiTokenService(repo, nil) is a supported strong-admission
// composition, and reporting reaches the observer through that nil service — which is safe in Go
// because the method guards its own receiver, but "safe because I read it carefully" is not a
// property. This makes it one.
func TestTokenAdmissionSurvivesAWithoutAuthServiceComposition(t *testing.T) {
	const presented = "nxc_no-auth-service"
	repository := newFakeApiTokenRepo()
	scope := "records.read"
	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 with no auth service: %v", err)
	}
	if validation == nil || validation.Claims == nil {
		t.Fatal("admission returned no validation")
	}
}

// TestTheCountedBoundaryIsTheAuthorityGrant records the boundary as a test rather than as prose,
// because "only successful admissions" turned out to be ambiguous about whose success.
//
// What is counted is a grant by this module: the point at which authentication and authorization
// have both said yes. A transport can still refuse afterwards — an auth-epoch that disagrees with
// the presented claims, a subject that fails re-validation — and those remain counted deliberately,
// because a credential failing them is already broken for reasons unrelated to scope and so is not
// part of the empty-scope backlog either way.
//
// Chasing each consumer's checks does not converge: any caller can add one, and two review rounds
// moved the reporting point outward before that became clear. The authority's own answer is the
// only stable boundary, so it is the one asserted here.
func TestTheCountedBoundaryIsTheAuthorityGrant(t *testing.T) {
	const sessionID, principalID = "SESSION_A", "USER_A"
	svc, repo := browserAdmissionService(t, browserSessionRow(sessionID, principalID, "verifier"))
	repo.state.Session.AuthEpoch = 7
	seen := observedScopes(t, svc)

	validation, err := svc.AdmitRuntimeAuthorizingSession(context.Background(), sessionID, principalID)
	if err != nil || validation == nil {
		t.Fatalf("AdmitRuntimeAuthorizingSession = %#v, %v; want a grant", validation, err)
	}
	if len(*seen) != 1 {
		t.Fatalf("observations = %#v, want exactly one at the moment of the grant", *seen)
	}

	// The epoch the grant carries is what a transport compares against its presented claims. A
	// mismatch there is that transport's refusal, after this module has already answered — the
	// observation above is not retracted, and this asserts that the grant carries what such a
	// check needs rather than that the check happens here.
	if validation.AuthEpoch != 7 {
		t.Fatalf("granted AuthEpoch = %d, want the durable epoch a consumer re-checks against", validation.AuthEpoch)
	}
}

// TestCookieOnlyAndBearerAdmissionsLandInDifferentBuckets is the property the class split exists
// for, asserted through the admission methods rather than the classifier — what could rot is which
// class each path reports, and a test of the classifier alone would not see that.
//
// Same session, same row, two ways of presenting it: the cookie-only admission is the end state and
// the bearer admission is the backlog. If they report the same class, the number that has to reach
// zero cannot be read.
func TestCookieOnlyAndBearerAdmissionsLandInDifferentBuckets(t *testing.T) {
	proof, verifier, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}
	svc, _ := browserAdmissionService(t, browserSessionRow("SESSION_A", "USER_A", verifier))
	seen := observedScopes(t, svc)

	if _, _, err := svc.AdmitAuthorizingBrowserSession(context.Background(), proof); err != nil {
		t.Fatalf("AdmitAuthorizingBrowserSession: %v", err)
	}
	if len(*seen) != 1 || (*seen)[0].Class != CredentialScopeClassBrowser {
		t.Fatalf("cookie-only admission reported %#v, want the browser class", *seen)
	}

	// The bearer side is covered by TestTheClassSeparatesTheLegacyBrowserBearer at the classifier,
	// since driving a full bearer admission needs a signed token; what matters here is that the
	// cookie-only lane does not report the transitional class.
	if (*seen)[0].Class == CredentialScopeClassBrowserBearer {
		t.Fatal("a cookie-only admission was reported as the transitional browser bearer")
	}
}
