package dto

import (
	"encoding/json"
	"testing"
	"time"

	"github.com/gin-gonic/gin/binding"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/pocket-id/pocket-id/backend/internal/model"
	datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)

func TestOidcClientUpdateDto_tokenLifetimes(t *testing.T) {
	const baseFields = `"name":"Test Client","callbackURLs":["https://example.com/callback"]`

	for _, test := range []struct {
		name        string
		body        string
		wantErr     bool
		wantAccess  int64
		wantRefresh int64
	}{
		{
			name: "lifetimes omitted",
			body: `{` + baseFields + `}`,
		},
		{
			name: "lifetimes null",
			body: `{` + baseFields + `,"accessTokenDurationMinutes":null,"refreshTokenDurationMinutes":null}`,
		},
		{
			name: "lifetimes zero",
			body: `{` + baseFields + `,"accessTokenDurationMinutes":0,"refreshTokenDurationMinutes":0}`,
		},
		{
			name:       "only the access token lifetime set",
			body:       `{` + baseFields + `,"accessTokenDurationMinutes":120}`,
			wantAccess: 120,
		},
		{
			name:        "both lifetimes set",
			body:        `{` + baseFields + `,"accessTokenDurationMinutes":120,"refreshTokenDurationMinutes":10080}`,
			wantAccess:  120,
			wantRefresh: 10080,
		},
		{
			name:    "negative value is rejected",
			body:    `{` + baseFields + `,"accessTokenDurationMinutes":-1}`,
			wantErr: true,
		},
		{
			name:    "value above the maximum is rejected",
			body:    `{` + baseFields + `,"refreshTokenDurationMinutes":525601}`,
			wantErr: true,
		},
	} {
		t.Run(test.name, func(t *testing.T) {
			var input OidcClientUpdateDto
			require.NoError(t, json.Unmarshal([]byte(test.body), &input))

			err := binding.Validator.ValidateStruct(input)
			if test.wantErr {
				require.Error(t, err)
				return
			}
			require.NoError(t, err)

			// A zero value is what the service turns into the default lifetime
			assert.Equal(t, test.wantAccess, input.AccessTokenDurationMinutes)
			assert.Equal(t, test.wantRefresh, input.RefreshTokenDurationMinutes)
		})
	}
}

func TestOidcClientDto_secrets(t *testing.T) {
	expired := datatype.DateTime(time.Now().Add(-time.Hour))
	valid := datatype.DateTime(time.Now().Add(time.Hour))
	client := model.OidcClient{
		Base: model.Base{ID: "client-id"},
		Name: "Test Client",
		Credentials: model.OidcClientCredentials{
			Secrets: []model.OidcClientSecret{
				{ID: "active", Algorithm: model.OidcClientSecretHashSHA256, Hash: "hash-1", Prefix: "abcd"},
				{ID: "expiring", Algorithm: model.OidcClientSecretHashSHA256, Hash: "hash-2", Prefix: "efgh", ExpiresAt: &valid},
				{ID: "expired", Algorithm: model.OidcClientSecretHashSHA256, Hash: "hash-3", Prefix: "ijkl", ExpiresAt: &expired},
			},
		},
	}

	var clientDto OidcClientDto
	require.NoError(t, MapStruct(client, &clientDto))
	require.Len(t, clientDto.Credentials.Secrets, 3)

	assert.Equal(t, "active", clientDto.Credentials.Secrets[0].ID)
	assert.Equal(t, "abcd", clientDto.Credentials.Secrets[0].Prefix)
	assert.True(t, clientDto.Credentials.Secrets[0].IsActive)
	assert.True(t, clientDto.Credentials.Secrets[1].IsActive)
	assert.False(t, clientDto.Credentials.Secrets[2].IsActive)

	// Serializing the client must never disclose the hashes of its secrets
	serialized, err := json.Marshal(clientDto)
	require.NoError(t, err)
	assert.NotContains(t, string(serialized), "hash-1")
	assert.Contains(t, string(serialized), `"isActive":true`)
}

func TestOidcClientDto_secretsAlwaysSerialized(t *testing.T) {
	client := model.OidcClient{
		Base: model.Base{ID: "client-id"},
		Name: "Test Client",
	}

	var clientDto OidcClientDto
	require.NoError(t, MapStruct(client, &clientDto))
	assert.Empty(t, clientDto.Credentials.Secrets)

	// A client without secrets must serialize an empty list rather than omitting the field, so consumers never have to handle a missing value
	serialized, err := json.Marshal(clientDto)
	require.NoError(t, err)
	assert.Contains(t, string(serialized), `"secrets":[]`)
}
