//go:build dev

package bundler

// The dev watcher: a lightweight mtime poll over frontend/src and frontend/css
// (no external dependency). A source-module change is turned into an HMR update
// or a full reload via the module graph; a CSS/Tailwind change triggers a
// (coalesced) stylesheet rebuild and hot-swap. The public-route manifest is
// regenerated on edit, then the page reloads.

import (
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"
)

// pollInterval is the mtime scan cadence. Kept tight so save→update latency isn't
// dominated by detection lag: the WalkDir+Stat over frontend/src+css is sub-ms, so
// scanning this often is cheap. (For zero detection lag, swap the poll for native
// FS events — deliberately avoided to keep the watcher dependency-free.)
const pollInterval = 50 * time.Millisecond

func (d *devServer) watch() {
	go d.cssLoop()

	roots := []string{d.srcRoot, filepath.Join(d.frontend, "css")}
	if webDir != "" { // cross-tree: also watch the shared kjol web kit + styles
		roots = append(roots, d.kitRoot)
	}
	mtimes := map[string]time.Time{}
	d.scan(roots, mtimes, nil) // seed: record current state, emit nothing

	for {
		time.Sleep(pollInterval)
		var changed []string
		d.scan(roots, mtimes, func(p string) { changed = append(changed, p) })
		if len(changed) > 0 {
			d.handleChanges(changed)
		}
	}
}

// scan walks roots, calling onchange for every file whose mtime advanced since
// the last scan (or is new). mtimes is updated in place.
func (d *devServer) scan(roots []string, mtimes map[string]time.Time, onchange func(string)) {
	for _, root := range roots {
		filepath.WalkDir(root, func(p string, entry os.DirEntry, err error) error {
			if err != nil || entry.IsDir() {
				return nil
			}
			switch filepath.Ext(p) {
			case ".ts", ".tsx", ".js", ".jsx", ".css":
			default:
				return nil
			}
			info, err := entry.Info()
			if err != nil {
				return nil
			}
			mt := info.ModTime()
			if prev, ok := mtimes[p]; !ok || mt.After(prev) {
				mtimes[p] = mt
				// onchange is nil on the seed pass, so nothing is reported until the
				// baseline is recorded; afterwards every new/changed file is reported.
				if onchange != nil {
					onchange(p)
				}
			}
			return nil
		})
	}
}

func (d *devServer) handleChanges(changed []string) {
	pagesManifestAbs := filepath.Join(d.frontend, filepath.FromSlash(pagesManifest))
	cssDirty := false
	srcDirty := false

	for _, p := range changed {
		base := filepath.Base(p)
		// Generated files are written by the tools below; ignore to avoid loops.
		if strings.HasSuffix(base, ".gen.ts") || strings.HasSuffix(base, ".gen.go") {
			continue
		}

		if p == pagesManifestAbs {
			// The public-page manifest drives generated route tables — regenerate,
			// then reload (these pages are live-reload scope, not component HMR).
			if err := generatePublicRoutes(); err != nil {
				fmt.Fprintf(os.Stderr, "[hmr] regenerating public routes: %v\n", err)
			}
			d.hub.broadcastJSON(hmrMessage{Type: "full-reload"})
			return
		}

		switch filepath.Ext(p) {
		case ".css":
			cssDirty = true
		case ".ts", ".tsx", ".js", ".jsx":
			d.hmrJS(p)
			cssDirty = true // a class may have been added/removed
			srcDirty = true
		}
	}

	if cssDirty {
		select {
		case d.cssTrigger <- struct{}{}:
		default: // a rebuild is already queued
		}
	}
	if srcDirty {
		// Proactively re-render the public pages' SSR so the no-JS ("static")
		// version served on the next reload is already current, not rendered lazily.
		RewarmDevPublicPages()
	}
}

// hmrJS turns one changed source module into a hot update or a full reload.
func (d *devServer) hmrJS(abs string) {
	rel := d.moduleID(abs)
	boundaries, version, reload := d.graph.invalidate(abs)
	if reload {
		fmt.Printf("[hmr] full reload (%s)\n", rel)
		d.hub.broadcastJSON(hmrMessage{Type: "full-reload"})
		return
	}
	if len(boundaries) == 0 {
		return // not on the current page's graph — nothing to do
	}

	updates := make([]hmrUpdate, 0, len(boundaries))
	for _, b := range boundaries {
		updates = append(updates, hmrUpdate{
			Path:      d.moduleURLBase(b),
			Timestamp: version,
		})
	}
	fmt.Printf("[hmr] update %s -> %d boundary(ies)\n", rel, len(boundaries))
	d.hub.broadcastJSON(hmrMessage{Type: "update", Updates: updates})
}

// cssLoop serializes and coalesces CSS rebuilds, hot-swapping the stylesheet when
// each completes.
func (d *devServer) cssLoop() {
	for range d.cssTrigger {
		if _, err := bundleSPACSS(); err != nil {
			fmt.Fprintf(os.Stderr, "[hmr] CSS rebuild failed: %v\n", err)
		} else {
			d.hub.broadcastJSON(hmrMessage{Type: "css-update", Path: "/bundle.min.css"})
		}
		// Public pages hot-reload too, off their own stylesheet.
		if _, err := bundlePublicCSS(); err != nil {
			fmt.Fprintf(os.Stderr, "[hmr] public CSS rebuild failed: %v\n", err)
		} else {
			d.hub.broadcastJSON(hmrMessage{Type: "css-update", Path: "/public.bundle.min.css"})
		}
	}
}
