package client

import (
	"crypto/sha256"
	"encoding/json"
	"fmt"
	"io"
	"net"
	"net/http"
	"os"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/theupdateframework/go-tuf/data"
	"github.com/theupdateframework/go-tuf/util"
	"github.com/theupdateframework/go-tuf/verify"
)

func TestGetTargetMeta(t *testing.T) {
	verify.IsExpired = func(t time.Time) bool { return false }
	c, closer := initTestDelegationClient(t, "testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation")
	defer func() { assert.Nil(t, closer()) }()
	_, err := c.Update()
	assert.Nil(t, err)

	f, err := c.getTargetFileMeta("f.txt")
	assert.Nil(t, err)
	hash := sha256.Sum256([]byte("Contents: f.txt"))
	assert.Equal(t, data.HexBytes(hash[:]), f.Hashes["sha256"])

	f, err = c.getTargetFileMeta("targets.txt")
	assert.Nil(t, err)
	hash = sha256.Sum256([]byte("Contents: targets.txt"))
	assert.Equal(t, data.HexBytes(hash[:]), f.Hashes["sha256"])
}

func TestMaxDelegations(t *testing.T) {
	verify.IsExpired = func(t time.Time) bool { return false }
	c, closer := initTestDelegationClient(t, "testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation")
	defer func() { assert.Nil(t, closer()) }()
	_, err := c.Update()
	assert.Nil(t, err)
	c.MaxDelegations = 2
	_, err = c.getTargetFileMeta("c.txt")
	assert.Equal(t, ErrMaxDelegations{Target: "c.txt", MaxDelegations: 2, SnapshotVersion: 2}, err)
}

func TestMetaNotFound(t *testing.T) {
	verify.IsExpired = func(t time.Time) bool { return false }
	c, closer := initTestDelegationClient(t, "testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation")
	defer func() { assert.Nil(t, closer()) }()
	_, err := c.Update()
	assert.Nil(t, err)
	_, err = c.getTargetFileMeta("unknown.txt")
	assert.Equal(t, ErrUnknownTarget{Name: "unknown.txt", SnapshotVersion: 2}, err)
}

type fakeRemote struct {
	getMeta   func(name string) (stream io.ReadCloser, size int64, err error)
	getTarget func(path string) (stream io.ReadCloser, size int64, err error)
}

func (f fakeRemote) GetMeta(name string) (stream io.ReadCloser, size int64, err error) {
	return f.getMeta(name)
}

func (f fakeRemote) GetTarget(name string) (stream io.ReadCloser, size int64, err error) {
	return f.getTarget(name)
}

func TestTargetsNotFound(t *testing.T) {
	verify.IsExpired = func(t time.Time) bool { return false }
	c, closer := initTestDelegationClient(t, "testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation")
	defer func() { assert.Nil(t, closer()) }()
	_, err := c.Update()
	assert.Nil(t, err)

	previousRemote := c.remote
	newRemote := fakeRemote{
		getMeta: func(path string) (stream io.ReadCloser, size int64, err error) {
			if path == "1.c.json" {
				return nil, 0, ErrNotFound{}
			}
			return previousRemote.GetMeta(path)
		},
		getTarget: previousRemote.GetTarget,
	}
	c.remote = newRemote

	_, err = c.getTargetFileMeta("c.txt")
	assert.Equal(t, ErrMissingRemoteMetadata{Name: "c.json"}, err)
}

func TestUnverifiedTargets(t *testing.T) {
	verify.IsExpired = func(t time.Time) bool { return false }
	c, closer := initTestDelegationClient(t, "testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation")
	defer closer()
	_, err := c.Update()
	assert.Nil(t, err)

	previousRemote := c.remote
	newRemote := fakeRemote{
		getMeta: func(path string) (stream io.ReadCloser, size int64, err error) {
			if path == "1.c.json" {
				// returns a snapshot that does not match
				return previousRemote.GetMeta("1.d.json")
			}
			return previousRemote.GetMeta(path)
		},
		getTarget: previousRemote.GetTarget,
	}
	c.remote = newRemote

	_, err = c.getTargetFileMeta("c.txt")
	assert.Equal(t, ErrDecodeFailed{File: "c.json", Err: verify.ErrRoleThreshold{Expected: 1, Actual: 0}}, err)
}

func TestPersistedMeta(t *testing.T) {
	verify.IsExpired = func(t time.Time) bool { return false }
	c, closer := initTestDelegationClient(t, "testdata/php-tuf-fixtures/TUFTestFixture3LevelDelegation")
	defer closer()
	_, err := c.Update()
	assert.Nil(t, err)

	_, err = c.local.GetMeta()
	assert.Nil(t, err)

	type expectedTargets struct {
		name    string
		version int64
	}
	var persistedTests = []struct {
		file          string
		targets       []expectedTargets
		downloadError error
		targetError   error
		fileContent   string
	}{
		{
			file: "unknown",
			targets: []expectedTargets{
				{
					name:    "targets.json",
					version: 2,
				},
			},
			downloadError: ErrUnknownTarget{Name: "unknown", SnapshotVersion: 2},
			targetError:   ErrNotFound{File: "unknown"},
			fileContent:   "",
		},
		{
			file: "b.txt",
			targets: []expectedTargets{
				{
					name:    "targets.json",
					version: 2,
				},
				{
					name:    "a.json",
					version: 1,
				},
				{
					name:    "b.json",
					version: 1,
				},
			},
			downloadError: nil,
			targetError:   nil,
			fileContent:   "Contents: b.txt",
		},
		{
			file: "f.txt",
			targets: []expectedTargets{
				{
					name:    "targets.json",
					version: 2,
				},
				{
					name:    "a.json",
					version: 1,
				},
				{
					name:    "b.json",
					version: 1,
				},
				{
					name:    "c.json",
					version: 1,
				},
				{
					name:    "d.json",
					version: 1,
				},
				{
					name:    "e.json",
					version: 1,
				},
				{
					name:    "f.json",
					version: 1,
				},
			},
			downloadError: nil,
			targetError:   nil,
			fileContent:   "Contents: f.txt",
		},
	}

	for _, tt := range persistedTests {
		t.Run("search "+tt.file, func(t *testing.T) {
			var dest testDestination
			err = c.Download(tt.file, &dest)
			assert.Equal(t, tt.downloadError, err)
			assert.Equal(t, tt.fileContent, dest.String())

			target, err := c.Target(tt.file)
			assert.Equal(t, tt.targetError, err)
			if tt.targetError == nil {
				meta, err := util.GenerateTargetFileMeta(strings.NewReader(tt.fileContent), target.HashAlgorithms()...)
				assert.Nil(t, err)
				assert.Nil(t, util.TargetFileMetaEqual(target, meta))
			}

			p, err := c.local.GetMeta()
			assert.Nil(t, err)
			persisted := copyStore(p)
			persistedLocal := copyStore(c.localMeta)
			// trim non targets metas
			for _, notTargets := range []string{"root.json", "snapshot.json", "timestamp.json"} {
				delete(persisted, notTargets)
				delete(persistedLocal, notTargets)
			}
			for _, targets := range tt.targets {
				// Test local store
				storedVersion, err := versionOfStoredTargets(targets.name, persisted)
				assert.Equal(t, targets.version, storedVersion)
				assert.Nil(t, err)
				delete(persisted, targets.name)

				// Test localMeta
				storedVersion, err = versionOfStoredTargets(targets.name, persistedLocal)
				assert.Equal(t, targets.version, storedVersion)
				assert.Nil(t, err)
				delete(persistedLocal, targets.name)
			}
			assert.Empty(t, persisted)
			assert.Empty(t, persistedLocal)
		})
	}
}

func TestGetDelegationPathWithNoTargetFile(t *testing.T) {
	// In this test, we have created a target file c.txt for a delegation
	// c.json, then we remove that target file and check if c.json is loaded
	// in the localMeta. It shouldn't as it has no target file at all and shouldn't
	// be used.
	verify.IsExpired = func(t time.Time) bool { return false }
	client, closer := initTestDelegationClient(t, "testdata/php-tuf-fixtures/TUFTestFixture2LevelDelegation")
	defer closer()
	_, err := client.Update()
	assert.Nil(t, err)

	err = client.getLocalMeta()
	assert.Nil(t, err)

	_, ok := client.localMeta["a.json"]
	assert.True(t, ok)

	_, ok = client.localMeta["b.json"]
	assert.True(t, ok)

	_, ok = client.localMeta["c.json"]
	assert.False(t, ok)
}

func versionOfStoredTargets(name string, store map[string]json.RawMessage) (int64, error) {
	rawTargets, ok := store[name]
	if !ok {
		return 0, nil
	}
	s := &data.Signed{}
	if err := json.Unmarshal(rawTargets, s); err != nil {
		return 0, err
	}
	targets := &data.Targets{}
	if err := json.Unmarshal(s.Signed, targets); err != nil {
		return 0, err
	}
	return targets.Version, nil
}

func initTestDelegationClient(t *testing.T, dirPrefix string) (*Client, func() error) {
	serverDir := dirPrefix + "/server"
	initialStateDir := dirPrefix + "/client/metadata/current"
	l, err := net.Listen("tcp", "127.0.0.1:0")
	assert.Nil(t, err)
	addr := l.Addr().String()
	go http.Serve(l, http.FileServer(http.Dir(serverDir)))

	opts := &HTTPRemoteOptions{
		MetadataPath: "metadata",
		TargetsPath:  "targets",
	}
	remote, err := HTTPRemoteStore(fmt.Sprintf("http://%s/", addr), opts, nil)
	assert.Nil(t, err)

	c := NewClient(MemoryLocalStore(), remote)
	rawFile, err := os.ReadFile(initialStateDir + "/" + "root.json")
	assert.Nil(t, err)
	assert.Nil(t, c.Init(rawFile))
	files, err := os.ReadDir(initialStateDir)
	assert.Nil(t, err)

	// load local files
	for _, f := range files {
		if f.IsDir() {
			continue
		}
		name := f.Name()
		// ignoring consistent snapshot when loading initial state
		if len(strings.Split(name, ".")) < 3 && strings.HasSuffix(name, ".json") {
			rawFile, err := os.ReadFile(initialStateDir + "/" + name)
			assert.Nil(t, err)
			assert.Nil(t, c.local.SetMeta(name, rawFile))
		}
	}
	return c, l.Close
}

func copyStore(store map[string]json.RawMessage) map[string]json.RawMessage {
	new := make(map[string]json.RawMessage, len(store))
	for k, raw := range store {
		newRaw := make([]byte, len(raw))
		copy(newRaw, []byte(raw))
		new[k] = json.RawMessage(newRaw)
	}
	return new
}
