// TiCS: disabled // Test helpers.

// Package testutils contains several helpers and assertions for our unit, package or integration tests.
//
// This package is not embed in the finale binary.
package testutils

import (
	"bytes"
	"errors"
	"io/fs"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/termie/go-shutil"
)

// Chdir changes current directory to dir.
// The previous current directory is restored when the test ends.
func Chdir(t *testing.T, dir string) {
	t.Helper()

	orig, err := os.Getwd()
	if err != nil {
		t.Fatalf("Setup: Can’t get current directory: %v", err)
	}
	if err := os.Chdir(dir); err != nil {
		t.Fatalf("Setup: Can’t change current directory: %v", err)
	}
	t.Cleanup(func() {
		if err := os.Chdir(orig); err != nil {
			t.Fatalf("Teardown: Can’t restore current directory: %v", err)
		}
	})
}

// CreatePath creates a directory or a file with dummy content based on whether
// the given path contains a slash character at the end.
func CreatePath(t *testing.T, path string) {
	t.Helper()

	if strings.HasSuffix(path, "/") {
		err := os.MkdirAll(path, 0750)
		require.NoError(t, err, "Setup: cannot create directory %s", path)
		return
	}

	err := os.MkdirAll(filepath.Dir(path), 0750)
	require.NoError(t, err, "Setup: can't create directory for file")
	WriteFile(t, path, []byte("new content"), 0600)
}

// Copy copies files and directories to dest.
func Copy(t *testing.T, src, dest string) {
	t.Helper()

	// check that src is a file
	info, err := os.Stat(src)
	require.NoError(t, err, "Cannot stat %s", src)
	if !info.IsDir() {
		require.NoError(t, shutil.CopyFile(src, dest, false), "Cannot copy %s to %s", src, dest)
		return
	}

	// Copy a directory
	require.NoError(t,
		shutil.CopyTree(
			src,
			dest,
			&shutil.CopyTreeOptions{Symlinks: true, CopyFunction: shutil.Copy}),
		"Cannot copy %s to %s", src, dest)
}

// MakeReadOnly makes dest read only and restore permission on cleanup.
func MakeReadOnly(t *testing.T, dest string) {
	t.Helper()

	// Get current dest permissions
	fi, err := os.Stat(dest)
	require.NoError(t, err, "Cannot stat %s", dest)
	mode := fi.Mode()

	var perms fs.FileMode = 0444
	if fi.IsDir() {
		perms = 0555
	}
	err = os.Chmod(dest, perms)
	require.NoError(t, err)

	t.Cleanup(func() {
		_, err := os.Stat(dest)
		if errors.Is(err, os.ErrNotExist) {
			return
		}

		err = os.Chmod(dest, mode)
		require.NoError(t, err)
	})
}

const fileForEmptyDir = ".empty"

// CompareTreesWithFiltering allows comparing a goldPath directory to p. Those can be updated via the dedicated flag.
// It will filter dconf database and not commit it in the new golden directory.
func CompareTreesWithFiltering(t *testing.T, p, goldPath string, update bool) {
	t.Helper()

	// Update golden file
	if update {
		t.Logf("updating golden file %s", goldPath)
		require.NoError(t, os.RemoveAll(goldPath), "Cannot remove target golden directory")

		// check the source directory exists before trying to copy it
		info, err := os.Stat(p)
		if errors.Is(err, fs.ErrNotExist) {
			return
		}
		require.NoErrorf(t, err, "Error on checking %q", p)

		if !info.IsDir() {
			// copy file
			data, err := os.ReadFile(p)
			require.NoError(t, err, "Cannot read new generated file file %s", p)
			require.NoError(t, os.WriteFile(goldPath, data, info.Mode()), "Cannot write golden file")
		} else {
			// Filter dconf generated DB files that are machine dependent
			require.NoError(t,
				shutil.CopyTree(
					p, goldPath,
					&shutil.CopyTreeOptions{Symlinks: true, Ignore: ignoreDconfDB, CopyFunction: shutil.Copy}),
				"Can’t update golden directory")
			require.NoError(t, addEmptyMarker(goldPath), "Cannot create empty file in empty directories")
		}
	}

	var err error
	var gotContent map[string]treeAttrs
	if _, err := os.Stat(p); err == nil {
		gotContent, err = treeContentAndAttrs(t, p, []byte("GVariant"))
		if err != nil {
			t.Fatalf("No generated content: %v", err)
		}
	}

	var goldContent map[string]treeAttrs
	if _, err := os.Stat(goldPath); err == nil {
		goldContent, err = treeContentAndAttrs(t, goldPath, nil)
		if err != nil {
			t.Fatalf("No golden directory found: %v", err)
		}
	}
	assert.Equal(t, goldContent, gotContent, "got and expected content differs")

	// No more verification on p if it doesn’t exists
	if _, err := os.Stat(p); errors.Is(err, fs.ErrNotExist) {
		return
	}

	// Verify that each <DB>.d has a corresponding gvariant db generated by dconf update
	// search for dconfDir
	dconfDir := p
	err = filepath.WalkDir(dconfDir, func(p string, info fs.DirEntry, err error) error {
		if err != nil {
			return err
		}

		if !info.IsDir() {
			return nil
		}
		if info.Name() == "db" {
			dconfDir = filepath.Dir(p)
		}
		return nil
	})
	require.NoError(t, err, "can't find dconf directory")

	dbs, err := filepath.Glob(filepath.Join(dconfDir, "db", "*.d"))
	require.NoError(t, err, "Checking pattern for dconf db failed")
	for _, db := range dbs {
		_, err = os.Stat(strings.TrimSuffix(db, ".db"))
		assert.NoError(t, err, "Binary version of dconf DB should exists")
	}
}

// WriteFile writes a file by using the standard open & write procedure instead of using
// os.WriteFile.
//
// This is useful because os.WriteFile truncates the file before writing, which
// trigger a fsnotify event and can create problems when writing files in watched directories.
func WriteFile(t *testing.T, name string, data []byte, mode os.FileMode) {
	t.Helper()

	f, err := os.OpenFile(name, os.O_RDWR|os.O_CREATE, mode)
	require.NoError(t, err, "Helper: couldn't open requested file %q", name)
	defer f.Close()
	_, err = f.Write(data)
	require.NoError(t, err, "Helper: couldn't write requested data to %q", name)
	f.Close()
}

// addEmptyMarker adds to any empty directory, fileForEmptyDir to it.
// That allows git to commit it.
func addEmptyMarker(p string) error {
	err := filepath.WalkDir(p, func(path string, de fs.DirEntry, err error) error {
		if err != nil {
			return err
		}

		if !de.IsDir() {
			return nil
		}

		entries, err := os.ReadDir(path)
		if err != nil {
			return err
		}
		if len(entries) == 0 {
			f, err := os.Create(filepath.Join(path, fileForEmptyDir))
			if err != nil {
				return err
			}
			f.Close()
		}
		return nil
	})

	return err
}

// treeAttrs are the attributes to take into consideration when comparing each file.
type treeAttrs struct {
	content    string
	path       string
	executable bool
}

// treeContentAndAttrs builds a recursive file list of dir with their content and other attributes.
// It can ignore files starting with ignoreHeaders.
func treeContentAndAttrs(t *testing.T, dir string, ignoreHeaders []byte) (map[string]treeAttrs, error) {
	t.Helper()

	r := make(map[string]treeAttrs)

	err := filepath.WalkDir(dir, func(path string, de fs.DirEntry, err error) error {
		if err != nil {
			return err
		}

		// Ignore markers for empty directories
		if filepath.Base(path) == fileForEmptyDir {
			return nil
		}

		content := ""
		info, err := os.Stat(path)
		require.NoError(t, err, "Cannot stat %s", path)
		if !de.IsDir() {
			d, err := os.ReadFile(path)
			if err != nil {
				return err
			}
			// ignore given header
			if ignoreHeaders != nil && bytes.HasPrefix(d, ignoreHeaders) {
				return nil
			}
			content = string(d)
		}
		trimmedPath := strings.TrimPrefix(path, dir)
		r[trimmedPath] = treeAttrs{content, strings.TrimPrefix(path, dir), info.Mode()&0111 != 0}
		return nil
	})
	if err != nil {
		return nil, err
	}

	return r, nil
}

// ignoreDconfDB is a utility function that returns the list of binary dconf db files to ignore during copy with shutils.CopyTree.
func ignoreDconfDB(src string, entries []os.FileInfo) []string {
	var r []string
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		d, err := os.ReadFile(filepath.Join(src, e.Name()))
		if err != nil {
			continue
		}

		if bytes.HasPrefix(d, []byte("GVariant")) {
			r = append(r, e.Name())
		}
	}
	return r
}
