// Copyright 2024 The Hugo Authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package esbuild provides functions for building JavaScript resources.
package esbuild

import (
	"bytes"
	"context"
	_ "embed"
	"encoding/json"
	"fmt"
	"io"
	"path"
	"path/filepath"
	"reflect"
	"sort"
	"strings"
	"sync"
	"sync/atomic"

	"github.com/evanw/esbuild/pkg/api"
	"github.com/gohugoio/hugo/cache/dynacache"
	"github.com/gohugoio/hugo/common/hmaps"
	"github.com/gohugoio/hugo/common/hsync"
	"github.com/gohugoio/hugo/common/hugio"
	"github.com/gohugoio/hugo/common/paths"
	"github.com/gohugoio/hugo/deps"
	"github.com/gohugoio/hugo/helpers"
	"github.com/gohugoio/hugo/identity"
	"github.com/gohugoio/hugo/internal/js"
	"github.com/gohugoio/hugo/media"
	"github.com/gohugoio/hugo/resources"
	"github.com/gohugoio/hugo/resources/resource"
	"github.com/gohugoio/hugo/resources/resource_factories/create"
	"github.com/gohugoio/hugo/tpl/tplimpl"
	"github.com/mitchellh/mapstructure"
	"github.com/spf13/cast"
)

var _ js.Batcher = (*batcher)(nil)

const (
	NsBatch = "_hugo-js-batch"

	propsKeyImportContext = "importContext"
	propsResoure          = "resource"
)

//go:embed batch-esm-runner.gotmpl
var runnerTemplateStr string

var _ js.BatchPackage = (*Package)(nil)

var _ buildToucher = (*optsHolder[scriptOptions])(nil)

var (
	_ buildToucher             = (*scriptGroup)(nil)
	_ isBuiltOrTouchedProvider = (*scriptGroup)(nil)
)

func NewBatcherClient(deps *deps.Deps) (js.BatcherClient, error) {
	c := &BatcherClient{
		d:            deps,
		buildClient:  NewBuildClient(deps.BaseFs.Assets, deps.ResourceSpec),
		createClient: create.New(deps.ResourceSpec),
		batcherStore: hmaps.NewCache[string, js.Batcher](),
		bundlesStore: hmaps.NewCache[string, js.BatchPackage](),
	}

	deps.BuildEndListeners.Add(func(...any) bool {
		c.bundlesStore.Reset()
		return false
	})

	return c, nil
}

func (o optionsMap[K, C]) ByKey() optionsGetSetters[K, C] {
	var values []optionsGetSetter[K, C]
	for _, v := range o {
		values = append(values, v)
	}

	sort.Slice(values, func(i, j int) bool {
		return values[i].Key().String() < values[j].Key().String()
	})

	return values
}

func (o *opts[K, C]) Compiled() C {
	o.h.checkCompileErr()
	return o.h.compiled
}

func (os optionsGetSetters[K, C]) Filter(predicate func(K) bool) optionsGetSetters[K, C] {
	var a optionsGetSetters[K, C]
	for _, v := range os {
		if predicate(v.Key()) {
			a = append(a, v)
		}
	}
	return a
}

func (o *optsHolder[C]) IdentifierBase() string {
	return o.optionsID
}

func (o *opts[K, C]) Key() K {
	return o.key
}

func (o *opts[K, C]) Reset() {
	o.once.Reset()
	o.h.resetCounter++
}

func (o *opts[K, C]) Get(id uint32) js.OptionsSetter {
	var b *optsHolder[C]
	o.once.Do(func() {
		b = o.h
		b.setBuilt(id)
	})
	return b
}

func (o *opts[K, C]) GetIdentity() identity.Identity {
	return o.h
}

func (o *optsHolder[C]) SetOptions(m map[string]any) string {
	o.optsSetCounter++
	o.optsPrev = o.optsCurr
	o.optsCurr = m
	o.compiledPrev = o.compiled
	o.compiled, o.compileErr = o.compiled.compileOptions(m, o.defaults)
	o.checkCompileErr()
	return ""
}

// ValidateBatchID validates the given ID according to some very
func ValidateBatchID(id string, isTopLevel bool) error {
	if id == "" {
		return fmt.Errorf("id must be set")
	}
	// No Windows slashes.
	if strings.Contains(id, "\\") {
		return fmt.Errorf("id must not contain backslashes")
	}

	// Allow forward slashes in top level IDs only.
	if !isTopLevel && strings.Contains(id, "/") {
		return fmt.Errorf("id must not contain forward slashes")
	}

	return nil
}

func newIsBuiltOrTouched() isBuiltOrTouched {
	return isBuiltOrTouched{
		built:   make(buildIDs),
		touched: make(buildIDs),
	}
}

func newOpts[K any, C optionsCompiler[C]](key K, optionsID string, defaults defaultOptionValues) *opts[K, C] {
	return &opts[K, C]{
		key: key,
		h: &optsHolder[C]{
			optionsID:        optionsID,
			defaults:         defaults,
			isBuiltOrTouched: newIsBuiltOrTouched(),
		},
	}
}

// BatcherClient is a client for building JavaScript packages.
type BatcherClient struct {
	d *deps.Deps

	once           sync.Once
	runnerTemplate *tplimpl.TemplInfo

	createClient *create.Client
	buildClient  *BuildClient

	batcherStore *hmaps.Cache[string, js.Batcher]
	bundlesStore *hmaps.Cache[string, js.BatchPackage]
}

// New creates a new Batcher with the given ID.
// This will be typically created once and reused across rebuilds.
func (c *BatcherClient) New(id string) (js.Batcher, error) {
	var initErr error
	c.once.Do(func() {
		// We should fix the initialization order here (or use the Go template package directly), but we need to wait
		// for the Hugo templates to be ready.
		tmpl, err := c.d.TemplateStore.TextParse("batch-esm-runner", runnerTemplateStr)
		if err != nil {
			initErr = err
			return
		}
		c.runnerTemplate = tmpl
	})

	if initErr != nil {
		return nil, initErr
	}

	dependencyManager := c.d.Conf.NewIdentityManager()
	configID := "config_" + id

	b := &batcher{
		id:                id,
		scriptGroups:      make(map[string]*scriptGroup),
		dependencyManager: dependencyManager,
		client:            c,
		configOptions: newOpts[scriptID, configOptions](
			scriptID(configID),
			configID,
			defaultOptionValues{},
		),
	}

	c.d.BuildEndListeners.Add(func(...any) bool {
		b.reset()
		return false
	})

	idFinder := identity.NewFinder(identity.FinderConfig{})

	c.d.OnChangeListeners.Add(func(ids ...identity.Identity) bool {
		for _, id := range ids {
			if r := idFinder.Contains(id, b.dependencyManager, 50); r > 0 {
				b.staleVersion.Add(1)
				return false
			}

			sp, ok := id.(identity.DependencyManagerScopedProvider)
			if !ok {
				continue
			}
			idms := sp.GetDependencyManagerForScopesAll()

			for _, g := range b.scriptGroups {
				g.forEachIdentity(func(id2 identity.Identity) bool {
					bt, ok := id2.(buildToucher)
					if !ok {
						return false
					}
					for _, id3 := range idms {
						// This handles the removal of the only source for a script group (e.g. all shortcodes in a contnt page).
						// Note the very shallow search.
						if r := idFinder.Contains(id2, id3, 0); r > 0 {
							bt.setTouched(b.buildCount)
							return false
						}
					}
					return false
				})
			}
		}

		return false
	})

	return b, nil
}

func (c *BatcherClient) Store() *hmaps.Cache[string, js.Batcher] {
	return c.batcherStore
}

func (c *BatcherClient) buildBatchGroup(ctx context.Context, t *batchGroupTemplateContext) (resource.Resource, string, error) {
	var buf bytes.Buffer

	if err := c.d.GetTemplateStore().ExecuteWithContext(ctx, c.runnerTemplate, &buf, t); err != nil {
		return nil, "", err
	}

	s := paths.AddLeadingSlash(t.keyPath + ".js")
	r, err := c.createClient.FromString(s, buf.String())
	if err != nil {
		return nil, "", err
	}

	return r, s, nil
}

// Package holds a group of JavaScript resources.
type Package struct {
	id string
	b  *batcher

	groups map[string]resource.Resources
}

func (p *Package) Groups() map[string]resource.Resources {
	return p.groups
}

type batchGroupTemplateContext struct {
	keyPath string
	ID      string
	Runners []scriptRunnerTemplateContext
	Scripts []scriptBatchTemplateContext
}

type batcher struct {
	mu           sync.Mutex
	id           string
	buildCount   uint32
	staleVersion atomic.Uint32
	scriptGroups scriptGroups

	client            *BatcherClient
	dependencyManager identity.Manager

	configOptions optionsGetSetter[scriptID, configOptions]

	// The last successfully built package.
	// If this is non-nil and not stale, we can reuse it (e.g. on server rebuilds)
	prevBuild *Package
}

// Build builds the batch if not already built or if it's stale.
func (b *batcher) Build(ctx context.Context) (js.BatchPackage, error) {
	key := dynacache.CleanKey(b.id + ".js")
	p, err := b.client.bundlesStore.GetOrCreate(key, func() (js.BatchPackage, error) {
		return b.build(ctx)
	})
	if err != nil {
		return nil, fmt.Errorf("failed to build JS batch %q: %w", b.id, err)
	}
	return p, nil
}

func (b *batcher) Config(ctx context.Context) js.OptionsSetter {
	return b.configOptions.Get(b.buildCount)
}

func (b *batcher) Group(ctx context.Context, id string) js.BatcherGroup {
	if err := ValidateBatchID(id, false); err != nil {
		panic(err)
	}

	b.mu.Lock()
	defer b.mu.Unlock()

	group, found := b.scriptGroups[id]
	if !found {
		idm := b.client.d.Conf.NewIdentityManager()
		b.dependencyManager.AddIdentity(idm)

		group = &scriptGroup{
			id: id, b: b,
			isBuiltOrTouched:  newIsBuiltOrTouched(),
			dependencyManager: idm,
			scriptsOptions:    make(optionsMap[scriptID, scriptOptions]),
			instancesOptions:  make(optionsMap[instanceID, paramsOptions]),
			runnersOptions:    make(optionsMap[scriptID, scriptOptions]),
		}
		b.scriptGroups[id] = group
	}

	group.setBuilt(b.buildCount)

	return group
}

func (b *batcher) isStale() bool {
	if b.staleVersion.Load() > 0 {
		return true
	}

	if b.removeNotSet() {
		return true
	}

	if b.configOptions.isStale() {
		return true
	}

	for _, v := range b.scriptGroups {
		if v.isStale() {
			return true
		}
	}

	return false
}

func (b *batcher) build(ctx context.Context) (js.BatchPackage, error) {
	b.mu.Lock()
	defer b.mu.Unlock()
	defer func() {
		b.staleVersion.Store(0)
		b.buildCount++
	}()

	if b.prevBuild != nil {
		if !b.isStale() {
			return b.prevBuild, nil
		}
	}

	p, err := b.doBuild(ctx)
	if err != nil {
		return nil, err
	}

	b.prevBuild = p

	return p, nil
}

func (b *batcher) doBuild(ctx context.Context) (*Package, error) {
	type importContext struct {
		name           string
		resourceGetter resource.ResourceGetter
		scriptOptions  scriptOptions
		dm             identity.Manager
	}

	state := struct {
		importResource        *hmaps.Cache[string, resource.Resource]
		resultResource        *hmaps.Cache[string, resource.Resource]
		importerImportContext *hmaps.Cache[string, importContext]
		pathGroup             *hmaps.Cache[string, string]
	}{
		importResource:        hmaps.NewCache[string, resource.Resource](),
		resultResource:        hmaps.NewCache[string, resource.Resource](),
		importerImportContext: hmaps.NewCache[string, importContext](),
		pathGroup:             hmaps.NewCache[string, string](),
	}

	multihostBasePaths := b.client.d.ResourceSpec.MultihostTargetBasePaths

	// Entry points passed to ESBuid.
	var entryPoints []string
	addResource := func(group, pth string, r resource.Resource, isResult bool) {
		state.pathGroup.Set(paths.TrimExt(pth), group)
		state.importResource.Set(pth, r)
		if isResult {
			state.resultResource.Set(pth, r)
		}
		entryPoints = append(entryPoints, pth)
	}

	for _, g := range b.scriptGroups.Sorted() {
		keyPath := g.id

		t := &batchGroupTemplateContext{
			keyPath: keyPath,
			ID:      g.id,
		}

		instances := g.instancesOptions.ByKey()

		for _, vv := range g.scriptsOptions.ByKey() {
			keyPath := keyPath + "_" + vv.Key().String()
			opts := vv.Compiled()
			impPath := path.Join(PrefixHugoVirtual, opts.Dir(), keyPath+opts.Resource.MediaType().FirstSuffix.FullSuffix)
			impCtx := opts.ImportContext

			state.importerImportContext.Set(impPath, importContext{
				name:           keyPath,
				resourceGetter: impCtx,
				scriptOptions:  opts,
				dm:             g.dependencyManager,
			})

			bt := scriptBatchTemplateContext{
				opts:      vv,
				Import:    impPath,
				Instances: []scriptInstanceBatchTemplateContext{},
			}
			state.importResource.Set(bt.Import, vv.Compiled().Resource)
			predicate := func(k instanceID) bool {
				return k.scriptID == vv.Key()
			}
			for _, vvv := range instances.Filter(predicate) {
				bt.Instances = append(bt.Instances, scriptInstanceBatchTemplateContext{opts: vvv})
			}

			t.Scripts = append(t.Scripts, bt)
		}

		for _, vv := range g.runnersOptions.ByKey() {
			runnerKeyPath := keyPath + "_" + vv.Key().String()
			runnerImpPath := paths.AddLeadingSlash(runnerKeyPath + "_runner" + vv.Compiled().Resource.MediaType().FirstSuffix.FullSuffix)
			t.Runners = append(t.Runners, scriptRunnerTemplateContext{opts: vv, Import: runnerImpPath})
			addResource(g.id, runnerImpPath, vv.Compiled().Resource, false)
		}

		r, s, err := b.client.buildBatchGroup(ctx, t)
		if err != nil {
			return nil, fmt.Errorf("failed to build JS batch: %w", err)
		}

		state.importerImportContext.Set(s, importContext{
			name:           s,
			resourceGetter: nil,
			dm:             g.dependencyManager,
		})

		addResource(g.id, s, r, true)
	}

	mediaTypes := b.client.d.ResourceSpec.MediaTypes()

	externalOptions := b.configOptions.Compiled().Options
	if externalOptions.Format == "" {
		externalOptions.Format = "esm"
	}
	if externalOptions.Format != "esm" {
		return nil, fmt.Errorf("only esm format is currently supported")
	}

	jsOpts := Options{
		ExternalOptions: externalOptions,
		InternalOptions: InternalOptions{
			DependencyManager: b.dependencyManager,
			Splitting:         true,
			ImportOnResolveFunc: func(imp string, args api.OnResolveArgs) string {
				var importContextPath string
				if args.Kind == api.ResolveEntryPoint {
					importContextPath = args.Path
				} else {
					importContextPath = args.Importer
				}
				importContext, importContextFound := state.importerImportContext.Get(importContextPath)

				// We want to track the dependencies closest to where they're used.
				dm := b.dependencyManager
				if importContextFound {
					dm = importContext.dm
				}

				if r, found := state.importResource.Get(imp); found {
					dm.AddIdentity(identity.FirstIdentity(r))
					return imp
				}

				if importContext.resourceGetter != nil {
					resolved := ResolveResource(imp, importContext.resourceGetter)
					if resolved != nil {
						resolvePath := resources.InternalResourceTargetPath(resolved)
						dm.AddIdentity(identity.FirstIdentity(resolved))
						imp := PrefixHugoVirtual + resolvePath
						state.importResource.Set(imp, resolved)
						state.importerImportContext.Set(imp, importContext)
						return imp

					}
				}
				return ""
			},
			ImportOnLoadFunc: func(args api.OnLoadArgs) string {
				imp := args.Path

				if r, found := state.importResource.Get(imp); found {
					content, err := r.(resource.ContentProvider).Content(ctx)
					if err != nil {
						panic(err)
					}
					return cast.ToString(content)
				}
				return ""
			},
			ImportParamsOnLoadFunc: func(args api.OnLoadArgs) json.RawMessage {
				if importContext, found := state.importerImportContext.Get(args.Path); found {
					if !importContext.scriptOptions.IsZero() {
						return importContext.scriptOptions.Params
					}
				}
				return nil
			},
			ErrorMessageResolveFunc: func(args api.Message) *ErrorMessageResolved {
				if loc := args.Location; loc != nil {
					path := strings.TrimPrefix(loc.File, NsHugoImportResolveFunc+":")
					if r, found := state.importResource.Get(path); found {
						sourcePath := resources.InternalResourceSourcePathBestEffort(r)

						var contentr hugio.ReadSeekCloser
						if cp, ok := r.(hugio.ReadSeekCloserProvider); ok {
							contentr, _ = cp.ReadSeekCloser()
						}
						return &ErrorMessageResolved{
							Content: contentr,
							Path:    sourcePath,
							Message: args.Text,
						}

					}

				}
				return nil
			},
			ResolveSourceMapSource: func(s string) string {
				if r, found := state.importResource.Get(s); found {
					if ss := resources.InternalResourceSourcePath(r); ss != "" {
						return ss
					}
					return PrefixHugoMemory + s
				}
				return ""
			},
			EntryPoints: entryPoints,
		},
	}

	result, err := b.client.buildClient.Build(jsOpts)
	if err != nil {
		return nil, fmt.Errorf("failed to build JS bundle: %w", err)
	}

	groups := make(map[string]resource.Resources)

	createAndAddResource := func(targetPath, group string, o api.OutputFile, mt media.Type) error {
		var sourceFilename string
		if r, found := state.importResource.Get(targetPath); found {
			sourceFilename = resources.InternalResourceSourcePathBestEffort(r)
		}
		targetPath = path.Join(b.id, targetPath)

		rd := resources.ResourceSourceDescriptor{
			LazyPublish: true,
			OpenReadSeekCloser: func() (hugio.ReadSeekCloser, error) {
				return hugio.NewReadSeekerNoOpCloserFromBytes(o.Contents), nil
			},
			MediaType:            mt,
			TargetPath:           targetPath,
			SourceFilenameOrPath: sourceFilename,
		}
		r, err := b.client.d.ResourceSpec.NewResource(rd)
		if err != nil {
			return err
		}

		groups[group] = append(groups[group], r)

		return nil
	}

	outDir := b.client.d.AbsPublishDir

	createAndAddResources := func(o api.OutputFile) (bool, error) {
		p := paths.ToSlashPreserveLeading(strings.TrimPrefix(o.Path, outDir))
		ext := path.Ext(p)
		mt, _, found := mediaTypes.GetBySuffix(ext)
		if !found {
			return false, nil
		}

		group, found := state.pathGroup.Get(paths.TrimExt(p))

		if !found {
			return false, nil
		}

		if err := createAndAddResource(p, group, o, mt); err != nil {
			return false, err
		}

		return true, nil
	}

	for _, o := range result.OutputFiles {
		handled, err := createAndAddResources(o)
		if err != nil {
			return nil, err
		}

		if !handled {
			//  Copy to destination.
			// In a multihost setup, we will have multiple targets.
			var targetFilenames []string
			if len(multihostBasePaths) > 0 {
				for _, base := range multihostBasePaths {
					p := strings.TrimPrefix(o.Path, outDir)
					targetFilename := filepath.Join(base, b.id, p)
					targetFilenames = append(targetFilenames, targetFilename)
				}
			} else {
				p := strings.TrimPrefix(o.Path, outDir)
				targetFilename := filepath.Join(b.id, p)
				targetFilenames = append(targetFilenames, targetFilename)
			}

			fs := b.client.d.BaseFs.PublishFs

			if err := func() error {
				fw, err := helpers.OpenFilesForWriting(fs, targetFilenames...)
				if err != nil {
					return err
				}
				defer fw.Close()

				fr := bytes.NewReader(o.Contents)

				_, err = io.Copy(fw, fr)

				return err
			}(); err != nil {
				return nil, fmt.Errorf("failed to copy to %q: %w", targetFilenames, err)
			}
		}
	}

	p := &Package{
		id:     path.Join(NsBatch, b.id),
		b:      b,
		groups: groups,
	}

	return p, nil
}

func (b *batcher) removeNotSet() bool {
	// We already have the lock.
	var removed bool
	currentBuildID := b.buildCount
	for k, v := range b.scriptGroups {
		if !v.isBuilt(currentBuildID) && v.isTouched(currentBuildID) {
			// Remove entire group.
			removed = true
			delete(b.scriptGroups, k)
			continue
		}
		if v.removeTouchedButNotSet() {
			removed = true
		}
		if v.removeNotSet() {
			removed = true
		}
	}

	return removed
}

func (b *batcher) reset() {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.configOptions.Reset()
	for _, v := range b.scriptGroups {
		v.Reset()
	}
}

type buildIDs map[uint32]bool

func (b buildIDs) Has(buildID uint32) bool {
	return b[buildID]
}

func (b buildIDs) Set(buildID uint32) {
	b[buildID] = true
}

type buildToucher interface {
	setTouched(buildID uint32)
}

type configOptions struct {
	Options ExternalOptions
}

func (s configOptions) isStaleCompiled(prev configOptions) bool {
	return false
}

func (s configOptions) compileOptions(m map[string]any, defaults defaultOptionValues) (configOptions, error) {
	config, err := DecodeExternalOptions(m)
	if err != nil {
		return configOptions{}, err
	}

	return configOptions{
		Options: config,
	}, nil
}

type defaultOptionValues struct {
	defaultExport string
}

type instanceID struct {
	scriptID   scriptID
	instanceID string
}

func (i instanceID) String() string {
	return i.scriptID.String() + "_" + i.instanceID
}

type isBuiltOrTouched struct {
	built   buildIDs
	touched buildIDs
}

func (i isBuiltOrTouched) setBuilt(id uint32) {
	i.built.Set(id)
}

func (i isBuiltOrTouched) isBuilt(id uint32) bool {
	return i.built.Has(id)
}

func (i isBuiltOrTouched) setTouched(id uint32) {
	i.touched.Set(id)
}

func (i isBuiltOrTouched) isTouched(id uint32) bool {
	return i.touched.Has(id)
}

type isBuiltOrTouchedProvider interface {
	isBuilt(uint32) bool
	isTouched(uint32) bool
}

type key interface {
	comparable
	fmt.Stringer
}

type optionsCompiler[C any] interface {
	isStaleCompiled(C) bool
	compileOptions(map[string]any, defaultOptionValues) (C, error)
}

type optionsGetSetter[K, C any] interface {
	isBuiltOrTouchedProvider
	identity.IdentityProvider
	// resource.StaleInfo

	Compiled() C
	Key() K
	Reset()

	Get(uint32) js.OptionsSetter
	isStale() bool
	currPrev() (map[string]any, map[string]any)
}

type optionsGetSetters[K key, C any] []optionsGetSetter[K, C]

type optionsMap[K key, C any] map[K]optionsGetSetter[K, C]

type opts[K any, C optionsCompiler[C]] struct {
	key  K
	h    *optsHolder[C]
	once hsync.OnceMore
}

type optsHolder[C optionsCompiler[C]] struct {
	optionsID string

	defaults defaultOptionValues

	// Keep track of one generation so we can detect changes.
	// Note that most of this tracking is performed on the options/map level.
	compiled     C
	compiledPrev C
	compileErr   error

	resetCounter   uint32
	optsSetCounter uint32
	optsCurr       map[string]any
	optsPrev       map[string]any

	isBuiltOrTouched
}

type paramsOptions struct {
	Params json.RawMessage
}

func (s paramsOptions) isStaleCompiled(prev paramsOptions) bool {
	return false
}

func (s paramsOptions) compileOptions(m map[string]any, defaults defaultOptionValues) (paramsOptions, error) {
	v := struct {
		Params map[string]any
	}{}

	if err := mapstructure.WeakDecode(m, &v); err != nil {
		return paramsOptions{}, err
	}

	paramsJSON, err := json.Marshal(v.Params)
	if err != nil {
		return paramsOptions{}, err
	}

	return paramsOptions{
		Params: paramsJSON,
	}, nil
}

type scriptBatchTemplateContext struct {
	opts      optionsGetSetter[scriptID, scriptOptions]
	Import    string
	Instances []scriptInstanceBatchTemplateContext
}

func (s *scriptBatchTemplateContext) Export() string {
	return s.opts.Compiled().Export
}

func (c scriptBatchTemplateContext) MarshalJSON() (b []byte, err error) {
	return json.Marshal(&struct {
		ID        string                               `json:"id"`
		Instances []scriptInstanceBatchTemplateContext `json:"instances"`
	}{
		ID:        c.opts.Key().String(),
		Instances: c.Instances,
	})
}

func (b scriptBatchTemplateContext) RunnerJSON(i int) string {
	script := fmt.Sprintf("Script%d", i)

	v := struct {
		ID string `json:"id"`

		// Read-only live JavaScript binding.
		Binding   string                               `json:"binding"`
		Instances []scriptInstanceBatchTemplateContext `json:"instances"`
	}{
		b.opts.Key().String(),
		script,
		b.Instances,
	}

	bb, err := json.Marshal(v)
	if err != nil {
		panic(err)
	}
	s := string(bb)

	// Remove the quotes to make it a valid JS object.
	s = strings.ReplaceAll(s, fmt.Sprintf("%q", script), script)

	return s
}

type scriptGroup struct {
	mu sync.Mutex
	id string
	b  *batcher
	isBuiltOrTouched
	dependencyManager identity.Manager

	scriptsOptions   optionsMap[scriptID, scriptOptions]
	instancesOptions optionsMap[instanceID, paramsOptions]
	runnersOptions   optionsMap[scriptID, scriptOptions]
}

// For internal use only.
func (b *scriptGroup) GetDependencyManager() identity.Manager {
	return b.dependencyManager
}

// For internal use only.
func (b *scriptGroup) IdentifierBase() string {
	return b.id
}

func (s *scriptGroup) Instance(sid, id string) js.OptionsSetter {
	if err := ValidateBatchID(sid, false); err != nil {
		panic(err)
	}
	if err := ValidateBatchID(id, false); err != nil {
		panic(err)
	}

	s.mu.Lock()
	defer s.mu.Unlock()

	iid := instanceID{scriptID: scriptID(sid), instanceID: id}
	if v, found := s.instancesOptions[iid]; found {
		return v.Get(s.b.buildCount)
	}

	fullID := "instance_" + s.key() + "_" + iid.String()

	s.instancesOptions[iid] = newOpts[instanceID, paramsOptions](
		iid,
		fullID,
		defaultOptionValues{},
	)

	return s.instancesOptions[iid].Get(s.b.buildCount)
}

func (g *scriptGroup) Reset() {
	for _, v := range g.scriptsOptions {
		v.Reset()
	}
	for _, v := range g.instancesOptions {
		v.Reset()
	}
	for _, v := range g.runnersOptions {
		v.Reset()
	}
}

func (s *scriptGroup) Runner(id string) js.OptionsSetter {
	if err := ValidateBatchID(id, false); err != nil {
		panic(err)
	}

	s.mu.Lock()
	defer s.mu.Unlock()
	sid := scriptID(id)
	if v, found := s.runnersOptions[sid]; found {
		return v.Get(s.b.buildCount)
	}

	runnerIdentity := "runner_" + s.key() + "_" + id

	// A typical signature for a runner would be:
	//     export default function Run(scripts) {}
	// The user can override the default export in the templates.

	s.runnersOptions[sid] = newOpts[scriptID, scriptOptions](
		sid,
		runnerIdentity,
		defaultOptionValues{
			defaultExport: "default",
		},
	)

	return s.runnersOptions[sid].Get(s.b.buildCount)
}

func (s *scriptGroup) Script(id string) js.OptionsSetter {
	if err := ValidateBatchID(id, false); err != nil {
		panic(err)
	}

	s.mu.Lock()
	defer s.mu.Unlock()
	sid := scriptID(id)
	if v, found := s.scriptsOptions[sid]; found {
		return v.Get(s.b.buildCount)
	}

	scriptIdentity := "script_" + s.key() + "_" + id

	s.scriptsOptions[sid] = newOpts[scriptID, scriptOptions](
		sid,
		scriptIdentity,
		defaultOptionValues{
			defaultExport: "*",
		},
	)

	return s.scriptsOptions[sid].Get(s.b.buildCount)
}

func (s *scriptGroup) isStale() bool {
	for _, v := range s.scriptsOptions {
		if v.isStale() {
			return true
		}
	}

	for _, v := range s.instancesOptions {
		if v.isStale() {
			return true
		}
	}

	for _, v := range s.runnersOptions {
		if v.isStale() {
			return true
		}
	}

	return false
}

func (v *scriptGroup) forEachIdentity(
	f func(id identity.Identity) bool,
) bool {
	if f(v) {
		return true
	}
	for _, vv := range v.instancesOptions {
		if f(vv.GetIdentity()) {
			return true
		}
	}

	for _, vv := range v.scriptsOptions {
		if f(vv.GetIdentity()) {
			return true
		}
	}

	for _, vv := range v.runnersOptions {
		if f(vv.GetIdentity()) {
			return true
		}
	}

	return false
}

func (s *scriptGroup) key() string {
	return s.b.id + "_" + s.id
}

func (g *scriptGroup) removeNotSet() bool {
	currentBuildID := g.b.buildCount
	if !g.isBuilt(currentBuildID) {
		// This group was never accessed in this build.
		return false
	}
	var removed bool

	if g.instancesOptions.isBuilt(currentBuildID) {
		// A new instance has been set in this group for this build.
		// Remove any instance that has not been set in this build.
		for k, v := range g.instancesOptions {
			if v.isBuilt(currentBuildID) {
				continue
			}
			delete(g.instancesOptions, k)
			removed = true
		}
	}

	if g.runnersOptions.isBuilt(currentBuildID) {
		// A new runner has been set in this group for this build.
		// Remove any runner that has not been set in this build.
		for k, v := range g.runnersOptions {
			if v.isBuilt(currentBuildID) {
				continue
			}
			delete(g.runnersOptions, k)
			removed = true
		}
	}

	if g.scriptsOptions.isBuilt(currentBuildID) {
		// A new script has been set in this group for this build.
		// Remove any script that has not been set in this build.
		for k, v := range g.scriptsOptions {
			if v.isBuilt(currentBuildID) {
				continue
			}
			delete(g.scriptsOptions, k)

			// Also remove any instance with this ID.
			for kk := range g.instancesOptions {
				if kk.scriptID == k {
					delete(g.instancesOptions, kk)
				}
			}
			removed = true
		}
	}

	return removed
}

func (g *scriptGroup) removeTouchedButNotSet() bool {
	currentBuildID := g.b.buildCount
	var removed bool
	for k, v := range g.instancesOptions {
		if v.isBuilt(currentBuildID) {
			continue
		}
		if v.isTouched(currentBuildID) {
			delete(g.instancesOptions, k)
			removed = true
		}
	}
	for k, v := range g.runnersOptions {
		if v.isBuilt(currentBuildID) {
			continue
		}
		if v.isTouched(currentBuildID) {
			delete(g.runnersOptions, k)
			removed = true
		}
	}
	for k, v := range g.scriptsOptions {
		if v.isBuilt(currentBuildID) {
			continue
		}
		if v.isTouched(currentBuildID) {
			delete(g.scriptsOptions, k)
			removed = true

			// Also remove any instance with this ID.
			for kk := range g.instancesOptions {
				if kk.scriptID == k {
					delete(g.instancesOptions, kk)
				}
			}
		}

	}
	return removed
}

type scriptGroups map[string]*scriptGroup

func (s scriptGroups) Sorted() []*scriptGroup {
	var a []*scriptGroup
	for _, v := range s {
		a = append(a, v)
	}
	sort.Slice(a, func(i, j int) bool {
		return a[i].id < a[j].id
	})
	return a
}

type scriptID string

func (s scriptID) String() string {
	return string(s)
}

type scriptInstanceBatchTemplateContext struct {
	opts optionsGetSetter[instanceID, paramsOptions]
}

func (c scriptInstanceBatchTemplateContext) ID() string {
	return c.opts.Key().instanceID
}

func (c scriptInstanceBatchTemplateContext) MarshalJSON() (b []byte, err error) {
	return json.Marshal(&struct {
		ID     string          `json:"id"`
		Params json.RawMessage `json:"params"`
	}{
		ID:     c.opts.Key().instanceID,
		Params: c.opts.Compiled().Params,
	})
}

type scriptOptions struct {
	// The script to build.
	Resource resource.Resource

	// The import context to use.
	// Note that we will always fall back to the resource's own import context.
	ImportContext resource.ResourceGetter

	// The export name to use for this script's group's runners (if any).
	// If not set, the default export will be used.
	Export string

	// Params marshaled to JSON.
	Params json.RawMessage
}

func (o *scriptOptions) Dir() string {
	return path.Dir(resources.InternalResourceTargetPath(o.Resource))
}

func (s scriptOptions) IsZero() bool {
	return s.Resource == nil
}

func (s scriptOptions) isStaleCompiled(prev scriptOptions) bool {
	if prev.IsZero() {
		return false
	}

	// All but the ImportContext are checked at the options/map level.
	i1nil, i2nil := prev.ImportContext == nil, s.ImportContext == nil
	if i1nil && i2nil {
		return false
	}
	if i1nil || i2nil {
		return true
	}
	// On its own this check would have too many false positives, but combined with the other checks it should be fine.
	// We cannot do equality checking here.
	if !prev.ImportContext.(resource.IsProbablySameResourceGetter).IsProbablySameResourceGetter(s.ImportContext) {
		return true
	}

	return false
}

func (s scriptOptions) compileOptions(m map[string]any, defaults defaultOptionValues) (scriptOptions, error) {
	v := struct {
		Resource      resource.Resource
		ImportContext any
		Export        string
		Params        map[string]any
	}{}

	if err := mapstructure.WeakDecode(m, &v); err != nil {
		panic(err)
	}

	var paramsJSON []byte
	if v.Params != nil {
		var err error
		paramsJSON, err = json.Marshal(v.Params)
		if err != nil {
			panic(err)
		}
	}

	if v.Export == "" {
		v.Export = defaults.defaultExport
	}

	compiled := scriptOptions{
		Resource:      v.Resource,
		Export:        v.Export,
		ImportContext: resource.NewCachedResourceGetter(v.ImportContext),
		Params:        paramsJSON,
	}

	if compiled.Resource == nil {
		return scriptOptions{}, fmt.Errorf("resource not set")
	}

	return compiled, nil
}

type scriptRunnerTemplateContext struct {
	opts   optionsGetSetter[scriptID, scriptOptions]
	Import string
}

func (s *scriptRunnerTemplateContext) Export() string {
	return s.opts.Compiled().Export
}

func (c scriptRunnerTemplateContext) MarshalJSON() (b []byte, err error) {
	return json.Marshal(&struct {
		ID string `json:"id"`
	}{
		ID: c.opts.Key().String(),
	})
}

func (o optionsMap[K, C]) isBuilt(id uint32) bool {
	for _, v := range o {
		if v.isBuilt(id) {
			return true
		}
	}

	return false
}

func (o *opts[K, C]) isBuilt(id uint32) bool {
	return o.h.isBuilt(id)
}

func (o *opts[K, C]) isStale() bool {
	if o.h.isStaleOpts() {
		return true
	}
	if o.h.compiled.isStaleCompiled(o.h.compiledPrev) {
		return true
	}
	return false
}

func (o *optsHolder[C]) isStaleOpts() bool {
	if o.optsSetCounter == 1 && o.resetCounter > 0 {
		return false
	}
	isStale := func() bool {
		if len(o.optsCurr) != len(o.optsPrev) {
			return true
		}
		for k, v := range o.optsPrev {
			vv, found := o.optsCurr[k]
			if !found {
				return true
			}
			if strings.EqualFold(k, propsKeyImportContext) {
				// This is checked later.
			} else if si, ok := vv.(resource.StaleInfo); ok {
				if si.StaleVersion() > 0 {
					return true
				}
			} else {
				if !reflect.DeepEqual(v, vv) {
					return true
				}
			}
		}
		return false
	}()

	return isStale
}

func (o *opts[K, C]) isTouched(id uint32) bool {
	return o.h.isTouched(id)
}

func (o *optsHolder[C]) checkCompileErr() {
	if o.compileErr != nil {
		panic(o.compileErr)
	}
}

func (o *opts[K, C]) currPrev() (map[string]any, map[string]any) {
	return o.h.optsCurr, o.h.optsPrev
}

func init() {
	// We don't want any dependencies/change tracking on the top level Package,
	// we want finer grained control via Package.Group.
	var p any = &Package{}
	if _, ok := p.(identity.Identity); ok {
		panic("esbuid.Package should not implement identity.Identity")
	}
	if _, ok := p.(identity.DependencyManagerProvider); ok {
		panic("esbuid.Package should not implement identity.DependencyManagerProvider")
	}
}
