package bundler

// Build-time caching + parallelism for public-page SSR (see genssr.go).
//
// Rendering a page means bundling it with esbuild and running it through a goja
// runtime — too slow to repeat for every page on every build when nothing
// changed.  So each render records the source files esbuild pulled in (from the
// metafile) and their content hashes; a later build reuses the cached HTML when
// the page's entry and every input file are byte-identical.  Cache misses are
// rendered concurrently, one goja runtime per worker.
//
// The cache lives under tmp/ (gitignored) and is purely an optimization: any
// read/parse/write error just falls back to rendering.

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"runtime"
	"sync"
)

// ssrCachePath is the on-disk render cache (gitignored via tmp/).
var ssrCachePath = filepath.Join("tmp", "ssr-cache.json")

// ssrCacheEntry fingerprints one rendered page.
type ssrCacheEntry struct {
	EntryHash string            `json:"entry"`              // hash of the goja entry source
	Inputs    map[string]string `json:"inputs"`             // input file path -> content hash
	HTML      string            `json:"html"`               // the rendered, baked body
	RenderJS  string            `json:"renderJS,omitempty"` // bundled JS, baked for dynamic (ISR) pages
}

// ssrCache is the whole render cache, keyed by page URL path.
type ssrCache struct {
	Engine string                   `json:"engine"` // EngineHash() at write time
	Pages  map[string]ssrCacheEntry `json:"pages"`
}

func loadSSRCache() ssrCache {
	data, err := os.ReadFile(ssrCachePath)
	if err != nil {
		return ssrCache{}
	}
	var c ssrCache
	if json.Unmarshal(data, &c) != nil || c.Pages == nil {
		return ssrCache{}
	}
	return c
}

func saveSSRCache(c ssrCache) {
	data, err := json.MarshalIndent(c, "", "  ")
	if err != nil {
		return
	}
	if err := os.MkdirAll(filepath.Dir(ssrCachePath), 0o755); err != nil {
		return
	}
	_ = os.WriteFile(ssrCachePath, data, 0o644)
}

func hashString(s string) string {
	sum := sha256.Sum256([]byte(s))
	return hex.EncodeToString(sum[:])
}

// fileHasher memoizes content hashes within a single build so files shared by
// several pages (the vendored Solid runtime, PublicLayout, shared UI) are read
// and hashed once.  Safe for concurrent use.
type fileHasher struct {
	mu sync.Mutex
	m  map[string]string // path -> hex hash; "" records a read failure
}

func newFileHasher() *fileHasher { return &fileHasher{m: map[string]string{}} }

// hash returns the file's content hash and whether it was readable.
func (f *fileHasher) hash(path string) (string, bool) {
	f.mu.Lock()
	if h, ok := f.m[path]; ok {
		f.mu.Unlock()
		return h, h != ""
	}
	f.mu.Unlock()

	h := ""
	if data, err := os.ReadFile(path); err == nil {
		sum := sha256.Sum256(data)
		h = hex.EncodeToString(sum[:])
	}

	f.mu.Lock()
	f.m[path] = h
	f.mu.Unlock()
	return h, h != ""
}

// inputsUnchanged reports whether every recorded input still hashes the same.
// A page can't gain a new dependency without editing one of these files, so an
// all-match means the bundle (and thus the rendered output) is identical.
func inputsUnchanged(old map[string]string, h *fileHasher) bool {
	if len(old) == 0 {
		return false
	}
	for path, want := range old {
		got, ok := h.hash(path)
		if !ok || got != want {
			return false
		}
	}
	return true
}

func hashInputs(paths []string, h *fileHasher) map[string]string {
	m := make(map[string]string, len(paths))
	for _, p := range paths {
		if sum, ok := h.hash(p); ok {
			m[p] = sum
		}
	}
	return m
}

// renderJob / renderResult carry a cache-miss page through the worker pool.
type renderJob struct {
	idx       int
	path      string
	component string
	entry     string
}

type renderResult struct {
	idx    int
	path   string
	html   string
	js     string // bundled render entry (baked for dynamic/ISR pages)
	inputs []string
}

// renderMisses renders the given jobs concurrently — one goja runtime per
// worker, capped at NumCPU — and returns their results in input order.  On the
// first render error it stops reporting and returns that error.
func renderMisses(jobs []renderJob) ([]renderResult, error) {
	if len(jobs) == 0 {
		return nil, nil
	}

	limit := runtime.NumCPU()
	if limit < 1 {
		limit = 1
	}
	if limit > len(jobs) {
		limit = len(jobs)
	}

	results := make([]renderResult, len(jobs))
	sem := make(chan struct{}, limit)
	var wg sync.WaitGroup
	var mu sync.Mutex
	var firstErr error

	for k := range jobs {
		wg.Add(1)
		go func(k int) {
			defer wg.Done()
			sem <- struct{}{}
			defer func() { <-sem }()

			j := jobs[k]
			html, js, inputs, err := RenderEntryFull(j.entry, ".")
			if err != nil {
				mu.Lock()
				if firstErr == nil {
					firstErr = fmt.Errorf("rendering %s (%s): %w", j.path, j.component, err)
				}
				mu.Unlock()
				return
			}
			results[k] = renderResult{idx: j.idx, path: j.path, html: html, js: js, inputs: inputs}
		}(k)
	}
	wg.Wait()

	if firstErr != nil {
		return nil, firstErr
	}
	return results, nil
}
