package ape

import (
	"context"
	"crypto/ecdsa"
	"encoding/hex"
	"errors"
	"fmt"
	"testing"

	"git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/object"
	"git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/refs"
	"git.frostfs.info/TrueCloudLab/frostfs-contract/frostfsid/client"
	"git.frostfs.info/TrueCloudLab/frostfs-node/pkg/core/container"
	frostfsidcore "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/core/frostfsid"
	apeSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/ape"
	"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/bearer"
	"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/checksum"
	containerSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container"
	cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id"
	netmapSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/netmap"
	objectSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object"
	oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id"
	oidtest "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id/test"
	"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/user"
	"git.frostfs.info/TrueCloudLab/frostfs-sdk-go/version"
	"git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain"
	policyengine "git.frostfs.info/TrueCloudLab/policy-engine/pkg/engine"
	"git.frostfs.info/TrueCloudLab/policy-engine/pkg/engine/inmemory"
	nativeschema "git.frostfs.info/TrueCloudLab/policy-engine/schema/native"
	"github.com/nspcc-dev/neo-go/pkg/crypto/keys"
	"github.com/nspcc-dev/neo-go/pkg/util"
	"github.com/stretchr/testify/require"
)

type headerProviderMock struct {
	m map[oid.Address]*objectSDK.Object
}

var _ HeaderProvider = (*headerProviderMock)(nil)

func (h *headerProviderMock) addHeader(c cid.ID, o oid.ID, header *objectSDK.Object) {
	var addr oid.Address
	addr.SetContainer(c)
	addr.SetObject(o)
	h.m[addr] = header
}

func (h *headerProviderMock) GetHeader(_ context.Context, c cid.ID, o oid.ID, _ bool) (*objectSDK.Object, error) {
	var addr oid.Address
	addr.SetContainer(c)
	addr.SetObject(o)
	obj, ok := h.m[addr]
	if !ok {
		return nil, fmt.Errorf("address not found")
	}
	return obj, nil
}

func newHeaderProviderMock() *headerProviderMock {
	return &headerProviderMock{
		m: make(map[oid.Address]*objectSDK.Object),
	}
}

func newContainerIDSDK(t *testing.T, encodedCID string) cid.ID {
	var cnr cid.ID
	require.NoError(t, cnr.DecodeString(encodedCID))
	return cnr
}

func newObjectIDSDK(t *testing.T, encodedOID *string) *oid.ID {
	if encodedOID == nil {
		return nil
	}
	obj := new(oid.ID)
	require.NoError(t, obj.DecodeString(*encodedOID))
	return obj
}

type headerObjectSDKParams struct {
	majorVersion, minorVersion uint32
	owner                      user.ID
	epoch                      uint64
	payloadSize                uint64
	typ                        objectSDK.Type
	payloadChecksum            checksum.Checksum
	payloadHomomorphicHash     checksum.Checksum
	attributes                 []struct {
		key string
		val string
	}
}

func stringPtr(s string) *string {
	return &s
}

func newHeaderObjectSDK(cnr cid.ID, oid *oid.ID, headerObjSDK *headerObjectSDKParams) *objectSDK.Object {
	objSDK := objectSDK.New()
	objSDK.SetContainerID(cnr)
	if oid != nil {
		objSDK.SetID(*oid)
	}
	if headerObjSDK == nil {
		return objSDK
	}
	ver := new(version.Version)
	ver.SetMajor(headerObjSDK.majorVersion)
	ver.SetMinor(headerObjSDK.minorVersion)
	objSDK.SetVersion(ver)
	objSDK.SetCreationEpoch(headerObjSDK.epoch)
	objSDK.SetOwnerID(headerObjSDK.owner)
	objSDK.SetPayloadSize(headerObjSDK.payloadSize)
	objSDK.SetType(headerObjSDK.typ)
	objSDK.SetPayloadChecksum(headerObjSDK.payloadChecksum)
	objSDK.SetPayloadHomomorphicHash(headerObjSDK.payloadHomomorphicHash)

	var attrs []objectSDK.Attribute
	for _, attr := range headerObjSDK.attributes {
		attrSDK := objectSDK.NewAttribute()
		attrSDK.SetKey(attr.key)
		attrSDK.SetValue(attr.val)
		attrs = append(attrs, *attrSDK)
	}
	objSDK.SetAttributes(attrs...)

	return objSDK
}

type testHeader struct {
	headerObjSDK *headerObjectSDKParams

	// If fromHeaderProvider is set, then running test should
	// consider that a header is recieved from a header provider.
	fromHeaderProvider bool

	// If fromHeaderProvider is set, then running test should
	// consider that a header is recieved from a message header.
	fromRequestResponseHeader bool
}

var (
	methodsRequiredOID = []string{
		nativeschema.MethodGetObject,
		nativeschema.MethodHeadObject,
		nativeschema.MethodRangeObject,
		nativeschema.MethodHashObject,
		nativeschema.MethodDeleteObject,
	}

	methodsOptionalOID = []string{
		nativeschema.MethodSearchObject, nativeschema.MethodPutObject,
	}

	namespace = "test_namespace"

	containerID = "73tQMTYyUkTgmvPR1HWib6pndbhSoBovbnMF7Pws8Rcy"

	objectID = "BzQw5HH3feoxFDD5tCT87Y1726qzgLfxEE7wgtoRzB3R"

	role = "Container"

	senderPrivateKey, _ = keys.NewPrivateKey()

	senderKey = hex.EncodeToString(senderPrivateKey.PublicKey().Bytes())
)

type frostfsIDProviderMock struct {
	subjects         map[util.Uint160]*client.Subject
	subjectsExtended map[util.Uint160]*client.SubjectExtended
}

var _ frostfsidcore.SubjectProvider = (*frostfsIDProviderMock)(nil)

func newFrostfsIDProviderMock(t *testing.T) *frostfsIDProviderMock {
	return &frostfsIDProviderMock{
		subjects: map[util.Uint160]*client.Subject{
			scriptHashFromSenderKey(t, senderKey): {
				Namespace: "testnamespace",
				Name:      "test",
				KV: map[string]string{
					"tag-attr1": "value1",
					"tag-attr2": "value2",
				},
			},
		},
		subjectsExtended: map[util.Uint160]*client.SubjectExtended{
			scriptHashFromSenderKey(t, senderKey): {
				Namespace: "testnamespace",
				Name:      "test",
				KV: map[string]string{
					"tag-attr1": "value1",
					"tag-attr2": "value2",
				},
				Groups: []*client.Group{
					{
						ID:        1,
						Name:      "test",
						Namespace: "testnamespace",
						KV: map[string]string{
							"attr1": "value1",
							"attr2": "value2",
						},
					},
				},
			},
		},
	}
}

func scriptHashFromSenderKey(t *testing.T, senderKey string) util.Uint160 {
	pk, err := keys.NewPublicKeyFromString(senderKey)
	require.NoError(t, err)
	return pk.GetScriptHash()
}

func (f *frostfsIDProviderMock) GetSubject(key util.Uint160) (*client.Subject, error) {
	v, ok := f.subjects[key]
	if !ok {
		return nil, fmt.Errorf("%s", frostfsidcore.SubjectNotFoundErrorMessage)
	}
	return v, nil
}

func (f *frostfsIDProviderMock) GetSubjectExtended(key util.Uint160) (*client.SubjectExtended, error) {
	v, ok := f.subjectsExtended[key]
	if !ok {
		return nil, fmt.Errorf("%s", frostfsidcore.SubjectNotFoundErrorMessage)
	}
	return v, nil
}

var apeCheckTestCases = []struct {
	name           string
	container      string
	object         *string
	methods        []string
	header         testHeader
	containerRules []chain.Rule
	expectAPEErr   bool
}{
	{
		name:      "oid required requests are allowed",
		container: containerID,
		object:    stringPtr(objectID),
		methods:   methodsRequiredOID,
		containerRules: []chain.Rule{
			{
				Status:  chain.Allow,
				Actions: chain.Actions{Names: methodsRequiredOID},
				Resources: chain.Resources{
					Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, containerID, objectID)},
				},
			},
		},
	},
	{
		name:      "oid optional requests are allowed",
		container: containerID,
		methods:   methodsOptionalOID,
		containerRules: []chain.Rule{
			{
				Status:  chain.Allow,
				Actions: chain.Actions{Names: methodsOptionalOID},
				Resources: chain.Resources{
					Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObjects, containerID)},
				},
			},
		},
	},
	{
		name:      "oid required requests are denied",
		container: containerID,
		object:    stringPtr(objectID),
		methods:   methodsRequiredOID,
		containerRules: []chain.Rule{
			{
				Status:  chain.AccessDenied,
				Actions: chain.Actions{Names: methodsRequiredOID},
				Resources: chain.Resources{
					Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, containerID, objectID)},
				},
			},
		},
		expectAPEErr: true,
	},
	{
		name:      "oid required requests are denied by an attribute",
		container: containerID,
		object:    stringPtr(objectID),
		methods:   methodsRequiredOID,
		header: testHeader{
			headerObjSDK: &headerObjectSDKParams{
				attributes: []struct {
					key string
					val string
				}{
					{
						key: "attr1",
						val: "attribute_value",
					},
				},
			},
			fromHeaderProvider: true,
		},
		containerRules: []chain.Rule{
			{
				Status:  chain.AccessDenied,
				Actions: chain.Actions{Names: methodsRequiredOID},
				Resources: chain.Resources{
					Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, containerID, objectID)},
				},
				Any: true,
				Condition: []chain.Condition{
					{
						Op:    chain.CondStringLike,
						Kind:  chain.KindResource,
						Key:   "attr1",
						Value: "attribute*",
					},
				},
			},
		},
		expectAPEErr: true,
	},
	{
		name:      "oid required requests are denied by sender",
		container: containerID,
		object:    stringPtr(objectID),
		methods:   methodsRequiredOID,
		header: testHeader{
			headerObjSDK: &headerObjectSDKParams{
				attributes: []struct {
					key string
					val string
				}{
					{
						key: "attr1",
						val: "attribute_value",
					},
				},
			},
			fromHeaderProvider: true,
		},
		containerRules: []chain.Rule{
			{
				Status:  chain.AccessDenied,
				Actions: chain.Actions{Names: methodsRequiredOID},
				Resources: chain.Resources{
					Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, containerID, objectID)},
				},
				Any: true,
				Condition: []chain.Condition{
					{
						Op:    chain.CondStringLike,
						Kind:  chain.KindRequest,
						Key:   nativeschema.PropertyKeyActorPublicKey,
						Value: senderKey,
					},
				},
			},
		},
		expectAPEErr: true,
	},
	{
		name:      "optional oid requests reached quota limit by an attribute",
		container: containerID,
		methods:   methodsOptionalOID,
		header: testHeader{
			headerObjSDK: &headerObjectSDKParams{
				payloadSize: 1000,
			},
			fromRequestResponseHeader: true,
		},
		containerRules: []chain.Rule{
			{
				Status:  chain.QuotaLimitReached,
				Actions: chain.Actions{Names: methodsOptionalOID},
				Resources: chain.Resources{
					Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObjects, containerID)},
				},
				Any: true,
				Condition: []chain.Condition{
					{
						Op:    chain.CondStringEquals,
						Kind:  chain.KindResource,
						Key:   nativeschema.PropertyKeyObjectPayloadLength,
						Value: "1000",
					},
				},
			},
		},
		expectAPEErr: true,
	},
}

type stMock struct{}

func (m *stMock) CurrentEpoch() uint64 {
	return 8
}

func TestAPECheck_BearerTokenOverrides(t *testing.T) {
	for _, test := range apeCheckTestCases {
		t.Run(test.name, func(t *testing.T) {
			chain := chain.Chain{
				Rules:     test.containerRules,
				MatchType: chain.MatchTypeFirstMatch,
			}
			chainSDK := apeSDK.Chain{
				Raw: chain.Bytes(),
			}
			bt := new(bearer.Token)
			bt.SetIat(1)
			bt.SetExp(10)
			bt.SetAPEOverride(bearer.APEOverride{
				Target: apeSDK.ChainTarget{
					TargetType: apeSDK.TargetTypeContainer,
					Name:       test.container,
				},
				Chains: []apeSDK.Chain{chainSDK},
			})
			bt.Sign(senderPrivateKey.PrivateKey)
			var cnrOwner user.ID
			user.IDFromKey(&cnrOwner, (ecdsa.PublicKey)(*senderPrivateKey.PublicKey()))

			for _, method := range test.methods {
				t.Run(method, func(t *testing.T) {
					headerProvider := newHeaderProviderMock()
					frostfsidProvider := newFrostfsIDProviderMock(t)

					cnr := newContainerIDSDK(t, test.container)
					obj := newObjectIDSDK(t, test.object)

					ls := inmemory.NewInmemoryLocalStorage()
					ms := inmemory.NewInmemoryMorphRuleChainStorage()
					r := policyengine.NewDefaultChainRouterWithLocalOverrides(ms, ls)

					checker := NewChecker(r, headerProvider, frostfsidProvider, nil, &stMock{}, nil, nil)

					prm := Prm{
						Method:         method,
						Container:      cnr,
						Object:         obj,
						Role:           role,
						ContainerOwner: cnrOwner,
						SenderKey:      senderKey,
						BearerToken:    bt,
					}

					var headerObjSDK *objectSDK.Object
					if test.header.headerObjSDK != nil {
						headerObjSDK = newHeaderObjectSDK(cnr, obj, test.header.headerObjSDK)
						if test.header.fromHeaderProvider {
							require.NotNil(t, obj, "oid is required if a header is expected to be found in header provider")
							headerProvider.addHeader(cnr, *obj, headerObjSDK)
						} else if test.header.fromRequestResponseHeader {
							prm.Header = headerObjSDK.ToV2().GetHeader()
						}
					}

					err := checker.CheckAPE(context.Background(), prm)
					if test.expectAPEErr {
						require.Error(t, err)
					} else {
						require.NoError(t, err)
					}
				})
			}
		})
	}
}

func TestAPECheck(t *testing.T) {
	for _, test := range apeCheckTestCases {
		t.Run(test.name, func(t *testing.T) {
			for _, method := range test.methods {
				t.Run(method, func(t *testing.T) {
					headerProvider := newHeaderProviderMock()
					frostfsidProvider := newFrostfsIDProviderMock(t)

					cnr := newContainerIDSDK(t, test.container)
					obj := newObjectIDSDK(t, test.object)

					ls := inmemory.NewInmemoryLocalStorage()
					ms := inmemory.NewInmemoryMorphRuleChainStorage()

					ls.AddOverride(chain.Ingress, policyengine.ContainerTarget(test.container), &chain.Chain{
						Rules:     test.containerRules,
						MatchType: chain.MatchTypeFirstMatch,
					})

					router := policyengine.NewDefaultChainRouterWithLocalOverrides(ms, ls)

					checker := NewChecker(router, headerProvider, frostfsidProvider, nil, &stMock{}, nil, nil)

					prm := Prm{
						Method:    method,
						Container: cnr,
						Object:    obj,
						Role:      role,
						SenderKey: senderKey,
					}

					var headerObjSDK *objectSDK.Object
					if test.header.headerObjSDK != nil {
						headerObjSDK = newHeaderObjectSDK(cnr, obj, test.header.headerObjSDK)
						if test.header.fromHeaderProvider {
							require.NotNil(t, obj, "oid is required if a header is expected to be found in header provider")
							headerProvider.addHeader(cnr, *obj, headerObjSDK)
						} else if test.header.fromRequestResponseHeader {
							prm.Header = headerObjSDK.ToV2().GetHeader()
						}
					}

					err := checker.CheckAPE(context.Background(), prm)
					if test.expectAPEErr {
						require.Error(t, err)
					} else {
						require.NoError(t, err)
					}
				})
			}
		})
	}
}

type netmapStub struct {
	netmaps      map[uint64]*netmapSDK.NetMap
	currentEpoch uint64
}

func (s *netmapStub) GetNetMap(diff uint64) (*netmapSDK.NetMap, error) {
	if diff >= s.currentEpoch {
		return nil, errors.New("invalid diff")
	}
	return s.GetNetMapByEpoch(s.currentEpoch - diff)
}

func (s *netmapStub) GetNetMapByEpoch(epoch uint64) (*netmapSDK.NetMap, error) {
	if nm, found := s.netmaps[epoch]; found {
		return nm, nil
	}
	return nil, errors.New("netmap not found")
}

func (s *netmapStub) Epoch() (uint64, error) {
	return s.currentEpoch, nil
}

type testContainerSource struct {
	containers map[cid.ID]*container.Container
}

func (s *testContainerSource) Get(cnrID cid.ID) (*container.Container, error) {
	if cnr, found := s.containers[cnrID]; found {
		return cnr, nil
	}
	return nil, fmt.Errorf("container not found")
}

func (s *testContainerSource) DeletionInfo(cid.ID) (*container.DelInfo, error) {
	return nil, nil
}

func TestPutECChunk(t *testing.T) {
	headerProvider := newHeaderProviderMock()
	frostfsidProvider := newFrostfsIDProviderMock(t)

	cnr := newContainerIDSDK(t, containerID)
	obj := newObjectIDSDK(t, &objectID)

	ls := inmemory.NewInmemoryLocalStorage()
	ms := inmemory.NewInmemoryMorphRuleChainStorage()

	ls.AddOverride(chain.Ingress, policyengine.ContainerTarget(containerID), &chain.Chain{
		Rules: []chain.Rule{
			{
				Status:  chain.AccessDenied,
				Actions: chain.Actions{Names: methodsOptionalOID},
				Resources: chain.Resources{
					Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObjects, containerID)},
				},
				Any: true,
				Condition: []chain.Condition{
					{
						Op:    chain.CondStringEquals,
						Kind:  chain.KindResource,
						Key:   "attr1",
						Value: "value",
					},
				},
			},
		},
		MatchType: chain.MatchTypeFirstMatch,
	})

	router := policyengine.NewDefaultChainRouterWithLocalOverrides(ms, ls)

	node1Key, err := keys.NewPrivateKey()
	require.NoError(t, err)
	node1 := netmapSDK.NodeInfo{}
	node1.SetPublicKey(node1Key.PublicKey().Bytes())
	netmap := &netmapSDK.NetMap{}
	netmap.SetEpoch(100)
	netmap.SetNodes([]netmapSDK.NodeInfo{node1})

	nm := &netmapStub{
		currentEpoch: 100,
		netmaps: map[uint64]*netmapSDK.NetMap{
			100: netmap,
		},
	}

	cont := containerSDK.Container{}
	cont.Init()
	pp := netmapSDK.PlacementPolicy{}
	require.NoError(t, pp.DecodeString("REP 1"))
	cont.SetPlacementPolicy(pp)
	cs := &testContainerSource{
		containers: map[cid.ID]*container.Container{
			cnr: {
				Value: cont,
			},
		},
	}

	checker := NewChecker(router, headerProvider, frostfsidProvider, nm, &stMock{}, cs, node1Key.PublicKey().Bytes())

	ecParentID := oidtest.ID()
	chunkHeader := newHeaderObjectSDK(cnr, obj, nil).ToV2().GetHeader()
	ecHeader := object.ECHeader{
		Index:  1,
		Total:  5,
		Parent: &refs.ObjectID{},
	}
	chunkHeader.SetEC(&ecHeader)
	ecParentID.WriteToV2(ecHeader.Parent)

	parentHeader := newHeaderObjectSDK(cnr, &ecParentID, &headerObjectSDKParams{
		attributes: []struct {
			key string
			val string
		}{
			{
				key: "attr1",
				val: "value",
			},
		},
	})
	headerProvider.addHeader(cnr, ecParentID, parentHeader)

	t.Run("access denied for container node", func(t *testing.T) {
		prm := Prm{
			Method:       nativeschema.MethodPutObject,
			Container:    cnr,
			Object:       obj,
			Role:         role,
			SenderKey:    senderKey,
			Header:       chunkHeader,
			SoftAPECheck: true,
		}

		err = checker.CheckAPE(context.Background(), prm)
		require.Error(t, err)
	})
	t.Run("access allowed for non container node", func(t *testing.T) {
		otherKey, err := keys.NewPrivateKey()
		require.NoError(t, err)
		checker = NewChecker(router, headerProvider, frostfsidProvider, nm, &stMock{}, cs, otherKey.PublicKey().Bytes())
		prm := Prm{
			Method:       nativeschema.MethodPutObject,
			Container:    cnr,
			Object:       obj,
			Role:         nativeschema.PropertyValueContainerRoleOthers,
			SenderKey:    senderKey,
			Header:       chunkHeader,
			SoftAPECheck: true,
		}

		err = checker.CheckAPE(context.Background(), prm)
		require.NoError(t, err)
	})
}