package user

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

	"code.justin.tv/beefcake/server/internal/awsmocks"
	"code.justin.tv/beefcake/server/internal/config"
	"code.justin.tv/beefcake/server/internal/perm"
	"code.justin.tv/beefcake/server/internal/testconfig"
	"code.justin.tv/beefcake/server/rpc/beefcake"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/awserr"
	"github.com/aws/aws-sdk-go/service/dynamodb"
	"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
)

type usersTest struct {
	Users    *Users
	Config   *config.Config
	DynamoDB *awsmocks.DynamoDBAPI
}

func newUsersTest(t *testing.T) *usersTest {
	dynamoDB := new(awsmocks.DynamoDBAPI)
	config := testconfig.New(t)
	return &usersTest{
		Users: &Users{
			Config:   config,
			DynamoDB: dynamoDB,
		},
		Config:   config,
		DynamoDB: dynamoDB,
	}
}

func (ct usersTest) Teardown(t *testing.T) {
	ct.DynamoDB.AssertExpectations(t)
}

func TestUsers(t *testing.T) {
	const testUserID = "test-user-id"
	const testRolePermID = "test-role-perm-id"

	testPermission := func() *beefcake.Permission {
		return &beefcake.Permission{Value: &beefcake.Permission_Legacy{
			Legacy: &beefcake.Permission_LegacyPermission{
				Id:   "p-id",
				Name: "p-name",
			},
		}}
	}

	t.Run("Get", func(t *testing.T) {
		t.Run("Success", func(t *testing.T) {
			ct := newUsersTest(t)
			defer ct.Teardown(t)

			testUser := func() *User {
				return &User{
					ID: testUserID,
					RoleMemberships: RoleMemberships([]*beefcake.User_RoleMembership{
						{Id: "my-id"},
					}),
					Permissions: perm.AttachedPermissions([]*beefcake.AttachedPermission{
						{Id: testRolePermID, Permission: testPermission()},
					}),
				}
			}

			item, err := dynamodbattribute.MarshalMap(testUser())
			require.NoError(t, err)

			ct.DynamoDB.
				On("GetItemWithContext", mock.Anything, &dynamodb.GetItemInput{
					Key: map[string]*dynamodb.AttributeValue{
						ct.Config.UsersHashKey.Get(): {S: aws.String(testUserID)},
					},
					TableName: aws.String(ct.Config.UsersTableName.Get()),
				}).
				Return(&dynamodb.GetItemOutput{Item: item}, nil)

			res, err := ct.Users.Get(context.Background(), testUserID)
			require.NoError(t, err)
			assert.Equal(t, testUser(), res)
		})
	})

	t.Run("AddRoleMembership", func(t *testing.T) {
		const testRolePermID = "testRolePermID"
		const testRoleID = "testRoleID"
		const testPerm = "testPerm"

		testUserRole := func() *beefcake.User_RoleMembership {
			return &beefcake.User_RoleMembership{Id: testRoleID}
		}

		testUserRoleMarshalled := func(t *testing.T) *dynamodb.AttributeValue {
			m, err := dynamodbattribute.Marshal(roleMembership(*testUserRole()))
			require.NoError(t, err)
			return m
		}

		updateCall := func(t *testing.T, ct *usersTest) *mock.Call {
			return ct.DynamoDB.
				On("UpdateItemWithContext", mock.Anything, &dynamodb.UpdateItemInput{
					ConditionExpression: aws.String("(attribute_exists (#0)) AND (attribute_exists (#1))"),
					ExpressionAttributeNames: map[string]*string{
						"#0": aws.String(PermissionsAttribute),
						"#1": aws.String(RoleMembershipsAttribute),
						"#2": aws.String(testRoleID),
						"#3": aws.String(testRolePermID),
					},
					ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
						":0": testUserRoleMarshalled(t),
						":1": {B: []byte(testPerm)},
					},
					Key: map[string]*dynamodb.AttributeValue{
						ct.Config.UsersHashKey.Get(): {S: aws.String(testUserID)},
					},
					TableName:        aws.String(ct.Config.UsersTableName.Get()),
					UpdateExpression: aws.String("SET #1.#2 = :0, #0.#3 = :1\n"),
				}).
				Once()
		}

		setCall := func(ct *usersTest) *mock.Call {
			return ct.DynamoDB.
				On("UpdateItemWithContext", mock.Anything, &dynamodb.UpdateItemInput{
					ConditionExpression: aws.String("(attribute_not_exists (#0)) AND (attribute_not_exists (#1))"),
					ExpressionAttributeNames: map[string]*string{
						"#0": aws.String(PermissionsAttribute),
						"#1": aws.String(RoleMembershipsAttribute),
					},
					ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
						":0": {
							M: map[string]*dynamodb.AttributeValue{
								testRoleID: testUserRoleMarshalled(t),
							},
						},
						":1": {
							M: map[string]*dynamodb.AttributeValue{
								testRolePermID: {B: []byte(testPerm)},
							},
						},
					},
					Key: map[string]*dynamodb.AttributeValue{
						ct.Config.UsersHashKey.Get(): {S: aws.String(testUserID)},
					},
					TableName:        aws.String(ct.Config.UsersTableName.Get()),
					UpdateExpression: aws.String("SET #1 = :0, #0 = :1\n"),
				}).
				Once()
		}

		t.Run("Success for existing user", func(t *testing.T) {
			ct := newUsersTest(t)
			defer ct.Teardown(t)

			updateCall(t, ct).
				Return(&dynamodb.UpdateItemOutput{}, nil)

			require.NoError(t, ct.Users.AddRoleMembership(
				context.Background(),
				testUserID,
				testUserRole(),
				Permissions(map[string][]byte{
					testRolePermID: []byte(testPerm),
				})))
		})

		t.Run("Success for new user", func(t *testing.T) {
			ct := newUsersTest(t)
			defer ct.Teardown(t)

			updateCall(t, ct).
				Return(nil, awserr.New(dynamodb.ErrCodeConditionalCheckFailedException, "", errors.New("")))

			setCall(ct).
				Return(&dynamodb.UpdateItemOutput{}, nil)

			require.NoError(t, ct.Users.AddRoleMembership(
				context.Background(),
				testUserID,
				testUserRole(),
				Permissions(map[string][]byte{
					testRolePermID: []byte(testPerm),
				})))
		})
	})

	t.Run("RemoveRoleMembership", func(t *testing.T) {
		const testRolePermID = "testRolePermID"
		const testRoleID = "testRoleID"
		const testPerm = "testPerm"

		t.Run("Success", func(t *testing.T) {
			ct := newUsersTest(t)
			defer ct.Teardown(t)

			ct.DynamoDB.
				On("UpdateItemWithContext", mock.Anything, &dynamodb.UpdateItemInput{
					ExpressionAttributeNames: map[string]*string{
						"#0": aws.String(RoleMembershipsAttribute),
						"#1": aws.String(testRoleID),
						"#2": aws.String(PermissionsAttribute),
						"#3": aws.String(testRolePermID),
					},
					Key: map[string]*dynamodb.AttributeValue{
						ct.Config.UsersHashKey.Get(): {S: aws.String(testUserID)},
					},
					TableName:        aws.String(ct.Config.UsersTableName.Get()),
					UpdateExpression: aws.String("REMOVE #0.#1, #2.#3\n"),
				}).
				Return(&dynamodb.UpdateItemOutput{}, nil)

			require.NoError(t, ct.Users.RemoveRoleMembership(
				context.Background(), testUserID, testRoleID,
				Permissions(map[string][]byte{
					testRolePermID: []byte(testPerm),
				})))
		})

		t.Run("No Permissions", func(t *testing.T) {
			ct := newUsersTest(t)
			defer ct.Teardown(t)

			require.NoError(t, ct.Users.RemovePermissions(
				context.Background(),
				testUserID,
				Permissions(map[string][]byte{})))
		})
	})

	t.Run("RemovePermissions", func(t *testing.T) {
		const testRolePermID = "testRolePermID"
		const testPerm = "testPerm"

		t.Run("Success", func(t *testing.T) {
			ct := newUsersTest(t)
			defer ct.Teardown(t)

			ct.DynamoDB.
				On("UpdateItemWithContext", mock.Anything, &dynamodb.UpdateItemInput{
					ExpressionAttributeNames: map[string]*string{
						"#0": aws.String(PermissionsAttribute),
						"#1": aws.String(testRolePermID),
					},
					Key: map[string]*dynamodb.AttributeValue{
						ct.Config.UsersHashKey.Get(): {S: aws.String(testUserID)},
					},
					TableName:        aws.String(ct.Config.UsersTableName.Get()),
					UpdateExpression: aws.String("REMOVE #0.#1\n"),
				}).
				Return(&dynamodb.UpdateItemOutput{}, nil)

			require.NoError(t, ct.Users.RemovePermissions(
				context.Background(),
				testUserID,
				Permissions(map[string][]byte{
					testRolePermID: []byte(testPerm),
				})))
		})

		t.Run("No Permissions", func(t *testing.T) {
			ct := newUsersTest(t)
			defer ct.Teardown(t)

			require.NoError(t, ct.Users.RemovePermissions(
				context.Background(),
				testUserID,
				Permissions(map[string][]byte{})))
		})
	})

	t.Run("UpdateAccessTime", func(t *testing.T) {
		testAccessTime := time.Now().Truncate(time.Second)

		t.Run("Success", func(t *testing.T) {
			ct := newUsersTest(t)
			defer ct.Teardown(t)

			ct.DynamoDB.
				On("UpdateItemWithContext", mock.Anything, &dynamodb.UpdateItemInput{
					ExpressionAttributeNames: map[string]*string{
						"#0": aws.String(LastAccessTimeAttribute),
					},
					ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
						":0": {S: aws.String(testAccessTime.Format(time.RFC3339))},
					},
					Key: map[string]*dynamodb.AttributeValue{
						ct.Config.UsersHashKey.Get(): {S: aws.String(testUserID)},
					},
					TableName:        aws.String(ct.Config.UsersTableName.Get()),
					UpdateExpression: aws.String("SET #0 = :0\n"),
				}).
				Return(&dynamodb.UpdateItemOutput{}, nil)

			require.NoError(t, ct.Users.UpdateAccessTime(context.Background(), testUserID, testAccessTime))
		})
	})
}
