//go:build dev

package bundler

// Vendor handling for the dev server. Bare specifiers (solid-js, @solidjs/router,
// solid-refresh, …) are served as single pre-bundled ESM files under /@vendor/,
// wired up by an import map the SPA shell inlines. Because each specifier maps to
// exactly one URL, and every vendor bundle re-externalizes the OTHER vendored
// specifiers (rather than inlining them), solid-js stays a single runtime
// instance across the whole app — the invariant the prod bundler also guards.

import (
	"encoding/json"
	"fmt"
	"net/http"
	"strings"

	esbuild "github.com/evanw/esbuild/pkg/api"
)

const vendorURLPrefix = "/@vendor/"

// buildImportMap returns the JSON `{"imports": {...}}` body the SPA shell inlines
// in <script type="importmap">, mapping every vendored bare specifier to its
// /@vendor/ URL.
func (d *devServer) buildImportMap() string {
	imports := make(map[string]string, len(d.vendorEntrypoints))
	for spec := range d.vendorEntrypoints {
		imports[spec] = vendorURLPrefix + spec + ".js"
	}
	body, err := json.MarshalIndent(map[string]any{"imports": imports}, "", "  ")
	if err != nil {
		return `{"imports":{}}`
	}
	return string(body)
}

// serveVendor serves a pre-bundled vendored package (lazily built + cached).
func (d *devServer) serveVendor(w http.ResponseWriter, r *http.Request) {
	spec := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, vendorURLPrefix), ".js")
	relFile, ok := d.vendorEntrypoints[spec]
	if !ok {
		http.NotFound(w, r)
		return
	}

	d.vendorMu.Lock()
	bundled, cached := d.vendorCache[r.URL.Path]
	d.vendorMu.Unlock()

	if !cached {
		b, err := d.bundleVendor(relFile)
		if err != nil {
			http.Error(w, "vendor bundle failed: "+err.Error(), http.StatusInternalServerError)
			return
		}
		d.vendorMu.Lock()
		d.vendorCache[r.URL.Path] = b
		d.vendorMu.Unlock()
		bundled = b
	}

	w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
	// Vendor rarely changes; let the browser cache within a session.
	w.Header().Set("Cache-Control", "public, max-age=3600")
	w.Write(bundled)
}

// bundleVendor bundles one vendored package entrypoint into a single ESM file,
// re-externalizing the OTHER vendored specifiers so they resolve (once) through
// the import map. Mirrors the prod vendor resolution (NodePaths + development
// condition) so the dev copy matches the shipped one.
func (d *devServer) bundleVendor(absFile string) ([]byte, error) {
	result := esbuild.Build(esbuild.BuildOptions{
		EntryPoints: []string{absFile},
		Bundle:      true,
		Write:       false,
		Format:      esbuild.FormatESModule,
		Target:      esbuild.ES2022,
		Platform:    esbuild.PlatformBrowser,
		Conditions:  []string{"development"},
		NodePaths:   d.vendorDirs,
		Sourcemap:   esbuild.SourceMapInline,
		LogLevel:    esbuild.LogLevelSilent,
		Plugins:     []esbuild.Plugin{d.vendorSharedExternalPlugin()},
	})
	if len(result.Errors) > 0 {
		msgs := esbuild.FormatMessages(result.Errors, esbuild.FormatMessagesOptions{})
		return nil, fmt.Errorf("%s", strings.Join(msgs, "\n"))
	}
	if len(result.OutputFiles) == 0 {
		return nil, fmt.Errorf("no output")
	}
	return result.OutputFiles[0].Contents, nil
}

// vendorSharedExternalPlugin marks a bare import external ONLY when it's an exact
// vendored entrypoint (thus resolvable via the import map). That keeps shared
// singletons — above all solid-js — as one instance across every vendor bundle,
// while deep subpaths and non-vendored transitive deps get bundled in.
func (d *devServer) vendorSharedExternalPlugin() esbuild.Plugin {
	return esbuild.Plugin{
		Name: "dev-vendor-shared-external",
		Setup: func(b esbuild.PluginBuild) {
			b.OnResolve(esbuild.OnResolveOptions{Filter: `^[^./]`}, func(a esbuild.OnResolveArgs) (esbuild.OnResolveResult, error) {
				if a.Kind == esbuild.ResolveEntryPoint {
					return esbuild.OnResolveResult{}, nil
				}
				if _, ok := d.vendorEntrypoints[a.Path]; ok {
					return esbuild.OnResolveResult{Path: a.Path, External: true}, nil
				}
				return esbuild.OnResolveResult{}, nil // bundle via NodePaths
			})
		},
	}
}
