package onetimeaccess

import (
	"context"
	"testing"
	"time"

	"github.com/italypaleale/francis/actor"
	"github.com/italypaleale/francis/host/local"
	"github.com/stretchr/testify/require"
	"gorm.io/gorm"

	"github.com/pocket-id/pocket-id/backend/internal/appconfig"
	"github.com/pocket-id/pocket-id/backend/internal/apperror"
	"github.com/pocket-id/pocket-id/backend/internal/model"
	testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)

type fakeSigner struct{}

func (fakeSigner) GenerateAccessToken(_ model.User, _ string, _ time.Duration) (string, error) {
	return "access-token", nil
}

type fakeAuditLogger struct {
	events []model.AuditLogEvent
}

func (f *fakeAuditLogger) Create(_ context.Context, event model.AuditLogEvent, _, _, _ string, _ model.AuditLogData, _ *gorm.DB) (model.AuditLog, bool) {
	f.events = append(f.events, event)
	return model.AuditLog{}, true
}

type fakeUserProvider struct {
	db *gorm.DB
}

func (f fakeUserProvider) GetUser(ctx context.Context, userID string) (model.User, error) {
	var user model.User
	err := f.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error
	return user, err
}

type fakeEmailSender struct{}

func (fakeEmailSender) SendOneTimeAccessEmail(_ context.Context, _ *appconfig.AppConfigModel, _, _, _, _, _, _ string) error {
	return nil
}

// newServiceForTest sets up a Service backed by an in-memory test actor host, and returns it together with the host and the audit logger it records into
func newServiceForTest(t *testing.T, db *gorm.DB) (*Service, *local.Host, *fakeAuditLogger) {
	t.Helper()

	auditLog := &fakeAuditLogger{}

	var svc *Service
	host := testutils.NewActorHostForTest(t, func(t *testing.T, h *local.Host) {
		err := h.RegisterActor(TokenActorType, NewTokenActor)
		require.NoError(t, err)

		svc = newService(Dependencies{
			DB:           db,
			Signer:       fakeSigner{},
			AuditLog:     auditLog,
			UserProvider: fakeUserProvider{db: db},
			EmailSender:  fakeEmailSender{},
		}, h.Service())
	})
	require.NotNil(t, svc)

	return svc, host, auditLog
}

func TestGenerateTokenLength(t *testing.T) {
	for _, test := range []struct {
		name       string
		ttl        time.Duration
		wantLength int
	}{
		{name: "short-lived", ttl: 15 * time.Minute, wantLength: shortTokenLength},
		{name: "long-lived", ttl: time.Hour, wantLength: longTokenLength},
	} {
		t.Run(test.name, func(t *testing.T) {
			token, _, err := generateToken(test.ttl, false)

			require.NoError(t, err)
			require.Len(t, token, test.wantLength)
		})
	}
}

func TestExchangeTokenSuccess(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	svc, host, auditLog := newServiceForTest(t, db)

	user := model.User{
		Base:     model.Base{ID: "enabled-user"},
		Username: "enabled-user",
	}
	require.NoError(t, db.Create(&user).Error)

	token, _, err := StoreToken(t.Context(), svc.actorService, user.ID, time.Minute, false)
	require.NoError(t, err)

	dbConfig := appconfig.NewTestConfig(nil)
	exchangedUser, accessToken, err := svc.ExchangeToken(t.Context(), dbConfig, token, "", "1.2.3.4", "test-agent")
	require.NoError(t, err)
	require.Equal(t, user.ID, exchangedUser.ID)
	require.NotEmpty(t, accessToken)

	// The token must have been consumed
	var state TokenState
	err = host.GetState(t.Context(), TokenActorType, token, &state)
	require.ErrorIs(t, err, actor.ErrStateNotFound)

	// A sign-in audit log must have been created
	require.Equal(t, []model.AuditLogEvent{model.AuditLogEventOneTimeAccessTokenSignIn}, auditLog.events)
}

func TestExchangeTokenAcceptsAmbiguousAliases(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	svc, host, _ := newServiceForTest(t, db)

	user := model.User{
		Base:     model.Base{ID: "alias-user"},
		Username: "alias-user",
	}
	require.NoError(t, db.Create(&user).Error)

	const token = "a10bc2"
	require.NoError(t, host.SetState(t.Context(), TokenActorType, token, TokenState{
		UserID:    user.ID,
		ExpiresAt: time.Now().Add(time.Minute),
	}, &actor.SetStateOpts{TTL: time.Minute}))

	dbConfig := appconfig.NewTestConfig(nil)
	exchangedUser, _, err := svc.ExchangeToken(t.Context(), dbConfig, "aIObc2", "", "", "")
	require.NoError(t, err)
	require.Equal(t, user.ID, exchangedUser.ID)
}

func TestExchangeTokenInvalidToken(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	svc, _, _ := newServiceForTest(t, db)

	dbConfig := appconfig.NewTestConfig(nil)
	_, _, err := svc.ExchangeToken(t.Context(), dbConfig, "does-not-exist", "", "", "")

	require.True(t, apperror.IsCode(err, apperror.CodeTokenInvalidOrExpired))
}

func TestExchangeTokenDeviceMismatch(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	svc, host, _ := newServiceForTest(t, db)

	user := model.User{
		Base:     model.Base{ID: "device-user"},
		Username: "device-user",
	}
	require.NoError(t, db.Create(&user).Error)

	// Store a token that requires a device token
	token, deviceToken, err := StoreToken(t.Context(), svc.actorService, user.ID, time.Minute, true)
	require.NoError(t, err)
	require.NotNil(t, deviceToken)

	dbConfig := appconfig.NewTestConfig(nil)
	_, _, err = svc.ExchangeToken(t.Context(), dbConfig, token, "wrong-device-token", "", "")

	require.True(t, apperror.IsCode(err, apperror.CodeDeviceCodeInvalid))

	// The token must not have been consumed on a device-token mismatch
	var state TokenState
	err = host.GetState(t.Context(), TokenActorType, token, &state)
	require.NoError(t, err)
	require.Equal(t, user.ID, state.UserID)
}

func TestExchangeTokenRejectsDisabledUser(t *testing.T) {
	db := testutils.NewDatabaseForTest(t)
	svc, host, auditLog := newServiceForTest(t, db)

	user := model.User{
		Base:     model.Base{ID: "disabled-user"},
		Username: "disabled-user",
		Disabled: true,
	}
	require.NoError(t, db.Create(&user).Error)

	// Store a one-time access token for the disabled user in the actor state store
	token, _, err := StoreToken(t.Context(), svc.actorService, user.ID, time.Minute, false)
	require.NoError(t, err)

	dbConfig := appconfig.NewTestConfig(nil)
	exchangedUser, accessToken, err := svc.ExchangeToken(t.Context(), dbConfig, token, "", "", "")

	require.True(t, apperror.IsCode(err, apperror.CodeUserDisabled))
	require.Empty(t, exchangedUser.ID)
	require.Empty(t, accessToken)

	// The token must have been restored (not consumed), since the exchange failed because the user is disabled
	var state TokenState
	err = host.GetState(t.Context(), TokenActorType, token, &state)
	require.NoError(t, err)
	require.Equal(t, user.ID, state.UserID)

	require.Empty(t, auditLog.events)
}
