package executor

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"html"
	"io"
	"net"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"time"

	"webby-builder/internal/security/urlguard"
)

// validateFontURL and fontSafeDialer are package-level hooks so same-package
// tests can swap in permissive versions when hitting an httptest.Server (which
// listens on loopback). Production always gets the real urlguard SSRF defense:
// design-system zips are admin-supplied, but the font URLs inside them (and any
// redirect target) must never reach internal/metadata endpoints. Validate gives
// an early, clear rejection; the SafeDialer Transport re-checks every dialed IP
// (including each redirect hop) against private ranges, defeating DNS rebinding.
var (
	validateFontURL = urlguard.Validate
	fontSafeDialer  = func() *net.Dialer { return urlguard.SafeDialer() }
)

// bundledFont is one downloaded woff2 file living in the theme workspace.
type bundledFont struct {
	Family  string // e.g. "Geist"
	Style   string // "normal" | "italic"
	Weight  string // e.g. "400", or a variable range like "300 800"
	RelPath string // workspace-relative, slash-separated (assets/fonts/…)
}

// A woff2-capable UA — Google serves legacy formats to unknown agents.
const fontFetchUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"

var (
	fontHrefRe       = regexp.MustCompile(`href="([^"]*family=[^"]*)"`)
	fontFaceRe       = regexp.MustCompile(`(?s)(?:/\*\s*([a-z0-9-]+)\s*\*/\s*)?@font-face\s*\{([^}]*)\}`)
	fontSrcURLRe     = regexp.MustCompile(`url\(([^)]+)\)`)
	fontNameSafeRe   = regexp.MustCompile(`[^a-z0-9-]`)
	fontFamilyPropRe = regexp.MustCompile(`font-family\s*:\s*([^;]+);`)
	fontStylePropRe  = regexp.MustCompile(`font-style\s*:\s*([^;]+);`)
	fontWeightPropRe = regexp.MustCompile(`font-weight\s*:\s*([^;]+);`)
)

func fontFaceProp(block string, re *regexp.Regexp) string {
	if m := re.FindStringSubmatch(block); m != nil {
		return strings.Trim(strings.TrimSpace(m[1]), `'"`)
	}
	return ""
}

type remoteFontFace struct {
	Family, Style, Weight, Subset, URL string
}

// pickLatinFaces parses a Google css2 response and keeps one face per
// (family, style, weight): the latin subset when present, else the last seen.
func pickLatinFaces(css string) []remoteFontFace {
	chosen := map[string]remoteFontFace{}
	var order []string
	for _, m := range fontFaceRe.FindAllStringSubmatch(css, -1) {
		block := m[2]
		face := remoteFontFace{
			Family: fontFaceProp(block, fontFamilyPropRe),
			Style:  fontFaceProp(block, fontStylePropRe),
			Weight: fontFaceProp(block, fontWeightPropRe),
			Subset: m[1],
		}
		if u := fontSrcURLRe.FindStringSubmatch(block); u != nil {
			face.URL = strings.Trim(strings.TrimSpace(u[1]), `'"`)
		}
		if face.Family == "" || face.URL == "" {
			continue
		}
		key := face.Family + "|" + face.Style + "|" + face.Weight
		prev, seen := chosen[key]
		if !seen {
			order = append(order, key)
		}
		if !seen || prev.Subset != "latin" {
			chosen[key] = face
		}
	}
	out := make([]remoteFontFace, 0, len(order))
	for _, k := range order {
		out = append(out, chosen[k])
	}
	return out
}

// bundleThemeFonts downloads the design system's Google fonts as woff2 files
// into {workspace}/assets/fonts and returns their metadata for theme.json
// fontFace entries. Downloads are cached in cacheDir keyed by URL (Google
// font-file URLs are immutable). Returns whatever succeeded plus the first
// error — callers treat errors as non-blocking.
func bundleThemeFonts(workspacePath, fontsHTML, cacheDir string) ([]bundledFont, error) {
	client := &http.Client{
		Timeout:   10 * time.Second,
		Transport: &http.Transport{DialContext: fontSafeDialer().DialContext},
	}
	deadline := time.Now().Add(30 * time.Second)
	var out []bundledFont

	for _, m := range fontHrefRe.FindAllStringSubmatch(fontsHTML, -1) {
		cssURL := html.UnescapeString(m[1])
		if err := validateFontURL(cssURL); err != nil {
			return out, fmt.Errorf("font css URL blocked: %w", err)
		}
		req, err := http.NewRequest(http.MethodGet, cssURL, nil)
		if err != nil {
			return out, err
		}
		req.Header.Set("User-Agent", fontFetchUA)
		resp, err := client.Do(req)
		if err != nil {
			return out, err
		}
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
		_ = resp.Body.Close()
		if resp.StatusCode != http.StatusOK {
			return out, fmt.Errorf("font css fetch %s: HTTP %d", cssURL, resp.StatusCode)
		}

		for _, face := range pickLatinFaces(string(body)) {
			if time.Now().After(deadline) {
				return out, fmt.Errorf("font download budget exceeded")
			}
			data, err := fetchFontCached(client, face.URL, cacheDir)
			if err != nil {
				return out, err
			}
			rel := "assets/fonts/" + fontFileName(face)
			abs := filepath.Join(workspacePath, filepath.FromSlash(rel))
			if !strings.HasPrefix(abs, filepath.Clean(workspacePath)+string(filepath.Separator)) {
				return out, fmt.Errorf("font path escapes workspace: %s", abs)
			}
			if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
				return out, err
			}
			if err := os.WriteFile(abs, data, 0o644); err != nil {
				return out, err
			}
			out = append(out, bundledFont{Family: face.Family, Style: face.Style, Weight: face.Weight, RelPath: rel})
		}
	}
	return out, nil
}

// fontFileName builds a filesystem-safe filename for a font face. Family and
// weight are CSS-derived (remote input), so each component is slugified down
// to [a-z0-9-] — never trust them as path segments.
func fontFileName(f remoteFontFace) string {
	slug := fontNameSafeRe.ReplaceAllString(strings.ToLower(strings.ReplaceAll(f.Family, " ", "-")), "")
	weight := fontNameSafeRe.ReplaceAllString(strings.ToLower(strings.ReplaceAll(f.Weight, " ", "-")), "")
	if slug == "" {
		slug = "font"
	}
	name := slug + "-" + weight
	if f.Style == "italic" {
		name += "-italic"
	}
	return name + ".woff2"
}

func fetchFontCached(client *http.Client, url, cacheDir string) ([]byte, error) {
	sum := sha256.Sum256([]byte(url))
	cachePath := filepath.Join(cacheDir, hex.EncodeToString(sum[:])+".woff2")
	if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 {
		return data, nil
	}
	// The woff2 URL comes from the (admin-supplied) CSS response body, so it is
	// re-validated against the SSRF guard before any fetch — the CSS server
	// could otherwise point src: url(...) at an internal endpoint.
	if err := validateFontURL(url); err != nil {
		return nil, fmt.Errorf("font URL blocked: %w", err)
	}
	req, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("User-Agent", fontFetchUA)
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("font fetch %s: HTTP %d", url, resp.StatusCode)
	}
	data, err := io.ReadAll(io.LimitReader(resp.Body, 5<<20))
	if err != nil {
		return nil, err
	}
	// Write the cache atomically (temp file + rename, like WriteJSONFileAtomic)
	// so a crash mid-write can't leave a truncated file that poisons the cache.
	if err := os.MkdirAll(cacheDir, 0o755); err == nil {
		if tmp, tErr := os.CreateTemp(cacheDir, ".font-*.tmp"); tErr == nil {
			if _, wErr := tmp.Write(data); wErr == nil && tmp.Sync() == nil {
				_ = tmp.Close()
				_ = os.Rename(tmp.Name(), cachePath)
			} else {
				_ = tmp.Close()
				_ = os.Remove(tmp.Name())
			}
		}
	}
	return data, nil
}

// attachFontFaces adds theme.json fontFace entries (file:./ srcs) to the
// overlay's fontFamilies whose family name matches a bundled font.
//
// Precondition: overlay must come straight from BuildThemeJSONMap, whose
// fontFamilies value is a []map[string]any. A JSON round-trip would decode
// that slice as []interface{}, making the assertion below silently no-op.
func attachFontFaces(overlay map[string]interface{}, fonts []bundledFont) {
	settings, _ := overlay["settings"].(map[string]interface{})
	if settings == nil {
		return
	}
	typography, _ := settings["typography"].(map[string]interface{})
	if typography == nil {
		return
	}
	fams, _ := typography["fontFamilies"].([]map[string]any)
	for _, fam := range fams {
		name, _ := fam["name"].(string)
		var faces []map[string]interface{}
		for _, f := range fonts {
			if f.Family != name {
				continue
			}
			faces = append(faces, map[string]interface{}{
				"fontFamily": f.Family,
				"fontStyle":  f.Style,
				"fontWeight": f.Weight,
				"src":        []string{"file:./" + f.RelPath},
			})
		}
		if len(faces) > 0 {
			fam["fontFace"] = faces
		}
	}
}
