package jsbundler

import (
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
)

// Tree-shaken FontAwesome. Instead of shipping the 41.5 MB `all.min.js` kit and
// looking icons up by runtime string, we scan the app for the icon names it
// actually references and emit a registry of just those icons' SVG data, pulled
// from the FontAwesome SVGs under the icon roots. Icons.tsx looks up that
// registry exactly like it used to call FontAwesome.findIconDefinition.

// FA prefix -> <iconRoot>/<dir>. cdrateline renders classic far/fas; a Sharp
// project (fasr/fass) would add "fasr": "sharp-regular", "fass": "sharp-solid".
var faStyleDirs = map[string]string{
	"far": "regular",
	"fas": "solid",
}

// The FA SVG source roots (iconsDirs — the app's kit, then kjol's) and the
// generated registry output path (faOutPath) are resolved from Config — see
// config.go. Only the styles in faStyleDirs are read; the rest of the kit is
// unused.

var (
	// icon="name" / icon: "name"
	reIconAttr = regexp.MustCompile(`\bicon\s*(?:=|:)\s*"([a-z0-9][a-z0-9-]*)"`)
	// icon={ ... } — a dynamic expression; pull any string literals out of it
	// (ternaries etc.). The `$` is optional because both authoring styles in this
	// codebase have to be seen: JSX writes icon={"bell"}, while a solid-js/html
	// tagged template writes icon=${"bell"}. Missing the second form drops the
	// icon from the registry silently — it simply renders blank at runtime.
	reIconBrace = regexp.MustCompile(`\bicon\s*=\s*\$?\{([^}]*)\}`)
	reStrLit    = regexp.MustCompile(`"([a-z0-9][a-z0-9-]*)"`)
	reViewBox   = regexp.MustCompile(`viewBox="0 0 ([0-9.]+) ([0-9.]+)"`)
	rePathD     = regexp.MustCompile(`<path[^>]*\bd="([^"]+)"`)
	// registerIcon("name", …) — custom (non-FontAwesome) icons defined in-app.
	reRegisterIcon = regexp.MustCompile(`registerIcon\(\s*"([a-z0-9][a-z0-9-]*)"`)
)

// presentIconsDirs is iconsDirs filtered to the roots that actually exist, so a
// missing kit is skipped rather than failing every icon lookup against it.
func presentIconsDirs() []string {
	var roots []string
	for _, d := range iconsDirs() {
		if _, err := os.Stat(d); err == nil {
			roots = append(roots, d)
		}
	}
	return roots
}

// readIconSVG returns the first <root>/<style>/<name>.svg that exists, walking
// roots in precedence order (app kit before kjol's — see iconsDirs).
func readIconSVG(roots []string, style, name string) (string, bool) {
	for _, root := range roots {
		if svg, err := os.ReadFile(filepath.Join(root, style, name+".svg")); err == nil {
			return string(svg), true
		}
	}
	return "", false
}

// generateFAIcons regenerates the icon registry from the FontAwesome kit. It's a
// no-op when no kit is present (CI builds use the committed registry).
func generateFAIcons() error {
	roots := presentIconsDirs()
	if len(roots) == 0 {
		return nil // SVGs absent — keep the committed registry
	}

	// Scan both the app pages and the shared kit so every icon either tree
	// references ends up in this app's registry.
	names, custom, err := scanIconNames([]string{filepath.Join(frontendDir, "src"), uikitDir()})
	if err != nil {
		return fmt.Errorf("scanning icon names: %w", err)
	}

	type entry struct {
		key, x, y, w, h, path string
	}
	var entries []entry
	var missing []string
	for _, name := range names {
		found := false
		for prefix, dir := range faStyleDirs {
			svg, ok := readIconSVG(roots, dir, name)
			if !ok {
				continue
			}
			x, y, w, h, d, ok := parseFASvg(svg)
			if !ok {
				continue
			}
			entries = append(entries, entry{prefix + ":" + name, x, y, w, h, d})
			found = true
		}
		if !found {
			missing = append(missing, name)
		}
	}
	sort.Slice(entries, func(i, j int) bool { return entries[i].key < entries[j].key })

	var b strings.Builder
	b.WriteString("// AUTO-GENERATED by cmd/bundle (generateFAIcons) — do not edit.\n")
	b.WriteString("// A tree-shaken subset of FontAwesome: only the icons this app references,\n")
	b.WriteString("// as [x, y, width, height, svgPath] keyed by \"prefix:name\" (viewBox inset to FA's\n")
	b.WriteString("// 512 design box within the 640 kit canvas). Regenerated each build while\n")
	b.WriteString("// an icon kit is present; committed so CI needs no SVGs.\n")
	b.WriteString("export const FA_ICONS: Record<string, readonly [number, number, number, number, string]> = {\n")
	for _, e := range entries {
		b.WriteString(fmt.Sprintf("    %q: [%s, %s, %s, %s, %q],\n", e.key, e.x, e.y, e.w, e.h, e.path))
	}
	b.WriteString("};\n")

	// Skip the write when content is unchanged so the file's mtime doesn't
	// advance on every SPA source edit — the dev watcher treats any mtime bump
	// as a real change and hot-reloads Icons.tsx (the nearest HMR boundary that
	// imports this file), which is wasteful when nothing about the icon set
	// actually changed.
	if existing, err := os.ReadFile(faOutPath()); err == nil && string(existing) == b.String() {
		return nil
	}

	if err := os.MkdirAll(filepath.Dir(faOutPath()), 0755); err != nil {
		return err
	}
	if err := os.WriteFile(faOutPath(), []byte(b.String()), 0644); err != nil {
		return err
	}

	// A referenced name that's not in the FA kit is either a registered custom icon
	// (expected) or an unknown name — almost always a typo (report separately).
	var customUsed, unknown []string
	for _, name := range missing {
		if custom[name] {
			customUsed = append(customUsed, name)
		} else {
			unknown = append(unknown, name)
		}
	}

	fmt.Printf("  FA icons: %d defs for %d names", len(entries), len(names))
	if len(customUsed) > 0 {
		sort.Strings(customUsed)
		fmt.Printf(" (%d custom: %s)", len(customUsed), strings.Join(customUsed, ", "))
	}
	if len(unknown) > 0 {
		sort.Strings(unknown)
		fmt.Printf(" (%d unknown, likely typos: %s)", len(unknown), strings.Join(unknown, ", "))
	}
	fmt.Println()
	return nil
}

// scanIconNames walks dir once and returns two things: the sorted list of icon
// names referenced anywhere (static `icon="x"`/`icon: "x"` plus string literals
// inside `icon={...}` expressions), and the set of custom icon names registered
// via registerIcon("name", …). The latter lets the caller tell a legitimate
// custom icon apart from a typo when a referenced name isn't in the FA kit.
func scanIconNames(dirs []string) (names []string, custom map[string]bool, err error) {
	set := map[string]bool{}
	custom = map[string]bool{}
	walk := func(path string, d os.DirEntry, err error) error {
		if err != nil || d.IsDir() {
			return err
		}
		switch filepath.Ext(path) {
		case ".ts", ".tsx", ".js", ".jsx":
		default:
			return nil
		}
		data, err := os.ReadFile(path)
		if err != nil {
			return err
		}
		s := string(data)
		for _, m := range reIconAttr.FindAllStringSubmatch(s, -1) {
			set[m[1]] = true
		}
		for _, bm := range reIconBrace.FindAllStringSubmatch(s, -1) {
			for _, sm := range reStrLit.FindAllStringSubmatch(bm[1], -1) {
				set[sm[1]] = true
			}
		}
		for _, m := range reRegisterIcon.FindAllStringSubmatch(s, -1) {
			custom[m[1]] = true
		}
		return nil
	}
	for _, dir := range dirs {
		if _, e := os.Stat(dir); e != nil {
			continue // tree not present (e.g. no separate kit in single-tree mode)
		}
		if err = filepath.WalkDir(dir, walk); err != nil {
			return nil, nil, err
		}
	}
	names = make([]string, 0, len(set))
	for n := range set {
		names = append(names, n)
	}
	sort.Strings(names)
	return names, custom, nil
}

// parseFASvg pulls the concatenated path data (solid/regular icons are
// single-path; joining is safe) and a viewBox out of a FontAwesome kit SVG.
//
// The kit's "full" SVGs keep FA's 512-unit icon design centred inside a 640x640
// canvas — a uniform 10% margin (square-full/circle/bars all span 64..576). We
// inset the viewBox to that 512 design box so icons render at their intended
// (FA6-equivalent) size instead of ~20% small on the padded canvas. The inset is
// UNIFORM across every icon, so proportions are preserved — a caret stays a small
// glyph. (A per-glyph bounding-box crop was wrong: it can't tell an icon meant to
// fill its box from one deliberately padded, so it blew small glyphs up to fill it.)
func parseFASvg(svg string) (x, y, w, h, path string, ok bool) {
	var ds []string
	for _, m := range rePathD.FindAllStringSubmatch(svg, -1) {
		ds = append(ds, m[1])
	}
	if len(ds) == 0 {
		return "", "", "", "", "", false
	}
	vb := reViewBox.FindStringSubmatch(svg)
	if vb == nil {
		return "", "", "", "", "", false
	}
	vw, e1 := strconv.ParseFloat(vb[1], 64)
	vh, e2 := strconv.ParseFloat(vb[2], 64)
	if e1 != nil || e2 != nil {
		return "0", "0", vb[1], vb[2], strings.Join(ds, " "), true // unparseable dims — use as-is
	}
	mx, my := vw/10, vh/10
	return numStr(mx), numStr(my), numStr(vw - 2*mx), numStr(vh - 2*my), strings.Join(ds, " "), true
}

func numStr(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) }
