package hugolib

import (
	"bytes"
	"encoding/base64"
	"errors"
	"fmt"
	"image"
	"io"
	"math/rand"
	"os"
	"path/filepath"
	"regexp"
	"runtime"
	"sort"
	"strings"
	"sync"
	"testing"

	"github.com/bep/logg"
	"github.com/yuin/goldmark/util"

	qt "github.com/frankban/quicktest"
	"github.com/fsnotify/fsnotify"
	"github.com/gohugoio/hugo/common/herrors"
	"github.com/gohugoio/hugo/common/hexec"
	"github.com/gohugoio/hugo/common/himage"
	"github.com/gohugoio/hugo/common/hmaps"
	"github.com/gohugoio/hugo/common/loggers"
	"github.com/gohugoio/hugo/common/types"
	"github.com/gohugoio/hugo/config"
	"github.com/gohugoio/hugo/config/allconfig"
	"github.com/gohugoio/hugo/config/security"
	"github.com/gohugoio/hugo/deps"
	"github.com/gohugoio/hugo/helpers"
	"github.com/gohugoio/hugo/htesting"
	"github.com/gohugoio/hugo/hugofs"
	"github.com/gohugoio/hugo/hugofs/hglob"
	"github.com/gohugoio/hugo/hugolib/sitesmatrix"
	"github.com/gohugoio/hugo/identity"
	"github.com/gohugoio/hugo/media"
	"github.com/spf13/afero"
	"golang.org/x/text/unicode/norm"
	"golang.org/x/tools/txtar"
)

type TestOpt func(*IntegrationTestConfig)

// TestOptRunning will enable running in integration tests.
func TestOptRunning() TestOpt {
	return func(c *IntegrationTestConfig) {
		c.Running = true
	}
}

// TestOptWatching will enable watching in integration tests.
func TestOptWatching() TestOpt {
	return func(c *IntegrationTestConfig) {
		c.Watching = true
	}
}

// Enable tracing in integration tests.
// THis should only be used during development and not committed to the repo.
func TestOptTrace() TestOpt {
	return func(c *IntegrationTestConfig) {
		c.LogLevel = logg.LevelTrace
	}
}

// TestOptDebug will enable debug logging in integration tests.
func TestOptDebug() TestOpt {
	return func(c *IntegrationTestConfig) {
		c.LogLevel = logg.LevelDebug
	}
}

// TestOptInfo will enable info logging in integration tests.
func TestOptInfo() TestOpt {
	return func(c *IntegrationTestConfig) {
		c.LogLevel = logg.LevelInfo
	}
}

// TestOptWarn will enable warn logging in integration tests.
func TestOptWarn() TestOpt {
	return func(c *IntegrationTestConfig) {
		c.LogLevel = logg.LevelWarn
	}
}

// TestOptSkipRender will skip the render phase in integration tests.
func TestOptSkipRender() TestOpt {
	return func(c *IntegrationTestConfig) {
		c.BuildCfg = BuildCfg{
			SkipRender: true,
		}
	}
}

// TestOptOsFs will enable the real file system in integration tests.
func TestOptOsFs() TestOpt {
	return func(c *IntegrationTestConfig) {
		c.NeedsOsFS = true
	}
}

// TestOptWithNpmInstall will enable npm install in integration tests.
func TestOptWithNpmInstall() TestOpt {
	return func(c *IntegrationTestConfig) {
		c.NeedsNpmInstall = true
	}
}

// TestOptWithNpmInstallGlobal enables global npm package installation in integration tests.
// This mutates the host environment and is therefore restricted to real CI.
func TestOptWithNpmInstallGlobal(packages ...string) TestOpt {
	return func(c *IntegrationTestConfig) {
		c.NeedsNpmGlobalInstall = packages
	}
}

// TestOptWithNFDOnDarwin will normalize the Unicode filenames to NFD on Darwin.
func TestOptWithNFDOnDarwin() TestOpt {
	return func(c *IntegrationTestConfig) {
		c.NFDFormOnDarwin = true
	}
}

// TestOptWithOSFs enables the real file system.
func TestOptWithOSFs() TestOpt {
	return func(c *IntegrationTestConfig) {
		c.NeedsOsFS = true
	}
}

func TestOptWithPrintAndKeepTempDir(b bool) TestOpt {
	return func(c *IntegrationTestConfig) {
		c.PrintAndKeepTempDir = b
	}
}

// TestOptWithWorkingDir allows setting any config optiona as a function al option.
func TestOptWithConfig(fn func(c *IntegrationTestConfig)) TestOpt {
	return func(c *IntegrationTestConfig) {
		fn(c)
	}
}

// Test is a convenience method to create a new IntegrationTestBuilder from some files and run a build.
func Test(t testing.TB, files string, opts ...TestOpt) *IntegrationTestBuilder {
	cfg := IntegrationTestConfig{T: t, TxtarString: files}
	for _, o := range opts {
		o(&cfg)
	}
	return NewIntegrationTestBuilder(cfg).Build()
}

// TestE is the same as Test, but returns an error instead of failing the test.
func TestE(t testing.TB, files string, opts ...TestOpt) (*IntegrationTestBuilder, error) {
	cfg := IntegrationTestConfig{T: t, TxtarString: files}
	for _, o := range opts {
		o(&cfg)
	}
	return NewIntegrationTestBuilder(cfg).BuildE()
}

// TestRunning is a convenience method to create a new IntegrationTestBuilder from some files with Running set to true and run a build.
// Deprecated: Use Test with TestOptRunning instead.
func TestRunning(t testing.TB, files string, opts ...TestOpt) *IntegrationTestBuilder {
	cfg := IntegrationTestConfig{T: t, TxtarString: files, Running: true}
	for _, o := range opts {
		o(&cfg)
	}
	return NewIntegrationTestBuilder(cfg).Build()
}

// In most cases you should not use this function directly, but the Test or TestRunning function.
func NewIntegrationTestBuilder(conf IntegrationTestConfig) *IntegrationTestBuilder {
	// Code fences.
	conf.TxtarString = strings.ReplaceAll(conf.TxtarString, "§§§", "```")
	// Multiline strings.
	conf.TxtarString = strings.ReplaceAll(conf.TxtarString, "§§", "`")

	data := txtar.Parse([]byte(conf.TxtarString))

	if conf.NFDFormOnDarwin {
		for i, f := range data.Files {
			data.Files[i].Name = norm.NFD.String(f.Name)
		}
	}

	c, ok := conf.T.(*qt.C)
	if !ok {
		c = qt.New(conf.T)
	}

	if conf.NeedsOsFS {
		if !filepath.IsAbs(conf.WorkingDir) {
			tempDir, clean, err := htesting.CreateTempDir(hugofs.Os, "hugo-integration-test")
			c.Assert(err, qt.IsNil)
			conf.WorkingDir = filepath.Join(tempDir, conf.WorkingDir)
			if !conf.PrintAndKeepTempDir {
				c.Cleanup(clean)
			} else {
				fmt.Println("\nUsing WorkingDir dir:", conf.WorkingDir)
			}
		}
	} else if conf.WorkingDir == "" {
		conf.WorkingDir = helpers.FilePathSeparator
	}

	return &IntegrationTestBuilder{
		Cfg:  conf,
		C:    c,
		data: data,
	}
}

// IntegrationTestBuilder is a (partial) rewrite of sitesBuilder.
// The main problem with the "old" one was that it was that the test data was often a little hidden,
// so it became hard to look at a test and determine what it should do, especially coming back to the
// test after a year or so.
type IntegrationTestBuilder struct {
	*qt.C

	data *txtar.Archive

	fs *hugofs.Fs
	H  *HugoSites

	Cfg IntegrationTestConfig

	changedFiles []string
	createdFiles []string
	removedFiles []string
	renamedFiles []string
	renamedDirs  []string

	buildCount   int
	GCCount      int
	counters     *buildCounters
	logBuff      lockingBuffer
	lastBuildLog string

	builderInit sync.Once
}

type IntegrationTestSiteHelper struct {
	*qt.C
	b *IntegrationTestBuilder
	S *Site
}

type IntegrationTestPageHelper struct {
	*qt.C
	b *IntegrationTestBuilder
	p *pageState
}

func (s *IntegrationTestPageHelper) siteIntsToMap(matrix, complements sitesmatrix.VectorStore) map[string]map[string][]string {
	dconf := s.p.s.Conf.ConfiguredDimensions()
	intSetsToMap := func(intSets sitesmatrix.VectorStore) map[string][]string {
		var languages, versions, roles []string

		keys1, keys2, keys3 := intSets.KeysSorted()

		for _, v := range keys1 {
			languages = append(languages, dconf.ConfiguredLanguages.ResolveName(v))
		}
		for _, v := range keys2 {
			versions = append(versions, dconf.ConfiguredVersions.ResolveName(v))
		}
		for _, v := range keys3 {
			roles = append(roles, dconf.ConfiguredRoles.ResolveName(v))
		}

		return map[string][]string{
			"languages": languages,
			"versions":  versions,
			"roles":     roles,
		}
	}

	return map[string]map[string][]string{
		"matrix":      intSetsToMap(matrix),
		"complements": intSetsToMap(complements),
	}
}

func (s *IntegrationTestPageHelper) MatrixFromPageConfig() map[string]map[string][]string {
	pc := s.p.m.pageConfigSource
	return s.siteIntsToMap(pc.SitesMatrix, pc.SitesComplements)
}

func (s *IntegrationTestPageHelper) MatrixFromFile() map[string]map[string][]string {
	if s.p.m.f == nil {
		return nil
	}
	m := s.p.m.f.FileInfo().Meta()
	return s.siteIntsToMap(m.SitesMatrix, m.SitesComplements)
}

func (s *IntegrationTestSiteHelper) PageHelper(path string) *IntegrationTestPageHelper {
	p, err := s.S.GetPage(path)
	s.Assert(err, qt.IsNil)
	s.Assert(p, qt.Not(qt.IsNil), qt.Commentf("Page not found: %s", path))
	ps, ok := p.(*pageState)
	s.Assert(ok, qt.IsTrue, qt.Commentf("Expected pageState, got %T", p))
	return &IntegrationTestPageHelper{
		C: s.C,
		b: s.b,
		p: ps,
	}
}

func (s *IntegrationTestSiteHelper) DimensionNames() types.Strings3 {
	return types.Strings3{
		s.S.Language().Name(),
		s.S.Version().Name(),
		s.S.Role().Name(),
	}
}

type lockingBuffer struct {
	sync.Mutex
	buf bytes.Buffer
}

func (b *lockingBuffer) String() string {
	b.Lock()
	defer b.Unlock()
	return b.buf.String()
}

func (b *lockingBuffer) Reset() {
	b.Lock()
	defer b.Unlock()
	b.buf.Reset()
}

func (b *lockingBuffer) ReadFrom(r io.Reader) (n int64, err error) {
	b.Lock()
	n, err = b.buf.ReadFrom(r)
	b.Unlock()
	return
}

func (b *lockingBuffer) Write(p []byte) (n int, err error) {
	b.Lock()
	n, err = b.buf.Write(p)
	b.Unlock()
	return
}

// SiteHelper returns a helper for the given language, version and role.
// Note that a blank value for an argument will use the default value for that dimension.
func (s *IntegrationTestBuilder) SiteHelper(language, version, role string) *IntegrationTestSiteHelper {
	s.Helper()
	if s.H == nil {
		s.Fatal("SiteHelper: no sites available")
	}
	v := s.H.Conf.ConfiguredDimensions().ResolveVector(types.Strings3{language, version, role})
	site, found := s.H.sitesVersionsRolesMap[v]
	if !found {
		s.Fatalf("SiteHelper: no site found for vector %v", v)
	}

	return &IntegrationTestSiteHelper{
		C: s.C,
		b: s,
		S: site,
	}
}

// AssertLogContains asserts that the last build log contains the given strings.
// Each string can be negated with a hglob.NegationPrefix prefix.
func (s *IntegrationTestBuilder) AssertLogContains(els ...string) {
	s.Helper()
	for _, el := range els {
		var negate bool
		el, negate = s.negate(el)
		check := qt.Contains
		if negate {
			check = qt.Not(qt.Contains)
		}
		s.Assert(s.lastBuildLog, check, el)
	}
}

// AssertLogMatches asserts that the last build log matches the given regular expressions.
// The regular expressions can be negated with a hglob.NegationPrefix prefix.
func (s *IntegrationTestBuilder) AssertLogMatches(expression string) {
	s.Helper()
	var negate bool
	expression, negate = s.negate(expression)
	re := regexp.MustCompile(expression)
	checker := qt.IsTrue
	if negate {
		checker = qt.IsFalse
	}

	s.Assert(re.MatchString(s.lastBuildLog), checker, qt.Commentf(s.lastBuildLog))
}

func (s *IntegrationTestBuilder) AssertFileCount(dirname string, expected int) {
	s.Helper()
	fs := s.fs.WorkingDirReadOnly
	count := 0
	afero.Walk(fs, dirname, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() {
			return nil
		}
		count++
		return nil
	})
	s.Assert(count, qt.Equals, expected)
}

func (s *IntegrationTestBuilder) negate(match string) (string, bool) {
	var negate bool
	if strings.HasPrefix(match, hglob.NegationPrefix) {
		negate = true
		match = strings.TrimPrefix(match, hglob.NegationPrefix)
	}
	return match, negate
}

// AssertFileContentStartsWith asserts that the content of the given file starts with s.
func (s *IntegrationTestBuilder) AssertFileContentStartsWith(filename, prefix string) {
	s.Helper()
	content := strings.TrimSpace(s.FileContent(filename))
	cm := qt.Commentf("File: %s Expect:\n%s Got:\n%s\nWith Space Visuals:\n%s", filename, prefix, content, util.VisualizeSpaces([]byte(content)))
	var negate bool
	prefix, negate = s.negate(prefix)
	s.Assert(strings.HasPrefix(content, prefix), qt.Equals, !negate, cm)
}

func (s *IntegrationTestBuilder) AssertFileContent(filename string, matches ...string) {
	s.Helper()
	content := strings.TrimSpace(s.FileContent(filename))

	for _, m := range matches {
		cm := qt.Commentf("File: %s Expect:\n%s Got:\n%s\nWith Space Visuals:\n%s", filename, m, content, util.VisualizeSpaces([]byte(content)))
		lines := strings.SplitSeq(m, "\n")
		for match := range lines {
			match = strings.TrimSpace(match)
			if match == "" || strings.HasPrefix(match, "#") {
				continue
			}
			var negate bool
			match, negate = s.negate(match)
			if negate {
				s.Assert(content, qt.Not(qt.Contains), match, cm)
				continue
			}
			s.Assert(content, qt.Contains, match, cm)
		}
	}
}

func (s *IntegrationTestBuilder) AssertFileContentEquals(filename string, match string) {
	s.Helper()
	content := s.FileContent(filename)
	s.Assert(content, qt.Equals, match, qt.Commentf(match))
}

func (s *IntegrationTestBuilder) AssertFileContentExact(filename string, matches ...string) {
	s.Helper()
	content := s.FileContent(filename)
	for _, m := range matches {
		cm := qt.Commentf("File: %s Match %s\nContent:\n%s", filename, m, content)
		s.Assert(content, qt.Contains, m, cm)
	}
}

func (s *IntegrationTestBuilder) AssertNoRenderShortcodesArtifacts() {
	s.Helper()
	afero.Walk(s.fs.PublishDir, "", func(path string, info os.FileInfo, err error) error {
		if err != nil || info.IsDir() {
			return err
		}
		ext := strings.TrimPrefix(filepath.Ext(path), ".")
		if !s.H.Conf.GetConfigSection("mediaTypes").(media.Types).IsTextSuffix(ext) {
			return nil
		}
		content, err := afero.ReadFile(s.fs.PublishDir, path)
		s.Assert(err, qt.IsNil)
		comment := qt.Commentf("File: %s\n%s", path, string(content))
		s.Assert(strings.Contains(string(content), "__hugo_ctx"), qt.IsFalse, comment)
		return nil
	})
}

type IntegrationTestImageHelper struct {
	*qt.C
	b *IntegrationTestBuilder

	img        image.Image
	formatName string
	congig     image.Config
}

func (h *IntegrationTestImageHelper) AssertFormat(formatName string) *IntegrationTestImageHelper {
	h.Assert(h.formatName, qt.Equals, formatName, qt.Commentf("Expected format %q, got %q", formatName, h.formatName))
	return h
}

func (h *IntegrationTestImageHelper) AssertFrameDurations(expect []int) *IntegrationTestImageHelper {
	anim, ok := h.img.(himage.AnimatedImage)
	h.Assert(ok, qt.IsTrue, qt.Commentf("Image is not animated"))
	h.Assert(anim.GetFrameDurations(), qt.DeepEquals, expect, qt.Commentf("Frame durations do not match"))
	return h
}

func (h *IntegrationTestImageHelper) AssertLoopCount(expect int) *IntegrationTestImageHelper {
	anim, ok := h.img.(himage.AnimatedImage)
	h.Assert(ok, qt.IsTrue, qt.Commentf("Image is not animated"))
	h.Assert(anim.GetLoopCount(), qt.Equals, expect, qt.Commentf("Loop count does not match"))
	return h
}

func (h *IntegrationTestImageHelper) AssertIsAnimated(b bool) *IntegrationTestImageHelper {
	_, ok := h.img.(himage.AnimatedImage)
	if b {
		h.Assert(ok, qt.IsTrue, qt.Commentf("Image is not animated"))
		return h
	}
	h.Assert(ok, qt.IsFalse, qt.Commentf("Image is animated"))
	return h
}

func (s *IntegrationTestBuilder) ImageHelper(filename string) *IntegrationTestImageHelper {
	filename = filepath.Clean(filename)
	fs := s.fs.WorkingDirReadOnly
	b, err := afero.ReadFile(fs, filename)
	s.Assert(err, qt.IsNil)
	conf, format, err := s.H.ResourceSpec.Imaging.Codec.DecodeConfig(0, bytes.NewReader(b))
	s.Assert(err, qt.IsNil)
	img, err := s.H.ResourceSpec.Imaging.Codec.Decode(bytes.NewReader(b))
	s.Assert(err, qt.IsNil)

	return &IntegrationTestImageHelper{
		C:          s.C,
		b:          s,
		img:        img,
		formatName: format,
		congig:     conf,
	}
}

func (s *IntegrationTestBuilder) AssertPublishDir(matches ...string) {
	s.AssertFs(s.fs.PublishDir, matches...)
}

func (s *IntegrationTestBuilder) AssertFs(fs afero.Fs, matches ...string) {
	s.Helper()
	var buff bytes.Buffer
	if err := s.printAndCheckFs(fs, "", &buff); err != nil {
		// E.g. public not created, treat that as an empty dir.
		if !errors.Is(err, os.ErrNotExist) {
			s.Fatal(err)
		}
	}
	printFsLines := strings.Split(buff.String(), "\n")
	sort.Strings(printFsLines)
	content := strings.TrimSpace((strings.Join(printFsLines, "\n")))
	for _, m := range matches {
		cm := qt.Commentf("Match: %q\nIn:\n%s", m, content)
		lines := strings.SplitSeq(m, "\n")
		for match := range lines {
			match = strings.TrimSpace(match)
			var negate bool
			if strings.HasPrefix(match, hglob.NegationPrefix) {
				negate = true
				match = strings.TrimPrefix(match, hglob.NegationPrefix)
			}
			if negate {
				s.Assert(content, qt.Not(qt.Contains), match, cm)
				continue
			}
			s.Assert(content, qt.Contains, match, cm)
		}
	}
}

func (s *IntegrationTestBuilder) printAndCheckFs(fs afero.Fs, path string, w io.Writer) error {
	if fs == nil {
		return nil
	}

	return afero.Walk(fs, path, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		path = filepath.ToSlash(path)
		if path == "" {
			path = "."
		}
		var size int64
		if !info.IsDir() {
			size = info.Size()
			f, err := fs.Open(path)
			if err != nil {
				return fmt.Errorf("error: path %q: %s", path, err)
			}
			defer f.Close()
			// This will panic if the file is a directory.
			var buf [1]byte
			io.ReadFull(f, buf[:])
		}
		fmt.Fprintf(w, "%06d %s %t\n", size, path, info.IsDir())
		return nil
	})
}

func (s *IntegrationTestBuilder) AssertFileExists(filename string, b bool) {
	checker := qt.IsNil
	if !b {
		checker = qt.IsNotNil
	}

	_, err := s.fs.WorkingDirReadOnly.Stat(filename)
	if err != nil && !herrors.IsNotExist(err) {
		s.Assert(err, qt.IsNil)
		return
	}
	s.Assert(err, checker)
}

func (s *IntegrationTestBuilder) AssertRenderCountContent(count int) {
	s.Helper()
	s.Assert(s.counters.contentRenderCounter.Load(), qt.Equals, uint64(count))
}

func (s *IntegrationTestBuilder) AssertRenderCountPage(count int) {
	s.Helper()
	s.Assert(s.counters.pageRenderCounter.Load(), qt.Equals, uint64(count))
}

func (s *IntegrationTestBuilder) AssertRenderCountPageBetween(from, to int) {
	s.Helper()
	i := int(s.counters.pageRenderCounter.Load())
	s.Assert(i >= from && i <= to, qt.IsTrue)
}

func (s *IntegrationTestBuilder) Build() *IntegrationTestBuilder {
	s.Helper()
	_, err := s.BuildE()

	if err != nil && strings.Contains(err.Error(), "error(s)") {
		err = fmt.Errorf("%w: %s", err, s.lastBuildLog)
	}

	if s.Cfg.Verbose || err != nil {
		if s.H != nil && err == nil {
			for s := range s.H.allSites(nil) {
				m := s.pageMap
				var buff bytes.Buffer
				fmt.Fprintf(&buff, "======= PageMap for site %q  =======\n\n", s.resolveDimensionNames())
				m.debugPrint("", 999, &buff)
				fmt.Println(buff.String())
			}
		}
	} else if s.Cfg.LogLevel <= logg.LevelDebug {
		fmt.Println(s.lastBuildLog)
	}
	s.Assert(err, qt.IsNil)
	if s.Cfg.RunGC {
		s.GCCount, err = s.H.GC()
		s.Assert(err, qt.IsNil)
	}

	s.Cleanup(func() {
		if h := s.H; h != nil {
			s.Assert(h.Close(), qt.IsNil)
		}
	})

	return s
}

func (s *IntegrationTestBuilder) BuildPartial(urls ...string) *IntegrationTestBuilder {
	if _, err := s.BuildPartialE(urls...); err != nil {
		s.Fatal(err)
	}
	return s
}

func (s *IntegrationTestBuilder) BuildPartialE(urls ...string) (*IntegrationTestBuilder, error) {
	if s.buildCount == 0 {
		panic("BuildPartial can only be used after a full build")
	}
	if !s.Cfg.Running {
		panic("BuildPartial can only be used in server mode")
	}
	visited := types.NewEvictingQueue[string](len(urls))
	for _, url := range urls {
		visited.Add(url)
	}
	buildCfg := BuildCfg{RecentlyTouched: visited, PartialReRender: true}
	return s, s.build(buildCfg)
}

func (s *IntegrationTestBuilder) Close() {
	s.Helper()
	s.Assert(s.H.Close(), qt.IsNil)
}

func (s *IntegrationTestBuilder) LogString() string {
	return s.lastBuildLog
}

func (s *IntegrationTestBuilder) BuildE() (*IntegrationTestBuilder, error) {
	s.Helper()
	if err := s.initBuilder(); err != nil {
		return s, err
	}

	err := s.build(s.Cfg.BuildCfg)
	return s, err
}

func (s *IntegrationTestBuilder) Init() *IntegrationTestBuilder {
	if err := s.initBuilder(); err != nil {
		s.Fatalf("Failed to init builder: %s", err)
	}
	s.lastBuildLog = s.logBuff.String()
	return s
}

type IntegrationTestDebugConfig struct {
	Out io.Writer

	PrintDestinationFs bool
	PrintPagemap       bool

	PrefixDestinationFs string
	PrefixPagemap       string
}

func (s *IntegrationTestBuilder) EditFileReplaceAll(filename, old, new string) *IntegrationTestBuilder {
	return s.EditFileReplaceFunc(filename, func(s string) string {
		return strings.ReplaceAll(s, old, new)
	})
}

func (s *IntegrationTestBuilder) EditFileReplaceFunc(filename string, replacementFunc func(s string) string) *IntegrationTestBuilder {
	absFilename := s.absFilename(filename)
	b, err := afero.ReadFile(s.fs.Source, absFilename)
	s.Assert(err, qt.IsNil)
	s.changedFiles = append(s.changedFiles, absFilename)
	oldContent := string(b)
	s.writeSource(absFilename, replacementFunc(oldContent))
	return s
}

func (s *IntegrationTestBuilder) EditFileAppend(filename, contnt string) *IntegrationTestBuilder {
	absFilename := s.absFilename(filename)
	b, err := afero.ReadFile(s.fs.Source, absFilename)
	s.Assert(err, qt.IsNil)
	s.changedFiles = append(s.changedFiles, absFilename)
	oldContent := string(b)
	s.writeSource(absFilename, oldContent+contnt)
	return s
}

func (s *IntegrationTestBuilder) EditFiles(filenameContent ...string) *IntegrationTestBuilder {
	for i := 0; i < len(filenameContent); i += 2 {
		filename, content := filepath.FromSlash(filenameContent[i]), filenameContent[i+1]
		absFilename := s.absFilename(filename)
		s.changedFiles = append(s.changedFiles, absFilename)
		s.writeSource(absFilename, content)
	}
	return s
}

func (s *IntegrationTestBuilder) AddFiles(filenameContent ...string) *IntegrationTestBuilder {
	for i := 0; i < len(filenameContent); i += 2 {
		filename, content := filepath.FromSlash(filenameContent[i]), filenameContent[i+1]
		absFilename := s.absFilename(filename)
		s.createdFiles = append(s.createdFiles, absFilename)
		s.writeSource(absFilename, content)
	}
	return s
}

func (s *IntegrationTestBuilder) CreateDirs(dirnames ...string) *IntegrationTestBuilder {
	for _, dirname := range dirnames {
		absDir := s.absFilename(filepath.FromSlash(dirname))
		s.Assert(s.fs.Source.MkdirAll(absDir, 0o777), qt.IsNil)
		s.createdFiles = append(s.createdFiles, absDir)
	}
	return s
}

func (s *IntegrationTestBuilder) RemoveFiles(filenames ...string) *IntegrationTestBuilder {
	for _, filename := range filenames {
		absFilename := s.absFilename(filename)
		s.removedFiles = append(s.removedFiles, absFilename)
		s.Assert(s.fs.Source.Remove(absFilename), qt.IsNil)

	}

	return s
}

func (s *IntegrationTestBuilder) RemovePublishDir() *IntegrationTestBuilder {
	s.Helper()
	if err := s.fs.PublishDir.RemoveAll(""); err != nil && !herrors.IsNotExist(err) {
		s.Fatalf("Failed to remove publish dir: %s", err)
	}

	return s
}

func (s *IntegrationTestBuilder) RenameFile(old, new string) *IntegrationTestBuilder {
	absOldFilename := s.absFilename(old)
	absNewFilename := s.absFilename(new)
	s.renamedFiles = append(s.renamedFiles, absOldFilename)
	s.createdFiles = append(s.createdFiles, absNewFilename)
	s.Assert(s.fs.Source.MkdirAll(filepath.Dir(absNewFilename), 0o777), qt.IsNil)
	s.Assert(s.fs.Source.Rename(absOldFilename, absNewFilename), qt.IsNil)
	return s
}

func (s *IntegrationTestBuilder) RenameDir(old, new string) *IntegrationTestBuilder {
	absOldFilename := s.absFilename(old)
	absNewFilename := s.absFilename(new)
	s.renamedDirs = append(s.renamedDirs, absOldFilename)
	s.changedFiles = append(s.changedFiles, absNewFilename)
	afero.Walk(s.fs.Source, absOldFilename, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() {
			return nil
		}
		s.createdFiles = append(s.createdFiles, strings.Replace(path, absOldFilename, absNewFilename, 1))
		return nil
	})
	s.Assert(s.fs.Source.MkdirAll(filepath.Dir(absNewFilename), 0o777), qt.IsNil)
	s.Assert(s.fs.Source.Rename(absOldFilename, absNewFilename), qt.IsNil)
	return s
}

func (s *IntegrationTestBuilder) FileContent(filename string) string {
	s.Helper()
	return s.readWorkingDir(s, s.fs, filepath.FromSlash(filename))
}

func (s *IntegrationTestBuilder) initBuilder() error {
	var initErr error
	s.builderInit.Do(func() {
		var afs afero.Fs
		if s.Cfg.NeedsOsFS {
			afs = afero.NewOsFs()
		} else {
			afs = afero.NewMemMapFs()
		}

		if s.Cfg.LogLevel == 0 {
			s.Cfg.LogLevel = logg.LevelError
		}

		isBinaryRe := regexp.MustCompile(`^(.*)(\.png|\.jpg)$`)

		const dataSourceFilenamePrefix = "sourcefilename:"

		for _, f := range s.data.Files {
			filename := filepath.Join(s.Cfg.WorkingDir, f.Name)
			data := bytes.TrimSuffix(f.Data, []byte("\n"))
			datastr := strings.TrimSpace(string(data))
			if strings.HasPrefix(datastr, dataSourceFilenamePrefix) {
				// Read from file relative to the current dir.
				var err error
				wd, _ := os.Getwd()
				filename := filepath.Join(wd, strings.TrimSpace(strings.TrimPrefix(datastr, dataSourceFilenamePrefix)))
				data, err = os.ReadFile(filename)
				s.Assert(err, qt.IsNil)
			} else if isBinaryRe.MatchString(filename) {
				var err error
				data, err = base64.StdEncoding.DecodeString(string(data))
				s.Assert(err, qt.IsNil)

			}
			s.Assert(afs.MkdirAll(filepath.Dir(filename), 0o777), qt.IsNil)
			s.Assert(afero.WriteFile(afs, filename, data, 0o666), qt.IsNil)
		}

		configDir := "config"
		if _, err := afs.Stat(filepath.Join(s.Cfg.WorkingDir, "config")); err != nil {
			configDir = ""
		}

		var flags config.Provider
		if s.Cfg.BaseCfg != nil {
			flags = s.Cfg.BaseCfg
		} else {
			flags = config.New()
		}

		if s.Cfg.Running {
			flags.Set("internal", hmaps.Params{
				"running": s.Cfg.Running,
				"watch":   s.Cfg.Running,
			})
		} else if s.Cfg.Watching {
			flags.Set("internal", hmaps.Params{
				"watch": s.Cfg.Watching,
			})
		}

		if s.Cfg.WorkingDir != "" {
			flags.Set("workingDir", s.Cfg.WorkingDir)
		}

		var w io.Writer
		if s.Cfg.Verbose || s.Cfg.LogLevel == logg.LevelTrace {
			w = io.MultiWriter(os.Stdout, &s.logBuff)
		} else {
			w = &s.logBuff
		}

		var logHookLast func(e *logg.Entry) error
		if s.Cfg.PanicOnWarning {
			logHookLast = loggers.PanicOnWarningHook
		}

		logger := loggers.New(
			loggers.Options{
				StdOut:        w,
				StdErr:        w,
				Level:         s.Cfg.LogLevel,
				DistinctLevel: logg.LevelWarn,
				HandlerPost:   logHookLast,
			},
		)

		res, err := allconfig.LoadConfig(
			allconfig.ConfigSourceDescriptor{
				Flags:     flags,
				ConfigDir: configDir,
				Fs:        afs,
				Logger:    logger,
				Environ:   s.Cfg.Environ,
			},
		)
		if err != nil {
			initErr = err
			return
		}

		fs := hugofs.NewFrom(afs, res.LoadingInfo.BaseConfig)

		s.Assert(err, qt.IsNil)

		// changes received from Hugo in watch mode.
		// In the full setup, this channel is created in the commands package.
		changesFromBuild := make(chan []identity.Identity, 10)

		depsCfg := deps.DepsCfg{Configs: res, Fs: fs, LogLevel: logger.Level(), StdErr: logger.StdErr(), ChangesFromBuild: changesFromBuild, IsIntegrationTest: true, TestCfg: deps.TestConfig{WarpcMemory: s.Cfg.WarpcMemory}}
		sites, err := NewHugoSites(depsCfg)
		if err != nil {
			initErr = err
			return
		}
		if sites == nil {
			initErr = errors.New("no sites")
			return
		}

		go func() {
			for id := range changesFromBuild {
				whatChanged := &WhatChanged{}
				for _, v := range id {
					whatChanged.Add(v)
				}
				bcfg := s.Cfg.BuildCfg
				bcfg.WhatChanged = whatChanged
				if err := s.build(bcfg); err != nil {
					s.Fatalf("Build failed after change: %s", err)
				}
			}
		}()

		s.H = sites
		s.fs = fs

		if s.Cfg.NeedsNpmInstall || len(s.Cfg.NeedsNpmGlobalInstall) > 0 {
			if s.Cfg.NeedsNpmInstall {
				wd, _ := os.Getwd()
				s.Assert(os.Chdir(s.Cfg.WorkingDir), qt.IsNil)
				s.C.Cleanup(func() { os.Chdir(wd) })
			}
			sc := security.DefaultConfig
			sc.Exec.Allow, err = security.NewWhitelist("npm")
			s.Assert(err, qt.IsNil)
			ex := hexec.New(sc, s.Cfg.WorkingDir, loggers.NewDefault())

			if len(s.Cfg.NeedsNpmGlobalInstall) > 0 {
				if !htesting.IsRealCI() && !htesting.IsGitHubAction() {
					panic("NeedsNpmGlobalInstall is restricted to real CI because it performs global npm installs")
				}
				for _, pkg := range s.Cfg.NeedsNpmGlobalInstall {
					command, err := ex.New("npm", "install", "-g", pkg)
					s.Assert(err, qt.IsNil)
					s.Assert(command.Run(), qt.IsNil)

				}
				s.C.Cleanup(func() {
					for _, pkg := range s.Cfg.NeedsNpmGlobalInstall {
						ex.New("npm", "uninstall", "-g", pkg)
					}
				})
			} else {
				command, err := ex.New("npm", "install")
				s.Assert(err, qt.IsNil)
				s.Assert(command.Run(), qt.IsNil)
			}
		}
	})

	return initErr
}

func (s *IntegrationTestBuilder) absFilename(filename string) string {
	filename = filepath.FromSlash(filename)
	if filepath.IsAbs(filename) {
		return filename
	}
	if s.Cfg.WorkingDir != "" && !strings.HasPrefix(filename, s.Cfg.WorkingDir) {
		filename = filepath.Join(s.Cfg.WorkingDir, filename)
	}
	return filename
}

func (s *IntegrationTestBuilder) reset() {
	s.changedFiles = nil
	s.createdFiles = nil
	s.removedFiles = nil
	s.renamedFiles = nil
}

func (s *IntegrationTestBuilder) build(cfg BuildCfg) error {
	s.Helper()
	defer func() {
		s.reset()
		s.lastBuildLog = s.logBuff.String()
		s.logBuff.Reset()
	}()

	changeEvents := s.changeEvents()
	s.counters = &buildCounters{}
	cfg.testCounters = s.counters

	s.buildCount++

	err := s.H.Build(cfg, changeEvents...)
	if err != nil {
		return err
	}

	return nil
}

// We simulate the fsnotify events.
// See the test output in https://github.com/bep/fsnotifyeventlister for what events gets produced
// by the different OSes.
func (s *IntegrationTestBuilder) changeEvents() []fsnotify.Event {
	var (
		events    []fsnotify.Event
		isLinux   = runtime.GOOS == "linux"
		isMacOs   = runtime.GOOS == "darwin"
		isWindows = runtime.GOOS == "windows"
	)

	for _, v := range s.removedFiles {
		events = append(events, fsnotify.Event{
			Name: v,
			Op:   fsnotify.Remove,
		})
	}
	for _, v := range s.renamedFiles {
		events = append(events, fsnotify.Event{
			Name: v,
			Op:   fsnotify.Rename,
		})
	}

	for _, v := range s.renamedDirs {
		events = append(events, fsnotify.Event{
			Name: v,
			// This is what we get on MacOS.
			Op: fsnotify.Remove | fsnotify.Rename,
		})
	}

	for _, v := range s.changedFiles {
		events = append(events, fsnotify.Event{
			Name: v,
			Op:   fsnotify.Write,
		})
		if isLinux || isWindows {
			// Duplicate write events, for some reason.
			events = append(events, fsnotify.Event{
				Name: v,
				Op:   fsnotify.Write,
			})
		}
		if isMacOs {
			events = append(events, fsnotify.Event{
				Name: v,
				Op:   fsnotify.Chmod,
			})
		}
	}
	for _, v := range s.createdFiles {
		events = append(events, fsnotify.Event{
			Name: v,
			Op:   fsnotify.Create,
		})
		if isLinux || isWindows {
			events = append(events, fsnotify.Event{
				Name: v,
				Op:   fsnotify.Write,
			})
		}

	}

	// Shuffle events.
	for i := range events {
		j := rand.Intn(i + 1)
		events[i], events[j] = events[j], events[i]
	}

	return events
}

func (s *IntegrationTestBuilder) readWorkingDir(t testing.TB, fs *hugofs.Fs, filename string) string {
	t.Helper()
	return s.readFileFromFs(t, fs.WorkingDirReadOnly, filename)
}

func (s *IntegrationTestBuilder) readFileFromFs(t testing.TB, fs afero.Fs, filename string) string {
	t.Helper()
	filename = filepath.Clean(filename)
	b, err := afero.ReadFile(fs, filename)
	if err != nil {
		// Print some debug info
		hadSlash := strings.HasPrefix(filename, helpers.FilePathSeparator)
		start := 0
		if hadSlash {
			start = 1
		}
		end := start + 1

		parts := strings.Split(filename, helpers.FilePathSeparator)
		if parts[start] == "work" {
			end++
		}

		s.Assert(err, qt.IsNil)

	}
	return string(b)
}

func (s *IntegrationTestBuilder) writeSource(filename, content string) {
	s.Helper()
	s.writeToFs(s.fs.Source, filename, content)
}

func (s *IntegrationTestBuilder) writeToFs(fs afero.Fs, filename, content string) {
	s.Helper()
	if err := afero.WriteFile(fs, filepath.FromSlash(filename), []byte(content), 0o755); err != nil {
		s.Fatalf("Failed to write file: %s", err)
	}
}

type IntegrationTestConfig struct {
	T testing.TB

	// The files to use on txtar format, see
	// https://pkg.go.dev/golang.org/x/exp/cmd/txtar
	// There are some contentions used in this test setup.
	// - §§§ can be used to wrap code fences.
	// - §§ can be used to wrap multiline strings.
	// - filenames prefixed with sourcefilename: will be read from the file system relative to the current dir.
	// - filenames with a .png or .jpg extension will be treated as binary and base64 decoded.
	TxtarString string

	// COnfig to use as the base. We will also read the config from the txtar.
	BaseCfg config.Provider

	// Environment variables passed to the config loader.
	Environ []string

	// Whether to simulate server mode.
	Running bool

	// Watch for changes.
	// This is (currently) always set to true when Running is set.
	// Note that the CLI for the server does allow for --watch=false, but that is not used in these test.
	Watching bool

	// Enable verbose logging.
	Verbose bool

	// The log level to use.
	LogLevel logg.Level

	// Whether to panic on warnings.
	PanicOnWarning bool

	// Whether it needs the real file system (e.g. for js.Build tests).
	NeedsOsFS bool

	// Whether to run GC after each build.
	RunGC bool

	// Do not remove the temp dir after the test.
	PrintAndKeepTempDir bool

	// Whether to run npm install before Build.
	NeedsNpmInstall bool

	// Global npm packages to install before Build. This is used for testing the npm integration in the Hugo Pipes.
	// Only used on CI.
	NeedsNpmGlobalInstall []string

	// Whether to normalize the Unicode filenames to NFD on Darwin.
	NFDFormOnDarwin bool

	// The working dir to use. If not absolute, a temp dir will be created.
	WorkingDir string

	// The config to pass to Build.
	BuildCfg BuildCfg

	// WarpcMemory, if set, overrides the memory limit in MiB for the WASM based
	// image processors (WebP and AVIF). Used to provoke memory allocation failures.
	WarpcMemory int
}
