package grpc

import (
	"context"
	"errors"
	"testing"

	"go.uber.org/mock/gomock"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"google.golang.org/grpc/codes"
	grpcstatus "google.golang.org/grpc/status"

	"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
	"github.com/netbirdio/netbird/management/server/types"
)

// capturingAuthorizer records the arguments of the last AuthorizeProxyConnect
// call and returns a fixed error.
type capturingAuthorizer struct {
	called  int
	token   *types.ProxyAccessToken
	proxyID string
	address string
	err     error
}

func (a *capturingAuthorizer) AuthorizeProxyConnect(_ context.Context, token *types.ProxyAccessToken, proxyID, address string) error {
	a.called++
	a.token = token
	a.proxyID = proxyID
	a.address = address
	return a.err
}

// authorizerServer builds a ProxyServiceServer whose proxy manager reports
// every cluster address as available, so the authorizer is the only thing
// standing between a claim and success.
func authorizerServer(t *testing.T) *ProxyServiceServer {
	t.Helper()
	ctrl := gomock.NewController(t)
	mgr := proxy.NewMockManager(ctrl)
	mgr.EXPECT().IsClusterAddressAvailable(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil).AnyTimes()
	return &ProxyServiceServer{proxyManager: mgr}
}

// TestValidateProxyConnect_NilAuthorizerUnchanged guards the no-behavior-change
// claim: with no authorizer installed — the OSS default — a well-formed claim
// succeeds and malformed input is rejected exactly as before the hook existed.
func TestValidateProxyConnect_NilAuthorizerUnchanged(t *testing.T) {
	s := authorizerServer(t)

	params, err := s.validateProxyConnect("proxy-1", "cluster.example.com", scopedCtx("acc-1"))
	require.NoError(t, err)
	assert.Equal(t, "proxy-1", params.proxyID)
	assert.Equal(t, "cluster.example.com", params.address)

	_, err = s.validateProxyConnect("", "cluster.example.com", scopedCtx("acc-1"))
	require.Error(t, err)
	st, ok := grpcstatus.FromError(err)
	require.True(t, ok)
	assert.Equal(t, codes.InvalidArgument, st.Code(), "missing proxy_id must stay InvalidArgument")

	_, err = s.validateProxyConnect("proxy-1", "not a hostname", scopedCtx("acc-1"))
	require.Error(t, err)
	st, ok = grpcstatus.FromError(err)
	require.True(t, ok)
	assert.Equal(t, codes.InvalidArgument, st.Code(), "invalid address must stay InvalidArgument")
}

// TestValidateProxyConnect_AuthorizerReceivesClaim pins the hook contract: the
// authorizer sees the presented token and the claimed proxy ID and address,
// and an authorized claim proceeds.
func TestValidateProxyConnect_AuthorizerReceivesClaim(t *testing.T) {
	s := authorizerServer(t)
	auth := &capturingAuthorizer{}
	s.SetProxyConnectAuthorizer(auth)

	params, err := s.validateProxyConnect("proxy-1", "cluster.example.com", scopedCtx("acc-1"))
	require.NoError(t, err)
	assert.Equal(t, "cluster.example.com", params.address)

	require.Equal(t, 1, auth.called, "authorizer must be consulted exactly once per connect")
	assert.Equal(t, "proxy-1", auth.proxyID)
	assert.Equal(t, "cluster.example.com", auth.address)
	require.NotNil(t, auth.token, "the presented token must be handed to the authorizer")
	require.NotNil(t, auth.token.AccountID)
	assert.Equal(t, "acc-1", *auth.token.AccountID)
}

// TestValidateProxyConnect_PlainErrorBecomesPermissionDenied pins the error
// mapping: a non-status error from the authorizer surfaces as
// PermissionDenied — distinguishable from the AlreadyExists used for address
// conflicts — and the claim does not proceed even though the address itself
// was available.
func TestValidateProxyConnect_PlainErrorBecomesPermissionDenied(t *testing.T) {
	s := authorizerServer(t)
	s.SetProxyConnectAuthorizer(&capturingAuthorizer{err: errors.New("not the assigned credential")})

	_, err := s.validateProxyConnect("proxy-1", "cluster.example.com", scopedCtx("acc-1"))
	require.Error(t, err)
	st, ok := grpcstatus.FromError(err)
	require.True(t, ok)
	assert.Equal(t, codes.PermissionDenied, st.Code())
	assert.Contains(t, st.Message(), "not the assigned credential", "the authorizer's reason must survive into the status message")
}

// TestValidateProxyConnect_StatusErrorPassesThrough pins that an authorizer
// which chooses its own status code is not second-guessed.
func TestValidateProxyConnect_StatusErrorPassesThrough(t *testing.T) {
	s := authorizerServer(t)
	s.SetProxyConnectAuthorizer(&capturingAuthorizer{
		err: grpcstatus.Errorf(codes.ResourceExhausted, "try later"),
	})

	_, err := s.validateProxyConnect("proxy-1", "cluster.example.com", scopedCtx("acc-1"))
	require.Error(t, err)
	st, ok := grpcstatus.FromError(err)
	require.True(t, ok)
	assert.Equal(t, codes.ResourceExhausted, st.Code(), "a status error must pass through unchanged")
	assert.Equal(t, "try later", st.Message())
}

// TestValidateProxyConnect_AuthorizerSeesGlobalAndTokenlessConnects pins the
// call-site placement: the authorizer sits outside the account-scoped branch,
// so management-wide tokens (AccountID == nil) and connections without any
// token are also presented to it rather than bypassing policy.
func TestValidateProxyConnect_AuthorizerSeesGlobalAndTokenlessConnects(t *testing.T) {
	s := &ProxyServiceServer{} // no proxy manager: neither path may reach the availability check

	auth := &capturingAuthorizer{}
	s.SetProxyConnectAuthorizer(auth)

	_, err := s.validateProxyConnect("proxy-1", "cluster.example.com", globalCtx())
	require.NoError(t, err)
	require.Equal(t, 1, auth.called, "a management-wide token must still be presented to the authorizer")
	require.NotNil(t, auth.token)
	assert.Nil(t, auth.token.AccountID)

	_, err = s.validateProxyConnect("proxy-1", "cluster.example.com", context.Background())
	require.NoError(t, err)
	require.Equal(t, 2, auth.called, "a token-less connect must still be presented to the authorizer")
	assert.Nil(t, auth.token, "no token in context must surface as a nil token, not a zero value")
}

// TestValidateProxyConnect_AuthorizerRunsLast pins the ordering: input
// validation and the availability check precede policy, so the authorizer is
// never consulted about a claim that is malformed or already rejected.
func TestValidateProxyConnect_AuthorizerRunsLast(t *testing.T) {
	ctrl := gomock.NewController(t)
	mgr := proxy.NewMockManager(ctrl)
	mgr.EXPECT().IsClusterAddressAvailable(gomock.Any(), gomock.Any(), gomock.Any()).Return(false, nil)
	s := &ProxyServiceServer{proxyManager: mgr}

	auth := &capturingAuthorizer{}
	s.SetProxyConnectAuthorizer(auth)

	_, err := s.validateProxyConnect("proxy-1", "not a hostname", scopedCtx("acc-1"))
	require.Error(t, err)
	assert.Zero(t, auth.called, "a malformed address must be rejected before policy runs")

	_, err = s.validateProxyConnect("proxy-1", "cluster.example.com", scopedCtx("acc-1"))
	require.Error(t, err)
	st, ok := grpcstatus.FromError(err)
	require.True(t, ok)
	assert.Equal(t, codes.AlreadyExists, st.Code(), "an address conflict must keep its own status")
	assert.Zero(t, auth.called, "a conflicting address must be rejected before policy runs")
}
