package bundler

import (
	"encoding/json"
	"os"
	"path/filepath"
	"sync"
	"time"
)

// devPageRenderers caches one dev Renderer per public-page SSR entry so repeated
// requests reuse the cached HTML until frontend/src changes.
var devPageRenderers sync.Map // entry string -> *Renderer

// DevRenderPublicPage live-renders a public page's SSR body for the dev server,
// re-rendering only when frontend/src changes (Renderer's dev-mode mtime check).
// module is relative to frontend/src (e.g. "pages/public/AboutUs.tsx"), component
// is the exported body name, and currentPath drives PublicLayout's active nav.
// The server runs from the repo root, so projectRoot is ".".
func DevRenderPublicPage(module, component, currentPath string) (string, error) {
	fullModule := filepath.ToSlash(filepath.Join("frontend", "src", module))
	entry := ssrEntrySolid(fullModule, component, currentPath)
	r, _ := devPageRenderers.LoadOrStore(entry, NewRenderer(".", entry, true))
	return r.(*Renderer).HTML()
}

// devPublicBundles caches one compiled SSR bundle per ISR entry, re-bundled only
// when frontend/src changes. Unlike devPageRenderers (which caches the finished
// data-free HTML), ISR renders with fresh data each request, so we cache the
// bundle and re-render it per request.
var devPublicBundles sync.Map // entry string -> *devBundle

type devBundle struct {
	root, entry string
	mu          sync.Mutex
	bundle      string
	builtAt     time.Time
	ok          bool
}

func (b *devBundle) get() (string, error) {
	b.mu.Lock()
	defer b.mu.Unlock()
	if b.ok && !newestSourceMtime(b.root).After(b.builtAt) {
		return b.bundle, nil
	}
	bundled, err := BundleEntry(b.entry, b.root)
	if err != nil {
		return "", err
	}
	b.bundle, b.builtAt, b.ok = bundled, newestSourceMtime(b.root), true
	return bundled, nil
}

// DevRenderPublicPageWithData live-renders an ISR public page in dev: it bundles
// the page from current source (re-bundling only when frontend/src changes) and
// renders it with the supplied server data. So editing an ISR page's component
// shows on the next reload even with JS disabled — only the Go data-loading code
// (the real server component) still needs a server restart.
func DevRenderPublicPageWithData(module, component, currentPath, dataJSON string) (string, error) {
	fullModule := filepath.ToSlash(filepath.Join("frontend", "src", module))
	entry := ssrEntrySolid(fullModule, component, currentPath)
	bv, _ := devPublicBundles.LoadOrStore(entry, &devBundle{root: ".", entry: entry})
	bundle, err := bv.(*devBundle).get()
	if err != nil {
		return "", err
	}
	return RenderBundleWithData(bundle, dataJSON)
}

// RewarmDevPublicPages re-renders, in the background, every public page whose dev
// SSR has already been requested — so after an edit the fresh no-JS SSR is ready
// before the next reload instead of being rendered lazily on it. The dev watcher
// calls this on a source change. Each Renderer only actually re-renders if
// frontend/src changed, so redundant calls are cheap; errors are left for the
// request path to surface.
func RewarmDevPublicPages() {
	devPageRenderers.Range(func(_, v any) bool {
		go v.(*Renderer).HTML()
		return true
	})
	devPublicBundles.Range(func(_, v any) bool {
		go v.(*devBundle).get()
		return true
	})
}

// newestSourceMtime returns the newest modification time under
// projectRoot/frontend/src, or the zero time if the tree can't be walked
// (treated as "unchanged"). Drives the dev-mode rebuild checks.
func newestSourceMtime(projectRoot string) time.Time {
	var newest time.Time
	srcDir := filepath.Join(projectRoot, "frontend", "src")
	filepath.WalkDir(srcDir, func(_ string, d os.DirEntry, err error) error {
		if err != nil || d.IsDir() {
			return nil
		}
		if info, e := d.Info(); e == nil && info.ModTime().After(newest) {
			newest = info.ModTime()
		}
		return nil
	})
	return newest
}

// Renderer renders one public-page entry to HTML and caches the result.
// Because SSR output is data-free it is identical every request, so a cached
// string can be reused indefinitely.  In dev the cache is invalidated when any
// file under frontend/src changes (a cheap mtime scan), so source edits show
// up on reload without a restart and without paying the ~1s bundle+render on
// every request.
type Renderer struct {
	projectRoot string
	entry       string
	dev         bool

	mu      sync.Mutex
	cached  string
	builtAt time.Time // newest source mtime seen at last build
	ok      bool
}

func NewRenderer(projectRoot, entry string, dev bool) *Renderer {
	return &Renderer{projectRoot: projectRoot, entry: entry, dev: dev}
}

// HTML returns the rendered markup, rebuilding only when necessary.
func (r *Renderer) HTML() (string, error) {
	r.mu.Lock()
	defer r.mu.Unlock()

	if r.ok {
		if !r.dev {
			return r.cached, nil
		}
		if !r.maxSourceMtime().After(r.builtAt) {
			return r.cached, nil // sources unchanged since last build
		}
	}

	out, err := renderOnce(r.entry, r.projectRoot)
	if err != nil {
		return "", err
	}
	r.cached, r.builtAt, r.ok = out, r.maxSourceMtime(), true
	return out, nil
}

// maxSourceMtime returns the newest modification time under frontend/src.
func (r *Renderer) maxSourceMtime() time.Time { return newestSourceMtime(r.projectRoot) }

func renderOnce(entry, projectRoot string) (string, error) {
	bundle, err := BundleEntry(entry, projectRoot)
	if err != nil {
		return "", err
	}
	eng, err := New()
	if err != nil {
		return "", err
	}
	if err := eng.LoadBundle(bundle); err != nil {
		return "", err
	}
	return eng.Render()
}

// RenderBundleWithData runs a pre-bundled render entry (baked into
// public_pages.gen.go by RenderEntryFull) in goja with server data injected, and
// returns the rendered HTML.  This is the runtime ISR path — no esbuild, no
// source files on disk, so it works in a single-binary release.
func RenderBundleWithData(bundleJS, dataJSON string) (string, error) {
	eng, err := New()
	if err != nil {
		return "", err
	}
	if err := eng.SetServerData(dataJSON); err != nil {
		return "", err
	}
	if err := eng.LoadBundle(bundleJS); err != nil {
		return "", err
	}
	return eng.Render()
}

// RenderEntryFull bundles and renders one SSR entry, returning the rendered
// (data-free) skeleton HTML, the bundled JS, and the on-disk input files esbuild
// pulled in.  The bundler (genssr.go) uses this at build time: it
// bakes the HTML for every page and, for dynamic (ISR) pages, also bakes the JS
// so the server can re-render it with data at request time (RenderBundleWithData)
// — no esbuild or source files on disk in production.  The inputs feed
// build-time change detection.
func RenderEntryFull(entry, projectRoot string) (html, js string, inputs []string, err error) {
	bundled, metafile, err := bundleEntry(entry, projectRoot, true)
	if err != nil {
		return "", "", nil, err
	}
	eng, err := New()
	if err != nil {
		return "", "", nil, err
	}
	if err := eng.LoadBundle(bundled); err != nil {
		return "", "", nil, err
	}
	out, err := eng.Render()
	if err != nil {
		return "", "", nil, err
	}
	return out, bundled, metafileInputs(metafile), nil
}

// metafileInputs returns the existing-on-disk input files from an esbuild
// metafile (paths are relative to the build's working directory).  Virtual
// inputs like the inline entry are skipped — they don't os.Stat.
func metafileInputs(metafile string) []string {
	var mf struct {
		Inputs map[string]json.RawMessage `json:"inputs"`
	}
	if metafile == "" || json.Unmarshal([]byte(metafile), &mf) != nil {
		return nil
	}
	out := make([]string, 0, len(mf.Inputs))
	for p := range mf.Inputs {
		if _, err := os.Stat(p); err == nil {
			out = append(out, filepath.ToSlash(p))
		}
	}
	return out
}
