package auth

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

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

// browserAdmissionRepo is one repository that can do both halves of a browser admission:
// resolve a proof to a session, and load that session's authoritative state. They are
// separate capabilities on the same store in production, and combining them here keeps the
// test asserting the *composition* rather than two independent stubs.
type browserAdmissionRepo struct {
	*proofLookupSessionRepo

	state     *ports.AuthorizingSessionState
	loadErr   error
	loadedFor []string
}

func (r *browserAdmissionRepo) LoadAuthorizingSessionState(
	_ context.Context,
	sessionID string,
) (*ports.AuthorizingSessionState, error) {
	r.loadedFor = append(r.loadedFor, sessionID)
	return r.state, r.loadErr
}

func browserAdmissionService(t *testing.T, session *models.SystemSession) (*AuthService, *browserAdmissionRepo) {
	t.Helper()
	svc, lookup := browserSessionService(t, session)
	// A complete, admissible state. The shared validator rejects an incomplete one, which
	// is the point: the first version of this fixture set only Subject and floor, so these
	// tests were passing against state that production would never accept.
	repo := &browserAdmissionRepo{
		proofLookupSessionRepo: lookup,
		state: &ports.AuthorizingSessionState{
			Session: models.SystemSession{
				ID:        "SESSION_A",
				UserID:    "USER_A",
				Token:     runtimeAuthorizingToken(t, "SESSION_A", "USER_A", "PROFILE_A", nil),
				ExpiresAt: time.Now().Add(time.Hour),
			},
			Subject:                authorizingSubject(t, "USER_A", "PROFILE_A"),
			UserAuthorized:         true,
			ConnectedAppAuthorized: true,
			PermissionFloor:        authorizingVersion(t, 23),
		},
	}
	svc.authorizingSessions = repo
	return svc, repo
}

// TestAdmitAuthorizingBrowserSessionResolvesTheFloorForTheProofsSession is the core claim.
// A cookie-only request must be authorized against the same authoritative state a bearer
// request would be, so the answer cannot depend on which credential the page held — and the
// state must be loaded for the session the *proof* identified, not one the caller named.
func TestAdmitAuthorizingBrowserSessionResolvesTheFloorForTheProofsSession(t *testing.T) {
	proof, verifier, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}
	svc, repo := browserAdmissionService(t, browserSessionRow("SESSION_A", "USER_A", verifier))

	validation, expiresAt, err := svc.AdmitAuthorizingBrowserSession(context.Background(), proof)
	if err != nil {
		t.Fatalf("AdmitAuthorizingBrowserSession: %v", err)
	}
	if validation == nil || validation.Claims == nil {
		t.Fatalf("validation = %#v, want claims", validation)
	}
	if validation.Claims.RegisteredClaims.ID != "SESSION_A" {
		t.Fatalf("session id = %q, want the session the proof identified", validation.Claims.RegisteredClaims.ID)
	}
	if validation.Claims.User.ID != "USER_A" {
		t.Fatalf("user = %q, want the session's user", validation.Claims.User.ID)
	}
	if expiresAt.IsZero() {
		t.Fatal("no expiry was reported, so the transport could not reissue the cookie through a renewed session")
	}
	if len(repo.loadedFor) != 1 || repo.loadedFor[0] != "SESSION_A" {
		t.Fatalf("permission state loaded for %v, want exactly [SESSION_A]", repo.loadedFor)
	}
	if validation.Subject.Validate() != nil || validation.PermissionFloor.Validate() != nil {
		t.Fatalf("subject/floor did not come through: %#v", validation)
	}
	if validation.PermissionFloor.Revision() != 23 {
		t.Fatalf("floor revision = %d, want the loaded floor", validation.PermissionFloor.Revision())
	}
}

// TestAdmitAuthorizingBrowserSessionRejectsAnUnusableProof keeps the lane from admitting on
// something that identifies nothing. Delegating to ValidateBrowserSession is what makes the
// malformed, unknown and mismatched cases behave identically, so this asserts the delegation
// holds — and that nothing is loaded before the proof has resolved, since a lookup keyed on
// an unresolved credential is how a lane authenticates on nothing.
func TestAdmitAuthorizingBrowserSessionRejectsAnUnusableProof(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)
	}

	cases := map[string]struct {
		proof   string
		session *models.SystemSession
	}{
		"malformed":         {proof: "short", session: browserSessionRow("SESSION_A", "USER_A", verifier)},
		"empty":             {proof: "", session: browserSessionRow("SESSION_A", "USER_A", verifier)},
		"no matching row":   {proof: proof, session: nil},
		"verifier mismatch": {proof: other, session: browserSessionRow("SESSION_A", "USER_A", verifier)},
	}
	for name, tc := range cases {
		t.Run(name, func(t *testing.T) {
			svc, repo := browserAdmissionService(t, tc.session)

			validation, expiresAt, err := svc.AdmitAuthorizingBrowserSession(context.Background(), tc.proof)
			if err == nil || validation != nil || !expiresAt.IsZero() {
				t.Fatalf("AdmitAuthorizingBrowserSession = %#v, %v, %v; want a rejection", validation, expiresAt, err)
			}
			if len(repo.loadedFor) != 0 {
				t.Fatalf("permission state was loaded for %v despite an unusable proof", repo.loadedFor)
			}
		})
	}
}

// TestAdmitAuthorizingBrowserSessionSeparatesOutageFromDenial is the distinction api-host
// depends on: it rejects the credential only for ErrAuthorizingCredentialRejected and treats
// every other error as an inability to establish authority. A snapshot outage reported as an
// invalid session would sign users out during an incident instead of failing their requests.
func TestAdmitAuthorizingBrowserSessionSeparatesOutageFromDenial(t *testing.T) {
	proof, verifier, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}
	svc, repo := browserAdmissionService(t, browserSessionRow("SESSION_A", "USER_A", verifier))
	repo.loadErr = stderrors.New("snapshot store unavailable")

	// Anti-vacuity: the same harness admits before the fault is injected, so this cannot
	// pass because the proof never resolved.
	repo.loadErr = nil
	if _, _, err := svc.AdmitAuthorizingBrowserSession(context.Background(), proof); err != nil {
		t.Fatalf("harness cannot admit before the fault is injected: %v", err)
	}
	repo.loadedFor = nil
	repo.loadErr = stderrors.New("snapshot store unavailable")

	validation, _, err := svc.AdmitAuthorizingBrowserSession(context.Background(), proof)
	if err == nil || validation != nil {
		t.Fatalf("AdmitAuthorizingBrowserSession = %#v, %v; want the outage propagated", validation, err)
	}
	// Asserted as membership in the right class, not as absence of the wrong one. The
	// earlier version only checked "not UnauthorizedError", which a generic error also
	// satisfies while losing the FailureAuthorityUnavailable classification the transport
	// projects — review named exactly that weakness.
	var unavailable *securityAuthorityUnavailableError
	if !stderrors.As(err, &unavailable) {
		t.Fatalf("outage error = %v (%T), want a security-authority-unavailable error so the transport reports an outage rather than a denial", err, err)
	}
	var rejected *errors.UnauthorizedError
	if stderrors.As(err, &rejected) {
		t.Fatalf("a floor outage was reported as a rejected credential (%v), which signs the user out during an incident", err)
	}
	if len(repo.loadedFor) != 1 {
		t.Fatalf("permission state loads = %d, want 1", len(repo.loadedFor))
	}
}

// TestAdmitAuthorizingBrowserSessionRejectsANilService covers the receiver guard: an
// unconfigured service must not report an admission.
func TestAdmitAuthorizingBrowserSessionRejectsANilService(t *testing.T) {
	var svc *AuthService
	validation, expiresAt, err := svc.AdmitAuthorizingBrowserSession(context.Background(), "")
	if err == nil || validation != nil || !expiresAt.IsZero() {
		t.Fatalf("AdmitAuthorizingBrowserSession = %#v, %v, %v; want a rejection", validation, expiresAt, err)
	}
}

// TestAdmitAuthorizingBrowserSessionRefusesStateTheBearerPathWouldRefuse is the finding this
// lane was rewritten for. It resolved the proof and then took the loaded state at face
// value, so every invariant the bearer path enforces after loading was skipped.
//
// The concurrent-revoke case is the one that matters most: the proof lookup and the state
// load are two reads, so a session revoked in between was still admitted — which defeats
// revocation for exactly the credential that cannot be taken away from a page.
//
// These are asserted through the browser lane rather than against the shared validator
// directly, because the claim is that this lane *reaches* it. A version that called the
// repository itself would pass a direct test of the validator and still be wrong.
func TestAdmitAuthorizingBrowserSessionRefusesStateTheBearerPathWouldRefuse(t *testing.T) {
	proof, verifier, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}

	cases := map[string]struct {
		breakState func(*ports.AuthorizingSessionState)
		wantOutage bool
	}{
		"revoked between the two reads": {
			breakState: func(state *ports.AuthorizingSessionState) { state.Session.IsRevoked = true },
		},
		"expired": {
			breakState: func(state *ports.AuthorizingSessionState) {
				state.Session.ExpiresAt = time.Now().Add(-time.Minute)
			},
		},
		"no expiry at all": {
			breakState: func(state *ports.AuthorizingSessionState) { state.Session.ExpiresAt = time.Time{} },
		},
		"user no longer authorized": {
			breakState: func(state *ports.AuthorizingSessionState) { state.UserAuthorized = false },
		},
		"connected app no longer authorized": {
			breakState: func(state *ports.AuthorizingSessionState) { state.ConnectedAppAuthorized = false },
		},
		"state names a different session": {
			breakState: func(state *ports.AuthorizingSessionState) { state.Session.ID = "SESSION_B" },
			wantOutage: true,
		},
		"state names a different user than its subject": {
			breakState: func(state *ports.AuthorizingSessionState) { state.Session.UserID = "USER_B" },
			wantOutage: true,
		},
	}

	for name, tc := range cases {
		t.Run(name, func(t *testing.T) {
			svc, repo := browserAdmissionService(t, browserSessionRow("SESSION_A", "USER_A", verifier))

			// Anti-vacuity: this exact harness admits before the state is broken, so a
			// refusal below is attributable to what was broken and nothing else.
			if _, _, err := svc.AdmitAuthorizingBrowserSession(context.Background(), proof); err != nil {
				t.Fatalf("harness cannot admit before the state is broken: %v", err)
			}
			tc.breakState(repo.state)

			validation, expiresAt, err := svc.AdmitAuthorizingBrowserSession(context.Background(), proof)
			if err == nil || validation != nil || !expiresAt.IsZero() {
				t.Fatalf("AdmitAuthorizingBrowserSession = %#v, %v, %v; want a refusal", validation, expiresAt, err)
			}

			var unavailable *securityAuthorityUnavailableError
			isOutage := stderrors.As(err, &unavailable)
			if isOutage != tc.wantOutage {
				t.Fatalf("outage classification = %t, want %t (err %v): inconsistent state is the authority failing to answer coherently, while a revoked or unauthorized session is a genuine denial", isOutage, tc.wantOutage, err)
			}
		})
	}
}

// TestAdmitAuthorizingBrowserSessionRefusesAMissingState covers the nil-state case
// separately, because persistence returns (nil, nil) for a session that is no longer there
// and the first version of this lane dereferenced it — a panic on the request path, reachable
// by presenting the proof of a session that had just been deleted.
func TestAdmitAuthorizingBrowserSessionRefusesAMissingState(t *testing.T) {
	proof, verifier, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}
	svc, repo := browserAdmissionService(t, browserSessionRow("SESSION_A", "USER_A", verifier))
	repo.state = nil

	validation, _, err := svc.AdmitAuthorizingBrowserSession(context.Background(), proof)
	if err == nil || validation != nil {
		t.Fatalf("AdmitAuthorizingBrowserSession = %#v, %v; want a refusal", validation, err)
	}
}

// TestAdmitAuthorizingBrowserSessionRefusesIdentityDrift keeps an admission from being built
// out of halves of two different truths. The projection and the authoritative state are
// separate reads, so they can disagree, and the bearer path rejects that drift rather than
// combining current ids with historical names.
func TestAdmitAuthorizingBrowserSessionRefusesIdentityDrift(t *testing.T) {
	proof, verifier, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}
	svc, repo := browserAdmissionService(t, browserSessionRow("SESSION_A", "USER_A", verifier))

	if _, _, err := svc.AdmitAuthorizingBrowserSession(context.Background(), proof); err != nil {
		t.Fatalf("harness cannot admit before the drift is introduced: %v", err)
	}
	// The state is internally consistent; it just describes a different profile than the
	// projection resolved.
	repo.state.Subject = authorizingSubject(t, "USER_A", "PROFILE_B")

	validation, _, err := svc.AdmitAuthorizingBrowserSession(context.Background(), proof)
	if err == nil || validation != nil {
		t.Fatalf("AdmitAuthorizingBrowserSession = %#v, %v; want a refusal on profile drift", validation, err)
	}
}

// TestAdmitAuthorizingBrowserSessionReconcilesDurableGrants is the authorization half of
// the drift problem. Identity drift changes who the request is; grant drift changes what it
// may do, and an empty Scope currently means full access — so a projection that disagreed
// with the durable row would authorize the browser differently from every other credential
// on the same session.
//
// The reconciliation owner decides whether a difference is a legitimate narrowing or a
// conflict, so this asserts the delegation rather than restating its policy: a durable grant
// present in the row must reach the admitted claims, and a conflicting one must fail closed.
func TestAdmitAuthorizingBrowserSessionReconcilesDurableGrants(t *testing.T) {
	proof, verifier, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}

	t.Run("durable grants reach the admitted claims", func(t *testing.T) {
		svc, repo := browserAdmissionService(t, browserSessionRow("SESSION_A", "USER_A", verifier))
		scope := "api"
		app := "APP_A"
		grant := "password"
		repo.state.Session.Scope = &scope
		repo.state.Session.ConnectedAppID = &app
		repo.state.Session.GrantType = &grant

		validation, _, err := svc.AdmitAuthorizingBrowserSession(context.Background(), proof)
		if err != nil {
			t.Fatalf("AdmitAuthorizingBrowserSession: %v", err)
		}
		if validation.Claims.User.Scope != "api" ||
			validation.Claims.User.ConnectedAppId != "APP_A" ||
			validation.Claims.User.GrantType != "password" {
			t.Fatalf("admitted claims did not adopt the durable grants: %+v", validation.Claims.User)
		}
	})

	t.Run("withdrawn authority fails closed", func(t *testing.T) {
		svc, repo := browserAdmissionService(t, browserSessionRow("SESSION_A", "USER_A", verifier))
		// The projection carries a grant the authoritative row no longer records. That is
		// the one difference the reconciliation owner treats as a conflict rather than as
		// the durable value winning, and it is the case that matters: authority was taken
		// away between the two reads.
		//
		// The policy itself belongs to reconcileDurableSessionGrant. This asserts that the
		// browser lane routes through it, which is what the earlier version of this test
		// got wrong — it asserted a "any difference is a conflict" rule that does not
		// exist, since the durable row is the authority and simply wins.
		repo.lookupSession.Scope = stringPointer("api")
		repo.loaded = repo.lookupSession
		repo.state.Session.Scope = nil

		if _, _, err := svc.AdmitAuthorizingBrowserSession(context.Background(), proof); err == nil {
			t.Fatal("a session whose scope had been withdrawn was still admitted with it")
		}
	})
}

// TestAdmitAuthorizingBrowserSessionReportsAProofStoreOutageAsAnOutage completes the
// classification contract. The proof lookup is a store read like any other, and collapsing
// its failures into "invalid session" signs the browser out during an incident — with no
// other credential to retry with, since holding none is the point of the lane.
func TestAdmitAuthorizingBrowserSessionReportsAProofStoreOutageAsAnOutage(t *testing.T) {
	proof, verifier, err := newSessionControlProof()
	if err != nil {
		t.Fatalf("newSessionControlProof: %v", err)
	}
	svc, repo := browserAdmissionService(t, browserSessionRow("SESSION_A", "USER_A", verifier))

	if _, _, err := svc.AdmitAuthorizingBrowserSession(context.Background(), proof); err != nil {
		t.Fatalf("harness cannot admit before the fault is injected: %v", err)
	}
	repo.lookupErr = stderrors.New("proof index unavailable")

	validation, _, err := svc.AdmitAuthorizingBrowserSession(context.Background(), proof)
	if err == nil || validation != nil {
		t.Fatalf("AdmitAuthorizingBrowserSession = %#v, %v; want the outage propagated", validation, err)
	}
	var unavailable *securityAuthorityUnavailableError
	if !stderrors.As(err, &unavailable) {
		t.Fatalf("proof-store outage error = %v (%T), want a security-authority-unavailable error", err, err)
	}
	var rejected *errors.UnauthorizedError
	if stderrors.As(err, &rejected) {
		t.Fatalf("a proof-store outage was reported as a rejected credential (%v), which signs the browser out during an incident", err)
	}
}

func stringPointer(value string) *string { return &value }

// TestRuntimeAdmissionEnforcesTheSharedStateInvariants is why the runtime path was unified with
// the shared owner rather than left with its own copy.
//
// Every case below is a way to admit a delegated runtime request that must not be admitted, and
// each was previously enforced by a second implementation of the same checks. Nothing failed when
// only one of the two was updated, which is the definition of silent drift — and this path is the
// one behind WebSocket, background work and Runtime Identity, so drift there is not academic.
func TestRuntimeAdmissionEnforcesTheSharedStateInvariants(t *testing.T) {
	const sessionID, principalID = "SESSION_A", "USER_A"

	runtimeService := func(t *testing.T) (*AuthService, *browserAdmissionRepo) {
		t.Helper()
		svc, repo := browserAdmissionService(t, browserSessionRow(sessionID, principalID, "unused-verifier"))
		repo.state.Session.AuthEpoch = 7
		return svc, repo
	}

	for name, tc := range map[string]struct {
		breakState func(*ports.AuthorizingSessionState)
		wantOutage bool
	}{
		"revoked":                    {breakState: func(s *ports.AuthorizingSessionState) { s.Session.IsRevoked = true }},
		"expired":                    {breakState: func(s *ports.AuthorizingSessionState) { s.Session.ExpiresAt = time.Now().Add(-time.Minute) }},
		"user no longer authorized":  {breakState: func(s *ports.AuthorizingSessionState) { s.UserAuthorized = false }},
		"connected app deauthorized": {breakState: func(s *ports.AuthorizingSessionState) { s.ConnectedAppAuthorized = false }},
		"no durable token":           {breakState: func(s *ports.AuthorizingSessionState) { s.Session.Token = "" }},
		"stored token profile drift": {
			breakState: func(state *ports.AuthorizingSessionState) {
				subject, err := permissionstatev1.NewSubject("USER_A", "PROFILE_B")
				if err != nil {
					panic(err)
				}
				state.Subject = subject
			},
		},
		"stored token role drift": {
			breakState: func(state *ports.AuthorizingSessionState) {
				state.UserRoleID = "ROLE_B"
			},
		},
		// Caller-influenced, so a denial — and the state is made *internally consistent* so the
		// shared owner is satisfied and only the runtime-specific binding can refuse. An
		// earlier version changed Session.UserID alone, which the shared owner catches first,
		// so dropping the binding entirely still passed.
		"session belongs to a different principal": {
			breakState: func(state *ports.AuthorizingSessionState) {
				state.Session.UserID = "USER_B"
				subject, err := permissionstatev1.NewSubject("USER_B", "PROFILE_A")
				if err != nil {
					panic(err)
				}
				state.Subject = subject
			},
		},
		// Same shape, from the other side: the subject disagrees with the caller while still
		// matching its own session row.
		"subject is a different principal": {
			breakState: func(state *ports.AuthorizingSessionState) {
				state.Session.UserID = "USER_C"
				subject, err := permissionstatev1.NewSubject("USER_C", "PROFILE_A")
				if err != nil {
					panic(err)
				}
				state.Subject = subject
			},
		},
		"missing auth epoch": {breakState: func(s *ports.AuthorizingSessionState) { s.Session.AuthEpoch = 0 }},
		// Not caller-influenced: the store returned a row for an id nobody asked for, which is
		// the authority answering incoherently rather than a verdict on the credential.
		"state names a different session": {
			breakState: func(s *ports.AuthorizingSessionState) { s.Session.ID = "SESSION_B" },
			wantOutage: true,
		},
	} {
		t.Run(name, func(t *testing.T) {
			svc, repo := runtimeService(t)

			// Anti-vacuity: this harness admits before the state is broken.
			if _, err := svc.AdmitRuntimeAuthorizingSession(context.Background(), sessionID, principalID); err != nil {
				t.Fatalf("harness cannot admit before the state is broken: %v", err)
			}
			tc.breakState(repo.state)

			validation, err := svc.AdmitRuntimeAuthorizingSession(context.Background(), sessionID, principalID)
			if err == nil || validation != nil {
				t.Fatalf("AdmitRuntimeAuthorizingSession = %#v, %v; want a refusal", validation, err)
			}
			var unavailable *securityAuthorityUnavailableError
			if isOutage := stderrors.As(err, &unavailable); isOutage != tc.wantOutage {
				t.Fatalf("outage classification = %t, want %t (err %v)", isOutage, tc.wantOutage, err)
			}
		})
	}
}
