package patcher

import (
	"bytes"
	"context"
	"io"
	"testing"

	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/object/transformer"
	"github.com/stretchr/testify/require"
)

type mockPatchedObjectWriter struct {
	obj *objectSDK.Object
}

func (m *mockPatchedObjectWriter) Write(_ context.Context, chunk []byte) (int, error) {
	res := append(m.obj.Payload(), chunk...)

	m.obj.SetPayload(res)
	m.obj.SetPayloadSize(uint64(len(res)))

	return len(chunk), nil
}

func (m *mockPatchedObjectWriter) WriteHeader(_ context.Context, hdr *objectSDK.Object) error {
	m.obj.ToV2().SetHeader(hdr.ToV2().GetHeader())
	return nil
}

func (m *mockPatchedObjectWriter) Close(context.Context) (*transformer.AccessIdentifiers, error) {
	return &transformer.AccessIdentifiers{}, nil
}

type mockRangeProvider struct {
	originalObjectPayload []byte
}

var _ RangeProvider = (*mockRangeProvider)(nil)

func (m *mockRangeProvider) GetRange(_ context.Context, rng *objectSDK.Range) io.Reader {
	offset := rng.GetOffset()
	length := rng.GetLength()

	if length == 0 {
		return bytes.NewReader(m.originalObjectPayload[offset:])
	}
	return bytes.NewReader(m.originalObjectPayload[offset : offset+length])
}

func newTestObject() (*objectSDK.Object, oid.Address) {
	obj := objectSDK.New()

	addr := oidtest.Address()
	obj.SetContainerID(addr.Container())
	obj.SetID(addr.Object())

	return obj, addr
}

func rangeWithOffestWithLength(offset, length uint64) *objectSDK.Range {
	rng := new(objectSDK.Range)
	rng.SetOffset(offset)
	rng.SetLength(length)
	return rng
}

func TestPatchRevert(t *testing.T) {
	obj, _ := newTestObject()

	modifPatch := &objectSDK.Patch{
		PayloadPatch: &objectSDK.PayloadPatch{
			Range: rangeWithOffestWithLength(0, 0),

			Chunk: []byte("inserted"),
		},
	}

	originalObjectPayload := []byte("*******************")

	obj.SetPayload(originalObjectPayload)
	obj.SetPayloadSize(uint64(len(originalObjectPayload)))

	exp := []byte("inserted*******************")

	rangeProvider := &mockRangeProvider{
		originalObjectPayload: originalObjectPayload,
	}

	patchedObj, _ := newTestObject()

	wr := &mockPatchedObjectWriter{
		obj: patchedObj,
	}

	prm := Params{
		Header: obj.CutPayload(),

		RangeProvider: rangeProvider,

		ObjectWriter: wr,
	}

	patcher := New(prm)

	err := patcher.ApplyAttributesPatch(context.Background(), modifPatch.NewAttributes, modifPatch.ReplaceAttributes)
	require.NoError(t, err)

	err = patcher.ApplyPayloadPatch(context.Background(), modifPatch.PayloadPatch)
	require.NoError(t, err)

	_, err = patcher.Close(context.Background())
	require.NoError(t, err)

	require.Equal(t, exp, patchedObj.Payload())

	revertPatch := &objectSDK.Patch{
		PayloadPatch: &objectSDK.PayloadPatch{
			Range: rangeWithOffestWithLength(0, uint64(len("inserted"))),

			Chunk: []byte{},
		},
	}

	rangeProvider = &mockRangeProvider{
		originalObjectPayload: exp,
	}

	patchedPatchedObj, _ := newTestObject()

	wr = &mockPatchedObjectWriter{
		obj: patchedPatchedObj,
	}

	prm = Params{
		Header: patchedObj.CutPayload(),

		RangeProvider: rangeProvider,

		ObjectWriter: wr,
	}

	patcher = New(prm)

	err = patcher.ApplyAttributesPatch(context.Background(), revertPatch.NewAttributes, revertPatch.ReplaceAttributes)
	require.NoError(t, err)

	err = patcher.ApplyPayloadPatch(context.Background(), revertPatch.PayloadPatch)
	require.NoError(t, err)

	_, err = patcher.Close(context.Background())
	require.NoError(t, err)

	require.Equal(t, originalObjectPayload, patchedPatchedObj.Payload())
}

func TestPatchRepeatAttributePatch(t *testing.T) {
	obj, _ := newTestObject()

	modifPatch := &objectSDK.Patch{}

	originalObjectPayload := []byte("*******************")

	obj.SetPayload(originalObjectPayload)
	obj.SetPayloadSize(uint64(len(originalObjectPayload)))

	rangeProvider := &mockRangeProvider{
		originalObjectPayload: originalObjectPayload,
	}

	patchedObj, _ := newTestObject()

	wr := &mockPatchedObjectWriter{
		obj: patchedObj,
	}

	prm := Params{
		Header: obj.CutPayload(),

		RangeProvider: rangeProvider,

		ObjectWriter: wr,
	}

	patcher := New(prm)

	err := patcher.ApplyAttributesPatch(context.Background(), modifPatch.NewAttributes, modifPatch.ReplaceAttributes)
	require.NoError(t, err)

	err = patcher.ApplyAttributesPatch(context.Background(), modifPatch.NewAttributes, modifPatch.ReplaceAttributes)
	require.ErrorIs(t, err, ErrAttrPatchAlreadyApplied)
}

func TestPatchEmptyPayloadPatch(t *testing.T) {
	obj, _ := newTestObject()

	modifPatch := &objectSDK.Patch{}

	originalObjectPayload := []byte("*******************")

	obj.SetPayload(originalObjectPayload)
	obj.SetPayloadSize(uint64(len(originalObjectPayload)))

	rangeProvider := &mockRangeProvider{
		originalObjectPayload: originalObjectPayload,
	}

	patchedObj, _ := newTestObject()

	wr := &mockPatchedObjectWriter{
		obj: patchedObj,
	}

	prm := Params{
		Header: obj.CutPayload(),

		RangeProvider: rangeProvider,

		ObjectWriter: wr,
	}

	patcher := New(prm)

	err := patcher.ApplyAttributesPatch(context.Background(), modifPatch.NewAttributes, modifPatch.ReplaceAttributes)
	require.NoError(t, err)

	err = patcher.ApplyPayloadPatch(context.Background(), nil)
	require.ErrorIs(t, err, ErrPayloadPatchIsNil)
}

func newTestAttribute(key, val string) objectSDK.Attribute {
	var attr objectSDK.Attribute
	attr.SetKey(key)
	attr.SetValue(val)
	return attr
}

func TestPatch(t *testing.T) {
	for _, test := range []struct {
		name                     string
		patches                  []objectSDK.Patch
		originalObjectPayload    []byte
		patchedPayload           []byte
		originalHeaderAttributes []objectSDK.Attribute
		patchedHeaderAttributes  []objectSDK.Attribute
		expectedPayloadPatchErr  error
	}{
		{
			name: "invalid offset",
			patches: []objectSDK.Patch{
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(100, 0),
						Chunk: []byte(""),
					},
				},
			},
			originalObjectPayload:   []byte("0123456789qwertyuiopasdfghjklzxcvbnm"),
			expectedPayloadPatchErr: ErrOffsetExceedsSize,
		},
		{
			name: "invalid following patch offset",
			patches: []objectSDK.Patch{
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(10, 0),
						Chunk: []byte(""),
					},
				},
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(7, 0),
						Chunk: []byte(""),
					},
				},
			},
			originalObjectPayload:   []byte("0123456789qwertyuiopasdfghjklzxcvbnm"),
			expectedPayloadPatchErr: ErrInvalidPatchOffsetOrder,
		},
		{
			name: "only header patch",
			patches: []objectSDK.Patch{
				{
					NewAttributes: []objectSDK.Attribute{
						newTestAttribute("key1", "val2"),
						newTestAttribute("key2", "val2"),
					},
				},
			},
			originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"),
			patchedPayload:        []byte("0123456789qwertyuiopasdfghjklzxcvbnm"),
			patchedHeaderAttributes: []objectSDK.Attribute{
				newTestAttribute("key1", "val2"),
				newTestAttribute("key2", "val2"),
			},
		},
		{
			name: "header and payload",
			patches: []objectSDK.Patch{
				{
					NewAttributes: []objectSDK.Attribute{
						newTestAttribute("key1", "val2"),
						newTestAttribute("key2", "val2"),
					},
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(0, 0),
						Chunk: []byte("inserted at the beginning"),
					},
				},
			},
			originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"),
			patchedPayload:        []byte("inserted at the beginning0123456789qwertyuiopasdfghjklzxcvbnm"),
			patchedHeaderAttributes: []objectSDK.Attribute{
				newTestAttribute("key1", "val2"),
				newTestAttribute("key2", "val2"),
			},
		},
		{
			name: "header only merge attributes",
			patches: []objectSDK.Patch{
				{
					NewAttributes: []objectSDK.Attribute{
						newTestAttribute("key1", "val2"),
						newTestAttribute("key2", "val2-incoming"),
					},
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(0, 0),
						Chunk: []byte("inserted at the beginning"),
					},
				},
			},
			originalHeaderAttributes: []objectSDK.Attribute{
				newTestAttribute("key2", "to be popped out"),
				newTestAttribute("key3", "val3"),
			},
			originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"),
			patchedPayload:        []byte("inserted at the beginning0123456789qwertyuiopasdfghjklzxcvbnm"),
			patchedHeaderAttributes: []objectSDK.Attribute{
				newTestAttribute("key2", "val2-incoming"),
				newTestAttribute("key3", "val3"),
				newTestAttribute("key1", "val2"),
			},
		},
		{
			name: "header only then payload",
			patches: []objectSDK.Patch{
				{
					NewAttributes: []objectSDK.Attribute{
						newTestAttribute("key1", "val2"),
						newTestAttribute("key2", "val2"),
					},
				},
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(0, 0),
						Chunk: []byte("inserted at the beginning"),
					},
				},
			},
			originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"),
			patchedPayload:        []byte("inserted at the beginning0123456789qwertyuiopasdfghjklzxcvbnm"),
			patchedHeaderAttributes: []objectSDK.Attribute{
				newTestAttribute("key1", "val2"),
				newTestAttribute("key2", "val2"),
			},
		},
		{
			name: "no effect",
			patches: []objectSDK.Patch{
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(0, 0),
						Chunk: []byte(""),
					},
				},
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(12, 0),
						Chunk: []byte(""),
					},
				},
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(20, 0),
						Chunk: []byte(""),
					},
				},
			},
			originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"),
			patchedPayload:        []byte("0123456789qwertyuiopasdfghjklzxcvbnm"),
		},
		{
			name: "insert prefix",
			patches: []objectSDK.Patch{
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(0, 0),
						Chunk: []byte("inserted at the beginning"),
					},
				},
			},
			originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"),
			patchedPayload:        []byte("inserted at the beginning0123456789qwertyuiopasdfghjklzxcvbnm"),
		},
		{
			name: "insert in the middle",
			patches: []objectSDK.Patch{
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(5, 0),
						Chunk: []byte("inserted somewhere in the middle"),
					},
				},
			},
			originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"),
			patchedPayload:        []byte("01234inserted somewhere in the middle56789qwertyuiopasdfghjklzxcvbnm"),
		},
		{
			name: "insert at the end",
			patches: []objectSDK.Patch{
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(36, 0),
						Chunk: []byte("inserted somewhere at the end"),
					},
				},
			},
			originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"),
			patchedPayload:        []byte("0123456789qwertyuiopasdfghjklzxcvbnminserted somewhere at the end"),
		},
		{
			name: "replace by range",
			patches: []objectSDK.Patch{
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(0, 12),
						Chunk: []byte("just replace"),
					},
				},
			},
			originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"),
			patchedPayload:        []byte("just replaceertyuiopasdfghjklzxcvbnm"),
		},
		{
			name: "replace and insert some bytes",
			patches: []objectSDK.Patch{
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(0, 11),
						Chunk: []byte("replace and append in the middle"),
					},
				},
			},
			originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"),
			patchedPayload:        []byte("replace and append in the middlewertyuiopasdfghjklzxcvbnm"),
		},
		{
			name: "replace and insert some bytes in the middle",
			patches: []objectSDK.Patch{
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(5, 3),
						Chunk: []byte("@@@@@"),
					},
				},
			},
			originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"),
			patchedPayload:        []byte("01234@@@@@89qwertyuiopasdfghjklzxcvbnm"),
		},
		{
			name: "a few patches: prefix, suffix",
			patches: []objectSDK.Patch{
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(0, 0),
						Chunk: []byte("this_will_be_prefix"),
					},
				},
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(36, 0),
						Chunk: []byte("this_will_be_suffix"),
					},
				},
			},
			originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"),
			patchedPayload:        []byte("this_will_be_prefix0123456789qwertyuiopasdfghjklzxcvbnmthis_will_be_suffix"),
		},
		{
			name: "a few patches: replace and insert some bytes",
			patches: []objectSDK.Patch{
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(10, 3),
						Chunk: []byte("aaaaa"),
					},
				},
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(16, 0),
						Chunk: []byte("bbbbb"),
					},
				},
			},
			originalObjectPayload: []byte("0123456789ABCDEF"),
			patchedPayload:        []byte("0123456789aaaaaDEFbbbbb"),
		},
		{
			name: "starting from the same offset",
			patches: []objectSDK.Patch{
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(8, 3),
						Chunk: []byte("1"),
					},
				},
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(11, 0),
						Chunk: []byte("2"),
					},
				},
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(11, 0),
						Chunk: []byte("3"),
					},
				},
			},
			originalObjectPayload: []byte("abcdefghijklmnop"),
			patchedPayload:        []byte("abcdefgh123lmnop"),
		},
		{
			name: "a few patches: various modifiactions",
			patches: []objectSDK.Patch{
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(4, 8),
						Chunk: []byte("earliest"),
					},
				},
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(13, 0),
						Chunk: []byte("known "),
					},
				},
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(35, 8),
						Chunk: []byte("a small town"),
					},
				},
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(62, 6),
						Chunk: []byte("tablet"),
					},
				},
				{
					PayloadPatch: &objectSDK.PayloadPatch{
						Range: rangeWithOffestWithLength(87, 0),
						Chunk: []byte("Shar-Kali-Sharri"),
					},
				},
			},
			originalObjectPayload: []byte("The ******** mention of Babylon as [insert] appears on a clay ****** from the reign of "),
			patchedPayload:        []byte("The earliest known mention of Babylon as a small town appears on a clay tablet from the reign of Shar-Kali-Sharri"),
		},
	} {
		t.Run(test.name, func(t *testing.T) {
			rangeProvider := &mockRangeProvider{
				originalObjectPayload: test.originalObjectPayload,
			}

			originalObject, _ := newTestObject()
			originalObject.SetPayload(test.originalObjectPayload)
			originalObject.SetPayloadSize(uint64(len(test.originalObjectPayload)))
			originalObject.SetAttributes(test.originalHeaderAttributes...)

			patchedObject, _ := newTestObject()

			wr := &mockPatchedObjectWriter{
				obj: patchedObject,
			}

			prm := Params{
				Header: originalObject.CutPayload(),

				RangeProvider: rangeProvider,

				ObjectWriter: wr,
			}

			patcher := New(prm)

			for i, patch := range test.patches {
				if i == 0 {
					_ = patcher.ApplyAttributesPatch(context.Background(), patch.NewAttributes, patch.ReplaceAttributes)
				}

				if patch.PayloadPatch == nil {
					continue
				}

				err := patcher.ApplyPayloadPatch(context.Background(), patch.PayloadPatch)
				if err != nil && test.expectedPayloadPatchErr != nil {
					require.ErrorIs(t, err, test.expectedPayloadPatchErr)
					return
				}
				require.NoError(t, err)
			}

			_, err := patcher.Close(context.Background())
			require.NoError(t, err)
			require.Equal(t, test.patchedPayload, patchedObject.Payload())

			patchedAttrs := append([]objectSDK.Attribute{}, test.patchedHeaderAttributes...)
			require.Equal(t, patchedAttrs, patchedObject.Attributes())
		})
	}

}