package bundler

import (
	"encoding/json"
	"fmt"
	"kjol/appenv"
	"os"
	"path/filepath"
	"strconv"
	"strings"

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

// esbuildDefine returns the compile-time constants substituted into every JS
// bundle — both the production bundles here and the dev HMR per-module
// transforms (hmr_server.go). __ENV_TYPE__ carries the Go compile-time
// deployment environment (appenv.Environment) into the JS, where
// frontend/src/env.ts reads it. The build-time SSR render deliberately does NOT
// define it (see internal/bundler/ssr.go), so the env badge renders only after
// the client takeover.
func esbuildDefine() map[string]string {
	return map[string]string{
		"__ENV_TYPE__": strconv.Quote(appenv.Environment),
	}
}

// resolveEntryPoint returns the path (relative to frontendDir) of the
// SPA entry point, preferring app.ts over app.js.
func resolveEntryPoint() string {
	if _, err := os.Stat(filepath.Join(frontendDir, "src/app.ts")); err == nil {
		return "src/app.ts"
	}
	return "src/app.js"
}

// bundleJS bundles the SPA entry (app.ts/app.js) into bundle.min.js.
func bundleJS() (bundleStats, error) {
	return bundleJSEntry(filepath.Join(frontendDir, resolveEntryPoint()), "bundle.min.js")
}

// bundlePublicJS bundles the public-page takeover entry into
// public.bundle.min.js.  This is the client half of the SSR'd public pages:
// it re-renders the Solid tree the server emitted (re-render takeover).
func bundlePublicJS() (bundleStats, error) {
	return bundleJSEntry(filepath.Join(frontendDir, "src/public.tsx"), "public.bundle.min.js")
}

// bundleJSEntry runs esbuild with the default-export shim plugin for one entry
// point.  Vendor modules (anything not a relative import) are kept external so
// the import map resolves them at runtime.  .tsx/.jsx files are Solid-compiled by
// the Go-native compiler (Plugin); the SPA entry is solid-js/html tagged
// templates (no JSX) and is unaffected.
func bundleJSEntry(entry, outName string) (bundleStats, error) {
	// Keep `debugger` statements in development so they can be hit when
	// DevTools is attached; strip them in staging/production builds.
	var drop esbuild.Drop
	if appenv.Environment != appenv.EnvTypeDevelopment {
		drop = esbuild.DropDebugger
	}

	vdirs := vendorDirs()

	// Every bundled vendored package's entrypoint is declared in vendor.json rather
	// than discovered from each package's `exports` — those mis-resolve (e.g.
	// solid-js's bare core to its SSR build, dist/server.js, where createEffect/
	// onMount are no-ops, so the DOM renders but every effect is silently dead).
	// Manifests from every vendor dir are merged (kjol runtime wins) so the shared
	// solid-js resolves to ONE copy across the app + kit trees.
	entrypoints, err := loadVendorManifest(vdirs)
	if err != nil {
		return bundleStats{}, fmt.Errorf("loading vendor manifest: %w", err)
	}

	// aliasPlugin resolves @ui/@kjol/@appgen into the kjol web tree (and the app's
	// generated dir) first. The Go-native Solid compiler (Plugin) sits between the
	// export shim and the vendor resolvers so it compiles .tsx/.jsx before they're
	// resolved.
	plugins := []esbuild.Plugin{aliasPlugin(), defaultExportShimPlugin(), Plugin()}
	plugins = append(plugins, assetURLPlugin(), vendorManifestPlugin(entrypoints, vdirs))

	result := esbuild.Build(esbuild.BuildOptions{
		EntryPoints:       []string{entry},
		Outfile:           filepath.Join(outputDir, outName),
		Bundle:            true,
		Write:             true,
		Format:            esbuild.FormatESModule,
		Target:            esbuild.ES2022,
		Sourcemap:         esbuild.SourceMapLinked,
		SourceRoot:        "./",
		Outbase:           frontendDir,
		SourcesContent:    esbuild.SourcesContentInclude,
		MinifyWhitespace:  true,
		MinifyIdentifiers: true,
		MinifySyntax:      true,
		Drop:              drop,
		Define:            esbuildDefine(),
		// Vendored packages live under frontend/vendor/<pkg> (npm-pack layout).
		// NodePaths makes esbuild resolve bare imports there and bundle + tree-shake
		// them like source (no node_modules). Bundling solid-js from a single vendored
		// copy — rather than leaving it external for the import map — keeps ONE reactive
		// runtime instance; a split instance silently breaks effect flushing (onMount
		// never fires). Browser + development conditions pick each package's DOM dev
		// build over any SSR entry its `module` field points at. Packages whose default
		// entry pulls an un-bundled dep tree point their own package.json at a
		// self-contained dist (e.g. pdf-lib's `browser` field) — config lives with the
		// package, not here. vendorResolvePlugin marks bare imports NOT under
		// frontend/vendor as external (import-map resolved), so the vendor set stays
		// data-driven with no per-package list in this source.
		Platform:   esbuild.PlatformBrowser,
		Conditions: []string{"development"},
		NodePaths:  vdirs,
		// Assets emitted by assetURLPlugin (e.g. the pdfjs worker, which can't be
		// inlined because it reads import.meta.url) land in wwwroot/vendor and are
		// referenced by a root-absolute URL.
		AssetNames: "vendor/[name]",
		PublicPath: "/",
		LogLevel:   esbuild.LogLevelWarning,
		Plugins:    plugins,
	})

	if len(result.Errors) > 0 {
		for _, e := range result.Errors {
			loc := ""
			if e.Location != nil {
				loc = fmt.Sprintf("%s:%d:%d ", e.Location.File, e.Location.Line, e.Location.Column)
			}
			fmt.Fprintf(os.Stderr, "  %s%s\n", loc, e.Text)
		}
		return bundleStats{}, fmt.Errorf("esbuild produced %d error(s)", len(result.Errors))
	}

	jsPath := filepath.Join(outputDir, outName)
	if err := stripSourcemapParentPrefix(jsPath + ".map"); err != nil {
		return bundleStats{}, fmt.Errorf("rewriting sourcemap paths: %w", err)
	}
	info, err := os.Stat(jsPath)
	if err != nil {
		return bundleStats{}, err
	}
	return bundleStats{files: countSources(result), bytes: int(info.Size())}, nil
}

// stripSourcemapParentPrefix rewrites the sourcemap's `sources` entries to
// drop leading `../` segments. esbuild writes paths relative to the output
// file's directory; since the bundle lives in wwwroot/ and the sources live
// in frontend/, every entry starts with `../frontend/`. Stripping the prefix
// yields project-rooted paths like `frontend/src/app.ts`.
func stripSourcemapParentPrefix(mapPath string) error {
	data, err := os.ReadFile(mapPath)
	if err != nil {
		return err
	}
	var m map[string]json.RawMessage
	if err := json.Unmarshal(data, &m); err != nil {
		return err
	}
	raw, ok := m["sources"]
	if !ok {
		return nil
	}
	var sources []string
	if err := json.Unmarshal(raw, &sources); err != nil {
		return err
	}
	for i, s := range sources {
		for strings.HasPrefix(s, "../") {
			s = s[3:]
		}
		sources[i] = s
	}
	newRaw, err := json.Marshal(sources)
	if err != nil {
		return err
	}
	m["sources"] = newRaw
	out, err := json.Marshal(m)
	if err != nil {
		return err
	}
	return os.WriteFile(mapPath, out, 0644)
}

// countSources returns the number of input files that contributed to
// the bundle, derived from esbuild's metafile.  When the metafile is
// not requested, falls back to the entry-count.
func countSources(result esbuild.BuildResult) int {
	if result.Metafile == "" {
		return 1
	}
	// Each "    \"path\":" line in the inputs section counts as a file.
	// Cheap heuristic — avoids pulling in encoding/json for a stat.
	idx := strings.Index(result.Metafile, `"inputs":{`)
	if idx < 0 {
		return 1
	}
	end := strings.Index(result.Metafile[idx:], `},"outputs"`)
	if end < 0 {
		return 1
	}
	return strings.Count(result.Metafile[idx:idx+end], `":{`)
}
