package auth

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

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

// proofLookupSessionRepo records what the browser path asked for and returns a
// scripted row, so the test can assert the verifier is what reaches persistence and
// that the raw proof never does.
type proofLookupSessionRepo struct {
	sessionRepoStub

	lookupCalls    int
	lookupVerifier string
	lookupSession  *models.SystemSession
	lookupErr      error
}

func (r *proofLookupSessionRepo) FindSessionByControlProofHash(_ context.Context, verifier string) (*models.SystemSession, error) {
	r.lookupCalls++
	r.lookupVerifier = verifier
	return r.lookupSession, r.lookupErr
}

func browserSessionRow(id, userID, verifier string) *models.SystemSession {
	hash := verifier
	return &models.SystemSession{
		ID:               id,
		UserID:           userID,
		ControlProofHash: &hash,
		ExpiresAt:        time.Now().Add(time.Hour),
		AuthEpoch:        1,
	}
}

// TestValidateBrowserSessionSendsOnlyTheVerifierToPersistence is the credential-
// handling rule: the raw proof is the browser's secret, so persistence must only
// ever see its SHA-256 verifier.
func TestValidateBrowserSessionSendsOnlyTheVerifierToPersistence(t *testing.T) {
	proof, verifier, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}
	repo := &proofLookupSessionRepo{lookupSession: browserSessionRow("SESSION_A", "USER_A", verifier)}
	svc := &AuthService{sessionRepo: repo}

	// The durable re-read is not stubbed here, so this asserts the lookup contract
	// only; a nil result from the authoritative owner still denies.
	_, _ = svc.ValidateBrowserSession(context.Background(), proof)

	if repo.lookupCalls != 1 {
		t.Fatalf("lookup calls = %d, want exactly one", repo.lookupCalls)
	}
	if repo.lookupVerifier == proof {
		t.Fatal("the raw proof reached persistence; only its verifier may")
	}
	if repo.lookupVerifier != verifier {
		t.Fatalf("lookup verifier = %q, want the SHA-256 verifier", repo.lookupVerifier)
	}
}

// TestValidateBrowserSessionRejectsMalformedProofsBeforeQuerying keeps a bad shape
// from reaching the database at all.
func TestValidateBrowserSessionRejectsMalformedProofsBeforeQuerying(t *testing.T) {
	for name, proof := range map[string]string{
		"empty":         "",
		"whitespace":    "   ",
		"too short":     "short",
		"not base64url": "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!",
	} {
		t.Run(name, func(t *testing.T) {
			repo := &proofLookupSessionRepo{}
			svc := &AuthService{sessionRepo: repo}

			got, err := svc.ValidateBrowserSession(context.Background(), proof)
			if err == nil || got != nil {
				t.Fatalf("want a denial, got %v, %v", got, err)
			}
			if repo.lookupCalls != 0 {
				t.Fatal("a malformed proof must be rejected before any query")
			}
		})
	}
}

// TestValidateBrowserSessionFailsClosedOnAmbiguity carries the persistence-level
// ambiguity refusal through to no principal. Two sessions sharing a verifier must
// never authenticate anyone.
func TestValidateBrowserSessionFailsClosedOnAmbiguity(t *testing.T) {
	proof, _, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}
	repo := &proofLookupSessionRepo{lookupErr: errors.New("more than one session carries the same control proof verifier")}
	svc := &AuthService{sessionRepo: repo}

	got, err := svc.ValidateBrowserSession(context.Background(), proof)
	if err == nil || got != nil {
		t.Fatalf("an ambiguous verifier must deny, got %v, %v", got, err)
	}
	// The caller presented a proof; it must not learn *why* the lookup failed.
	// Assert the absence of the reason rather than an exact string, so a reworded
	// denial stays passing while a leaked reason fails.
	for _, leak := range []string{"more than one", "verifier", "proof", "collision", "duplicate"} {
		if strings.Contains(strings.ToLower(err.Error()), leak) {
			t.Fatalf("denial leaks the lookup reason (%q): %v", leak, err)
		}
	}
}

// The constant-time verifier re-comparison and the projection's scope handling are
// covered below, by tests that reach the success path through browserSessionService.
// They were briefly covered *badly*: at a stubbing level where the durable read
// always failed, a mismatch test denied for the wrong reason and passed whether or
// not the comparison existed. TestValidateBrowserSessionRejectsAVerifierMismatch now
// asserts the harness reaches success first, so it cannot silently go vacuous again.

// TestValidateBrowserSessionDeniesWhenNoRowMatches covers the ordinary miss.
func TestValidateBrowserSessionDeniesWhenNoRowMatches(t *testing.T) {
	proof, _, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}
	repo := &proofLookupSessionRepo{}
	svc := &AuthService{sessionRepo: repo}

	got, err := svc.ValidateBrowserSession(context.Background(), proof)
	if err == nil || got != nil {
		t.Fatalf("want a denial for no match, got %v, %v", got, err)
	}
}

// TestValidateBrowserSessionDeniesWithoutALookupCapableRepository keeps a
// deployment whose repository cannot resolve a proof from falling back to any bearer
// path, since the browser is not supposed to hold one.
func TestValidateBrowserSessionDeniesWithoutALookupCapableRepository(t *testing.T) {
	proof, _, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}
	svc := &AuthService{sessionRepo: &sessionRepoStub{}}

	got, err := svc.ValidateBrowserSession(context.Background(), proof)
	if err == nil || got != nil {
		t.Fatalf("want a denial without the lookup capability, got %v, %v", got, err)
	}
}

// TestBrowserSessionValidationCarriesNoCredential is a structural assertion: the
// result type must not gain a bearer or a proof field, because the browser lane
// exists so the page holds no readable credential.
func TestBrowserSessionValidationCarriesNoCredential(t *testing.T) {
	validation := BrowserSessionValidation{}
	typ := reflect.TypeOf(validation)

	want := map[string]struct{}{"User": {}, "SessionID": {}, "AuthEpoch": {}, "ExpiresAt": {}}
	if typ.NumField() != len(want) {
		t.Fatalf("BrowserSessionValidation has %d fields, want exactly %d — a credential field would defeat the lane",
			typ.NumField(), len(want))
	}
	for i := 0; i < typ.NumField(); i++ {
		name := typ.Field(i).Name
		if _, ok := want[name]; !ok {
			t.Fatalf("unexpected field %q on BrowserSessionValidation", name)
		}
	}
}

// browserSessionService builds the smallest AuthService that can reach
// ValidateBrowserSession's success path: a proof lookup, the authoritative durable
// re-read, and an active user for the projection.
func browserSessionService(t *testing.T, session *models.SystemSession) (*AuthService, *proofLookupSessionRepo) {
	t.Helper()
	repo := &proofLookupSessionRepo{lookupSession: session}
	repo.loaded = session // the authoritative re-read by id returns the same row
	// A nil row is a legitimate case (nothing matched the verifier), so the helper
	// must build a usable service for it rather than panicking.
	userID := "USER_A"
	if session != nil {
		userID = session.UserID
	}
	return &AuthService{
		sessionRepo: repo,
		userRepo: &userRepoStub{user: &models.User{
			ID:        userID,
			IsActive:  true,
			ProfileID: "PROFILE_A",
			Username:  "ada",
			Email:     "ada@example.com",
			FirstName: "Ada",
			LastName:  "Lovelace",
		}},
		activeCache:       newActiveUserCache(),
		connectedAppCache: newActiveUserCache(),
	}, repo
}

// TestValidateBrowserSessionResolvesAnActiveSession is the success path, and it is
// what makes the two invariants below testable at all: without it, every denial-side
// test passes for the wrong reason.
func TestValidateBrowserSessionResolvesAnActiveSession(t *testing.T) {
	proof, verifier, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}
	row := browserSessionRow("SESSION_A", "USER_A", verifier)
	svc, _ := browserSessionService(t, row)

	got, err := svc.ValidateBrowserSession(context.Background(), proof)
	if err != nil {
		t.Fatalf("ValidateBrowserSession: %v", err)
	}
	if got == nil || got.SessionID != "SESSION_A" || got.User.ID != "USER_A" {
		t.Fatalf("got %+v, want the resolved session for USER_A", got)
	}
	if got.AuthEpoch != row.AuthEpoch {
		t.Fatalf("AuthEpoch = %d, want the durable %d", got.AuthEpoch, row.AuthEpoch)
	}
	if !got.ExpiresAt.Equal(row.ExpiresAt) {
		t.Fatal("the durable expiry must be reported so the caller can reissue the cookie")
	}
}

// TestValidateBrowserSessionRejectsAVerifierMismatch now denies for the right
// reason. The durable read succeeds, so the only thing that can refuse this request
// is the constant-time re-comparison — which is the guard that still holds if the
// verifier column ever ends up with a case-folding collation and the database
// matches a near-miss.
func TestValidateBrowserSessionRejectsAVerifierMismatch(t *testing.T) {
	proof, verifier, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}
	other, _, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}
	otherVerifier := hashSessionControlProof(other)
	if otherVerifier == verifier {
		t.Fatal("fixture verifiers collided")
	}

	// Prove the harness would otherwise succeed with the matching verifier.
	svc, _ := browserSessionService(t, browserSessionRow("SESSION_A", "USER_A", verifier))
	if _, err := svc.ValidateBrowserSession(context.Background(), proof); err != nil {
		t.Fatalf("harness cannot reach the success path, so this test would be vacuous: %v", err)
	}

	// Same harness, but the returned row carries a different verifier.
	svc, _ = browserSessionService(t, browserSessionRow("SESSION_A", "USER_A", otherVerifier))
	got, err := svc.ValidateBrowserSession(context.Background(), proof)
	if err == nil || got != nil {
		t.Fatalf("a verifier mismatch must deny, got %v, %v", got, err)
	}
}

// TestBrowserSessionProjectionKeepsProgrammaticFactsUnchanged is the invariant that
// had no guard at all. Blanking Scope would promote a browser session to full API
// authority, because an empty scope currently means full access. The projection must
// carry the durable row's values through untouched; a browser session is
// distinguished by the credential class the admission owner selected, not by
// mutating these fields.
func TestBrowserSessionProjectionKeepsProgrammaticFactsUnchanged(t *testing.T) {
	proof, verifier, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}
	scope := "api"
	grant := "password"
	app := "APP_A"
	row := browserSessionRow("SESSION_A", "USER_A", verifier)
	row.Scope = &scope
	row.GrantType = &grant
	row.ConnectedAppID = &app

	svc, _ := browserSessionService(t, row)
	got, err := svc.ValidateBrowserSession(context.Background(), proof)
	if err != nil {
		t.Fatalf("ValidateBrowserSession: %v", err)
	}
	if got.User.Scope != scope {
		t.Fatalf("Scope = %q, want the durable %q — blanking it grants full API authority",
			got.User.Scope, scope)
	}
	if got.User.GrantType != grant {
		t.Fatalf("GrantType = %q, want the durable %q", got.User.GrantType, grant)
	}
	if got.User.ConnectedAppId != app {
		t.Fatalf("ConnectedAppId = %q, want the durable %q", got.User.ConnectedAppId, app)
	}
	// SessionClass is a bearer-owned admission input, so the browser lane claims none.
	if got.User.SessionClass != "" {
		t.Fatalf("SessionClass = %q, want empty", got.User.SessionClass)
	}
}

// recordingControlRepo captures what revocation received, so the test can assert the
// verifier is sent and the raw proof is not.
type recordingControlRepo struct {
	revokedSessionID string
	revokedVerifier  string
	revokedCalls     int
	revoked          bool
	revokeErr        error
}

func (r *recordingControlRepo) RevokeSessionWithControlProof(_ context.Context, sessionID, verifier string) (bool, error) {
	r.revokedCalls++
	r.revokedSessionID = sessionID
	r.revokedVerifier = verifier
	return r.revoked, r.revokeErr
}

func (r *recordingControlRepo) RenewSessionWithControlProof(context.Context, string, string, string, string, time.Time, time.Time) (bool, error) {
	return false, nil
}

func revokeService(t *testing.T, session *models.SystemSession, control *recordingControlRepo) *AuthService {
	t.Helper()
	svc, _ := browserSessionService(t, session)
	svc.sessionControlRepo = control
	return svc
}

func TestRevokeBrowserSessionRevokesByVerifier(t *testing.T) {
	proof, verifier, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}
	control := &recordingControlRepo{revoked: true}
	svc := revokeService(t, browserSessionRow("SESSION_A", "USER_A", verifier), control)

	got, err := svc.RevokeBrowserSession(context.Background(), proof)
	if err != nil || !got {
		t.Fatalf("RevokeBrowserSession = %v, %v; want true, nil", got, err)
	}
	if control.revokedSessionID != "SESSION_A" {
		t.Fatalf("revoked session = %q, want SESSION_A", control.revokedSessionID)
	}
	if control.revokedVerifier == proof {
		t.Fatal("the raw proof reached persistence; only its verifier may")
	}
	if control.revokedVerifier != verifier {
		t.Fatalf("revoked with %q, want the verifier", control.revokedVerifier)
	}
}

// TestRevokeBrowserSessionIsNotAnOracle is one half of the logout contract. Every
// proof that provably identifies nothing reports "nothing revoked" without an error,
// so the transport sends one identical expired-cookie response and a caller cannot
// learn whether a proof was ever valid. The malformed cases matter most: the cookie is
// HttpOnly, so a browser holding a corrupt one has no other way to get rid of it.
//
// Each case here must be provable from the presented proof alone. Anything that only
// *might* have nothing to revoke belongs in the test below.
func TestRevokeBrowserSessionIsNotAnOracle(t *testing.T) {
	usableProof, verifier, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}
	otherProof, _, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}

	cases := map[string]struct {
		proof   string
		session *models.SystemSession
	}{
		"malformed proof":   {proof: "short"},
		"empty proof":       {proof: ""},
		"no matching row":   {proof: usableProof},
		"verifier mismatch": {proof: otherProof, session: browserSessionRow("SESSION_A", "USER_A", verifier)},
	}
	for name, tc := range cases {
		t.Run(name, func(t *testing.T) {
			control := &recordingControlRepo{revoked: true}
			svc := revokeService(t, tc.session, control)

			got, err := svc.RevokeBrowserSession(context.Background(), tc.proof)
			if err != nil {
				t.Fatalf("logout must not error when nothing can be revoked: %v", err)
			}
			if got {
				t.Fatal("nothing should have been revoked")
			}
			if control.revokedCalls != 0 {
				t.Fatal("an unusable proof must not reach revocation")
			}
		})
	}
}

// TestRevokeBrowserSessionReportsInabilityToRevoke is the other half, and it is the
// half that was wrong: every one of these used to answer (false, nil), which the
// transport reads as "nothing to revoke" and answers by clearing the cookie. The
// browser would lose its proof while the session it identified stayed live — the exact
// theft window this lane exists to close.
//
// The repository cases are worse than the transient one. A deployment wired without a
// proof-lookup repository would have skipped every revocation it was ever asked for,
// reporting success each time, with nothing anywhere to notice.
func TestRevokeBrowserSessionReportsInabilityToRevoke(t *testing.T) {
	proof, verifier, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}
	row := browserSessionRow("SESSION_A", "USER_A", verifier)

	cases := map[string]func(*AuthService){
		"lookup fails": func(svc *AuthService) {
			svc.sessionRepo.(*proofLookupSessionRepo).lookupErr = errors.New("connection reset")
		},
		// Propagated rather than special-cased: it takes a SHA-256 collision on two
		// live proofs, and calling it "nothing to revoke" would need a dependency edge
		// on the persistence sentinel to say which error it was.
		"ambiguous verifier": func(svc *AuthService) {
			svc.sessionRepo.(*proofLookupSessionRepo).lookupErr = errors.New("more than one session carries the same control proof verifier")
		},
		"no revocation repository": func(svc *AuthService) {
			svc.sessionControlRepo = nil
		},
		"repository cannot look up by proof": func(svc *AuthService) {
			svc.sessionRepo = &sessionRepoStub{}
		},
	}

	for name, break_ := range cases {
		t.Run(name, func(t *testing.T) {
			control := &recordingControlRepo{revoked: true}
			svc := revokeService(t, row, control)

			// Anti-vacuity: prove this harness revokes before it is broken, so a case
			// cannot pass because the setup never reached revocation in the first
			// place.
			if got, err := svc.RevokeBrowserSession(context.Background(), proof); err != nil || !got {
				t.Fatalf("harness cannot revoke before the fault is injected: %v, %v", got, err)
			}

			control.revokedCalls = 0
			break_(svc)

			got, err := svc.RevokeBrowserSession(context.Background(), proof)
			if err == nil {
				t.Fatal("revocation could not be completed, so it must not report a determinate answer — the transport would clear the cookie and leave the session live")
			}
			if got {
				t.Fatal("reported a revocation that did not happen")
			}
		})
	}
}

// TestRevokeBrowserSessionRejectsANilService pins the receiver guard separately: it is
// the one case revokeService cannot construct.
func TestRevokeBrowserSessionRejectsANilService(t *testing.T) {
	var svc *AuthService
	got, err := svc.RevokeBrowserSession(context.Background(), "")
	if err == nil || got {
		t.Fatalf("RevokeBrowserSession = %v, %v; want false and an error", got, err)
	}
}

// TestValidateBrowserSessionCarriesIdentityAndDisplayData is review finding #2. The
// browser path first used projectSelectedRootUserSession, which populates ids only —
// so a cookie-only /current would have answered with an empty name and email, and the
// session-bound formula context would have lost its identity fields. It now uses
// buildUserSessionForUser, the same constructor the login path uses.
func TestValidateBrowserSessionCarriesIdentityAndDisplayData(t *testing.T) {
	proof, verifier, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}
	svc, _ := browserSessionService(t, browserSessionRow("SESSION_A", "USER_A", verifier))

	got, err := svc.ValidateBrowserSession(context.Background(), proof)
	if err != nil {
		t.Fatalf("ValidateBrowserSession: %v", err)
	}
	if got.User.Email != "ada@example.com" {
		t.Fatalf("Email = %q, want the user record's email — an identity-less session breaks /current", got.User.Email)
	}
	if got.User.Name == "" {
		t.Fatal("Name is empty; the display name must come through for the UI to render itself")
	}
	if got.User.ProfileId != "PROFILE_A" {
		t.Fatalf("ProfileId = %q, want PROFILE_A", got.User.ProfileId)
	}
	// Permissions still load through their own plane, so the session must not carry
	// a half-populated copy that could become a staler second source.
	if got.User.SystemPermissions != nil {
		t.Fatal("the browser session must not carry permissions")
	}
}
