package archiver

import (
	"context"
	"os"
	"path"
	"path/filepath"
	"runtime"
	"sort"
	"strings"
	"testing"
	"time"

	"github.com/restic/restic/internal/debug"
	"github.com/restic/restic/internal/fs"
	"github.com/restic/restic/internal/restic"
)

// TestSnapshot creates a new snapshot of path.
func TestSnapshot(t testing.TB, repo restic.Repository, path string, parent *restic.ID) *restic.Snapshot {
	arch := New(repo, fs.Local{}, Options{})
	opts := SnapshotOptions{
		Time:     time.Now(),
		Hostname: "localhost",
		Tags:     []string{"test"},
	}
	if parent != nil {
		sn, err := restic.LoadSnapshot(context.TODO(), repo, *parent)
		if err != nil {
			t.Fatal(err)
		}
		opts.ParentSnapshot = sn
	}
	sn, _, _, err := arch.Snapshot(context.TODO(), []string{path}, opts)
	if err != nil {
		t.Fatal(err)
	}
	return sn
}

// TestDir describes a directory structure to create for a test.
type TestDir map[string]interface{}

func (d TestDir) String() string {
	return "<Dir>"
}

// TestFile describes a file created for a test.
type TestFile struct {
	Content string
}

func (f TestFile) String() string {
	return "<File>"
}

// TestSymlink describes a symlink created for a test.
type TestSymlink struct {
	Target string
}

func (s TestSymlink) String() string {
	return "<Symlink>"
}

// TestHardlink describes a hardlink created for a test.
type TestHardlink struct {
	Target string
}

func (s TestHardlink) String() string {
	return "<Hardlink>"
}

// TestCreateFiles creates a directory structure described by dir at target,
// which must already exist. On Windows, symlinks aren't created.
func TestCreateFiles(t testing.TB, target string, dir TestDir) {
	t.Helper()

	// ensure a stable order such that it can be guaranteed that a hardlink target already exists
	var names []string
	for name := range dir {
		names = append(names, name)
	}
	sort.Strings(names)

	for _, name := range names {
		item := dir[name]
		targetPath := filepath.Join(target, name)

		switch it := item.(type) {
		case TestFile:
			err := os.WriteFile(targetPath, []byte(it.Content), 0644)
			if err != nil {
				t.Fatal(err)
			}
		case TestSymlink:
			err := fs.Symlink(filepath.FromSlash(it.Target), targetPath)
			if err != nil {
				t.Fatal(err)
			}
		case TestHardlink:
			err := fs.Link(filepath.Join(target, filepath.FromSlash(it.Target)), targetPath)
			if err != nil {
				t.Fatal(err)
			}
		case TestDir:
			err := fs.Mkdir(targetPath, 0755)
			if err != nil {
				t.Fatal(err)
			}

			TestCreateFiles(t, targetPath, it)
		}
	}
}

// TestWalkFunc is used by TestWalkFiles to traverse the dir. When an error is
// returned, traversal stops and the surrounding test is marked as failed.
type TestWalkFunc func(path string, item interface{}) error

// TestWalkFiles runs fn for each file/directory in dir, the filename will be
// constructed with target as the prefix. Symlinks on Windows are ignored.
func TestWalkFiles(t testing.TB, target string, dir TestDir, fn TestWalkFunc) {
	t.Helper()
	for name, item := range dir {
		targetPath := filepath.Join(target, name)

		err := fn(targetPath, item)
		if err != nil {
			t.Fatalf("TestWalkFunc returned error for %v: %v", targetPath, err)
			return
		}

		if dir, ok := item.(TestDir); ok {
			TestWalkFiles(t, targetPath, dir, fn)
		}
	}
}

// fixpath removes UNC paths (starting with `\\?`) on windows. On Linux, it's a noop.
func fixpath(item string) string {
	if runtime.GOOS != "windows" {
		return item
	}
	if strings.HasPrefix(item, `\\?`) {
		return item[4:]
	}
	return item
}

// TestEnsureFiles tests if the directory structure at target is the same as
// described in dir.
func TestEnsureFiles(t testing.TB, target string, dir TestDir) {
	t.Helper()
	pathsChecked := make(map[string]struct{})

	// first, test that all items are there
	TestWalkFiles(t, target, dir, func(path string, item interface{}) error {
		fi, err := fs.Lstat(path)
		if err != nil {
			return err
		}

		switch node := item.(type) {
		case TestDir:
			if !fi.IsDir() {
				t.Errorf("is not a directory: %v", path)
			}
			return nil
		case TestFile:
			if !fi.Mode().IsRegular() {
				t.Errorf("is not a regular file: %v", path)
				return nil
			}

			content, err := os.ReadFile(path)
			if err != nil {
				return err
			}

			if string(content) != node.Content {
				t.Errorf("wrong content for %v, want %q, got %q", path, node.Content, content)
			}
		case TestSymlink:
			if fi.Mode()&os.ModeType != os.ModeSymlink {
				t.Errorf("is not a symlink: %v", path)
				return nil
			}

			target, err := fs.Readlink(path)
			if err != nil {
				return err
			}

			if target != node.Target {
				t.Errorf("wrong target for %v, want %v, got %v", path, node.Target, target)
			}
		}

		pathsChecked[path] = struct{}{}

		for parent := filepath.Dir(path); parent != target; parent = filepath.Dir(parent) {
			pathsChecked[parent] = struct{}{}
		}

		return nil
	})

	// then, traverse the directory again, looking for additional files
	err := filepath.Walk(target, func(path string, fi os.FileInfo, err error) error {
		if err != nil {
			return err
		}

		path = fixpath(path)

		if path == target {
			return nil
		}

		_, ok := pathsChecked[path]
		if !ok {
			t.Errorf("additional item found: %v %v", path, fi.Mode())
		}

		return nil
	})
	if err != nil {
		t.Fatal(err)
	}
}

// TestEnsureFileContent checks if the file in the repo is the same as file.
func TestEnsureFileContent(ctx context.Context, t testing.TB, repo restic.BlobLoader, filename string, node *restic.Node, file TestFile) {
	if int(node.Size) != len(file.Content) {
		t.Fatalf("%v: wrong node size: want %d, got %d", filename, node.Size, len(file.Content))
		return
	}

	content := make([]byte, len(file.Content))
	pos := 0
	for _, id := range node.Content {
		part, err := repo.LoadBlob(ctx, restic.DataBlob, id, content[pos:])
		if err != nil {
			t.Fatalf("error loading blob %v: %v", id.Str(), err)
			return
		}

		copy(content[pos:pos+len(part)], part)
		pos += len(part)
	}

	content = content[:pos]

	if string(content) != file.Content {
		t.Fatalf("%v: wrong content returned, want %q, got %q", filename, file.Content, content)
	}
}

// TestEnsureTree checks that the tree ID in the repo matches dir. On Windows,
// Symlinks are ignored.
func TestEnsureTree(ctx context.Context, t testing.TB, prefix string, repo restic.BlobLoader, treeID restic.ID, dir TestDir) {
	t.Helper()

	tree, err := restic.LoadTree(ctx, repo, treeID)
	if err != nil {
		t.Fatal(err)
		return
	}

	var nodeNames []string
	for _, node := range tree.Nodes {
		nodeNames = append(nodeNames, node.Name)
	}
	debug.Log("%v (%v) %v", prefix, treeID.Str(), nodeNames)

	checked := make(map[string]struct{})
	for _, node := range tree.Nodes {
		nodePrefix := path.Join(prefix, node.Name)

		entry, ok := dir[node.Name]
		if !ok {
			t.Errorf("unexpected tree node %q found, want: %#v", node.Name, dir)
			return
		}

		checked[node.Name] = struct{}{}

		switch e := entry.(type) {
		case TestDir:
			if node.Type != restic.NodeTypeDir {
				t.Errorf("tree node %v has wrong type %q, want %q", nodePrefix, node.Type, "dir")
				return
			}

			if node.Subtree == nil {
				t.Errorf("tree node %v has nil subtree", nodePrefix)
				return
			}

			TestEnsureTree(ctx, t, path.Join(prefix, node.Name), repo, *node.Subtree, e)
		case TestFile:
			if node.Type != restic.NodeTypeFile {
				t.Errorf("tree node %v has wrong type %q, want %q", nodePrefix, node.Type, "file")
			}
			TestEnsureFileContent(ctx, t, repo, nodePrefix, node, e)
		case TestSymlink:
			if node.Type != restic.NodeTypeSymlink {
				t.Errorf("tree node %v has wrong type %q, want %q", nodePrefix, node.Type, "symlink")
			}

			if e.Target != node.LinkTarget {
				t.Errorf("symlink %v has wrong target, want %q, got %q", nodePrefix, e.Target, node.LinkTarget)
			}
		}
	}

	for name := range dir {
		_, ok := checked[name]
		if !ok {
			t.Errorf("tree %v: expected node %q not found, has: %v", prefix, name, nodeNames)
		}
	}
}

// TestEnsureSnapshot tests if the snapshot in the repo has exactly the same
// structure as dir. On Windows, Symlinks are ignored.
func TestEnsureSnapshot(t testing.TB, repo restic.Repository, snapshotID restic.ID, dir TestDir) {
	t.Helper()
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	sn, err := restic.LoadSnapshot(ctx, repo, snapshotID)
	if err != nil {
		t.Fatal(err)
		return
	}

	if sn.Tree == nil {
		t.Fatal("snapshot has nil tree ID")
		return
	}

	TestEnsureTree(ctx, t, "/", repo, *sn.Tree, dir)
}